test(euclidean): Tier-1 circular-edge φ cross-validation + cyclic-solve blocker

Build-verified attempt to port the Java EuclideanCyclicConvergenceTest
(cathead, "circular hole edge" φ = π−0.1).

-  GREEN `CyclicCircularEdge_PhiEntersGradient_CatHead`: solver-free
  cross-validation that the circular-edge φ target enters the cyclic edge
  gradient exactly (ΔG_e = −Δφ_e at 1e-12; no other component moves).
- ⏸️ DISABLED `CyclicCircularEdge_CatHead_JavaXVal`: the full Java convergence
  assertion (α_opp+α_opp = π−0.1), kept with golden semantics. Auto-activates
  once the edge-DOF Hessian lands.

Build-verification finding: `newton_euclidean` -> `euclidean_hessian` throws
"edge DOFs are not supported" — the cyclic full solve is blocked by a missing
edge-DOF Euclidean Hessian (gradient supports edge DOFs, analytic Hessian does
not). Spherical convergence (Tier-1 #2) is already covered by test_newton_solver.
Documented in doc/reviewer/java-ignore-crossvalidation.md.

All 13 EuclideanFunctional tests pass (1 disabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-29 23:55:16 +02:00
committed by Tarik Moussa
parent 41ad4a84d2
commit 5d74a94b78
2 changed files with 211 additions and 0 deletions

View File

@@ -27,9 +27,12 @@
#include "euclidean_functional.hpp" #include "euclidean_functional.hpp"
#include "euclidean_hessian.hpp" #include "euclidean_hessian.hpp"
#include "clausen.hpp" #include "clausen.hpp"
#include "mesh_io.hpp"
#include "newton_solver.hpp"
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <cmath> #include <cmath>
#include <vector> #include <vector>
#include <string>
using namespace conformallab; using namespace conformallab;
@@ -405,3 +408,148 @@ TEST(EuclideanGoldenJava, FullMeshGradientAndEnergy_Tetrahedron)
- euclidean_energy(mesh, x0, maps); - euclidean_energy(mesh, x0, maps);
EXPECT_NEAR(dE, 0.15962619236187336, 1e-12); EXPECT_NEAR(dE, 0.15962619236187336, 1e-12);
} }
// ════════════════════════════════════════════════════════════════════════════
// Java cross-validation (Tier 1, GREEN) — circular-edge φ wiring (no solver)
//
// The "circular hole edge" of EuclideanCyclicConvergenceTest works by setting a
// non-default edge turn angle φ_e. Since the cyclic edge gradient is exactly
// G_e = α_opp(f⁺) + α_opp(f⁻) φ_e,
// lowering φ_e by 0.1 must raise G_e by exactly 0.1 — independent of geometry —
// and must leave every other gradient component untouched. This pins the φ
// wiring without needing the (not-yet-implemented) edge-DOF Hessian, so it runs
// today and is the evaluation-level prerequisite of the DISABLED convergence
// test below.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, CyclicCircularEdge_PhiEntersGradient_CatHead)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/cathead.obj";
ConformalMesh mesh;
ASSERT_NO_THROW(mesh = load_mesh(path)) << "cathead.obj not found: " << path;
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
int idx = 0;
for (auto v : mesh.vertices())
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
for (auto e : mesh.edges())
maps.e_idx[e] = idx++;
const int n = idx;
ASSERT_GT(n, 0);
Edge_index e_circ{};
bool found = false;
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
auto ho = mesh.opposite(h);
if (mesh.is_border(h) || mesh.is_border(ho)) continue;
e_circ = e; found = true; break;
}
ASSERT_TRUE(found) << "no interior edge found on cathead";
const std::size_t ie = static_cast<std::size_t>(maps.e_idx[e_circ]);
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
maps.phi_e[e_circ] = PI;
auto G1 = euclidean_gradient(mesh, x, maps);
maps.phi_e[e_circ] = PI - 0.1;
auto G2 = euclidean_gradient(mesh, x, maps);
// Lowering φ_e by 0.1 raises exactly this edge's gradient component by 0.1.
EXPECT_NEAR(0.1, G2[ie] - G1[ie], 1e-12)
<< "circular-edge φ target not wired into the cyclic gradient";
// No other gradient component changes.
double max_other = 0.0;
for (std::size_t k = 0; k < G1.size(); ++k)
if (k != ie) max_other = std::max(max_other, std::abs(G2[k] - G1[k]));
EXPECT_LT(max_other, 1e-12)
<< "changing one φ_e perturbed unrelated gradient components";
}
// ════════════════════════════════════════════════════════════════════════════
// Java cross-validation (Tier 1) — EuclideanCyclicConvergenceTest
//
// Ports de.varylab.discreteconformal.functional.EuclideanCyclicConvergenceTest:
// prescribe a non-default edge turn angle φ = π 0.1 on one interior edge of
// cathead.obj ("circular hole edge"), solve the cyclic Euclidean functional
// (vertex + edge DOFs), then assert the realised opposite-corner-angle sum
// across that edge equals π 0.1.
//
// The C++ edge gradient is G_e = α_opp(f⁺) + α_opp(f⁻) φ_e, so at the
// solution (G_e = 0) the geometric angle sum equals φ_e — exactly the Java
// assertion `circularEdge.getAlpha() + opposite.getAlpha() == π 0.1`.
//
// "Natural targets" first make x = 0 the equilibrium (so the *only* deviation
// is the prescribed φ); the test therefore FAILS if the solver ignores a
// non-default φ (the sum would stay at its natural value, not π 0.1).
//
// ⚠️ DISABLED (build-verified 2026-05-29): blocked by a missing feature, not a
// bug. `newton_euclidean` uses `euclidean_hessian`, which throws
// "euclidean_hessian: edge DOFs are not supported
// (only the vertex-block cotangent Laplacian is implemented)".
// The cyclic functional needs vertex+edge DOFs, so the full Newton solve is not
// yet possible in C++ (the gradient supports edge DOFs; the analytic Hessian
// does not). PREREQUISITE: edge-DOF Euclidean Hessian — see
// `doc/roadmap/research-track.md` and `doc/reviewer/java-ignore-crossvalidation.md`.
// The golden semantics (φ = π0.1 ⇒ realised α_opp+α_opp = π0.1) are kept here
// so this test auto-activates once that Hessian lands; just drop the DISABLED_.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanFunctional, DISABLED_CyclicCircularEdge_CatHead_JavaXVal)
{
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/cathead.obj";
ConformalMesh mesh;
ASSERT_NO_THROW(mesh = load_mesh(path)) << "cathead.obj not found: " << path;
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Cyclic DOFs: interior vertices (border pinned) + all edges.
int idx = 0;
for (auto v : mesh.vertices())
maps.v_idx[v] = mesh.is_border(v) ? -1 : idx++;
for (auto e : mesh.edges())
maps.e_idx[e] = idx++;
const int n = idx;
ASSERT_GT(n, 0);
// Natural targets: set Θ_v / φ_e so that x = 0 is the equilibrium (G(0)=0).
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = euclidean_gradient(mesh, x0, maps);
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0) maps.theta_v[v] -= G0[static_cast<std::size_t>(iv)];
}
for (auto e : mesh.edges()) {
int ie = maps.e_idx[e];
if (ie >= 0) maps.phi_e[e] += G0[static_cast<std::size_t>(ie)];
}
// Pick one interior edge: both incident faces present, both endpoints interior.
Edge_index circular{};
bool found = false;
for (auto e : mesh.edges()) {
auto h = mesh.halfedge(e);
auto ho = mesh.opposite(h);
if (mesh.is_border(h) || mesh.is_border(ho)) continue;
if (mesh.is_border(mesh.source(h)) || mesh.is_border(mesh.target(h))) continue;
circular = e; found = true; break;
}
ASSERT_TRUE(found) << "no interior edge found on cathead";
const std::size_t ie = static_cast<std::size_t>(maps.e_idx[circular]);
// Prescribe the circular edge turn angle φ = π 0.1 (Java CustomEdgeInfo.phi).
const double phi_target = PI - 0.1;
maps.phi_e[circular] = phi_target;
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-11, /*max_iter=*/200);
ASSERT_TRUE(res.converged)
<< "Newton did not converge; ||G||=" << res.grad_inf_norm;
// Realised geometric opposite-corner-angle sum = φ_e + G_e(x*) (= α_opp+α_opp).
auto Gf = euclidean_gradient(mesh, res.x, maps);
const double realised = maps.phi_e[circular] + Gf[ie];
EXPECT_NEAR(phi_target, realised, 1e-9)
<< "prescribed circular edge turn angle π0.1 not realised at the solution";
}

View File

@@ -93,6 +93,69 @@ Assertion strategy: because the values are symmetric per DOF-class, assert
--- ---
## 3b — Effort-adjusted tiering (2026-05-29 inventory)
> A broader scan (not just `@Ignore`d tests) revealed that the §3 ranking was
> **effort-blind**: cheaper, equally-valuable oracles exist than the Lawson
> generator. Re-tiered by **value / cost**:
| Tier | Oracle | Mesh / asset | Solver | Golden | C++ status | Cost |
|---|---|---|---|---|---|---|
| **1** | `EuclideanCyclicConvergenceTest` | `cathead.obj` *(already in C++)* | MTJ BiCGstab (no PETSc) | `α_opp+α_opp = π0.1` @1e-12 (per-edge φ target) | `euclidean_functional` ✅, `phi_e` ✅, cathead ✅ | **near-zero** |
| **1** | `SphericalConvergenceTest` | octahedron | MTJ Newton (no PETSc) | self-consistency (const. curvature) | spherical ✅, `make_octahedron_face` ✅ | low |
| **2** | Wente genus-1 uniformization | `wente_torus02.obj` + `wente_uniformization.xml` | — (stored result) | exact `IsometryPSL2R` uniformizing-group generators | holonomy/`MobiusMap` ✅; **needs XML reader** | medium |
| **2** | `regular_uniformization.xml` | (stored) | — | uniformization data | needs XML reader | medium |
| **3** | HyperIdeal Lawson convergence (3 golden vectors) | combinatorial `createLawsonSquareTiled` | — (hard-coded u\*) | symmetric u\* | needs **low-level half-edge generator** (§5) | high |
| **4** | genus-2 (`genus2.xml`/`lawson2498.obj`), holomorphic forms (`HolomorphicEuclideanUniformization_*.xml`), Schottky (`genus2.xml`), hyperelliptic | assets present in Java | — | various | Phases 10a/10c/11/13 | future |
**Key correction:** the Lawson generator (originally flagged HIGH) is actually
**Tier 3** — higher effort than Tier 1/2. Cheapest first wins:
1. **Tier 1 — `EuclideanCyclicConvergenceTest` on cathead** (asset already
present; the C++ edge gradient `G_e = α_opp(f⁺)+α_opp(f⁻) φ_e` makes the
Java assertion `α_opp+α_opp = π0.1` exactly the converged `G_e = 0`
condition with `phi_e = π0.1`). Fails if the solver ignores a non-default
`φ`, so it is a genuine check, not a tautology.
2. **Tier 1 — `SphericalConvergenceTest`** on the octahedron.
3. **Tier 2 — Wente genus-1 uniformization** (needs a small `UniformizationData`
XML reader; strongest deterministic pipeline oracle that maps to landed C++).
4. **Tier 3 — Lawson generator** (only after Tiers 12).
### Build-verification finding (2026-05-29) — Tier 1 cyclic is blocked
Attempting the Tier-1 `EuclideanCyclicConvergenceTest` port **build-verified** a
blocker: `newton_euclidean` calls `euclidean_hessian`, which throws
*"edge DOFs are not supported (only the vertex-block cotangent Laplacian is
implemented)"*. The cyclic functional needs **vertex + edge** DOFs, so the full
Newton solve is **not yet possible** in C++ — the *gradient* supports edge DOFs,
the *analytic Hessian* does not. (This is also why PR #27 cross-validated only
the cyclic *evaluation*, never a solve.)
**Landed in this PR instead** (`test_euclidean_functional.cpp`):
-`CyclicCircularEdge_PhiEntersGradient_CatHead` (**GREEN**) — solver-free
cross-validation that the circular-edge φ target enters the cyclic gradient
exactly (`ΔG_e = Δφ_e` at 1e-12, no other component moves). This is the
evaluation-level prerequisite of the convergence oracle.
- ⏸️ `DISABLED_CyclicCircularEdge_CatHead_JavaXVal` — the full Java convergence
assertion (`α_opp+α_opp = π0.1`), kept in-code with the golden semantics; it
auto-activates (drop `DISABLED_`) once the edge-DOF Hessian lands.
**New prerequisite for Tier-1 cyclic:** an **edge-DOF Euclidean Hessian** (the
cyclic Hessian over vertex + edge variables, cf. Java `getNonZeroPattern`). Spherical
convergence (Tier-1 #2) turned out to be **already covered** by
`test_newton_solver` (`Spherical_ConvergesFromPerturbation` et al. on the
spherical tetrahedron), and `make_octahedron_face()` is a single face, not a
closed octahedron — so Tier-1 #2 adds little. → the next genuinely-new
cross-validation target is **Tier 2 (Wente uniformization XML)** or implementing
the edge-DOF Hessian to unblock the cyclic convergence oracle.
### Other generators in the Java tree
`HyperellipticCurveGenerator` (→ Phase 13), `SchottkyGenerator` (→ Phase 11a),
`FunctionalTest.createOctahedron/createTetrahedron` (trivial — C++ has
`make_octahedron_face` / `make_tetrahedron`).
---
## 4 — Recommended order of work ## 4 — Recommended order of work
1. **Port `make_lawson_square_tiled()`** (low-level half-edge; see §5) → 1. **Port `make_lawson_square_tiled()`** (low-level half-edge; see §5) →