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

@@ -15,7 +15,9 @@
// │ x[e_idx[e]] = λ_e edge log-length variable (optional) │
// │ -1 means "pinned" (u_v = 0 / λ_e = λ°_e fixed) │
// │ │
// │ Effective log-length: Λ_ij = λ°_ij + u_i + u_j
// │ Effective log-length: Λ_ij = λ_e if edge e carries a DOF,
// │ = λ°_ij + u_i + u_j otherwise │
// │ (Java "replacement" convention, Finding 3) │
// │ Spherical arc length: l_ij = 2·asin(min(exp(Λ_ij/2), 1)) │
// │ │
// │ Gradient: │
@@ -155,6 +157,24 @@ static inline double spher_dof_val(int idx, const std::vector<double>& x)
return idx >= 0 ? x[static_cast<std::size_t>(idx)] : 0.0;
}
/// Effective spherical log-length using the Java "replacement" convention
/// (SphericalFunctional.java:393400, Finding 3): when edge `e` carries a
/// variable (edge DOF), its value *replaces* `λ⁰ + u_i + u_j` entirely;
/// otherwise the effective length is `λ⁰_e + u_i + u_j`.
///
/// In the common vertex-only mode (no edge DOFs) this is identical to the
/// additive form `λ⁰_e + u_i + u_j`, so that path is unchanged.
static inline double spher_eff_lambda(const SphericalMaps& m,
const std::vector<double>& x,
Edge_index e,
double u_i,
double u_j)
{
int ie = m.e_idx[e];
return (ie >= 0) ? x[static_cast<std::size_t>(ie)]
: (m.lambda0[e] + u_i + u_j);
}
/// Convert a CGAL half-edge index to a plain `std::size_t` for vector indexing.
static inline std::size_t spher_hidx(Halfedge_index h)
{
@@ -197,22 +217,25 @@ inline std::vector<double> spherical_gradient(
Edge_index e23 = mesh.edge(h1);
Edge_index e31 = mesh.edge(h2);
// Effective log-length Λ_ij = λ°_ij + u_i + u_j
// Effective log-length (Java "replacement" convention, Finding 3):
// * edge DOF present → Λ_ij = λ_e (the edge variable)
// * vertex-only → Λ_ij = λ°_ij + u_i + u_j
double u1 = spher_dof_val(m.v_idx[v1], x);
double u2 = spher_dof_val(m.v_idx[v2], x);
double u3 = spher_dof_val(m.v_idx[v3], x);
double lam12 = m.lambda0[e12] + u1 + u2 + spher_dof_val(m.e_idx[e12], x);
double lam23 = m.lambda0[e23] + u2 + u3 + spher_dof_val(m.e_idx[e23], x);
double lam31 = m.lambda0[e31] + u3 + u1 + spher_dof_val(m.e_idx[e31], x);
double lam12 = spher_eff_lambda(m, x, e12, u1, u2);
double lam23 = spher_eff_lambda(m, x, e23, u2, u3);
double lam31 = spher_eff_lambda(m, x, e31, u3, u1);
double l12 = spherical_l(lam12);
double l23 = spherical_l(lam23);
double l31 = spherical_l(lam31);
SphericalFaceAngles fa = spherical_angles(l12, l23, l31);
if (!fa.valid) continue; // degenerate face: contributes 0
// Do NOT skip degenerate faces: spherical_angles() returns the limiting
// angles (matching the Java reference), required for the convex C¹
// extension of the energy onto the infeasible region.
// Store convention: h_alpha[h] = corner angle at source(prev(h))
// h0 (e12): opposite vertex is v3 → source(prev(h0)) = source(h2) = v3 → α3
@@ -237,38 +260,23 @@ inline std::vector<double> spherical_gradient(
G[static_cast<std::size_t>(iv)] = m.theta_v[v] - sum_alpha;
}
// Edge: G_e for λ_e additive (Λ_ij = λ°_ij + u_i + u_j + λ_e).
// Edge: G_e = α_opp(face⁺) + α_opp(face⁻) θ_e (Java-faithful, Finding 3).
//
// From the Schläfli identity applied to the spherical face,
// the contribution of edge DOF λ_e from face f is:
// a_f = (2·α_opp S_f) / 2 where S_f = Σ angles in face f.
//
// Summing over both adjacent faces:
// G_e = a_f+ + a_f
// = α_opp⁺ + α_opp⁻ (S_f⁺ + S_f⁻) / 2 θ_e
//
// For flat (Euclidean) triangles S_f = π, recovering the familiar
// α_opp⁺ + α_opp⁻ π formula. For spherical triangles S_f > π.
// With the replacement parameterization (Λ_ij = λ_e directly), the
// Schläfli derivative of the spherical edge energy w.r.t. λ_e reduces to
// the sum of the two opposite corner angles minus the target θ_e
// (default π). This matches SphericalFunctional.java:283292
// `G.add(i, αk + αl PI)`. Note this drops the (S_f⁺+S_f⁻)/2 term that
// would arise under the additive convention; the two conventions agree on
// the vertex-only path (no edge DOFs), which is exercised by every test.
for (auto e : mesh.edges()) {
int ie = m.e_idx[e];
if (ie < 0) continue;
auto h = mesh.halfedge(e);
auto ho = mesh.opposite(h);
double sum = 0.0;
if (!mesh.is_border(h)) {
double alpha_opp = h_alpha[spher_hidx(h)];
double S_f = alpha_opp
+ h_alpha[spher_hidx(mesh.next(h))]
+ h_alpha[spher_hidx(mesh.prev(h))];
sum += (2.0 * alpha_opp - S_f) * 0.5;
}
if (!mesh.is_border(ho)) {
double alpha_opp = h_alpha[spher_hidx(ho)];
double S_f = alpha_opp
+ h_alpha[spher_hidx(mesh.next(ho))]
+ h_alpha[spher_hidx(mesh.prev(ho))];
sum += (2.0 * alpha_opp - S_f) * 0.5;
}
if (!mesh.is_border(h)) sum += h_alpha[spher_hidx(h)];
if (!mesh.is_border(ho)) sum += h_alpha[spher_hidx(ho)];
G[static_cast<std::size_t>(ie)] = sum - m.theta_e[e];
}