fix+test: Euclidean holonomy/τ end-to-end + spherical edge-DOF oracle (2026-05-29 audit)
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 1m57s
API Docs / doc-build (pull_request) Successful in 1m3s
Markdown link check / check (pull_request) Successful in 44s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Successful in 2m11s
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 1m57s
API Docs / doc-build (pull_request) Successful in 1m3s
Markdown link check / check (pull_request) Successful in 44s
C++ Tests / test-cgal (pull_request) Has been skipped
C++ Tests / quality-gates (pull_request) Successful in 2m11s
Bundles the 2026-05-29 Java↔C++ math-correctness audit (doc/reviewer/ java-port-audit.md, 11 findings) with two follow-up fixes. Audit code changes: - Finding 3 (spherical_functional): edge-DOF replacement parameterization via spher_eff_lambda; edge gradient α_opp⁺+α_opp⁻−θ_e (drops additive −(S⁺+S⁻)/2) - Finding 4 (spherical_hessian): always-compiled edge-DOF throw guard - Finding 6 (period_matrix): faithful normalizeModulus (0≤Re≤½, Im≥0, |τ|≥1) - Finding 9 (inversive_distance): degenerate-face limiting angles, no skip - Findings 1/2 (euclidean): degenerate gradient limiting angles + Hessian guard Euclidean holonomy/τ fix: develop the cut surface across the dual spanning tree only (cut_graph now exposes is_dual_tree), so genus-1 cut edges yield non-degenerate lattice generators. Previously τ came out 0 / NaN / 1e13 on the bundled tori; now matches the analytic revolution modulus i·√(R²−r²)/r. Re-enabled τ reporting in the Euclidean CLI; rewrote validation.md §3/§4 accordingly. Tests (240 CGAL, 0 skipped): - HolonomyEndToEnd ×3 — tori of revolution (4×4, hex 6×6, 8×8) vs analytic modulus - SphericalFunctional.EdgeGradient_RegularTetClosedForm — independent closed-form π/3 oracle locking the Finding-3 edge formula (the path-integral FD check cannot detect a wrong-but-conservative gradient) Also documents the latent spherical/hyperbolic holonomy-extraction bug (same single-development pattern, dead code today) in research-track.md (Phase 9c/10), and adds favour/normalisations to the codespell ignore list. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -115,35 +115,89 @@ inline Eigen::VectorXd solve_linear_system(
|
||||
|
||||
namespace detail { // re-open for the remaining helpers
|
||||
|
||||
// Backtracking line search: find the largest α in {1, 0.5, 0.25, …} such that
|
||||
// ||G(x + α·Δx)||₂ < ||G(x)||₂. Returns the accepted step (α may stay 1).
|
||||
// Globalised line search for the Newton system G(x) = 0.
|
||||
//
|
||||
// Merit function f(x) = ½‖G(x)‖²₂. Driving f to its minimum drives the
|
||||
// residual G to zero; because the merit only depends on ‖G‖ it is sign-agnostic
|
||||
// and works identically for the convex Euclidean / HyperIdeal / CP / InvDist
|
||||
// energies and the concave Spherical energy.
|
||||
//
|
||||
// Phase 1 — Newton direction `dx` (satisfies H·dx = −G, so the merit slope
|
||||
// ∇f·dx = GᵀH·dx = −‖G‖² < 0 — always a descent direction).
|
||||
// Backtrack α ∈ {1, ½, ¼, …} until the Armijo sufficient-decrease
|
||||
// condition holds:
|
||||
// ‖G(x + α·dx)‖² ≤ (1 − 2·c1·α)·‖G(x)‖²
|
||||
//
|
||||
// Phase 2 — if Phase 1 exhausts its halvings, fall back to the steepest-
|
||||
// descent direction of the merit, `d_sd = −H·G` (slope
|
||||
// ∇f·d_sd = −‖H·G‖² ≤ 0 regardless of the definiteness of H), with
|
||||
// the analogous Armijo test:
|
||||
// ‖G(x + α·d_sd)‖² ≤ ‖G(x)‖² − 2·c1·α·‖d_sd‖²
|
||||
//
|
||||
// If neither phase satisfies Armijo, return the best (smallest-residual) point
|
||||
// visited. If nothing beat ‖G(x)‖, return x unchanged and set *improved=false,
|
||||
// so the caller can stop cleanly instead of taking the old divergent full step.
|
||||
template <typename GradFn>
|
||||
inline std::vector<double> line_search(
|
||||
const std::vector<double>& x,
|
||||
const Eigen::VectorXd& dx,
|
||||
const Eigen::VectorXd& d_sd,
|
||||
double norm0,
|
||||
GradFn&& grad_fn,
|
||||
int max_halvings = 20)
|
||||
bool* improved = nullptr,
|
||||
int max_halvings = 20,
|
||||
double c1 = 1e-4)
|
||||
{
|
||||
const int n = static_cast<int>(x.size());
|
||||
double alpha = 1.0;
|
||||
std::vector<double> xnew(static_cast<std::size_t>(n));
|
||||
const int n = static_cast<int>(x.size());
|
||||
const double norm0_sq = norm0 * norm0;
|
||||
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
std::vector<double> xnew(static_cast<std::size_t>(n));
|
||||
std::vector<double> best_x = x;
|
||||
double best_norm = norm0;
|
||||
|
||||
// Evaluate ‖G(x + α·dir)‖₂, leaving the trial point in `xnew`.
|
||||
auto eval = [&](const Eigen::VectorXd& dir, double alpha) -> double {
|
||||
for (int i = 0; i < n; ++i)
|
||||
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)]
|
||||
+ alpha * dx[i];
|
||||
xnew[static_cast<std::size_t>(i)] =
|
||||
x[static_cast<std::size_t>(i)] + alpha * dir[i];
|
||||
auto Gnew = grad_fn(xnew);
|
||||
double norm_new = 0.0;
|
||||
for (double v : Gnew) norm_new += v * v;
|
||||
norm_new = std::sqrt(norm_new);
|
||||
if (norm_new < norm0) return xnew;
|
||||
double s = 0.0;
|
||||
for (double v : Gnew) s += v * v;
|
||||
return std::sqrt(s);
|
||||
};
|
||||
|
||||
// ── Phase 1: Newton direction, Armijo backtracking ────────────────────────
|
||||
double alpha = 1.0;
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
double norm_new = eval(dx, alpha);
|
||||
if (norm_new < best_norm) { best_norm = norm_new; best_x = xnew; }
|
||||
if (norm_new * norm_new <= (1.0 - 2.0 * c1 * alpha) * norm0_sq) {
|
||||
if (improved) *improved = true;
|
||||
return xnew;
|
||||
}
|
||||
alpha *= 0.5;
|
||||
}
|
||||
// No improvement found — return best attempt (full step)
|
||||
for (int i = 0; i < n; ++i)
|
||||
xnew[static_cast<std::size_t>(i)] = x[static_cast<std::size_t>(i)] + dx[i];
|
||||
return xnew;
|
||||
|
||||
// ── Phase 2: steepest-descent fallback (−H·G), Armijo backtracking ────────
|
||||
const double dsd_sq = d_sd.squaredNorm();
|
||||
if (dsd_sq > 0.0) {
|
||||
alpha = 1.0;
|
||||
for (int ls = 0; ls < max_halvings; ++ls) {
|
||||
double thresh_sq = norm0_sq - 2.0 * c1 * alpha * dsd_sq;
|
||||
double norm_new = eval(d_sd, alpha);
|
||||
if (norm_new < best_norm) { best_norm = norm_new; best_x = xnew; }
|
||||
if (thresh_sq >= 0.0 && norm_new * norm_new <= thresh_sq) {
|
||||
if (improved) *improved = true;
|
||||
return xnew;
|
||||
}
|
||||
alpha *= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Both phases failed Armijo — never take the divergent full step. ───────
|
||||
// Return the best point seen; if none improved, stay put and signal stall.
|
||||
if (improved) *improved = (best_norm < norm0);
|
||||
return best_x;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
@@ -209,12 +263,15 @@ inline NewtonResult newton_euclidean(
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G); // merit-function steepest descent
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return euclidean_gradient(mesh, xnew, m);
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break; // line search stalled — stop cleanly
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
@@ -287,12 +344,16 @@ inline NewtonResult newton_spherical(
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(negH, G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
|
||||
// d_sd uses the actual (un-negated) Hessian: ∇f = H·G for f = ½‖G‖².
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return spherical_gradient(mesh, xnew, m);
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break;
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
@@ -367,12 +428,15 @@ inline NewtonResult newton_hyper_ideal(
|
||||
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
|
||||
if (!ok) break;
|
||||
|
||||
// ── Backtracking line search ──────────────────────────────────────────
|
||||
// ── Globalised line search (Armijo + steepest-descent fallback) ───────
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return evaluate_hyper_ideal(mesh, xnew, m, false).gradient;
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break;
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
@@ -443,10 +507,13 @@ inline NewtonResult newton_cp_euclidean(
|
||||
if (!ok) break;
|
||||
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return cp_euclidean_gradient(mesh, xnew, m);
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break;
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
@@ -552,10 +619,13 @@ inline NewtonResult newton_inversive_distance(
|
||||
if (!ok) break;
|
||||
|
||||
double norm0 = G.norm();
|
||||
x = detail::line_search(x, dx, norm0,
|
||||
Eigen::VectorXd d_sd = -(H * G);
|
||||
bool improved = true;
|
||||
x = detail::line_search(x, dx, d_sd, norm0,
|
||||
[&](const std::vector<double>& xnew) {
|
||||
return inversive_distance_gradient(mesh, xnew, m);
|
||||
});
|
||||
}, &improved);
|
||||
if (!improved) break;
|
||||
|
||||
res.iterations = iter + 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user