feat(euclidean): block-FD edge-DOF Hessian → cyclic Newton + Java convergence oracle
Implements the edge-DOF (cyclic) Euclidean Hessian, unblocking the full cyclic
Newton solve, and enables the Java EuclideanCyclicConvergenceTest cross-validation.
- euclidean_hessian.hpp: `euclidean_hessian_block_fd` / `_sym` — per-face 6×6
block FD over (u1,u2,u3,λ12,λ23,λ31), mirroring hyper_ideal_hessian_block_fd.
Per-face outputs carry the gradient signs (−α vertex, +α_opp edge), so the
result equals ∂G/∂x by construction (locality lemma). Analytic vertex-only
cotangent Hessian unchanged (still used for vertex-only layouts).
- newton_solver.hpp: newton_euclidean routes cyclic layouts (edge DOFs present)
through the block-FD Hessian; vertex-only path unchanged.
- tests:
* CyclicCircularEdge_CatHead_JavaXVal (now GREEN) — prescribe φ=π−0.1 on one
interior edge, solve, assert realised α_opp+α_opp = π−0.1 @1e-9.
* CyclicCircularEdge_PhiEntersGradient_CatHead — solver-free φ-wiring check.
* CyclicHessian_BlockFD_MatchesGradientFD_Tetrahedron — Hessian correctness.
243/243 cgal tests pass; vertex-only Euclidean Newton unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
Tarik Moussa
parent
5d74a94b78
commit
ea5f01d73d
@@ -485,18 +485,10 @@ TEST(EuclideanFunctional, CyclicCircularEdge_PhiEntersGradient_CatHead)
|
||||
// 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_.
|
||||
// Enabled 2026-05-30: `newton_euclidean` now uses the block-FD edge-DOF Hessian
|
||||
// (`euclidean_hessian_block_fd_sym`) for cyclic layouts, so the full solve runs.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
TEST(EuclideanFunctional, DISABLED_CyclicCircularEdge_CatHead_JavaXVal)
|
||||
TEST(EuclideanFunctional, CyclicCircularEdge_CatHead_JavaXVal)
|
||||
{
|
||||
const std::string path = std::string(CONFORMALLAB_DATA_DIR) + "/obj/cathead.obj";
|
||||
ConformalMesh mesh;
|
||||
@@ -505,26 +497,13 @@ TEST(EuclideanFunctional, DISABLED_CyclicCircularEdge_CatHead_JavaXVal)
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// Cyclic DOFs: interior vertices (border pinned) + all edges.
|
||||
// DOFs: interior vertices (border pinned) + exactly ONE edge DOF on the
|
||||
// "circular" edge (Java marks a single circularHoleEdge). Giving every edge
|
||||
// a DOF would make λ_e redundant with u_i+u_j (a V-dim null space) and stall
|
||||
// Newton; the single extra edge variable keeps the system well-posed.
|
||||
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{};
|
||||
@@ -537,8 +516,21 @@ TEST(EuclideanFunctional, DISABLED_CyclicCircularEdge_CatHead_JavaXVal)
|
||||
circular = e; found = true; break;
|
||||
}
|
||||
ASSERT_TRUE(found) << "no interior edge found on cathead";
|
||||
maps.e_idx[circular] = idx++; // the single circular-edge DOF
|
||||
const int n = idx;
|
||||
ASSERT_GT(n, 0);
|
||||
const std::size_t ie = static_cast<std::size_t>(maps.e_idx[circular]);
|
||||
|
||||
// Natural targets: set Θ_v / φ_e so that x = 0 is the equilibrium (G(0)=0),
|
||||
// so the only deviation is the prescribed circular φ below.
|
||||
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)];
|
||||
}
|
||||
maps.phi_e[circular] += G0[ie];
|
||||
|
||||
// Prescribe the circular edge turn angle φ = π − 0.1 (Java CustomEdgeInfo.phi).
|
||||
const double phi_target = PI - 0.1;
|
||||
maps.phi_e[circular] = phi_target;
|
||||
@@ -553,3 +545,45 @@ TEST(EuclideanFunctional, DISABLED_CyclicCircularEdge_CatHead_JavaXVal)
|
||||
EXPECT_NEAR(phi_target, realised, 1e-9)
|
||||
<< "prescribed circular edge turn angle π−0.1 not realised at the solution";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Correctness of the block-FD edge-DOF (cyclic) Hessian
|
||||
//
|
||||
// Validates `euclidean_hessian_block_fd` directly: on a tetrahedron with the
|
||||
// full cyclic DOF layout (4 vertex + 6 edge), every entry must match the
|
||||
// column-wise finite difference of `euclidean_gradient` (the true Jacobian of
|
||||
// G). This pins the per-face output sign mapping (−α vertex / +α_opp edge) and
|
||||
// the scatter independently of the convergence test above.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
TEST(EuclideanFunctional, CyclicHessian_BlockFD_MatchesGradientFD_Tetrahedron)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
const int n = assign_euclidean_all_dof_indices(mesh, maps); // 4 + 6 = 10
|
||||
|
||||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||||
for (int i = 0; i < 4; ++i) x[static_cast<std::size_t>(i)] = -0.15; // vertices
|
||||
for (int i = 4; i < n; ++i) x[static_cast<std::size_t>(i)] = 0.05; // edges
|
||||
|
||||
auto H = euclidean_hessian_block_fd(mesh, x, maps);
|
||||
|
||||
const double eps = 1e-6;
|
||||
std::vector<double> xp = x, xm = x;
|
||||
double max_err = 0.0;
|
||||
for (int j = 0; j < n; ++j) {
|
||||
const std::size_t sj = static_cast<std::size_t>(j);
|
||||
xp[sj] = x[sj] + eps;
|
||||
xm[sj] = x[sj] - eps;
|
||||
auto Gp = euclidean_gradient(mesh, xp, maps);
|
||||
auto Gm = euclidean_gradient(mesh, xm, maps);
|
||||
xp[sj] = xm[sj] = x[sj];
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const double fd = (Gp[static_cast<std::size_t>(i)]
|
||||
- Gm[static_cast<std::size_t>(i)]) / (2.0 * eps);
|
||||
max_err = std::max(max_err, std::abs(H.coeff(i, j) - fd));
|
||||
}
|
||||
}
|
||||
EXPECT_LT(max_err, 1e-5)
|
||||
<< "block-FD cyclic Hessian disagrees with the gradient finite difference";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user