Files
ConformalLabpp/code/tests/cgal/test_newton_solver.cpp
Tarik Moussa 2fc465f5cf refactor(solver): H2 unify 5 Newton loops into newton_core (+B2/B3/B4/B5)
The five DCE solvers (Euclidean, Spherical, HyperIdeal, CP-Euclidean,
Inversive-Distance) carried near-identical Newton loops differing only in the
gradient function, the Hessian function, and the spherical concave sign
(test-coverage audit H2 — "5 near-identical Newton loops").  Extract one
detail::newton_core<GradFn, HessFn>(x, grad, hess, concave, tol, max_iter);
each public solver is now a thin wrapper passing two lambdas.  This lets the
performance fixes land once instead of five times:

- B2 (api-perf): line_search now optionally returns the gradient at the
  accepted point; newton_core reuses it as the next iteration's convergence
  gradient, removing ~1 redundant full-mesh gradient evaluation per iteration.
- B4 (api-perf): NewtonLinearSolver keeps one persistent SimplicialLDLT and
  runs analyzePattern once (symbolic factorization cached across iterations),
  re-analyzing only when nnz changes (self-healing for the FD Hessians that
  prune near-zero triplets).  SparseQR fallback preserved verbatim.
- B3 (api-perf): the Euclidean has_edge_dof scan is hoisted out of the loop
  (it is layout-invariant) — now done once before newton_core.
- B5 (api-perf): the dead SimplicialLDLT variable in newton_euclidean is gone.

Behaviour is unchanged: concave spherical still factors −H and solves
(−H)·Δx = G; the merit steepest-descent still uses the true H; HardJava clamp
default preserved.  Tests (+2): NewtonCore.ReusesLineSearchGradient_B2_Convex
asserts exactly 2 gradient evals on a unit-Hessian quadratic (vs 3 pre-B2), and
ConcavePathConverges covers the −H branch directly.  298/298 CGAL tests pass,
including all Java golden-vector parity tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:44:50 +02:00

657 lines
30 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_solver.cpp
//
// 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:
// 1. Converges from x=[-0.2,...] to x*=0 (spherical tetrahedron).
// 2. Converges in few iterations (quadratic convergence near equilibrium).
// 3. Converges from a large perturbation x=[-0.5,...].
// 4. Result fields (x.size, grad_inf_norm) are self-consistent.
//
// Euclidean:
// 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>
using namespace conformallab;
// ────────────────────────────────────────────────────────────────────────────
// Helper: set theta_v[v] = actual angle sum at x=0 for each variable vertex.
//
// Requires that DOF indices have already been assigned (v_idx populated).
// Uses euclidean_gradient directly so the formula is exact and consistent.
// ────────────────────────────────────────────────────────────────────────────
static void set_natural_euclidean_theta(ConformalMesh& mesh, EuclideanMaps& maps, int n)
{
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
// G[iv] = theta_v[v] - sum_alpha(x=0)
// so sum_alpha(x=0) = theta_v[v] - G[iv]
auto G = euclidean_gradient(mesh, x0, maps);
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)];
}
}
// ════════════════════════════════════════════════════════════════════════════
// Spherical 1 — Converges from moderate perturbation
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Spherical_ConvergesFromPerturbation)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_spherical_lambda0_from_mesh(mesh, maps);
int n = assign_spherical_vertex_dof_indices(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(n), -0.2);
auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/50);
EXPECT_TRUE(res.converged)
<< "Newton (spherical) should converge; grad_inf_norm = " << res.grad_inf_norm;
EXPECT_LT(res.grad_inf_norm, 1e-8);
}
// ════════════════════════════════════════════════════════════════════════════
// Spherical 2 — Quadratic convergence: few iterations suffice
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Spherical_FewIterations)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_spherical_lambda0_from_mesh(mesh, maps);
int n = assign_spherical_vertex_dof_indices(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(n), -0.2);
auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/50);
EXPECT_LE(res.iterations, 20)
<< "Newton should converge in ≤ 20 iterations; took " << res.iterations;
}
// ════════════════════════════════════════════════════════════════════════════
// Spherical 3 — Large perturbation: global convergence via line search
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Spherical_ConvergesFromLargePerturbation)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_spherical_lambda0_from_mesh(mesh, maps);
int n = assign_spherical_vertex_dof_indices(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(n), -0.5);
auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100);
EXPECT_TRUE(res.converged)
<< "Newton (spherical, large perturbation) should converge; "
"grad_inf_norm = " << res.grad_inf_norm;
EXPECT_LT(res.grad_inf_norm, 1e-8);
}
// ════════════════════════════════════════════════════════════════════════════
// Spherical 4 — Result fields are self-consistent
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Spherical_ResultFieldsConsistent)
{
auto mesh = make_spherical_tetrahedron();
auto maps = setup_spherical_maps(mesh);
compute_spherical_lambda0_from_mesh(mesh, maps);
int n = assign_spherical_vertex_dof_indices(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(n), -0.1);
auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8);
EXPECT_EQ(static_cast<int>(res.x.size()), n);
// Reported grad_inf_norm must match re-computed gradient at res.x
auto G = spherical_gradient(mesh, res.x, maps);
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-10);
}
// ════════════════════════════════════════════════════════════════════════════
// Euclidean 1 — Triangle with 1 pinned vertex + natural theta
//
// make_triangle(): 3 vertices. Pin v0 → 2 free DOFs.
// With natural theta: x* = 0 (G(0) = 0 by construction).
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Euclidean_ConvergesTrianglePinned)
{
auto mesh = make_triangle();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Pin first vertex, assign sequential DOFs to the other two
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
maps.v_idx[v0] = -1; // pinned
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit)
maps.v_idx[*vit] = idx++;
int n = idx; // = 2
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=*/50);
EXPECT_TRUE(res.converged)
<< "Newton (Euclidean, triangle, pinned) should converge; "
"grad_inf_norm = " << res.grad_inf_norm;
EXPECT_LT(res.grad_inf_norm, 1e-8);
}
// ════════════════════════════════════════════════════════════════════════════
// Euclidean 2 — Quad strip with 1 pinned vertex + natural theta
//
// make_quad_strip(): 4 vertices, 2 faces. Pin v0 → 3 free DOFs.
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Euclidean_ConvergesQuadStripPinned)
{
auto mesh = make_quad_strip();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Pin first vertex
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
maps.v_idx[v0] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit)
maps.v_idx[*vit] = idx++;
int n = idx; // = 3
set_natural_euclidean_theta(mesh, maps, n);
std::vector<double> x0(static_cast<std::size_t>(n), -0.15);
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/50);
EXPECT_TRUE(res.converged)
<< "Newton (Euclidean, quad strip, pinned) should converge; "
"grad_inf_norm = " << res.grad_inf_norm;
EXPECT_LT(res.grad_inf_norm, 1e-8);
}
// ════════════════════════════════════════════════════════════════════════════
// Euclidean 3 — Mixed pinned layout: explicit vertex assignment
//
// Quad strip: v0 pinned, v1/v2/v3 free.
// Natural theta set AFTER DOF assignment so that x* = 0 is the equilibrium
// for the free vertices (with v0 fixed at u0=0).
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Euclidean_ConvergesMixedPinned)
{
auto mesh = make_quad_strip();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Explicitly assign DOF indices
auto vit = mesh.vertices().begin();
Vertex_index v0 = *vit++;
Vertex_index v1 = *vit++;
Vertex_index v2 = *vit++;
Vertex_index v3 = *vit;
maps.v_idx[v0] = -1; // pinned at u0 = 0
maps.v_idx[v1] = 0;
maps.v_idx[v2] = 1;
maps.v_idx[v3] = 2;
const int n = 3;
// Set natural theta AFTER pinning so that x* = [0,0,0] is the equilibrium
set_natural_euclidean_theta(mesh, maps, n);
std::vector<double> x0 = {-0.1, -0.15, -0.05};
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/50);
EXPECT_TRUE(res.converged)
<< "Newton (Euclidean, mixed pinned) should converge; "
"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_hyper_ideal_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_hyper_ideal_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_hyper_ideal_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_hyper_ideal_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);
}
// ════════════════════════════════════════════════════════════════════════════
// C1: solve_linear_system exposes the ok flag via the new out-parameter
//
// The C1 audit finding was that solve_linear_system silently dropped the
// internal ok flag, making double-solver failure invisible to callers.
// These tests verify that the new optional bool* ok parameter is correctly
// wired: ok=true for successful solves, and the parameter is optional (null
// is safe).
//
// Note: constructing a matrix where Eigen's SparseQR hard-fails is not
// practical — SparseQR reports Eigen::Success even for rank-0 inputs,
// returning a zero min-norm solution. The observable contract for callers is
// therefore: ok=true whenever a solver reports success; ok=false only when
// both report failure (an edge case Eigen essentially never produces). The
// fix in C1 ensures that if that edge case ever fires it propagates to the
// caller — previously it was silently swallowed.
// ════════════════════════════════════════════════════════════════════════════
TEST(SparseQRFallback, OkFlag_TrueOnFullRankSystem)
{
// 3×3 diagonal PD matrix: LDLT succeeds → ok must be true.
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;
bool fallback = true;
bool ok = false;
Eigen::VectorXd x = conformallab::solve_linear_system(A, rhs, &fallback, &ok);
EXPECT_TRUE(ok) << "Full-rank system: ok must be true";
EXPECT_FALSE(fallback) << "Full-rank system: LDLT path, no fallback expected";
EXPECT_NEAR(x[0], 1.0, 1e-12);
EXPECT_NEAR(x[1], 2.0, 1e-12);
EXPECT_NEAR(x[2], 3.0, 1e-12);
}
TEST(SparseQRFallback, OkFlag_TrueOnSingularSystemViaFallback)
{
// Singular matrix (zero row/col 1) — LDLT fails, SparseQR succeeds → ok=true.
Eigen::SparseMatrix<double> A(3, 3);
A.insert(0, 0) = 2.0;
A.insert(2, 2) = 3.0;
A.makeCompressed();
Eigen::VectorXd rhs(3);
rhs << 2.0, 0.0, 3.0;
bool fallback = false;
bool ok = false;
Eigen::VectorXd x = conformallab::solve_linear_system(A, rhs, &fallback, &ok);
EXPECT_TRUE(ok) << "Singular-but-consistent system: SparseQR succeeds → ok must be true";
EXPECT_TRUE(fallback) << "Singular system: fallback must have been triggered";
}
TEST(SparseQRFallback, OkFlag_NullPointerIsSafe)
{
// Calling with ok=nullptr must not crash (backward-compatibility).
Eigen::SparseMatrix<double> A(2, 2);
A.insert(0, 0) = 1.0;
A.insert(1, 1) = 1.0;
A.makeCompressed();
Eigen::VectorXd rhs(2);
rhs << 3.0, 5.0;
EXPECT_NO_THROW({
Eigen::VectorXd x = conformallab::solve_linear_system(A, rhs, nullptr, nullptr);
EXPECT_NEAR(x[0], 3.0, 1e-12);
EXPECT_NEAR(x[1], 5.0, 1e-12);
});
}
// ════════════════════════════════════════════════════════════════════════════
// H2 / B2 — newton_core reuses the line-search gradient
//
// All five solvers now share detail::newton_core. B2: the gradient computed at
// the accepted line-search point is carried into the next iteration's
// convergence check instead of being recomputed at the loop top. On a
// unit-Hessian quadratic, exact Newton reaches the minimum in one step, so the
// total gradient-eval count is exactly 2 (1 initial + 1 accepted trial); the
// pre-B2 loop would have recomputed G at the top of iteration 1 → 3.
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonCore, ReusesLineSearchGradient_B2_Convex)
{
const std::vector<double> xstar = {1.0, -2.0, 3.5};
int grad_calls = 0;
auto grad = [&](const std::vector<double>& x) {
++grad_calls;
std::vector<double> g(x.size());
for (std::size_t i = 0; i < x.size(); ++i) g[i] = x[i] - xstar[i];
return g; // G(x) = x x*, H = +I
};
auto hess = [&](const std::vector<double>&) {
Eigen::SparseMatrix<double> H(3, 3);
for (int i = 0; i < 3; ++i) H.insert(i, i) = 1.0;
H.makeCompressed();
return H;
};
auto res = conformallab::detail::newton_core(
std::vector<double>{0.0, 0.0, 0.0}, grad, hess,
/*concave=*/false, /*tol=*/1e-9, /*max_iter=*/50);
ASSERT_TRUE(res.converged);
EXPECT_EQ(res.iterations, 1);
EXPECT_NEAR(res.x[0], 1.0, 1e-9);
EXPECT_NEAR(res.x[1], -2.0, 1e-9);
EXPECT_NEAR(res.x[2], 3.5, 1e-9);
EXPECT_EQ(grad_calls, 2)
<< "B2: the loop-top gradient must be reused from the line search";
}
// Concave path (spherical-style): H is NSD, newton_core factors H and solves
// (H)·Δx = G. G(x) = x* x with H = I makes x* a maximiser; the core must
// still converge in one step.
TEST(NewtonCore, ConcavePathConverges)
{
const std::vector<double> xstar = {0.5, -1.5, 2.0};
int grad_calls = 0;
auto grad = [&](const std::vector<double>& x) {
++grad_calls;
std::vector<double> g(x.size());
for (std::size_t i = 0; i < x.size(); ++i) g[i] = xstar[i] - x[i];
return g; // G(x) = x* x, H = I
};
auto hess = [&](const std::vector<double>&) {
Eigen::SparseMatrix<double> H(3, 3);
for (int i = 0; i < 3; ++i) H.insert(i, i) = -1.0;
H.makeCompressed();
return H;
};
auto res = conformallab::detail::newton_core(
std::vector<double>{0.0, 0.0, 0.0}, grad, hess,
/*concave=*/true, /*tol=*/1e-9, /*max_iter=*/50);
ASSERT_TRUE(res.converged);
EXPECT_EQ(res.iterations, 1);
EXPECT_NEAR(res.x[0], 0.5, 1e-9);
EXPECT_NEAR(res.x[1], -1.5, 1e-9);
EXPECT_NEAR(res.x[2], 2.0, 1e-9);
EXPECT_EQ(grad_calls, 2);
}