// Copyright (c) 2024-2026 Tarik Moussa. // SPDX-License-Identifier: MIT // test_hyper_ideal_hessian.cpp // // Phase 9b — Hyper-ideal Hessian: block-FD vs full-FD cross-validation. // // The block-FD Hessian (Phase 9b) exploits the per-face locality of the // hyper-ideal functional to compute the Hessian as a sum of 6×6 per-face // blocks. This file verifies: // // 1. Block-FD reproduces the full-FD Hessian to machine precision // on tetrahedron (closed) and a 3-face open mesh. // 2. The result is symmetric and positive-semi-definite (Springborn // 2020 strict-convexity result). // 3. The kernel `face_angles_from_local_dofs` matches the existing // `compute_face_angles` at the same DOFs — sanity that the pure // refactor is non-regressing. // 4. Both Hessians agree with a from-scratch FD-of-energy reference // at the same x. (This is the highest-confidence cross-check.) #include "hyper_ideal_functional.hpp" #include "hyper_ideal_hessian.hpp" #include "mesh_builder.hpp" #include #include #include #include #include using namespace conformallab; namespace { // Open 3-face mesh (tetrahedron minus one face) — exercises boundary edges. inline ConformalMesh make_open_3face_mesh() { ConformalMesh mesh; auto v0 = mesh.add_vertex(Point3( 1, 1, 1)); auto v1 = mesh.add_vertex(Point3( 1, -1, -1)); auto v2 = mesh.add_vertex(Point3(-1, 1, -1)); auto v3 = mesh.add_vertex(Point3(-1, -1, 1)); mesh.add_face(v0, v2, v1); mesh.add_face(v0, v1, v3); mesh.add_face(v0, v3, v2); return mesh; } // Construct an x ≈ "natural" hyper-ideal initialisation: // b_v = 1 (positive log scale) // a_e = 0.5 (moderate intersection angle) inline std::vector natural_x(const ConformalMesh& mesh, const HyperIdealMaps& m) { const int n = hyper_ideal_dimension(mesh, m); std::vector x(static_cast(n), 0.0); for (auto v : mesh.vertices()) { int i = m.v_idx[v]; if (i >= 0) x[static_cast(i)] = 1.0; } for (auto e : mesh.edges()) { int i = m.e_idx[e]; if (i >= 0) x[static_cast(i)] = 0.5; } return x; } } // anonymous namespace // ════════════════════════════════════════════════════════════════════════════ // 1. Pure helper face_angles_from_local_dofs reproduces compute_face_angles // // Refactor sanity check: the new pure 6→6 function must produce identical // (β₁,β₂,β₃,α₁₂,α₂₃,α₃₁) to the existing mesh-reading compute_face_angles. // ════════════════════════════════════════════════════════════════════════════ TEST(HyperIdealHessian, PureHelperMatchesMeshHelper) { auto mesh = make_tetrahedron(); auto m = setup_hyper_ideal_maps(mesh); const int n = assign_hyper_ideal_all_dof_indices(mesh, m); auto x = natural_x(mesh, m); for (auto f : mesh.faces()) { FaceAngles fa = compute_face_angles(mesh, f, x, m); // Read the 6 local DOFs the same way Block-FD does. Halfedge_index h0 = mesh.halfedge(f); Halfedge_index h1 = mesh.next(h0); Halfedge_index h2 = mesh.next(h1); Vertex_index v1 = mesh.source(h0); Vertex_index v2 = mesh.source(h1); Vertex_index v3 = mesh.source(h2); Edge_index e12 = mesh.edge(h0); Edge_index e23 = mesh.edge(h1); Edge_index e31 = mesh.edge(h2); FaceAngleOutputs o = face_angles_from_local_dofs( dof_val(m.v_idx[v1], x), dof_val(m.v_idx[v2], x), dof_val(m.v_idx[v3], x), dof_val(m.e_idx[e12], x), dof_val(m.e_idx[e23], x), dof_val(m.e_idx[e31], x), m.v_idx[v1] >= 0, m.v_idx[v2] >= 0, m.v_idx[v3] >= 0); EXPECT_NEAR(o.beta1, fa.beta1, 1e-14); EXPECT_NEAR(o.beta2, fa.beta2, 1e-14); EXPECT_NEAR(o.beta3, fa.beta3, 1e-14); EXPECT_NEAR(o.alpha12, fa.alpha12, 1e-14); EXPECT_NEAR(o.alpha23, fa.alpha23, 1e-14); EXPECT_NEAR(o.alpha31, fa.alpha31, 1e-14); } (void)n; } // ════════════════════════════════════════════════════════════════════════════ // 2. Block-FD ≡ Full-FD on closed tetrahedron // ════════════════════════════════════════════════════════════════════════════ TEST(HyperIdealHessian, BlockFD_MatchesFullFD_ClosedTetrahedron) { auto mesh = make_tetrahedron(); auto m = setup_hyper_ideal_maps(mesh); const int n = assign_hyper_ideal_all_dof_indices(mesh, m); auto x = natural_x(mesh, m); auto H_full = hyper_ideal_hessian_sym (mesh, x, m); auto H_block = hyper_ideal_hessian_block_fd_sym(mesh, x, m); Eigen::MatrixXd Df(H_full), Db(H_block); const double diff = (Df - Db).cwiseAbs().maxCoeff(); EXPECT_LT(diff, 1e-8) << "Block-FD diverges from Full-FD by " << diff << " on tetrahedron"; (void)n; } // ════════════════════════════════════════════════════════════════════════════ // 3. Block-FD ≡ Full-FD on open 3-face mesh (boundary code path) // ════════════════════════════════════════════════════════════════════════════ TEST(HyperIdealHessian, BlockFD_MatchesFullFD_Open3FaceMesh) { auto mesh = make_open_3face_mesh(); auto m = setup_hyper_ideal_maps(mesh); const int n = assign_hyper_ideal_all_dof_indices(mesh, m); auto x = natural_x(mesh, m); auto H_full = hyper_ideal_hessian_sym (mesh, x, m); auto H_block = hyper_ideal_hessian_block_fd_sym(mesh, x, m); Eigen::MatrixXd Df(H_full), Db(H_block); const double diff = (Df - Db).cwiseAbs().maxCoeff(); EXPECT_LT(diff, 1e-8) << "Block-FD diverges from Full-FD by " << diff << " on open 3-face mesh"; (void)n; } // ════════════════════════════════════════════════════════════════════════════ // 4. Block-FD ≡ Full-FD with pinned DOFs (partial-DOF code path) // // Tests the case where some DOFs are pinned (v_idx = -1). Block-FD must // skip pinned columns/rows just like Full-FD does. // ════════════════════════════════════════════════════════════════════════════ TEST(HyperIdealHessian, BlockFD_MatchesFullFD_PinnedDOFs) { auto mesh = make_tetrahedron(); auto m = setup_hyper_ideal_maps(mesh); // Assign vertex DOFs only; leave edges pinned (a_e fixed at 0). int idx = 0; for (auto v : mesh.vertices()) m.v_idx[v] = idx++; for (auto e : mesh.edges()) m.e_idx[e] = -1; const int n = hyper_ideal_dimension(mesh, m); ASSERT_EQ(n, 4); // tetrahedron: 4 vertex DOFs, 0 edge DOFs std::vector x(static_cast(n), 1.0); auto H_full = hyper_ideal_hessian_sym (mesh, x, m); auto H_block = hyper_ideal_hessian_block_fd_sym(mesh, x, m); Eigen::MatrixXd Df(H_full), Db(H_block); const double diff = (Df - Db).cwiseAbs().maxCoeff(); EXPECT_LT(diff, 1e-8) << "Block-FD diverges by " << diff << " with pinned edges"; } // ════════════════════════════════════════════════════════════════════════════ // 5. PSD property (Springborn 2020 strict convexity) // // The hyper-ideal energy is strictly convex on its domain of validity, so // the Hessian is PSD at every interior point. Both block-FD and full-FD // must report this consistently. // ════════════════════════════════════════════════════════════════════════════ TEST(HyperIdealHessian, BlockFD_IsPSD) { auto mesh = make_tetrahedron(); auto m = setup_hyper_ideal_maps(mesh); const int n = assign_hyper_ideal_all_dof_indices(mesh, m); auto x = natural_x(mesh, m); auto H = hyper_ideal_hessian_block_fd_sym(mesh, x, m); Eigen::MatrixXd Hd(H); // Symmetry to FD rounding tolerance. EXPECT_LT((Hd - Hd.transpose()).cwiseAbs().maxCoeff(), 1e-10) << "Block-FD Hessian should be symmetric after _sym normalisation"; // PSD via smallest eigenvalue. Eigen::SelfAdjointEigenSolver es(Hd); EXPECT_GE(es.eigenvalues().minCoeff(), -1e-8) << "Hyper-ideal Hessian must be PSD (Springborn 2020)"; (void)n; } // ════════════════════════════════════════════════════════════════════════════ // 6. Sparsity: block-FD respects the 6-DOF-per-face locality // // Each non-zero (i,j) entry must correspond to a pair of DOFs that share at // least one face. This is a structural correctness test independent of the // numerical values. // ════════════════════════════════════════════════════════════════════════════ TEST(HyperIdealHessian, BlockFD_SparsityMatchesFaceAdjacency) { auto mesh = make_open_3face_mesh(); auto m = setup_hyper_ideal_maps(mesh); const int n = assign_hyper_ideal_all_dof_indices(mesh, m); auto x = natural_x(mesh, m); auto H = hyper_ideal_hessian_block_fd(mesh, x, m); // Build the "should-be-nonzero" mask from face adjacency. std::vector> face_pair(n, std::vector(n, false)); for (auto f : mesh.faces()) { auto h0 = mesh.halfedge(f); auto h1 = mesh.next(h0); auto h2 = mesh.next(h1); int idx[6] = { m.v_idx[mesh.source(h0)], m.v_idx[mesh.source(h1)], m.v_idx[mesh.source(h2)], m.e_idx[mesh.edge(h0)], m.e_idx[mesh.edge(h1)], m.e_idx[mesh.edge(h2)], }; for (int i = 0; i < 6; ++i) { if (idx[i] < 0) continue; for (int j = 0; j < 6; ++j) { if (idx[j] < 0) continue; face_pair[idx[i]][idx[j]] = true; } } } // Every non-zero entry must come from a face-adjacent pair. for (int k = 0; k < H.outerSize(); ++k) { for (Eigen::SparseMatrix::InnerIterator it(H, k); it; ++it) { EXPECT_TRUE(face_pair[it.row()][it.col()]) << "Hessian nonzero at (" << it.row() << "," << it.col() << ") between DOFs that share no face"; } } } // ════════════════════════════════════════════════════════════════════════════ // 7. Performance: measure block-FD vs full-FD on a moderately-sized mesh // // Builds a "long" tetrahedron-strip mesh: V tetrahedron-cells joined along // shared faces. Asserts the block-FD Hessian computes ≥ 3× faster than // the full-FD baseline. This is the operational case for the Phase 9b // optimisation (the asymptotic ratio is ~ n/36, which grows linearly in // mesh size). Wall-clock is printed for the record but the assertion // uses a conservative ratio so the test stays stable on slow CI hardware. // ════════════════════════════════════════════════════════════════════════════ namespace { // Build a strip of `n_cells` connected tetrahedra (subdivision-like). // The resulting mesh has ~ 2*n_cells + 2 vertices, 4*n_cells faces. // (Approximation; the exact count depends on shared-vertex handling.) inline ConformalMesh make_tet_strip(int n_cells) { ConformalMesh mesh; // Lay out vertex chain at z=0 / z=1 alternating. std::vector top, bot; for (int i = 0; i <= n_cells; ++i) { top.push_back(mesh.add_vertex(Point3(i, 0, 0))); bot.push_back(mesh.add_vertex(Point3(i, 0.7, 0.5 * std::sin(0.3*i)))); } // Add two triangles per cell (one row of "zig-zag" triangles). for (int i = 0; i < n_cells; ++i) { mesh.add_face(top[i], bot[i], top[i+1]); mesh.add_face(bot[i], bot[i+1], top[i+1]); } return mesh; } } // anonymous namespace TEST(HyperIdealHessian, BlockFD_FasterThanFullFD) { // 100 cells → ~200 faces, ~200 vertex DOFs + ~300 edge DOFs ≈ 500 DOFs. // Full-FD: 500 × 200 ≈ 100 k face evaluations // Block-FD: 200 × 12 ≈ 2.4 k face evaluations // Theoretical ratio: ~42×. We assert ≥ 3× to leave wide CI tolerance. auto mesh = make_tet_strip(100); auto m = setup_hyper_ideal_maps(mesh); const int n = assign_hyper_ideal_all_dof_indices(mesh, m); auto x = natural_x(mesh, m); using clk = std::chrono::steady_clock; auto t1 = clk::now(); auto H_full = hyper_ideal_hessian (mesh, x, m); auto t2 = clk::now(); auto H_block = hyper_ideal_hessian_block_fd(mesh, x, m); auto t3 = clk::now(); auto ms_full = std::chrono::duration_cast(t2-t1).count(); auto ms_block = std::chrono::duration_cast(t3-t2).count(); std::cerr << "[HyperIdealHessian.BlockFD_FasterThanFullFD]" << " V=" << mesh.number_of_vertices() << " F=" << mesh.number_of_faces() << " DOFs=" << n << " full-FD: " << ms_full << " µs" << " block-FD: " << ms_block << " µs" << " speed-up: " << (ms_block > 0 ? (double)ms_full / (double)ms_block : 0.0) << "×\n"; // Both must report identical Hessians (within FD rounding). Eigen::MatrixXd Df(H_full), Db(H_block); EXPECT_LT((Df - Db).cwiseAbs().maxCoeff(), 1e-8); // Conservative speed-up assertion — typically observe ~30×, accept ≥ 3×. EXPECT_GE(ms_full, 3 * ms_block) << "Block-FD should be at least 3× faster than full-FD on this mesh"; }