feat(phase4): HyperIdeal Newton solver, SparseQR fallback, examples, docs
Phase 4 complete — 87 CGAL tests pass, 2 skipped. Newton solver (phase4a): - hyper_ideal_hessian.hpp: symmetric FD Hessian (O(ε²), PSD by convexity) - newton_hyper_ideal(): Newton + backtracking for the HyperIdeal functional - detail::solve_with_fallback(): optional bool* fallback_used parameter - solve_linear_system(): public API exposing LDLT→SparseQR fallback SparseQR fallback tests (SparseQRFallback.*): - FullRankSystem_CorrectSolution: LDLT path, fallback_used=false - SingularMatrix_FallbackActivated: zero-pivot → QR activated, fallback_used=true - Euclidean_ClosedMeshNoPinConverges: gauge-mode null space handled via QR HyperIdeal Newton tests (NewtonSolver.HyperIdeal_*): - ConvergesTriangleAllVariable, ResultFieldsConsistent, ConvergesTetrahedron, SparseQRFallbackNoCrash - Natural-target base point (b=1.0, a=0.5) — x=0 is degenerate in log-space Pipeline tests (test_pipeline.cpp): - End-to-end: all three geometries, mesh I/O round-trip, solve+export Example programs (code/examples/): - example_euclidean.cpp: headless Euclidean pipeline - example_hyper_ideal.cpp: headless HyperIdeal pipeline - example_viewer.cpp: interactive libigl viewer with jet colour map README: - Mathematical scope table: C++ vs Java original (18 rows) - "For mathematicians" section: mental model, step-by-step new-functional guide, half-edge traversal snippets, recommended reading Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
// test_newton_solver.cpp
|
||||
//
|
||||
// Phase 4a — Newton solver tests.
|
||||
// Phase 4 — Newton solver tests.
|
||||
//
|
||||
// Design principle:
|
||||
// We test convergence to a KNOWN equilibrium. For the spherical tetrahedron
|
||||
// x* = 0 is built-in (G(0) ≈ 0 by construction). For Euclidean meshes we
|
||||
// use "natural theta": set theta_v[v] = actual angle sum at x=0, which makes
|
||||
// x* = 0 the exact equilibrium by definition.
|
||||
// For HyperIdeal we use the same "natural target" trick at a valid base point
|
||||
// (b=1.0, a=0.5), since x=0 is degenerate for the HyperIdeal functional.
|
||||
//
|
||||
// Tests:
|
||||
// Spherical:
|
||||
@@ -19,12 +21,26 @@
|
||||
// 5. Converges (triangle, 1 pinned vertex, natural theta).
|
||||
// 6. Converges (quad strip, 1 pinned vertex, natural theta).
|
||||
// 7. Converges with explicitly chosen mixed pinned/variable layout.
|
||||
//
|
||||
// HyperIdeal:
|
||||
// 8. Converges on triangle (all variable, natural targets).
|
||||
// 9. Result fields self-consistent.
|
||||
// 10. Converges on tetrahedron (10 DOFs, larger mesh).
|
||||
// 11. SparseQR: result consistent (valid starting region).
|
||||
//
|
||||
// SparseQR fallback (direct unit tests):
|
||||
// 12. solve_linear_system recovers correct solution on rank-deficient matrix.
|
||||
// 13. solve_linear_system sets fallback_used=true on a singular matrix.
|
||||
// 14. Euclidean Newton on closed tetrahedron (no pinned vertex) converges
|
||||
// via SparseQR gauge-mode handling.
|
||||
|
||||
#include "conformal_mesh.hpp"
|
||||
#include "mesh_builder.hpp"
|
||||
#include "euclidean_functional.hpp"
|
||||
#include "spherical_functional.hpp"
|
||||
#include "hyper_ideal_functional.hpp"
|
||||
#include "newton_solver.hpp"
|
||||
#include <Eigen/Dense>
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
@@ -233,3 +249,252 @@ TEST(NewtonSolver, Euclidean_ConvergesMixedPinned)
|
||||
"grad_inf_norm = " << res.grad_inf_norm;
|
||||
EXPECT_LT(res.grad_inf_norm, 1e-8);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Helper: set HyperIdeal target angles to actual sums at a non-degenerate
|
||||
// base point (b_base, a_base), making that point the equilibrium x*.
|
||||
//
|
||||
// Note: x = 0 is degenerate for the HyperIdeal functional (log-space; the
|
||||
// functional requires b_i > 0 / a_e > 0). We therefore choose a valid base
|
||||
// point, evaluate G there, and absorb G into the targets so that G(xbase) = 0.
|
||||
// Newton tests then start from a perturbation of xbase.
|
||||
//
|
||||
// Returns xbase so callers can construct a perturbed starting point.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
static std::vector<double> set_natural_hyper_ideal_targets(
|
||||
ConformalMesh& mesh, HyperIdealMaps& maps, int n,
|
||||
double b_base = 1.0, double a_base = 0.5)
|
||||
{
|
||||
const auto sz = static_cast<std::size_t>(n);
|
||||
std::vector<double> xbase(sz, 0.0);
|
||||
|
||||
for (auto v : mesh.vertices()) {
|
||||
int iv = maps.v_idx[v];
|
||||
if (iv >= 0) xbase[static_cast<std::size_t>(iv)] = b_base;
|
||||
}
|
||||
for (auto e : mesh.edges()) {
|
||||
int ie = maps.e_idx[e];
|
||||
if (ie >= 0) xbase[static_cast<std::size_t>(ie)] = a_base;
|
||||
}
|
||||
|
||||
// G = Σβ - theta_target (initial target = 0 → G = Σβ = "actual" angles)
|
||||
auto G = evaluate_hyper_ideal(mesh, xbase, maps, /*energy=*/false).gradient;
|
||||
|
||||
// Set target := actual so that G(xbase) = actual - target = 0
|
||||
for (auto v : mesh.vertices()) {
|
||||
int iv = maps.v_idx[v];
|
||||
if (iv < 0) continue;
|
||||
maps.theta_v[v] += G[static_cast<std::size_t>(iv)];
|
||||
}
|
||||
for (auto e : mesh.edges()) {
|
||||
int ie = maps.e_idx[e];
|
||||
if (ie < 0) continue;
|
||||
maps.theta_e[e] += G[static_cast<std::size_t>(ie)];
|
||||
}
|
||||
|
||||
return xbase;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// HyperIdeal 1 — Triangle, all DOFs variable, converges from perturbation
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(NewtonSolver, HyperIdeal_ConvergesTriangleAllVariable)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
|
||||
// xbase = (b=1.0, a=0.5) is the equilibrium after natural-target setup.
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
|
||||
// Perturb by +0.2 uniformly
|
||||
std::vector<double> x0 = xbase;
|
||||
for (auto& v : x0) v += 0.2;
|
||||
|
||||
auto res = newton_hyper_ideal(mesh, x0, maps, /*tol=*/1e-7, /*max_iter=*/100);
|
||||
|
||||
EXPECT_TRUE(res.converged)
|
||||
<< "Newton (HyperIdeal, triangle) should converge; "
|
||||
"grad_inf_norm = " << res.grad_inf_norm;
|
||||
EXPECT_LT(res.grad_inf_norm, 1e-7);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// HyperIdeal 2 — Result fields self-consistent
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(NewtonSolver, HyperIdeal_ResultFieldsConsistent)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
|
||||
std::vector<double> x0 = xbase;
|
||||
for (auto& v : x0) v += 0.1;
|
||||
|
||||
auto res = newton_hyper_ideal(mesh, x0, maps, /*tol=*/1e-7, /*max_iter=*/100);
|
||||
|
||||
EXPECT_EQ(static_cast<int>(res.x.size()), n);
|
||||
|
||||
// Reported grad_inf_norm must match re-computed gradient at res.x
|
||||
auto G = evaluate_hyper_ideal(mesh, res.x, maps, false).gradient;
|
||||
double actual_inf = 0.0;
|
||||
for (double v : G) actual_inf = std::max(actual_inf, std::abs(v));
|
||||
EXPECT_NEAR(actual_inf, res.grad_inf_norm, 1e-9);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// HyperIdeal 3 — Tetrahedron (10 DOFs): 4 vertex b-vals + 6 edge a-vals
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(NewtonSolver, HyperIdeal_ConvergesTetrahedron)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
|
||||
auto xbase = set_natural_hyper_ideal_targets(mesh, maps, n);
|
||||
|
||||
// Perturb by +0.15
|
||||
std::vector<double> x0 = xbase;
|
||||
for (auto& v : x0) v += 0.15;
|
||||
|
||||
auto res = newton_hyper_ideal(mesh, x0, maps, /*tol=*/1e-7, /*max_iter=*/200);
|
||||
|
||||
EXPECT_TRUE(res.converged)
|
||||
<< "Newton (HyperIdeal, tetrahedron) should converge; "
|
||||
"grad_inf_norm = " << res.grad_inf_norm;
|
||||
EXPECT_LT(res.grad_inf_norm, 1e-7);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// HyperIdeal 4 — SparseQR fallback: solver returns a result (no crash)
|
||||
//
|
||||
// With all targets = 0 the equilibrium is not at x=0 but the solver should
|
||||
// at minimum not crash and return a consistent result struct.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(NewtonSolver, HyperIdeal_SparseQRFallbackNoCrash)
|
||||
{
|
||||
auto mesh = make_triangle();
|
||||
auto maps = setup_hyper_ideal_maps(mesh);
|
||||
int n = assign_all_dof_indices(mesh, maps);
|
||||
|
||||
// Leave targets at their default (0): solver tries to solve but the
|
||||
// "equilibrium" is at some unknown x*. With valid starting point the
|
||||
// Hessian is positive-definite and the solver should not crash.
|
||||
// We don't assert convergence — just that the result struct is consistent.
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), 1.0);
|
||||
// Mix vertex / edge DOFs: b=1.0, a=0.5 (valid region of the functional)
|
||||
for (auto e : mesh.edges()) {
|
||||
int ie = maps.e_idx[e];
|
||||
if (ie >= 0) x0[static_cast<std::size_t>(ie)] = 0.5;
|
||||
}
|
||||
auto res = newton_hyper_ideal(mesh, x0, maps, /*tol=*/1e-7, /*max_iter=*/50);
|
||||
|
||||
// Struct fields must always be populated
|
||||
EXPECT_EQ(static_cast<int>(res.x.size()), n);
|
||||
EXPECT_GE(res.iterations, 0);
|
||||
EXPECT_FALSE(std::isnan(res.grad_inf_norm));
|
||||
EXPECT_FALSE(std::isinf(res.grad_inf_norm));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// SparseQR fallback — Test 12: solve_linear_system recovers correct solution
|
||||
//
|
||||
// The public API solve_linear_system(A, rhs) must return the correct answer
|
||||
// for a well-conditioned full-rank system (LDLT path taken).
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(SparseQRFallback, FullRankSystem_CorrectSolution)
|
||||
{
|
||||
// Build a simple 3×3 diagonal PD matrix: A = diag(1, 2, 3)
|
||||
Eigen::SparseMatrix<double> A(3, 3);
|
||||
A.insert(0, 0) = 1.0;
|
||||
A.insert(1, 1) = 2.0;
|
||||
A.insert(2, 2) = 3.0;
|
||||
A.makeCompressed();
|
||||
|
||||
Eigen::VectorXd rhs(3);
|
||||
rhs << 1.0, 4.0, 9.0; // solution = [1, 2, 3]
|
||||
|
||||
bool fallback = true; // expect it to be set to false (LDLT succeeds)
|
||||
Eigen::VectorXd x = conformallab::solve_linear_system(A, rhs, &fallback);
|
||||
|
||||
EXPECT_FALSE(fallback) << "Full-rank system: LDLT should succeed (no SparseQR needed)";
|
||||
EXPECT_NEAR(x[0], 1.0, 1e-12);
|
||||
EXPECT_NEAR(x[1], 2.0, 1e-12);
|
||||
EXPECT_NEAR(x[2], 3.0, 1e-12);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// SparseQR fallback — Test 13: fallback_used=true on a singular matrix
|
||||
//
|
||||
// Construct a symmetric 3×3 matrix of rank 1 where LDLT fails (the (2,2)
|
||||
// pivot is zero). SparseQR finds the minimum-norm least-squares solution.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(SparseQRFallback, SingularMatrix_FallbackActivated)
|
||||
{
|
||||
// A = [[2, 0, 0],
|
||||
// [0, 0, 0], ← zero pivot → LDLT failure
|
||||
// [0, 0, 3]]
|
||||
// rhs compatible with the row space: [2, 0, 3] → solution [1, 0, 1]
|
||||
Eigen::SparseMatrix<double> A(3, 3);
|
||||
A.insert(0, 0) = 2.0;
|
||||
// row/col 1 deliberately all-zero
|
||||
A.insert(2, 2) = 3.0;
|
||||
A.makeCompressed();
|
||||
|
||||
Eigen::VectorXd rhs(3);
|
||||
rhs << 2.0, 0.0, 3.0;
|
||||
|
||||
bool fallback = false;
|
||||
Eigen::VectorXd x = conformallab::solve_linear_system(A, rhs, &fallback);
|
||||
|
||||
EXPECT_TRUE(fallback) << "Singular matrix: SparseQR fallback must be triggered";
|
||||
// SparseQR min-norm solution: x[0]=1, x[1]=0, x[2]=1
|
||||
EXPECT_NEAR(x[0], 1.0, 1e-10);
|
||||
EXPECT_NEAR(x[1], 0.0, 1e-10);
|
||||
EXPECT_NEAR(x[2], 1.0, 1e-10);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// SparseQR fallback — Test 14: Euclidean Newton on a closed mesh, no pinning
|
||||
//
|
||||
// make_tetrahedron() is a closed surface (4 vertices, 4 faces). Without a
|
||||
// pinned vertex the Euclidean Hessian has a 1-D null space (uniform scale
|
||||
// gauge mode): H·1 = 0. SimplicialLDLT fails on this rank-deficient H;
|
||||
// SparseQR finds the min-norm Newton step orthogonal to the null space.
|
||||
//
|
||||
// The gradient always lives in the row space of H (Σ G_v = 0 by angle-sum
|
||||
// invariance), so the SparseQR step is also the Newton step and the solver
|
||||
// converges to the natural equilibrium.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(SparseQRFallback, Euclidean_ClosedMeshNoPinConverges)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// Assign all 4 vertices as free DOFs (no pinning).
|
||||
int idx = 0;
|
||||
for (auto v : mesh.vertices())
|
||||
maps.v_idx[v] = idx++;
|
||||
const int n = idx; // = 4
|
||||
|
||||
// Natural theta: equilibrium at x* = 0.
|
||||
set_natural_euclidean_theta(mesh, maps, n);
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), -0.1);
|
||||
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100);
|
||||
|
||||
EXPECT_TRUE(res.converged)
|
||||
<< "Euclidean Newton on closed tetrahedron (no pin) must converge via SparseQR; "
|
||||
"grad_inf_norm = " << res.grad_inf_norm;
|
||||
EXPECT_LT(res.grad_inf_norm, 1e-8);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user