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:
committed by
Tarik Moussa
parent
41ad4a84d2
commit
5d74a94b78
@@ -27,9 +27,12 @@
|
||||
#include "euclidean_functional.hpp"
|
||||
#include "euclidean_hessian.hpp"
|
||||
#include "clausen.hpp"
|
||||
#include "mesh_io.hpp"
|
||||
#include "newton_solver.hpp"
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
using namespace conformallab;
|
||||
|
||||
@@ -405,3 +408,148 @@ TEST(EuclideanGoldenJava, FullMeshGradientAndEnergy_Tetrahedron)
|
||||
- euclidean_energy(mesh, x0, maps);
|
||||
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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user