Files
ConformalLabpp/code/tests/cgal/test_newton_phase9a.cpp
Tarik Moussa 2dc4ddcc32 perf(inv-dist): B1 port — block-FD Hessian for newton_inversive_distance
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>
2026-05-31 19:44:50 +02:00

312 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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";
}