feat(num): N3 selectable scale-floor clamp mode (HardJava | SmoothBarrier)

The hyper-ideal vertex scale b is floored to keep the geometry valid.  The
original clamp `b<0 → 0.01` mirrors the Java oracle but is only C⁰ (in fact
value-discontinuous at b=0): a Newton step crossing the feasibility boundary
hits a kink that can stall convergence (numerical-stability audit N3).

Rather than replace the Java-faithful behaviour (which would break the golden
parity tests), make the floor a selectable mode so BOTH the Java standpoint
and the clean mathematics are available:

- HyperIdealScaleClamp::HardJava (DEFAULT) — the original snap, bit-for-bit
  faithful to HyperIdealFunctional.java → all parity tests unchanged.
- HyperIdealScaleClamp::SmoothBarrier — C¹ softplus floor
  b ↦ floor + softplus_β(b−floor), β = HYPER_IDEAL_SCALE_SHARPNESS (=100);
  ≈ identity away from the floor, smooth across b=0.  Opt-in.

clamp_hyper_ideal_scale centralises the logic (also folds in the N4 nachzügler:
compute_face_angles used a bare 0.01).  The mode threads with a defaulted
trailing parameter through compute_face_angles, face_angles_from_local_dofs,
evaluate_hyper_ideal, the four hyper_ideal_hessian* variants and
newton_hyper_ideal — so every existing call site keeps HardJava behaviour.

Tests (+4): clamp-function C¹/floor/identity contract, mode-equivalence away
from the boundary, and end-to-end SmoothBarrier convergence to the same Java
golden vector (LawsonHyperIdeal).  296/296 CGAL tests pass.
Documented in doc/math/geometry-modes.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-31 16:27:48 +02:00
parent a1a7f216e0
commit 202b9a108d
7 changed files with 242 additions and 27 deletions

View File

@@ -400,6 +400,12 @@ inline NewtonResult newton_spherical(
/// \param max_iter Maximum Newton iterations. Default: 200.
/// \param hess_eps Finite-difference step for Hessian approximation. Default: 1e-5.
/// (Phase 9b will replace this with an analytic Hessian.)
/// \param clamp Vertex-scale floor mode (N3 audit). Default `HardJava`
/// reproduces the Java oracle's hard `b<0 → floor` snap
/// (C⁰, parity-faithful). `SmoothBarrier` uses the C¹
/// softplus floor — smoother near the feasibility boundary
/// but deviates from the Java golden values. See
/// `HyperIdealScaleClamp` in hyper_ideal_functional.hpp.
/// \return NewtonResult{x*, iterations, grad_inf_norm, converged}.
///
/// \see Springborn (2020), Theorem 1.3 for the strict convexity proof.
@@ -410,7 +416,8 @@ inline NewtonResult newton_hyper_ideal(
const HyperIdealMaps& m,
double tol = 1e-8,
int max_iter = 200,
double hess_eps = 1e-5)
double hess_eps = 1e-5,
HyperIdealScaleClamp clamp = HyperIdealScaleClamp::HardJava)
{
std::vector<double> x = x0;
const int n = static_cast<int>(x.size());
@@ -422,7 +429,7 @@ inline NewtonResult newton_hyper_ideal(
for (int iter = 0; iter < max_iter; ++iter) {
// ── Gradient ──────────────────────────────────────────────────────────
auto G_std = evaluate_hyper_ideal(mesh, x, m, /*energy=*/false).gradient;
auto G_std = evaluate_hyper_ideal(mesh, x, m, /*energy=*/false, /*grad=*/true, clamp).gradient;
Eigen::Map<const Eigen::VectorXd> G(G_std.data(), n);
double inf_norm = G.cwiseAbs().maxCoeff();
@@ -435,7 +442,7 @@ inline NewtonResult newton_hyper_ideal(
}
// ── Hessian (block-FD, ~331166× faster than full-FD) + solve H·Δx = G
auto H = hyper_ideal_hessian_block_fd_sym(mesh, x, m, hess_eps);
auto H = hyper_ideal_hessian_block_fd_sym(mesh, x, m, hess_eps, clamp);
bool ok = false;
Eigen::VectorXd dx = detail::solve_with_fallback(H, -G, ok);
if (!ok) break;
@@ -446,14 +453,14 @@ inline NewtonResult newton_hyper_ideal(
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;
return evaluate_hyper_ideal(mesh, xnew, m, false, true, clamp).gradient;
}, &improved);
if (!improved) break;
res.iterations = iter + 1;
}
auto G_final = evaluate_hyper_ideal(mesh, x, m, false).gradient;
auto G_final = evaluate_hyper_ideal(mesh, x, m, false, true, clamp).gradient;
double inf_final = 0.0;
for (double v : G_final) inf_final = std::max(inf_final, std::abs(v));
res.grad_inf_norm = inf_final;