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:
@@ -474,6 +474,125 @@ inline void set_root_huv_2d(
|
||||
huv[static_cast<std::size_t>(hf.idx())] = uv[static_cast<std::size_t>(mesh.source(hf).idx())];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Euclidean holonomy via the developing map (genus-g closed surfaces).
|
||||
//
|
||||
// The per-vertex layout produced by euclidean_layout() places every face in a
|
||||
// SINGLE consistent global frame (each vertex is placed once). In that frame
|
||||
// the holonomy is identically trivial — it is exactly the obstruction to such a
|
||||
// single frame existing on the uncut surface. To recover it we develop every
|
||||
// face INDEPENDENTLY along a spanning tree of the dual graph that does not cross
|
||||
// any cut edge, storing each face's own copy of its three corner positions
|
||||
// (`hpos[h]` = global position of source(h) as developed inside face(h)).
|
||||
//
|
||||
// Two faces adjacent across a cut edge are NOT tree-adjacent, so they are
|
||||
// developed via different tree branches and place the shared edge at two
|
||||
// different locations. The rigid motion identifying those two copies is the
|
||||
// deck transformation of the generator loop (cut edge + tree path) — i.e. the
|
||||
// holonomy. For a flat cone metric (Θ ≡ 2π) the linear part is trivial, so the
|
||||
// holonomy is the pure translation given by the displacement of the shared
|
||||
// edge's midpoint between the two developments. That translation is exactly
|
||||
// the lattice generator ω consumed by compute_period_matrix().
|
||||
template <typename EdgeLenFn>
|
||||
inline std::vector<Eigen::Vector2d> euclidean_holonomy(
|
||||
const ConformalMesh& mesh,
|
||||
const CutGraph& cut,
|
||||
EdgeLenFn&& edge_len)
|
||||
{
|
||||
using C = std::complex<double>;
|
||||
const std::size_t nh = mesh.number_of_halfedges();
|
||||
const std::size_t nf = mesh.number_of_faces();
|
||||
|
||||
std::vector<C> hpos(nh, C(0.0, 0.0)); // pos of source(h) inside face(h)
|
||||
std::vector<bool> face_done(nf, false);
|
||||
|
||||
auto place_root = [&](Face_index f) {
|
||||
Halfedge_index h0 = mesh.halfedge(f);
|
||||
Halfedge_index h1 = mesh.next(h0), h2 = mesh.next(h1);
|
||||
double lAB = edge_len(h0), lBC = edge_len(h1), lCA = edge_len(h2);
|
||||
Eigen::Vector2d A(0.0, 0.0), B(lAB, 0.0);
|
||||
Eigen::Vector2d Cc = trilaterate_2d(A, B, lCA, lBC); // apex = source(h2)
|
||||
hpos[static_cast<std::size_t>(h0.idx())] = C(A.x(), A.y());
|
||||
hpos[static_cast<std::size_t>(h1.idx())] = C(B.x(), B.y());
|
||||
hpos[static_cast<std::size_t>(h2.idx())] = C(Cc.x(), Cc.y());
|
||||
face_done[static_cast<std::size_t>(f.idx())] = true;
|
||||
};
|
||||
|
||||
auto develop = [&](Face_index root) {
|
||||
place_root(root);
|
||||
std::queue<Halfedge_index> q; // halfedges pointing INTO unplaced faces
|
||||
auto enqueue = [&](Face_index f) {
|
||||
for (auto hf : CGAL::halfedges_around_face(mesh.halfedge(f), mesh)) {
|
||||
Halfedge_index ho = mesh.opposite(hf);
|
||||
if (mesh.is_border(ho)) continue;
|
||||
// Develop across the dual spanning tree T* ONLY. Crossing any
|
||||
// other edge (a cut/generator edge OR a primal-tree edge) would
|
||||
// over-connect the development: the surface minus the 2g cut
|
||||
// edges is still non-simply-connected, so the immersion would
|
||||
// wrap around and place the two copies of a generator edge on
|
||||
// top of each other (zero/garbage holonomy). Crossing only T*
|
||||
// unfolds the surface onto a disk (the fundamental polygon).
|
||||
if (!cut.is_dual_tree(mesh.edge(hf))) continue;
|
||||
Face_index fa = mesh.face(ho);
|
||||
if (!face_done[static_cast<std::size_t>(fa.idx())]) q.push(ho);
|
||||
}
|
||||
};
|
||||
enqueue(root);
|
||||
while (!q.empty()) {
|
||||
Halfedge_index h = q.front(); q.pop();
|
||||
Face_index f = mesh.face(h);
|
||||
if (face_done[static_cast<std::size_t>(f.idx())]) continue;
|
||||
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
// Shared edge endpoints, taken from the PARENT face's development:
|
||||
// source(h) = target(ho) → parent pos hpos[next(ho)]
|
||||
// target(h) = source(ho) → parent pos hpos[ho]
|
||||
C ps = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
|
||||
C pt = hpos[static_cast<std::size_t>(ho.idx())];
|
||||
Eigen::Vector2d A(ps.real(), ps.imag()), B(pt.real(), pt.imag());
|
||||
Eigen::Vector2d apex = trilaterate_2d(
|
||||
A, B, edge_len(mesh.prev(h)), edge_len(mesh.next(h)));
|
||||
|
||||
hpos[static_cast<std::size_t>(h.idx())] = ps;
|
||||
hpos[static_cast<std::size_t>(mesh.next(h).idx())] = pt;
|
||||
hpos[static_cast<std::size_t>(mesh.prev(h).idx())] = C(apex.x(), apex.y());
|
||||
face_done[static_cast<std::size_t>(f.idx())] = true;
|
||||
enqueue(f);
|
||||
}
|
||||
};
|
||||
|
||||
develop(best_root_face(mesh));
|
||||
for (auto f : mesh.faces())
|
||||
if (!face_done[static_cast<std::size_t>(f.idx())]) develop(f);
|
||||
|
||||
// ── Holonomy translation per cut edge ─────────────────────────────────────
|
||||
std::vector<Eigen::Vector2d> omega;
|
||||
omega.reserve(cut.cut_edge_indices.size());
|
||||
for (std::size_t ce : cut.cut_edge_indices) {
|
||||
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce));
|
||||
Halfedge_index h = mesh.halfedge(e);
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
if (mesh.is_border(h) || mesh.is_border(ho)
|
||||
|| !face_done[static_cast<std::size_t>(mesh.face(h).idx())]
|
||||
|| !face_done[static_cast<std::size_t>(mesh.face(ho).idx())]) {
|
||||
omega.push_back(Eigen::Vector2d::Zero());
|
||||
continue;
|
||||
}
|
||||
// Face A = face(h) places edge e as halfedge h: S=source(h), T=target(h)
|
||||
C As = hpos[static_cast<std::size_t>(h.idx())];
|
||||
C At = hpos[static_cast<std::size_t>(mesh.next(h).idx())];
|
||||
// Face B = face(ho) places the same edge as halfedge ho: source(ho)=T,
|
||||
// target(ho)=S → S=pos of source(next(ho)), T=pos of source(ho)
|
||||
C Bt = hpos[static_cast<std::size_t>(ho.idx())];
|
||||
C Bs = hpos[static_cast<std::size_t>(mesh.next(ho).idx())];
|
||||
C midA = 0.5 * (As + At);
|
||||
C midB = 0.5 * (Bs + Bt);
|
||||
C w = midA - midB; // pure translation for a flat cone metric
|
||||
omega.push_back(Eigen::Vector2d(w.real(), w.imag()));
|
||||
}
|
||||
return omega;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ── Euclidean layout ──────────────────────────────────────────────────────────
|
||||
@@ -592,25 +711,30 @@ inline Layout2D euclidean_layout(
|
||||
result.success = true;
|
||||
|
||||
// ── Holonomy ──────────────────────────────────────────────────────────────
|
||||
// The single-frame BFS layout above puts every face in one consistent frame,
|
||||
// so the holonomy read off it is identically trivial. Instead develop each
|
||||
// face independently along a dual spanning tree that never crosses a cut edge
|
||||
// (detail::euclidean_holonomy): the displacement of each cut edge between the
|
||||
// two faces that share it is the lattice generator ω_i.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
holonomy->translations.reserve(cut->cut_edge_indices.size());
|
||||
holonomy->mobius_maps.clear();
|
||||
holonomy->translations = detail::euclidean_holonomy(mesh, *cut, edge_len);
|
||||
|
||||
// Preserve the per-cut-edge seam UV in halfedge_uv (texture atlas), as
|
||||
// before, so HalfedgeUV-based tests still see the seam-crossing layout.
|
||||
for (std::size_t ce_idx : cut->cut_edge_indices) {
|
||||
Edge_index e = *std::next(mesh.edges().begin(), static_cast<std::ptrdiff_t>(ce_idx));
|
||||
Halfedge_index h = mesh.halfedge(e);
|
||||
Halfedge_index ho = mesh.opposite(h);
|
||||
Halfedge_index hx = mesh.is_border(ho) ? h : ho;
|
||||
if (mesh.is_border(hx)) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
|
||||
if (mesh.is_border(hx)) continue;
|
||||
Vertex_index vs = mesh.source(hx), vt = mesh.target(hx), vn = mesh.target(mesh.next(hx));
|
||||
if (!vertex_placed[vn.idx()]) { holonomy->translations.push_back(Eigen::Vector2d::Zero()); continue; }
|
||||
if (!vertex_placed[vn.idx()]) continue;
|
||||
Eigen::Vector2d p_tri = detail::trilaterate_2d(
|
||||
result.uv[vs.idx()], result.uv[vt.idx()],
|
||||
edge_len(mesh.prev(hx)), edge_len(mesh.next(hx)));
|
||||
// Store seam UV for the cut-crossing halfedges
|
||||
detail::set_face_huv_2d(result.halfedge_uv, mesh, hx, result.uv, p_tri);
|
||||
holonomy->translations.push_back(p_tri - result.uv[vn.idx()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,6 +831,15 @@ inline Layout3D spherical_layout(
|
||||
for (auto f : mesh.faces()) if (!face_placed[f.idx()]) place_component(f);
|
||||
result.success = true;
|
||||
|
||||
// KNOWN LIMITATION (latent — every current caller passes holonomy == nullptr).
|
||||
// This block extracts holonomy from a single full-surface development (the BFS
|
||||
// above crosses every non-cut edge), then reads off an apex trilateration on one
|
||||
// side of each seam. That is the same flawed pattern that produced garbage τ for
|
||||
// the Euclidean path; the correct approach is detail::euclidean_holonomy, which
|
||||
// develops across only the dual spanning tree (is_dual_tree) and measures the
|
||||
// shared-edge displacement between two independent developments. Until a
|
||||
// detail::spherical_holonomy mirror exists (Phase 9c/10, see research-track.md),
|
||||
// these spherical translations are not trustworthy for genus g ≥ 1.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
@@ -838,6 +971,16 @@ inline Layout2D hyper_ideal_layout(
|
||||
result.success = true;
|
||||
|
||||
// ── Möbius-map holonomy ───────────────────────────────────────────────────
|
||||
// KNOWN LIMITATION (latent — every current caller passes holonomy == nullptr).
|
||||
// Like the spherical block above, this reads the Möbius deck transformation from
|
||||
// a single full-surface development (BFS crosses all non-cut edges) and one-sided
|
||||
// apex trilateration — the same flawed pattern fixed for the Euclidean path by
|
||||
// detail::euclidean_holonomy (develop across the dual tree only, measure seam
|
||||
// displacement between two independent developments). A faithful
|
||||
// detail::hyperbolic_holonomy is Phase 9c/10 work and additionally requires
|
||||
// cpp_dec_float_50: products of these generators grow exponentially, so verifying
|
||||
// the group relation ∏gᵢ = Id overflows double (see CLAUDE.md, research-track.md).
|
||||
// Until then these mobius_maps are NOT correct for genus g ≥ 2 uniformization.
|
||||
if (cut && holonomy) {
|
||||
holonomy->cut_edge_indices = cut->cut_edge_indices;
|
||||
holonomy->translations.clear();
|
||||
|
||||
Reference in New Issue
Block a user