fix+test: Euclidean holonomy/τ end-to-end + spherical edge-DOF oracle (2026-05-29 audit)
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 1m57s
API Docs / doc-build (pull_request) Successful in 1m3s
Markdown link check / check (pull_request) Successful in 44s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Successful in 2m11s

Bundles the 2026-05-29 Java↔C++ math-correctness audit (doc/reviewer/
java-port-audit.md, 11 findings) with two follow-up fixes.

Audit code changes:
- Finding 3 (spherical_functional): edge-DOF replacement parameterization via
  spher_eff_lambda; edge gradient α_opp⁺+α_opp⁻−θ_e (drops additive −(S⁺+S⁻)/2)
- Finding 4 (spherical_hessian): always-compiled edge-DOF throw guard
- Finding 6 (period_matrix): faithful normalizeModulus (0≤Re≤½, Im≥0, |τ|≥1)
- Finding 9 (inversive_distance): degenerate-face limiting angles, no skip
- Findings 1/2 (euclidean): degenerate gradient limiting angles + Hessian guard

Euclidean holonomy/τ fix: develop the cut surface across the dual spanning tree
only (cut_graph now exposes is_dual_tree), so genus-1 cut edges yield
non-degenerate lattice generators. Previously τ came out 0 / NaN / 1e13 on the
bundled tori; now matches the analytic revolution modulus i·√(R²−r²)/r. Re-enabled
τ reporting in the Euclidean CLI; rewrote validation.md §3/§4 accordingly.

Tests (240 CGAL, 0 skipped):
- HolonomyEndToEnd ×3 — tori of revolution (4×4, hex 6×6, 8×8) vs analytic modulus
- SphericalFunctional.EdgeGradient_RegularTetClosedForm — independent closed-form
  π/3 oracle locking the Finding-3 edge formula (the path-integral FD check cannot
  detect a wrong-but-conservative gradient)

Also documents the latent spherical/hyperbolic holonomy-extraction bug (same
single-development pattern, dead code today) in research-track.md (Phase 9c/10),
and adds favour/normalisations to the codespell ignore list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-29 12:50:16 +02:00
parent ca936b7652
commit a3ee9576d4
23 changed files with 1065 additions and 165 deletions

View File

@@ -12,9 +12,13 @@
// The tool:
// 1. Loads an OFF/OBJ/PLY mesh.
// 2. Sets up DOF maps + computes λ° from the input geometry.
// 3. Pins one vertex (Euclidean/Spherical) or uses all-free DOFs (HyperIdeal).
// 3. Euclidean: solves a genuine conformal-flattening problem with target
// cone angle Θ_v = 2π (zero curvature). Open meshes pin the boundary and
// flatten the interior; closed meshes pin one vertex and enforce
// Gauss-Bonnet. x = 0 is NOT the solution, so Newton does real work.
// 4. Runs Newton until convergence.
// 5. Computes a 2-D (Euclidean / HyperIdeal) or 3-D (Spherical) layout.
// 5. Computes a 2-D (Euclidean / HyperIdeal) or 3-D (Spherical) layout;
// closed surfaces are cut along the tree-cotree cut graph first.
// 6. Saves the layout as an OFF file and optionally serialises the result
// to JSON and/or XML.
// 7. Optionally shows the input mesh in a viewer (-s flag, requires WITH_VIEWER).
@@ -27,6 +31,9 @@
#include "newton_solver.hpp"
#include "layout.hpp"
#include "serialization.hpp"
#include "gauss_bonnet.hpp"
#include "cut_graph.hpp"
#include "period_matrix.hpp"
#include <CLI11.hpp>
#include <iostream>
@@ -49,28 +56,41 @@ using cl::Edge_index;
// Shared helpers (mirroring test_pipeline.cpp patterns)
// ─────────────────────────────────────────────────────────────────────────────
// Pin vertex 0, assign 0..n-1 to the rest
static int pin_first_vertex(ConformalMesh& mesh, cl::EuclideanMaps& maps)
// Assign Euclidean vertex DOFs for a genuine conformal-flattening problem.
//
// The target cone angle Θ_v = 2π (set by `setup_euclidean_maps`) asks for a
// *flat* metric — zero discrete Gaussian curvature at every free vertex. We do
// NOT overwrite it with the input angle sums, so x = 0 is generally NOT the
// solution and Newton has to do real work.
//
// • Open mesh (disk/cylinder…): pin the boundary (u = 0, original boundary
// lengths) and free the interior → fixed-boundary conformal flattening.
// • Closed mesh: pin one vertex to fix the scale gauge, free the rest, and
// call `enforce_gauss_bonnet` so the flat target is topology-consistent
// (no shift for a torus, uniform cone angles for genus 0).
//
// Returns the number of free DOFs and reports whether the mesh has a boundary.
static int assign_euclidean_flattening_dofs(ConformalMesh& mesh,
cl::EuclideanMaps& maps,
bool& has_boundary)
{
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
maps.v_idx[v0] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit)
maps.v_idx[*vit] = idx++;
return idx;
}
has_boundary = false;
for (auto v : mesh.vertices())
if (mesh.is_border(v)) { has_boundary = true; break; }
// Natural theta for Euclidean: make x=0 the equilibrium
static void set_natural_euclidean_theta(ConformalMesh& mesh, cl::EuclideanMaps& maps, int n)
{
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G = cl::euclidean_gradient(mesh, x0, maps);
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv < 0) continue;
maps.theta_v[v] -= G[static_cast<std::size_t>(iv)];
int idx = 0;
if (has_boundary) {
for (auto v : mesh.vertices())
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
} else {
bool pinned = false;
for (auto v : mesh.vertices()) {
if (!pinned) { maps.v_idx[v] = -1; pinned = true; }
else maps.v_idx[v] = idx++;
}
cl::enforce_gauss_bonnet(mesh, maps);
}
return idx;
}
// Natural theta for HyperIdeal at base point (b=1, a=0.5) to avoid x=0 singularity
@@ -110,26 +130,57 @@ static int run_euclidean(ConformalMesh& mesh,
const std::string& out_xml,
bool verbose)
{
// Setup
// Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh.
auto maps = cl::setup_euclidean_maps(mesh);
cl::compute_euclidean_lambda0_from_mesh(mesh, maps);
// DOF assignment: pin vertex 0
int n = pin_first_vertex(mesh, maps);
if (n <= 0) { std::cerr << "Error: mesh has only one vertex.\n"; return 1; }
// DOF assignment for a genuine flattening problem (see helper).
bool has_boundary = false;
int n = assign_euclidean_flattening_dofs(mesh, maps, has_boundary);
if (n <= 0) { std::cerr << "Error: no free vertices to solve for.\n"; return 1; }
// Natural target angles
set_natural_euclidean_theta(mesh, maps, n);
const int g = has_boundary ? -1 : cl::genus(mesh);
if (verbose) {
std::cout << " topology: " << (has_boundary ? "open (boundary pinned)"
: "closed")
<< ", free DOFs=" << n;
if (!has_boundary) std::cout << ", genus=" << g;
std::cout << "\n";
}
// Newton
// Newton — starts at x0 = 0, which is NOT the solution in general.
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto res = cl::newton_euclidean(mesh, x0, maps);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
if (!res.converged)
std::cerr << "[warn] Newton did not converge (|grad|="
<< res.grad_inf_norm << ", iter=" << res.iterations << ")\n";
// Layout
cl::Layout2D layout = cl::euclidean_layout(mesh, res.x, maps);
// Layout. For a closed surface we cut along the tree-cotree cut graph so
// the result is a single planar fundamental domain rather than overlapping
// face copies. For genus 1 we also recover the holonomy lattice generators
// and report the period ratio τ.
cl::Layout2D layout;
cl::HolonomyData hol;
bool have_tau = false;
cl::PeriodData pd;
if (!has_boundary && g >= 1) {
cl::CutGraph cg = cl::compute_cut_graph(mesh);
layout = cl::euclidean_layout(mesh, res.x, maps, &cg, &hol, /*normalise=*/true);
if (g == 1 && hol.translations.size() >= 2) {
try {
pd = cl::compute_period_matrix(hol, /*reduce=*/true);
have_tau = std::isfinite(pd.tau.real())
&& std::isfinite(pd.tau.imag())
&& pd.tau.imag() > 0.0;
} catch (const std::exception& e) {
std::cerr << "[warn] period-matrix τ extraction failed: "
<< e.what() << "\n";
}
}
} else {
layout = cl::euclidean_layout(mesh, res.x, maps);
}
// Output
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
@@ -149,6 +200,13 @@ static int run_euclidean(ConformalMesh& mesh,
<< " iter=" << res.iterations
<< " |grad|_inf=" << std::scientific << std::setprecision(3)
<< res.grad_inf_norm << "\n";
if (have_tau) {
std::cout << std::fixed << std::setprecision(6)
<< " period ratio τ = " << pd.tau.real()
<< (pd.tau.imag() >= 0.0 ? " + " : " - ")
<< std::abs(pd.tau.imag()) << "i"
<< " (genus 1, reduced to fundamental domain)\n";
}
if (!out_layout.empty()) std::cout << " layout → " << out_layout << "\n";
if (!out_json.empty()) std::cout << " json → " << out_json << "\n";
if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\n";
@@ -164,6 +222,17 @@ static int run_spherical(ConformalMesh& mesh,
const std::string& out_xml,
bool verbose)
{
// Spherical uniformisation targets a closed genus-0 surface (sphere).
for (auto v : mesh.vertices())
if (mesh.is_border(v)) {
std::cerr << "Error: spherical mode needs a closed mesh; this mesh "
"has a boundary. Use '-g euclidean' for open meshes.\n";
return 1;
}
if (int g = cl::genus(mesh); g != 0)
std::cerr << "[warn] spherical uniformisation assumes genus 0; this mesh "
"has genus " << g << " — convergence is not guaranteed.\n";
auto maps = cl::setup_spherical_maps(mesh);
cl::compute_lambda0_from_mesh(mesh, maps);
int n = cl::assign_vertex_dof_indices(mesh, maps);