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>
This commit is contained in:
Tarik Moussa
2026-05-31 16:37:26 +02:00
parent 202b9a108d
commit 2fc465f5cf
2 changed files with 280 additions and 250 deletions

View File

@@ -579,3 +579,78 @@ TEST(SparseQRFallback, OkFlag_NullPointerIsSafe)
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);
}