From 0c502536b9a7ea5515547525bc9a36b5b3213cea Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Sat, 30 May 2026 01:06:35 +0200 Subject: [PATCH 1/2] test(hyperideal): Tier-3 Lawson square-tiled golden-vector Java cross-validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the Java HyperIdealConvergenceTest (Lawson square-tiled) — the strongest remaining @Ignore'd oracle (a hard-coded converged solution from a historical x86 PETSc run). - make_lawson_square_tiled(): builds the genus-2 base (4 vertices, 12 edges, 6 quads) via the low-level CGAL Surface_mesh half-edge API (add_edge + set_target/set_next/set_face/set_halfedge), since the multi-edges (≥2 edges per vertex pair) make add_face / OFF / polygon-soup impossible. Then triangulate_faces → 12 triangles, 18 edges (12 original + 6 diagonals). BuildsValidGenus2Mesh: is_valid + V=4/F=12/E=18 + χ=−2. - ConvergenceGoldenVector_JavaXVal: Θ_v=2π, θ_e=π/2 (12 original edges), θ_e=π (6 diagonals); newton_hyper_ideal converges (from x0=1.0, unconstrained) to the Java golden vector: vertices → 1.1462158341786262 original → 1.7627471737467797 aux → 2.633915794495759 asserted per symmetry class @1e-5 (robust to DOF ordering). The perfect symmetry of the golden vector means any consistent one-diagonal triangulation reproduces the three values, so the external jtem Triangulator choice need not be replicated. Resolves the Tier-3 item from PR #29's analysis. 242/242 cgal tests pass. Co-Authored-By: Claude Opus 4.8 --- code/tests/cgal/CMakeLists.txt | 4 + code/tests/cgal/test_lawson_hyperideal.cpp | 164 +++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 code/tests/cgal/test_lawson_hyperideal.cpp diff --git a/code/tests/cgal/CMakeLists.txt b/code/tests/cgal/CMakeLists.txt index 4c95f99..7e0b45e 100644 --- a/code/tests/cgal/CMakeLists.txt +++ b/code/tests/cgal/CMakeLists.txt @@ -86,6 +86,10 @@ add_executable(conformallab_cgal_tests # newton_inversive_distance (FD Hessian). test_newton_phase9a.cpp + # ── Tier-3 Java cross-validation: Lawson square-tiled HyperIdeal ───────── + # Low-level half-edge genus-2 generator + golden-vector convergence. + test_lawson_hyperideal.cpp + # ── Phase 8b-Lite: CGAL entry wrappers for the 4 non-Euclidean modes ───── # Spherical, HyperIdeal, CircleP-Euclidean, Inversive-Distance via # public API + Conformal_layout.h wrapper. diff --git a/code/tests/cgal/test_lawson_hyperideal.cpp b/code/tests/cgal/test_lawson_hyperideal.cpp new file mode 100644 index 0000000..3e8b3f1 --- /dev/null +++ b/code/tests/cgal/test_lawson_hyperideal.cpp @@ -0,0 +1,164 @@ +// Copyright (c) 2024-2026 Tarik Moussa. +// SPDX-License-Identifier: MIT + +// test_lawson_hyperideal.cpp +// +// Tier-3 Java cross-validation: HyperIdealConvergenceTest (Lawson square-tiled). +// +// Ports de.varylab.discreteconformal.functional.HyperIdealGenerator +// .createLawsonSquareTiled() — a genus-2 surface with 4 vertices and 12 edges +// (so it has MULTI-EDGES: CGAL add_face/OFF cannot build it; we use the +// low-level Surface_mesh half-edge API directly), triangulated to 12 faces. +// +// Step A (this commit): build the mesh, verify is_valid + V/E/F + genus 2. + +#include "conformal_mesh.hpp" +#include "hyper_ideal_functional.hpp" +#include "newton_solver.hpp" +#include +#include +#include +#include +#include +#include + +using namespace conformallab; + +namespace { + +// Build the Lawson square-tiled base (4 vertices, 12 edges, 6 quad faces, +// genus 2) via the low-level half-edge API, then triangulate. Returns the +// mesh; `original_edges` receives the 12 pre-triangulation edges (the 6 added +// diagonals are the "aux" edges). +ConformalMesh make_lawson_square_tiled(std::set* original_edges = nullptr) +{ + ConformalMesh m; + + // 4 vertices A,B,C,D (positions are dummy — only combinatorics matter). + std::array V = { + m.add_vertex(Point3(0, 0, 0)), // A + m.add_vertex(Point3(1, 0, 0)), // B + m.add_vertex(Point3(0, 1, 0)), // C + m.add_vertex(Point3(0, 0, 1)) // D + }; + + // Java half-edge target vertices (A=0,B=1,C=2,D=3), indices 0..23. + const int tgt[24] = { + 0,1,3,2, 1,0,2,3, 3,2,0,1, 2,3,1,0, 0,1,3,2, 1,0,2,3 + }; + // Java opposite-edge pairings (linkOppositeEdge). + const int pairs[12][2] = { + {0,6},{1,21},{2,4},{3,23},{5,11},{7,9}, + {8,14},{10,12},{13,19},{15,17},{16,22},{18,20} + }; + // Java next-edge cycles → the 6 quad faces. + const int cyc[6][4] = { + {0,1,2,3},{4,5,6,7},{8,9,10,11},{12,13,14,15},{16,17,18,19},{20,21,22,23} + }; + + // Allocate 12 edges; map Java half-edge id → CGAL Halfedge_index. + std::array he; + for (const auto& p : pairs) { + Halfedge_index h = m.add_edge(); + he[static_cast(p[0])] = h; + he[static_cast(p[1])] = m.opposite(h); + } + // Targets. + for (int j = 0; j < 24; ++j) + m.set_target(he[static_cast(j)], V[static_cast(tgt[j])]); + // Faces + next links. + for (const auto& c : cyc) { + Face_index f = m.add_face(); + m.set_halfedge(f, he[static_cast(c[0])]); + for (int k = 0; k < 4; ++k) { + m.set_face(he[static_cast(c[k])], f); + m.set_next(he[static_cast(c[k])], + he[static_cast(c[(k + 1) % 4])]); + } + } + // Each vertex points to one incoming half-edge. + for (int j = 0; j < 24; ++j) + m.set_halfedge(V[static_cast(tgt[j])], he[static_cast(j)]); + + if (original_edges) { + original_edges->clear(); + for (auto e : m.edges()) original_edges->insert(e); + } + + // Triangulate the 6 quads → 12 triangles (adds 6 diagonal "aux" edges). + CGAL::Polygon_mesh_processing::triangulate_faces(m); + + return m; +} + +} // namespace + +TEST(LawsonHyperIdeal, BuildsValidGenus2Mesh) +{ + std::set original; + ConformalMesh m = make_lawson_square_tiled(&original); + + EXPECT_TRUE(m.is_valid(false)) << "Lawson square-tiled mesh is not a valid halfedge structure"; + EXPECT_EQ(m.number_of_vertices(), 4u); + EXPECT_EQ(m.number_of_faces(), 12u); // 6 quads → 12 triangles + EXPECT_EQ(m.number_of_edges(), 18u); // 12 original + 6 diagonals + EXPECT_EQ(original.size(), 12u); + + // Euler characteristic χ = V − E + F = 4 − 18 + 12 = −2 ⇒ genus 2. + const int chi = static_cast(m.number_of_vertices()) + - static_cast(m.number_of_edges()) + + static_cast(m.number_of_faces()); + EXPECT_EQ(chi, -2) << "expected genus 2 (χ = −2), got χ = " << chi; +} + +// ════════════════════════════════════════════════════════════════════════════ +// Golden-vector cross-validation against Java HyperIdealConvergenceTest +// +// Java sets Θ_v = 2π (vertices), θ_e = π/2 (the 12 original edges) and θ_e = π +// (the 6 triangulation/aux edges), then solves with TAO/BLMVM and asserts the +// converged solution vector. The solution is perfectly symmetric: +// vertices (×4) → 1.1462158341786262 +// original edges (×12) → 1.7627471737467797 +// aux/diagonal edges (×6) → 2.633915794495759 +// We assert membership in these three classes (robust to DOF ordering, which +// differs between Java and CGAL). +// ════════════════════════════════════════════════════════════════════════════ +TEST(LawsonHyperIdeal, ConvergenceGoldenVector_JavaXVal) +{ + std::set original; + ConformalMesh m = make_lawson_square_tiled(&original); + + HyperIdealMaps maps = setup_hyper_ideal_maps(m); // Θ_v=2π, θ_e=π + const int n = assign_all_dof_indices(m, maps); // all vertices + edges + ASSERT_EQ(n, 4 + 18); // 4 b + 18 a = 22 DOFs + + // θ_e = π/2 for the 12 original edges; the 6 diagonals keep the default π. + for (auto e : m.edges()) + if (original.count(e)) maps.theta_e[e] = PI / 2.0; + + // Solve from a positive interior point (HyperIdeal variables b,a > 0). + std::vector x0(static_cast(n), 1.0); + auto res = newton_hyper_ideal(m, x0, maps, /*tol=*/1e-10, /*max_iter=*/200); + ASSERT_TRUE(res.converged) + << "HyperIdeal Newton did not converge; ||G||=" << res.grad_inf_norm; + + constexpr double b_gold = 1.1462158341786262; + constexpr double a_orig_gold = 1.7627471737467797; + constexpr double a_aux_gold = 2.633915794495759; + const double tol = 1e-5; + + for (auto v : m.vertices()) { + const int iv = maps.v_idx[v]; + ASSERT_GE(iv, 0); + EXPECT_NEAR(res.x[static_cast(iv)], b_gold, tol) + << "vertex DOF " << iv << " off golden b"; + } + for (auto e : m.edges()) { + const int ie = maps.e_idx[e]; + ASSERT_GE(ie, 0); + const double gold = original.count(e) ? a_orig_gold : a_aux_gold; + EXPECT_NEAR(res.x[static_cast(ie)], gold, tol) + << "edge DOF " << ie << " off golden a (" + << (original.count(e) ? "original π/2" : "aux π") << ")"; + } +} From 5de95a7a93c47d8e8f7d0f0a00f342eab7c96d4c Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Sat, 30 May 2026 01:14:46 +0200 Subject: [PATCH 2/2] test(hyperideal): add Lawson branch-points golden-vector cross-validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Lawson variant from HyperIdealConvergenceTest...WithBranchPoints. - make_lawson_branch_points(): base + STELLAR subdivision (Java StellarLinear) via CGAL Euler::add_center_vertex — a centre vertex per quad fan-connected to its 4 corners (6 centres, 24 triangles, 36 edges). The 6 centres are IDEAL vertices (b=0, v_idx=-1); θ_e=π on the 12 base edges, θ_e=π/2 on the 24 spokes. - BranchPointsGoldenVector_JavaXVal: newton_hyper_ideal converges to the Java golden (per symmetry class @1e-4): original vertices → 1.3169579 base edges → 2.2924317 spoke edges → 0 (the π/2 spokes collapse to ideal) Key insight: Java's index-based θ split (first 12 edges π, rest π/2) is geometric — base edges vs stellar spokes — so the symmetry shortcut applies. createLawsonHyperelliptic() NOT ported: needs a reader for the Java conformal-data XML (lawson_curve_source.xml) + a port of HyperIdealHyperellipticUtility, and its golden vector is not class-symmetric. Documented as a deferred task. 243/243 cgal tests pass. Co-Authored-By: Claude Opus 4.8 --- code/tests/cgal/test_lawson_hyperideal.cpp | 141 +++++++++++++++++---- 1 file changed, 117 insertions(+), 24 deletions(-) diff --git a/code/tests/cgal/test_lawson_hyperideal.cpp b/code/tests/cgal/test_lawson_hyperideal.cpp index 3e8b3f1..c6e45e6 100644 --- a/code/tests/cgal/test_lawson_hyperideal.cpp +++ b/code/tests/cgal/test_lawson_hyperideal.cpp @@ -5,17 +5,28 @@ // // Tier-3 Java cross-validation: HyperIdealConvergenceTest (Lawson square-tiled). // -// Ports de.varylab.discreteconformal.functional.HyperIdealGenerator -// .createLawsonSquareTiled() — a genus-2 surface with 4 vertices and 12 edges -// (so it has MULTI-EDGES: CGAL add_face/OFF cannot build it; we use the -// low-level Surface_mesh half-edge API directly), triangulated to 12 faces. +// Ports de.varylab.discreteconformal.functional.HyperIdealGenerator. The base is +// a genus-2 surface with 4 vertices and 12 edges → MULTI-EDGES (≥2 edges per +// vertex pair), so CGAL add_face/OFF/polygon-soup cannot build it; we use the +// low-level Surface_mesh half-edge API directly. // -// Step A (this commit): build the mesh, verify is_valid + V/E/F + genus 2. +// Variants ported (vs HyperIdealConvergenceTest golden vectors): +// 1. createLawsonSquareTiled() → diagonal triangulation +// 2. createLawsonSquareTiledWithBranchPoints() → stellar subdivision + ideal +// branch-point centres +// +// NOT ported — createLawsonHyperelliptic(): loads `lawson_curve_source.xml` +// (a Java conformal-data HalfedgeEmbedding, not OBJ/OFF) and derives θ via +// HyperIdealHyperellipticUtility.calculateCircleIntersections. It needs a reader +// for that XML format + a port of that utility, and its golden vector is not +// class-symmetric (mixed ±values), so the symmetry shortcut does not apply. +// Deferred as its own task; see doc/reviewer/java-ignore-crossvalidation.md. #include "conformal_mesh.hpp" #include "hyper_ideal_functional.hpp" #include "newton_solver.hpp" #include +#include #include #include #include @@ -26,16 +37,13 @@ using namespace conformallab; namespace { -// Build the Lawson square-tiled base (4 vertices, 12 edges, 6 quad faces, -// genus 2) via the low-level half-edge API, then triangulate. Returns the -// mesh; `original_edges` receives the 12 pre-triangulation edges (the 6 added -// diagonals are the "aux" edges). -ConformalMesh make_lawson_square_tiled(std::set* original_edges = nullptr) +// Build the Lawson square-tiled BASE (4 vertices, 12 edges, 6 quad faces, +// genus 2) via the low-level half-edge API — multi-edges (≥2 edges per vertex +// pair) make add_face/OFF/polygon-soup impossible. Returns the 4 original +// vertices in `V`. Not yet triangulated. +void build_lawson_base(ConformalMesh& m, std::array& V) { - ConformalMesh m; - - // 4 vertices A,B,C,D (positions are dummy — only combinatorics matter). - std::array V = { + V = { m.add_vertex(Point3(0, 0, 0)), // A m.add_vertex(Point3(1, 0, 0)), // B m.add_vertex(Point3(0, 1, 0)), // C @@ -46,27 +54,22 @@ ConformalMesh make_lawson_square_tiled(std::set* original_edges = nu const int tgt[24] = { 0,1,3,2, 1,0,2,3, 3,2,0,1, 2,3,1,0, 0,1,3,2, 1,0,2,3 }; - // Java opposite-edge pairings (linkOppositeEdge). - const int pairs[12][2] = { + const int pairs[12][2] = { // Java linkOppositeEdge {0,6},{1,21},{2,4},{3,23},{5,11},{7,9}, {8,14},{10,12},{13,19},{15,17},{16,22},{18,20} }; - // Java next-edge cycles → the 6 quad faces. - const int cyc[6][4] = { + const int cyc[6][4] = { // Java next-edge cycles → 6 quads {0,1,2,3},{4,5,6,7},{8,9,10,11},{12,13,14,15},{16,17,18,19},{20,21,22,23} }; - // Allocate 12 edges; map Java half-edge id → CGAL Halfedge_index. std::array he; for (const auto& p : pairs) { Halfedge_index h = m.add_edge(); he[static_cast(p[0])] = h; he[static_cast(p[1])] = m.opposite(h); } - // Targets. for (int j = 0; j < 24; ++j) m.set_target(he[static_cast(j)], V[static_cast(tgt[j])]); - // Faces + next links. for (const auto& c : cyc) { Face_index f = m.add_face(); m.set_halfedge(f, he[static_cast(c[0])]); @@ -76,17 +79,45 @@ ConformalMesh make_lawson_square_tiled(std::set* original_edges = nu he[static_cast(c[(k + 1) % 4])]); } } - // Each vertex points to one incoming half-edge. for (int j = 0; j < 24; ++j) m.set_halfedge(V[static_cast(tgt[j])], he[static_cast(j)]); +} + +// Plain Lawson: base + diagonal triangulation (6 quads → 12 triangles). +// `original_edges` receives the 12 base edges (the 6 diagonals are "aux"). +ConformalMesh make_lawson_square_tiled(std::set* original_edges = nullptr) +{ + ConformalMesh m; + std::array V; + build_lawson_base(m, V); if (original_edges) { original_edges->clear(); for (auto e : m.edges()) original_edges->insert(e); } - - // Triangulate the 6 quads → 12 triangles (adds 6 diagonal "aux" edges). CGAL::Polygon_mesh_processing::triangulate_faces(m); + return m; +} + +// Branch-points Lawson: base + STELLAR subdivision (Java StellarLinear) — a +// center vertex per quad fan-connected to its 4 corners (6 ideal "branch" +// centers, 24 triangles, 36 edges). `orig_verts` = the 4 real vertices; +// `base_edges` = the 12 base edges (θ=π); the 24 spokes are θ=π/2. +ConformalMesh make_lawson_branch_points(std::array& orig_verts, + std::set& base_edges) +{ + ConformalMesh m; + build_lawson_base(m, orig_verts); + + base_edges.clear(); + for (auto e : m.edges()) base_edges.insert(e); + + // Stellar-subdivide each of the 6 quads (collect halfedges first; the loop + // mutates the mesh). + std::vector face_he; + for (auto f : m.faces()) face_he.push_back(m.halfedge(f)); + for (auto h : face_he) + CGAL::Euler::add_center_vertex(h, m); return m; } @@ -162,3 +193,65 @@ TEST(LawsonHyperIdeal, ConvergenceGoldenVector_JavaXVal) << (original.count(e) ? "original π/2" : "aux π") << ")"; } } + +// ════════════════════════════════════════════════════════════════════════════ +// Branch-points variant — Java HyperIdealConvergenceTest...WithBranchPoints +// +// Java applies StellarLinear (stellar subdivision: a center vertex per quad) to +// the base, makes the 4 original vertices variable and the 6 centers IDEAL +// (b = 0, solver index −1), with Θ_v = 2π, θ_e = π on the 12 base edges and +// θ_e = π/2 on the 24 spokes. The converged solution (symmetric per class): +// original vertices (×4) → 1.3169579… +// base edges (×12) → 2.2924317… +// spoke edges (×24) → 0 (the π/2 spokes collapse to ideal) +// ════════════════════════════════════════════════════════════════════════════ +TEST(LawsonHyperIdeal, BranchPointsGoldenVector_JavaXVal) +{ + std::array orig; + std::set base; + ConformalMesh m = make_lawson_branch_points(orig, base); + + EXPECT_TRUE(m.is_valid(false)); + EXPECT_EQ(m.number_of_vertices(), 10u); // 4 original + 6 stellar centers + EXPECT_EQ(m.number_of_faces(), 24u); // 6 quads × 4 + EXPECT_EQ(m.number_of_edges(), 36u); // 12 base + 24 spokes + EXPECT_EQ(base.size(), 12u); + + HyperIdealMaps maps = setup_hyper_ideal_maps(m); // Θ_v=2π, θ_e=π + const std::set origset(orig.begin(), orig.end()); + + // 4 original vertices variable; 6 centers ideal (−1); all 36 edges variable. + int idx = 0; + for (auto v : m.vertices()) maps.v_idx[v] = origset.count(v) ? idx++ : -1; + for (auto e : m.edges()) maps.e_idx[e] = idx++; + const int n = idx; + ASSERT_EQ(n, 4 + 36); + + // θ_e = π/2 on the 24 spokes (base edges keep the default π). + for (auto e : m.edges()) + if (!base.count(e)) maps.theta_e[e] = PI / 2.0; + + std::vector x0(static_cast(n), 1.0); + auto res = newton_hyper_ideal(m, x0, maps, /*tol=*/1e-10, /*max_iter=*/300); + ASSERT_TRUE(res.converged) + << "branch-points Newton did not converge; ||G||=" << res.grad_inf_norm; + + constexpr double b_gold = 1.3169579; + constexpr double a_base_gold = 2.2924317; + constexpr double a_spoke_gold = 0.0; + const double tol = 1e-4; // golden per-class spread is ~1e-7 + + for (auto v : m.vertices()) { + const int iv = maps.v_idx[v]; + if (iv < 0) continue; // ideal centers + EXPECT_NEAR(res.x[static_cast(iv)], b_gold, tol) + << "branch-points vertex DOF " << iv << " off golden b"; + } + for (auto e : m.edges()) { + const int ie = maps.e_idx[e]; + const double gold = base.count(e) ? a_base_gold : a_spoke_gold; + EXPECT_NEAR(res.x[static_cast(ie)], gold, tol) + << "branch-points edge DOF " << ie << " off golden a (" + << (base.count(e) ? "base π" : "spoke π/2") << ")"; + } +}