The Inversive-Distance solver built its Hessian inline by full finite
differences: n perturbations × a full O(F) gradient eval = O(n·F) per Newton
iteration (quadratic in mesh size), with no fast path at all (api-performance
audit B1, second half).
Port the per-face block-FD scheme already used by HyperIdeal (Phase 9b):
the gradient decomposes by face (G_v = Θ_v − Σ_{f∋v} α_v, and each face's
angles depend only on its 3 vertex DOFs), so the Hessian decomposes into
per-face 3×3 blocks. Cost drops to O(F) face evaluations, a ≈ n/6 speed-up.
- inversive_distance_functional.hpp: add the pure 3→3 kernel
inversive_distance_face_grad_contribs (returns the per-face contribution
−α to G; mirrors the gradient's face-skip on ℓ²≤0 exactly).
- inversive_distance_hessian.hpp (new): full-FD baseline + block-FD + sym
variants, mirroring hyper_ideal_hessian.hpp.
- newton_solver.hpp: drop the inline full-FD lambda; call
inversive_distance_hessian_block_fd_sym.
- test: InversiveDistance_BlockFDHessianMatchesFullFD cross-validates the
two Hessians entry-wise on a perturbed (off-equilibrium) config.
291/291 CGAL tests pass; all Inversive-Distance convergence tests unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
312 lines
14 KiB
C++
312 lines
14 KiB
C++
// Copyright (c) 2024-2026 Tarik Moussa.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// test_newton_phase9a.cpp
|
||
//
|
||
// Phase 9a Newton solvers — convergence tests for the two new
|
||
// circle-packing functionals.
|
||
//
|
||
// Validates that:
|
||
// • newton_cp_euclidean() — face-based BPS-2010 functional.
|
||
// • newton_inversive_distance() — vertex-based Luo-2004 functional.
|
||
// both reach a Newton equilibrium (‖G‖∞ < 1e-8) in < 30 iterations
|
||
// on a range of test meshes, and that the converged solution satisfies
|
||
// the relevant geometric invariants.
|
||
|
||
#include "newton_solver.hpp"
|
||
#include "cp_euclidean_functional.hpp"
|
||
#include "inversive_distance_functional.hpp"
|
||
#include "inversive_distance_hessian.hpp"
|
||
#include "mesh_builder.hpp"
|
||
#include "conformal_mesh.hpp"
|
||
|
||
#include <gtest/gtest.h>
|
||
#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;
|
||
}
|
||
|
||
} // anonymous
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 1. CP-Euclidean Newton — orthogonal circle packing
|
||
//
|
||
// Setup matches CPEuclideanFunctionalTest.java (Java parity at the
|
||
// solver level): θ_e = π/2 everywhere, φ_f = 2π for all faces. Use
|
||
// the "natural-phi" trick (analog of natural-theta in Euclidean):
|
||
// adjust φ so that ρ = 0 is the natural equilibrium → Newton must
|
||
// converge in zero iterations.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(NewtonPhase9a, CPEuclidean_NaturalPhi_ClosedTetrahedron_ConvergesInZeroIterations)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
ASSERT_EQ(n, 3);
|
||
|
||
// Natural-phi: shift φ_f so the gradient at ρ = 0 is zero.
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
auto G0 = cp_euclidean_gradient(mesh, x0, m);
|
||
for (auto f : mesh.faces()) {
|
||
int i = m.f_idx[f];
|
||
if (i < 0) continue;
|
||
m.phi_f[f] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
auto res = newton_cp_euclidean(mesh, x0, m);
|
||
EXPECT_TRUE(res.converged);
|
||
EXPECT_EQ(res.iterations, 0)
|
||
<< "natural-phi pre-shift should make x=0 the equilibrium";
|
||
EXPECT_LT(res.grad_inf_norm, 1e-10);
|
||
for (double r : res.x) EXPECT_NEAR(r, 0.0, 1e-12);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 2. CP-Euclidean Newton — perturbed equilibrium converges back to 0
|
||
//
|
||
// Same setup as test 1, but start from a small perturbation. The
|
||
// strictly-convex BPS-2010 energy means Newton must converge back
|
||
// to the natural-phi equilibrium ρ = 0.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(NewtonPhase9a, CPEuclidean_PerturbedStart_ConvergesBackToEquilibrium)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
// Apply natural-phi (equilibrium at ρ=0).
|
||
std::vector<double> x0_zero(static_cast<std::size_t>(n), 0.0);
|
||
auto G0 = cp_euclidean_gradient(mesh, x0_zero, m);
|
||
for (auto f : mesh.faces()) {
|
||
int i = m.f_idx[f];
|
||
if (i < 0) continue;
|
||
m.phi_f[f] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
// Start from a perturbation.
|
||
std::vector<double> x0 = {0.1, -0.2, 0.15};
|
||
auto res = newton_cp_euclidean(mesh, x0, m);
|
||
|
||
EXPECT_TRUE(res.converged);
|
||
EXPECT_LT(res.iterations, 30);
|
||
EXPECT_LT(res.grad_inf_norm, 1e-8);
|
||
// Strictly-convex unique minimum → converges back to ρ=0.
|
||
for (double r : res.x) EXPECT_NEAR(r, 0.0, 1e-6);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 3. CP-Euclidean Newton — open mesh (boundary edges)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(NewtonPhase9a, CPEuclidean_OpenTetrahedron_NaturalPhi_Converges)
|
||
{
|
||
auto mesh = make_open_3face_mesh();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
ASSERT_EQ(n, 2);
|
||
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
auto G0 = cp_euclidean_gradient(mesh, x0, m);
|
||
for (auto f : mesh.faces()) {
|
||
int i = m.f_idx[f];
|
||
if (i < 0) continue;
|
||
m.phi_f[f] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
auto res = newton_cp_euclidean(mesh, x0, m);
|
||
EXPECT_TRUE(res.converged);
|
||
EXPECT_LT(res.iterations, 30);
|
||
EXPECT_LT(res.grad_inf_norm, 1e-8);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 4. Inversive-Distance Newton — natural-theta on triangle
|
||
//
|
||
// At u = 0, Bowers-Stephenson init reproduces the input edge lengths
|
||
// exactly. Natural-theta then shifts Θ so the gradient is zero, making
|
||
// u = 0 the equilibrium. Newton must converge in zero iterations.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(NewtonPhase9a, InversiveDistance_NaturalTheta_Triangle_ConvergesInZero)
|
||
{
|
||
auto mesh = make_triangle();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
int n = 0;
|
||
for (auto v : mesh.vertices()) m.v_idx[v] = n++;
|
||
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
auto G0 = inversive_distance_gradient(mesh, x0, m);
|
||
for (auto v : mesh.vertices()) {
|
||
int i = m.v_idx[v];
|
||
m.theta_v[v] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
auto res = newton_inversive_distance(mesh, x0, m);
|
||
EXPECT_TRUE(res.converged);
|
||
EXPECT_EQ(res.iterations, 0);
|
||
EXPECT_LT(res.grad_inf_norm, 1e-10);
|
||
for (double u : res.x) EXPECT_NEAR(u, 0.0, 1e-12);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 5. Inversive-Distance Newton — perturbed start on quad strip
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(NewtonPhase9a, InversiveDistance_PerturbedQuadStrip_Converges)
|
||
{
|
||
auto mesh = make_quad_strip();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
// Pin vertex 0; index the rest.
|
||
auto vit = mesh.vertices().begin();
|
||
m.v_idx[*vit++] = -1;
|
||
int n = 0;
|
||
for (; vit != mesh.vertices().end(); ++vit) m.v_idx[*vit] = n++;
|
||
|
||
// Natural-theta with the pin in place.
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
auto G0 = inversive_distance_gradient(mesh, x0, m);
|
||
for (auto v : mesh.vertices()) {
|
||
int i = m.v_idx[v];
|
||
if (i >= 0) m.theta_v[v] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
// Perturb away from the equilibrium and watch it return.
|
||
std::vector<double> x_pert(static_cast<std::size_t>(n), -0.05);
|
||
auto res = newton_inversive_distance(mesh, x_pert, m);
|
||
|
||
EXPECT_TRUE(res.converged);
|
||
EXPECT_LT(res.iterations, 30);
|
||
EXPECT_LT(res.grad_inf_norm, 1e-8);
|
||
// Strictly-convex unique minimum on the open domain → back to 0.
|
||
for (double u : res.x) EXPECT_NEAR(u, 0.0, 1e-6);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 6. Inversive-Distance Newton — tetrahedron (closed mesh)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(NewtonPhase9a, InversiveDistance_PerturbedTetrahedron_Converges)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
// Closed mesh — pin one vertex to remove the gauge mode.
|
||
auto vit = mesh.vertices().begin();
|
||
m.v_idx[*vit++] = -1;
|
||
int n = 0;
|
||
for (; vit != mesh.vertices().end(); ++vit) m.v_idx[*vit] = n++;
|
||
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
auto G0 = inversive_distance_gradient(mesh, x0, m);
|
||
for (auto v : mesh.vertices()) {
|
||
int i = m.v_idx[v];
|
||
if (i >= 0) m.theta_v[v] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
std::vector<double> x_pert(static_cast<std::size_t>(n), -0.1);
|
||
auto res = newton_inversive_distance(mesh, x_pert, m);
|
||
|
||
EXPECT_TRUE(res.converged);
|
||
EXPECT_LT(res.iterations, 30);
|
||
EXPECT_LT(res.grad_inf_norm, 1e-8);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 6b. Inversive-Distance block-FD Hessian == full-FD Hessian (B1 port)
|
||
//
|
||
// The solver was switched from the O(n·F) full-FD Hessian to the O(F)
|
||
// per-face block-FD Hessian. The two must agree to FD rounding on any
|
||
// configuration — including a perturbed (non-equilibrium) one, where the
|
||
// off-diagonal coupling is non-trivial. This is the locality-lemma
|
||
// cross-validation that licenses the faster path.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(NewtonPhase9a, InversiveDistance_BlockFDHessianMatchesFullFD)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_inversive_distance_maps(mesh);
|
||
compute_inversive_distance_init_from_mesh(mesh, m);
|
||
|
||
// Pin one vertex; index the rest.
|
||
auto vit = mesh.vertices().begin();
|
||
m.v_idx[*vit++] = -1;
|
||
int n = 0;
|
||
for (; vit != mesh.vertices().end(); ++vit) m.v_idx[*vit] = n++;
|
||
|
||
// Evaluate the two Hessians away from equilibrium so off-diagonals are live.
|
||
std::vector<double> x(static_cast<std::size_t>(n), 0.0);
|
||
for (int i = 0; i < n; ++i) x[static_cast<std::size_t>(i)] = 0.07 * (i + 1);
|
||
|
||
auto H_full = inversive_distance_hessian_sym(mesh, x, m);
|
||
auto H_block = inversive_distance_hessian_block_fd_sym(mesh, x, m);
|
||
|
||
ASSERT_EQ(H_full.rows(), H_block.rows());
|
||
ASSERT_EQ(H_full.cols(), H_block.cols());
|
||
|
||
Eigen::MatrixXd Df = Eigen::MatrixXd(H_full);
|
||
Eigen::MatrixXd Db = Eigen::MatrixXd(H_block);
|
||
double max_abs_diff = (Df - Db).cwiseAbs().maxCoeff();
|
||
EXPECT_LT(max_abs_diff, 1e-7)
|
||
<< "block-FD and full-FD Hessians must agree to FD rounding;\n"
|
||
<< "max |Δ| = " << max_abs_diff;
|
||
|
||
// Sanity: the matrices are non-trivial (not both accidentally zero).
|
||
EXPECT_GT(Df.cwiseAbs().maxCoeff(), 1e-3);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// 7. CP-Euclidean Newton — uses analytic Hessian (NOT FD)
|
||
//
|
||
// Regression guard: verify the solver actually calls cp_euclidean_hessian
|
||
// (the analytic 2×2-per-edge formula) rather than degenerating to a
|
||
// per-iteration FD pass. If iteration count exceeds a tight upper bound
|
||
// for a tiny mesh, that would suggest a slow inner Hessian computation
|
||
// or a wrong-sign mistake.
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
TEST(NewtonPhase9a, CPEuclidean_UsesAnalyticHessian)
|
||
{
|
||
auto mesh = make_tetrahedron();
|
||
auto m = setup_cp_euclidean_maps(mesh);
|
||
const int n = assign_cp_euclidean_face_dof_indices(mesh, m);
|
||
|
||
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
|
||
auto G0 = cp_euclidean_gradient(mesh, x0, m);
|
||
for (auto f : mesh.faces()) {
|
||
int i = m.f_idx[f];
|
||
if (i < 0) continue;
|
||
m.phi_f[f] -= G0[static_cast<std::size_t>(i)];
|
||
}
|
||
|
||
// Strong perturbation — quadratic Newton with analytic Hessian
|
||
// should still converge in a handful of iterations.
|
||
std::vector<double> x_pert = {0.5, -0.4, 0.3};
|
||
auto res = newton_cp_euclidean(mesh, x_pert, m);
|
||
|
||
EXPECT_TRUE(res.converged);
|
||
EXPECT_LE(res.iterations, 10)
|
||
<< "analytic Hessian: expect very fast convergence on a 3-DOF problem";
|
||
}
|