feat(solver): S2 — NewtonResult status enum (I1) + iteration fix (H1) + conditioning diagnostics (N7)

Builds on the newton_core refactor; all three findings live in exactly that code.

I1 (test-coverage): add NewtonStatus { Converged, MaxIterations,
LinearSolverFailed, LineSearchStalled } and a `status` field to NewtonResult, so
the three non-convergent exits that `converged == false` previously conflated are
now distinguishable.  `converged` is kept (== status==Converged) for back-compat;
+ to_string(NewtonStatus) for logs/tests.  newton_core sets the status at each
exit point (centralised by the H2 refactor).

H1 (test-coverage): set res.iterations explicitly at the LinearSolverFailed and
LineSearchStalled breaks (= completed steps), instead of relying on the last
successful iteration's stale value.

N7 (numerical-stability): surface linear-algebra conditioning in the result —
`sparse_qr_fallback_used` (any iteration fell back to SparseQR) and
`min_ldlt_pivot` (smallest |Dᵢᵢ| of the last LDLT, a cheap near-singularity
proxy).  This catches the silent case the audit flagged: on a gauge-singular
Hessian SimplicialLDLT "succeeds" with a ~0 pivot and no fallback fires — now
min_ldlt_pivot is tiny and observable.

Tests (+3 synthetic newton_core status/diagnostic tests; +1 assertion on the
closed-mesh-no-pin fallback test).  All purely additive — no control-flow or
convergence behaviour changes; 301/301 CGAL tests pass incl. Java parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-31 18:39:19 +02:00
parent 74d41e99a8
commit 1bf1defc70
2 changed files with 146 additions and 8 deletions

View File

@@ -500,6 +500,15 @@ TEST(SparseQRFallback, Euclidean_ClosedMeshNoPinConverges)
<< "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;
}
// ════════════════════════════════════════════════════════════════════════════
@@ -654,3 +663,77 @@ TEST(NewtonCore, ConcavePathConverges)
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<double> diag_sparse(std::initializer_list<double> d)
{
const int n = static_cast<int>(d.size());
Eigen::SparseMatrix<double> 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<double> xstar = {2.0, -1.0};
auto grad = [&](const std::vector<double>& x) {
return std::vector<double>{ x[0] - xstar[0], x[1] - xstar[1] };
};
auto hess = [&](const std::vector<double>&) { return diag_sparse({1.0, 1.0}); };
auto res = conformallab::detail::newton_core(
std::vector<double>{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<double>& x) {
return std::vector<double>{ x[0]*x[0]*x[0] };
};
auto hess = [&](const std::vector<double>& x) {
return diag_sparse({ 3.0 * x[0] * x[0] });
};
auto res = conformallab::detail::newton_core(
std::vector<double>{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<double>&) {
return std::vector<double>{ 1.0, 1.0 }; // constant, independent of x
};
auto hess = [&](const std::vector<double>&) { return diag_sparse({1.0, 1.0}); };
auto res = conformallab::detail::newton_core(
std::vector<double>{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
}