The hyper-ideal vertex scale b is floored to keep the geometry valid. The original clamp `b<0 → 0.01` mirrors the Java oracle but is only C⁰ (in fact value-discontinuous at b=0): a Newton step crossing the feasibility boundary hits a kink that can stall convergence (numerical-stability audit N3). Rather than replace the Java-faithful behaviour (which would break the golden parity tests), make the floor a selectable mode so BOTH the Java standpoint and the clean mathematics are available: - HyperIdealScaleClamp::HardJava (DEFAULT) — the original snap, bit-for-bit faithful to HyperIdealFunctional.java → all parity tests unchanged. - HyperIdealScaleClamp::SmoothBarrier — C¹ softplus floor b ↦ floor + softplus_β(b−floor), β = HYPER_IDEAL_SCALE_SHARPNESS (=100); ≈ identity away from the floor, smooth across b=0. Opt-in. clamp_hyper_ideal_scale centralises the logic (also folds in the N4 nachzügler: compute_face_angles used a bare 0.01). The mode threads with a defaulted trailing parameter through compute_face_angles, face_angles_from_local_dofs, evaluate_hyper_ideal, the four hyper_ideal_hessian* variants and newton_hyper_ideal — so every existing call site keeps HardJava behaviour. Tests (+4): clamp-function C¹/floor/identity contract, mode-equivalence away from the boundary, and end-to-end SmoothBarrier convergence to the same Java golden vector (LawsonHyperIdeal). 296/296 CGAL tests pass. Documented in doc/math/geometry-modes.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
419 lines
19 KiB
C++
419 lines
19 KiB
C++
// 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 <Eigen/Eigenvalues>
|
||
#include <gtest/gtest.h>
|
||
#include <chrono>
|
||
#include <iostream>
|
||
#include <vector>
|
||
|
||
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<double> natural_x(const ConformalMesh& mesh,
|
||
const HyperIdealMaps& m)
|
||
{
|
||
const int n = hyper_ideal_dimension(mesh, m);
|
||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||
for (auto v : mesh.vertices()) {
|
||
int i = m.v_idx[v];
|
||
if (i >= 0) x[static_cast<std::size_t>(i)] = 1.0;
|
||
}
|
||
for (auto e : mesh.edges()) {
|
||
int i = m.e_idx[e];
|
||
if (i >= 0) x[static_cast<std::size_t>(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<double> x(static_cast<std::size_t>(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<Eigen::MatrixXd> 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<std::vector<bool>> face_pair(n, std::vector<bool>(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<double>::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<Vertex_index> 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<std::chrono::microseconds>(t2-t1).count();
|
||
auto ms_block = std::chrono::duration_cast<std::chrono::microseconds>(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";
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// N3 — Scale-floor clamp modes (HardJava vs SmoothBarrier)
|
||
//
|
||
// The vertex-scale floor can be applied two ways (HyperIdealScaleClamp):
|
||
// • HardJava (default): b<0 → floor. Faithful to Java, but only C⁰ — and
|
||
// in fact value-discontinuous at b=0 (jumps from `floor` to 0).
|
||
// • SmoothBarrier: floor + softplus_β(b−floor). C¹ everywhere, ≈ b away
|
||
// from the floor, and keeps b ≥ floor > 0 smoothly.
|
||
// These tests pin the contract of both modes and the default-preservation.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(HyperIdealClamp, SmoothBarrierIsContinuousAndC1AcrossZero)
|
||
{
|
||
const auto Hard = HyperIdealScaleClamp::HardJava;
|
||
const auto Smooth = HyperIdealScaleClamp::SmoothBarrier;
|
||
auto f = [](double b, HyperIdealScaleClamp mode) {
|
||
return clamp_hyper_ideal_scale(b, /*variable=*/true, mode);
|
||
};
|
||
|
||
const double eps = 1e-6;
|
||
|
||
// HardJava jumps in VALUE at b=0 (floor on the left, 0 on the right).
|
||
EXPECT_GT(std::abs(f(-eps, Hard) - f(+eps, Hard)), 0.5 * HYPER_IDEAL_SCALE_FLOOR);
|
||
|
||
// SmoothBarrier is continuous in value across b=0 …
|
||
EXPECT_NEAR(f(-eps, Smooth), f(+eps, Smooth), 1e-4);
|
||
|
||
// … and C¹: the one-sided slopes across b=0 agree (the HardJava slopes,
|
||
// 0 on the left and 1 on the right, would not).
|
||
auto slope = [&](double b0, HyperIdealScaleClamp mode) {
|
||
return (f(b0 + eps, mode) - f(b0 - eps, mode)) / (2.0 * eps);
|
||
};
|
||
const double sL = slope(-1e-3, Smooth);
|
||
const double sR = slope(+1e-3, Smooth);
|
||
EXPECT_NEAR(sL, sR, 5e-2) << "SmoothBarrier derivative should be continuous";
|
||
}
|
||
|
||
TEST(HyperIdealClamp, SmoothBarrierStaysAboveFloorAndApproachesIdentity)
|
||
{
|
||
const auto Smooth = HyperIdealScaleClamp::SmoothBarrier;
|
||
auto f = [&](double b) { return clamp_hyper_ideal_scale(b, true, Smooth); };
|
||
|
||
// At or above the floor for any input (mathematically > floor; for very
|
||
// negative b the softplus underflows to 0 in double, giving exactly floor).
|
||
EXPECT_GE(f(-1e6), HYPER_IDEAL_SCALE_FLOOR);
|
||
EXPECT_GE(f(-1.0), HYPER_IDEAL_SCALE_FLOOR);
|
||
EXPECT_GT(f(0.0), 0.0);
|
||
EXPECT_LT(f(-1e6) - HYPER_IDEAL_SCALE_FLOOR, 1e-9); // converges down to floor
|
||
|
||
// ≈ identity well above the floor (the normal operating regime).
|
||
EXPECT_NEAR(f(1.0), 1.0, 1e-9);
|
||
EXPECT_NEAR(f(5.0), 5.0, 1e-12);
|
||
|
||
// Pinned DOFs are never clamped, regardless of mode.
|
||
EXPECT_EQ(clamp_hyper_ideal_scale(-3.0, /*variable=*/false, Smooth), -3.0);
|
||
}
|
||
|
||
// Away from the feasibility boundary (all b = 1 ≫ floor), the two modes must
|
||
// produce the same Hessian — SmoothBarrier only differs near b ≈ floor, so the
|
||
// default-mode parity results are not perturbed in the normal regime.
|
||
TEST(HyperIdealClamp, ModesAgreeAwayFromBoundary)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_hyper_ideal_maps(mesh);
|
||
const int n = assign_all_dof_indices(mesh, m);
|
||
auto x = natural_x(mesh, m); // b = 1, a = 0.5 — all well above the floor
|
||
|
||
auto H_hard = hyper_ideal_hessian_block_fd_sym(
|
||
mesh, x, m, 1e-5, HyperIdealScaleClamp::HardJava);
|
||
auto H_soft = hyper_ideal_hessian_block_fd_sym(
|
||
mesh, x, m, 1e-5, HyperIdealScaleClamp::SmoothBarrier);
|
||
|
||
Eigen::MatrixXd Dh(H_hard), Ds(H_soft);
|
||
EXPECT_LT((Dh - Ds).cwiseAbs().maxCoeff(), 1e-9)
|
||
<< "clamp modes must agree when every scale is well above the floor";
|
||
(void)n;
|
||
}
|