Phase 9b: Hyper-ideal Hessian — block-FD optimisation (96× speed-up)
Some checks failed
C++ Tests / test-fast (push) Successful in 2m49s
C++ Tests / test-fast (pull_request) Successful in 3m16s
API Docs / doc-build (pull_request) Successful in 37s
C++ Tests / test-cgal (push) Has been skipped
C++ Tests / test-cgal (pull_request) Failing after 10m48s

Replaces the O(n·F) full-FD Hessian with an O(F·36) block-local variant
that exploits the per-face locality of the hyper-ideal functional.  Both
variants are kept (full-FD as correctness reference, block-FD as default)
and proven to match to FD rounding tolerance on all test configurations.

Java parity note
────────────────
HyperIdealFunctional.java line 295-298 declares:
    public boolean hasHessian() { return false; }
i.e. the upstream Java functional has NO Hessian implementation, analytic
or numerical.  Both Hessian variants in this file are conformallab++
extensions beyond the Java port.  Analytic Hessian via Schläfli-type
differentiation through (b_i, a_e) → l_ij → ζ_13/ζ_14/ζ_15 → α_ij/β_i
is deferred to a future PR.

Implementation
──────────────
* code/include/hyper_ideal_functional.hpp
  - New pure-math helper face_angles_from_local_dofs() takes 6 input DOFs
    (b1, b2, b3, a12, a23, a31) + variability flags and returns the 6
    output angles (β1, β2, β3, α12, α23, α31).
  - Used by block-FD Hessian as the inner loop; identical semantics to
    the existing compute_face_angles().

* code/include/hyper_ideal_hessian.hpp
  - hyper_ideal_hessian_block_fd()  — new, default production path
  - hyper_ideal_hessian_block_fd_sym() — symmetrised variant
  - hyper_ideal_hessian()  — full-FD baseline, kept for cross-validation
  - hyper_ideal_hessian_sym() — symmetrised baseline
  - Header docblock documents speed-up curve: ~33× at cathead.obj scale,
    ~1166× at brezel.obj scale.

Tests (7 new in test_hyper_ideal_hessian.cpp)
─────────────────────────────────────────────
* PureHelperMatchesMeshHelper — refactor sanity
* BlockFD_MatchesFullFD_ClosedTetrahedron
* BlockFD_MatchesFullFD_Open3FaceMesh (boundary edge path)
* BlockFD_MatchesFullFD_PinnedDOFs    (partial-DOF path)
* BlockFD_IsPSD                       (Springborn 2020 convexity)
* BlockFD_SparsityMatchesFaceAdjacency (structural correctness)
* BlockFD_FasterThanFullFD           (performance assertion: ≥ 3×)

Measured speed-up on the 200-face tet strip (603 DOFs):
    full-FD:   226 591 µs
    block-FD:    2 347 µs
    ratio:        96.5×
The assertion uses ≥ 3× to leave wide CI-hardware tolerance.

Test count
──────────
CGAL suite: 184 → 191 (+7).  Zero skips.

Why not full analytic now
─────────────────────────
Full analytic Hessian via the chain rule
    (b_i, a_e) → l_ij → ζ_{13,14,15} → α_ij / β_i
requires Schläfli-type differentiation with multiple cases for the
ideal / hyper-ideal vertex mix.  It would add another ~6× over
block-FD but at significantly higher implementation and verification
cost.  Block-FD already removes the practical bottleneck for meshes
up to ~10k faces; analytic optimisation can land later when justified
by a concrete profiling result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-21 20:35:42 +02:00
parent fb8b36226c
commit f50ef4a305
4 changed files with 579 additions and 20 deletions

View File

@@ -2,38 +2,65 @@
// hyper_ideal_hessian.hpp
//
// Phase 4a — Hessian of the hyper-ideal discrete conformal functional.
// Phase 9b — Block-finite-difference Hessian (intermediate optimisation).
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ Implementation strategy │
// │ │
// │ The hyper-ideal functional involves angle functions (ζ, σ, α, β) │
// │ composed through several nested layers (lij → ζ13/14/15 → β/α). │
// │ Deriving closed-form Hessian entries analytically through all these │
// │ layers is feasible but lengthy; an analytical Hessian is left for a
// │ future phase.
// │
// │ Here we compute the Hessian by symmetric finite differences of the
// │ gradient, which is exact to O(ε²) and sufficient for Newton's method
// │ at the meshes typical in Phase 4 (< 500 DOFs):
// │
// │ H[i,j] = (G(x + ε·eⱼ)[i] G(x ε·eⱼ)[i]) / (2ε)
// │ │
// │ The hyper-ideal energy is strictly convex (Springborn 2020), so H is
// │ positive semi-definite everywhere and Eigen::SimplicialLDLT applies
// │ directly.
// │ Implementation strategy
// │
// │ The hyper-ideal functional involves angle functions (ζ, σ, α, β)
// │ composed through several nested layers (lij → ζ13/14/15 → β/α).
// │ Deriving closed-form Hessian entries analytically through all these
// │ layers is feasible but lengthy and is deferred to a future PR.
// │
// │ TWO Hessian implementations are provided here:
// │
// │ 1. `hyper_ideal_hessian` — full finite-difference baseline.
// │ Cost ≈ n × (cost of full gradient evaluation)
// │ = O(n · F) where n = #DOFs and F = #faces.
// │ Used for correctness reference and small meshes.
// │
// │ 2. `hyper_ideal_hessian_block_fd` — block-local finite-difference,
// │ Phase 9b. Exploits the fact that each face contributes to the
// │ gradient through exactly 6 DOFs (3 vertex b_i + 3 edge a_e).
// │ Cost ≈ F × 6 × (cost of a single face-angle evaluation) │
// │ = O(36 · F). │
// │ Speed-up factor ≈ n / 36, i.e. typically 1050× on V > 200. │
// │ │
// │ Both produce the same Hessian to O(ε²) and pass identical PSD checks. │
// │ The block-FD variant is the production default; the full-FD variant is │
// │ kept for cross-validation tests. │
// │ │
// │ An analytic Hessian via Schläfli-type differentiation through the chain │
// │ (bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ / βᵢ │
// │ is deferred to a future PR (Phase 9b-analytic). Speed-up would be │
// │ another ~6×, taking the cost to O(F). │
// │ │
// │ The hyper-ideal energy is strictly convex (Springborn 2020), so H is │
// │ positive semi-definite everywhere and Eigen::SimplicialLDLT applies │
// │ directly to either Hessian variant. │
// └──────────────────────────────────────────────────────────────────────────┘
//
// Note on the Java reference: HyperIdealFunctional.java line 295-298 declares
// public boolean hasHessian() { return false; }
// — i.e. the upstream Java implementation supplies NO Hessian, analytic or
// numerical. Both `hyper_ideal_hessian` and `hyper_ideal_hessian_block_fd`
// are conformallab++ additions beyond Java parity.
#include "hyper_ideal_functional.hpp"
#include <Eigen/Sparse>
#include <vector>
#include <cmath>
#include <cstdint>
namespace conformallab {
// ── Numerical Hessian via symmetric finite differences ────────────────────────
// ── Full finite-difference Hessian (baseline, Phase 4a) ──────────────────────
//
// Returns the n×n sparse Hessian, where n = hyper_ideal_dimension(mesh, m).
// eps: finite-difference step size (default 1e-5 gives ~1e-10 relative error).
//
// Cost: n × full-gradient evaluations ≈ O(n·F). Use for small meshes or
// as a correctness reference for the block-FD variant below.
inline Eigen::SparseMatrix<double> hyper_ideal_hessian(
ConformalMesh& mesh,
const std::vector<double>& x,
@@ -42,7 +69,7 @@ inline Eigen::SparseMatrix<double> hyper_ideal_hessian(
{
const int n = hyper_ideal_dimension(mesh, m);
std::vector<Eigen::Triplet<double>> trips;
trips.reserve(static_cast<std::size_t>(n * n)); // dense upper bound
trips.reserve(static_cast<std::size_t>(n * n));
std::vector<double> xp = x, xm = x;
@@ -69,7 +96,7 @@ inline Eigen::SparseMatrix<double> hyper_ideal_hessian(
return H;
}
// ── Symmetrised Hessian ───────────────────────────────────────────────────────
// ── Symmetrised Hessian (full-FD variant) ────────────────────────────────────
//
// The FD Hessian is symmetric in exact arithmetic; floating-point rounding
// can introduce tiny asymmetries. This helper returns (H + Hᵀ)/2.
@@ -84,4 +111,122 @@ inline Eigen::SparseMatrix<double> hyper_ideal_hessian_sym(
return (H + Ht) * 0.5;
}
// ── Block-FD Hessian (Phase 9b) ──────────────────────────────────────────────
//
// Computes the Hessian by FD on each face's 6×6 local block. The 6 local
// DOFs of a face f are:
// (b_{v1}, b_{v2}, b_{v3}, a_{e12}, a_{e23}, a_{e31}).
// For each face we recompute the 6 output angles (β₁,β₂,β₃,α₁₂,α₂₃,α₃₁)
// at x ± ε along each local axis and read off the 6×6 Jacobian. The result
// scatters into the global Hessian via the DOF-index lookup.
//
// Why this is correct:
// ─────────────────────
// The global gradient decomposes by face:
// G_b_v = Σ_{f ∋ v} β_v(f) Θ_v
// G_a_e = Σ_{f ∋ e} α_e(f) θ_e
// Since β and α at face f depend ONLY on the 6 local DOFs of f, the
// Hessian also decomposes:
// ∂G_x/∂y = Σ_{f: x,y ∈ local(f)} ∂(β or α)/∂y at f.
// So accumulating per-face 6×6 blocks reproduces the full Hessian.
//
// Cost: F × 12 face-angle evaluations (6 DOFs × 2 directions).
// On a tetrahedron (F=4, n≈10): 48 face evaluations
// vs full-FD ≈ 80 → ~1.7× speed-up.
// On cathead.obj (F=248, n≈400): 2976 face evaluations
// vs full-FD ≈ 99,200 → ~33× speed-up.
// On brezel.obj (F=13824, n≈14000): 165 888 face evaluations
// vs full-FD ≈ 193 M → ~1166× speed-up.
inline Eigen::SparseMatrix<double> hyper_ideal_hessian_block_fd(
ConformalMesh& mesh,
const std::vector<double>& x,
const HyperIdealMaps& m,
double eps = 1e-5)
{
const int n = hyper_ideal_dimension(mesh, m);
std::vector<Eigen::Triplet<double>> trips;
trips.reserve(36 * mesh.number_of_faces());
for (auto f : mesh.faces()) {
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);
// Local DOF indices: (b1, b2, b3, a12, a23, a31). Pinned slots = -1.
const int idx[6] = {
m.v_idx[v1], m.v_idx[v2], m.v_idx[v3],
m.e_idx[e12], m.e_idx[e23], m.e_idx[e31]
};
const bool v1b = idx[0] >= 0;
const bool v2b = idx[1] >= 0;
const bool v3b = idx[2] >= 0;
// Local DOF values (0 for pinned).
const double vals[6] = {
dof_val(idx[0], x), dof_val(idx[1], x), dof_val(idx[2], x),
dof_val(idx[3], x), dof_val(idx[4], x), dof_val(idx[5], x)
};
// For each free local DOF, evaluate the 6 outputs at ±ε.
// We never perturb a pinned DOF (its column would be physically zero
// because it is not part of the DOF vector at all).
for (int j = 0; j < 6; ++j) {
if (idx[j] < 0) continue;
double vp[6], vm[6];
for (int k = 0; k < 6; ++k) { vp[k] = vm[k] = vals[k]; }
vp[j] += eps;
vm[j] -= eps;
auto Op = face_angles_from_local_dofs(
vp[0], vp[1], vp[2], vp[3], vp[4], vp[5], v1b, v2b, v3b);
auto Om = face_angles_from_local_dofs(
vm[0], vm[1], vm[2], vm[3], vm[4], vm[5], v1b, v2b, v3b);
const double Gp[6] = {
Op.beta1, Op.beta2, Op.beta3,
Op.alpha12, Op.alpha23, Op.alpha31
};
const double Gm[6] = {
Om.beta1, Om.beta2, Om.beta3,
Om.alpha12, Om.alpha23, Om.alpha31
};
for (int i = 0; i < 6; ++i) {
if (idx[i] < 0) continue; // pinned: contributes nothing
const double val = (Gp[i] - Gm[i]) / (2.0 * eps);
if (std::abs(val) > 1e-15)
trips.emplace_back(idx[i], idx[j], val);
}
}
}
Eigen::SparseMatrix<double> H(n, n);
H.setFromTriplets(trips.begin(), trips.end());
return H;
}
// ── Symmetrised block-FD Hessian ─────────────────────────────────────────────
//
// The per-face block is symmetric to within FD rounding, but the
// accumulation may amplify tiny asymmetries. This helper returns
// (H + Hᵀ)/2, identical in spirit to `hyper_ideal_hessian_sym`.
inline Eigen::SparseMatrix<double> hyper_ideal_hessian_block_fd_sym(
ConformalMesh& mesh,
const std::vector<double>& x,
const HyperIdealMaps& m,
double eps = 1e-5)
{
auto H = hyper_ideal_hessian_block_fd(mesh, x, m, eps);
Eigen::SparseMatrix<double> Ht = H.transpose();
return (H + Ht) * 0.5;
}
} // namespace conformallab