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
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:
@@ -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:393–400, 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:283–292
|
||||
// `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];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user