// 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 #include #include #include 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 x0(static_cast(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(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 x0(static_cast(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 x0(static_cast(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 x0(static_cast(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 x0(static_cast(n), -0.1); auto res = newton_spherical(mesh, x0, maps, /*tol=*/1e-8); EXPECT_EQ(static_cast(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 x0(static_cast(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 x0(static_cast(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 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 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(n); std::vector xbase(sz, 0.0); for (auto v : mesh.vertices()) { int iv = maps.v_idx[v]; if (iv >= 0) xbase[static_cast(iv)] = b_base; } for (auto e : mesh.edges()) { int ie = maps.e_idx[e]; if (ie >= 0) xbase[static_cast(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(iv)]; } for (auto e : mesh.edges()) { int ie = maps.e_idx[e]; if (ie < 0) continue; maps.theta_e[e] += G[static_cast(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 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 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(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 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 x0(static_cast(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(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(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 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 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 x0(static_cast(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); EXPECT_EQ(res.status, NewtonStatus::Converged); // N7: the closed mesh with no pinned vertex has a gauge-singular Hessian. // That near-singularity must be observable in the diagnostics — either the // SparseQR fallback fired, or (when LDLT "succeeds" with a ~0 pivot, which is // exactly the silent case N7 targets) the smallest LDLT pivot is tiny. EXPECT_TRUE(res.sparse_qr_fallback_used || res.min_ldlt_pivot < 1e-6) << "gauge singularity should surface via fallback flag or min_ldlt_pivot; " << "fallback=" << res.sparse_qr_fallback_used << " min_pivot=" << res.min_ldlt_pivot; } // ════════════════════════════════════════════════════════════════════════════ // 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 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 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 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 xstar = {1.0, -2.0, 3.5}; int grad_calls = 0; auto grad = [&](const std::vector& x) { ++grad_calls; std::vector 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&) { Eigen::SparseMatrix 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{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 xstar = {0.5, -1.5, 2.0}; int grad_calls = 0; auto grad = [&](const std::vector& x) { ++grad_calls; std::vector 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&) { Eigen::SparseMatrix 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{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); } // ════════════════════════════════════════════════════════════════════════════ // I1 / H1 / N7 — newton_core termination status, iteration count, diagnostics // ════════════════════════════════════════════════════════════════════════════ namespace { // 1×1 sparse identity / scalar helpers for the synthetic newton_core tests. inline Eigen::SparseMatrix diag_sparse(std::initializer_list d) { const int n = static_cast(d.size()); Eigen::SparseMatrix H(n, n); int i = 0; for (double v : d) { H.insert(i, i) = v; ++i; } H.makeCompressed(); return H; } } // namespace // I1: a converging solve reports status == Converged and the N7 pivot is the // (well-conditioned) unit diagonal; no SparseQR fallback. TEST(NewtonCore, Status_Converged_AndDiagnostics_N7) { const std::vector xstar = {2.0, -1.0}; auto grad = [&](const std::vector& x) { return std::vector{ x[0] - xstar[0], x[1] - xstar[1] }; }; auto hess = [&](const std::vector&) { return diag_sparse({1.0, 1.0}); }; auto res = conformallab::detail::newton_core( std::vector{0.0, 0.0}, grad, hess, /*concave=*/false, 1e-9, 50); EXPECT_TRUE(res.converged); EXPECT_EQ(res.status, conformallab::NewtonStatus::Converged); EXPECT_EQ(res.iterations, 1); EXPECT_FALSE(res.sparse_qr_fallback_used); // N7 EXPECT_NEAR(res.min_ldlt_pivot, 1.0, 1e-12); // N7: D = I } // I1: a slow (linearly-convergent cubic) problem that does not reach tol within // max_iter reports MaxIterations, with iterations == max_iter (H1). TEST(NewtonCore, Status_MaxIterations) { // G(x) = x³, H = 3x² → Newton map x ← (2/3)x (linear convergence). auto grad = [&](const std::vector& x) { return std::vector{ x[0]*x[0]*x[0] }; }; auto hess = [&](const std::vector& x) { return diag_sparse({ 3.0 * x[0] * x[0] }); }; auto res = conformallab::detail::newton_core( std::vector{1.0}, grad, hess, /*concave=*/false, /*tol=*/1e-8, /*max_iter=*/3); EXPECT_FALSE(res.converged); EXPECT_EQ(res.status, conformallab::NewtonStatus::MaxIterations); EXPECT_EQ(res.iterations, 3); } // I1/H1: a constant non-zero gradient cannot be reduced by any step, so the // line search stalls on the first iteration → LineSearchStalled, iterations == 0. TEST(NewtonCore, Status_LineSearchStalled) { auto grad = [&](const std::vector&) { return std::vector{ 1.0, 1.0 }; // constant, independent of x }; auto hess = [&](const std::vector&) { return diag_sparse({1.0, 1.0}); }; auto res = conformallab::detail::newton_core( std::vector{0.0, 0.0}, grad, hess, /*concave=*/false, 1e-9, 50); EXPECT_FALSE(res.converged); EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled); EXPECT_EQ(res.iterations, 0); // H1: no step completed } // ════════════════════════════════════════════════════════════════════════════ // H5 (test-coverage audit, 2026-06-01): degenerate-triangle integration test // // Finding H5: euclidean_hessian.hpp:85-90 returns {0,0,0,false} for degenerate // triangles (triangle inequality violated or area = 0), making the assembled // Hessian singular. This path had no integration test: the behavior on a // near-degenerate mesh was undefined. // // Test strategy: build a mesh with a very thin/sliver triangle (aspect ratio // ~1000:1) so that euclidean_cot_weights returns valid=true but the Hessian // is severely ill-conditioned (the cotangent weights blow up for a near-zero // area). Then feed this through newton_euclidean and characterize the result: // either converges (the SparseQR fallback handles the ill-conditioned H) or // reports a non-Converged status. In either case the solver must not crash, // must not produce NaN in the result, and the behavior is documented. // // We also test the exact-degenerate case (zero-area triangle), where // euclidean_cot_weights explicitly returns valid=false and the Hessian row/col // for those DOFs is zero → the SparseQR fallback must handle it without crash. // ════════════════════════════════════════════════════════════════════════════ TEST(NewtonSolver, Euclidean_SliverTriangle_CharacterizedBehavior) { // Build a very thin sliver triangle: v0=(0,0), v1=(1,0), v2=(0,1e-4). // Area ≈ 5e-5, aspect ratio ≈ 10000. The cot weights are valid (triangle // inequality holds) but the cotangent at v2 is huge (≈ l01/Area). ConformalMesh mesh; auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0)); auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0)); auto v2 = mesh.add_vertex(Point3(0.0, 1e-4, 0.0)); mesh.add_face(v0, v1, v2); auto maps = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, maps); // Pin v0; assign DOF indices to v1 and v2. maps.v_idx[v0] = -1; maps.v_idx[v1] = 0; maps.v_idx[v2] = 1; const int n = 2; // Natural theta: equilibrium at x* = 0 by construction. set_natural_euclidean_theta(mesh, maps, n); std::vector x0(n, 0.0); auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100); // H5 acceptance criterion: behavior is characterized, not undefined. // The solver must not crash or produce NaN. EXPECT_EQ(static_cast(res.x.size()), n) << "Result vector must always be populated"; for (double xi : res.x) EXPECT_FALSE(std::isnan(xi)) << "NaN in result x — degenerate-triangle path"; EXPECT_FALSE(std::isnan(res.grad_inf_norm)) << "NaN in grad_inf_norm — degenerate-triangle path"; // Document the outcome: the sliver has valid cotangent weights (they are // large but finite), so the Hessian is positive-definite; Newton converges // (possibly via SparseQR for numerical stability). // We tolerate both converged and non-converged outcomes; what matters is // that the result is finite and the status is meaningful. EXPECT_NE(res.status, NewtonStatus::LinearSolverFailed) << "A sliver triangle should not cause both LDLT and SparseQR to fail;" " the system is still consistent (just ill-conditioned)."; } TEST(NewtonSolver, Euclidean_ExactDegenerateTriangle_NoCrash) { // Build a degenerate triangle: all three vertices collinear → area = 0. // v0=(0,0), v1=(1,0), v2=(2,0). This forces kahan <= 0 in // euclidean_cot_weights → {0,0,0,false}. The assembled Hessian is the // zero matrix → both LDLT and SparseQR fall through gracefully. ConformalMesh mesh; auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0)); auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0)); auto v2 = mesh.add_vertex(Point3(2.0, 0.0, 0.0)); mesh.add_face(v0, v1, v2); auto maps = setup_euclidean_maps(mesh); compute_euclidean_lambda0_from_mesh(mesh, maps); maps.v_idx[v0] = -1; maps.v_idx[v1] = 0; maps.v_idx[v2] = 1; const int n = 2; // Use zero theta (not natural theta) — we just want to verify no crash. std::vector x0(n, 0.0); // H5 acceptance criterion: no crash, no UB, result struct populated. NewtonResult res; ASSERT_NO_THROW(res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/5)); EXPECT_EQ(static_cast(res.x.size()), n); // A zero Hessian cannot be solved → either solver fails → LinearSolverFailed, // OR SparseQR finds a trivially-zero step and the loop exits via MaxIterations. // Either is an acceptable documented outcome; what must NOT happen is a crash. EXPECT_TRUE(res.status == NewtonStatus::LinearSolverFailed || res.status == NewtonStatus::MaxIterations || res.status == NewtonStatus::LineSearchStalled) << "Exact-degenerate triangle: expected documented failure status, got " << to_string(res.status); }