// 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_lambda0_from_mesh(mesh, maps); int n = assign_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_lambda0_from_mesh(mesh, maps); int n = assign_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_lambda0_from_mesh(mesh, maps); int n = assign_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_lambda0_from_mesh(mesh, maps); int n = assign_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_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_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_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_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); }