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:
@@ -338,3 +338,81 @@ TEST(HyperIdealHessian, BlockFD_FasterThanFullFD)
|
||||
EXPECT_GE(ms_full, 3 * ms_block)
|
||||
<< "Block-FD should be at least 3× faster than full-FD on this mesh";
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// N3 — Scale-floor clamp modes (HardJava vs SmoothBarrier)
|
||||
//
|
||||
// The vertex-scale floor can be applied two ways (HyperIdealScaleClamp):
|
||||
// • HardJava (default): b<0 → floor. Faithful to Java, but only C⁰ — and
|
||||
// in fact value-discontinuous at b=0 (jumps from `floor` to 0).
|
||||
// • SmoothBarrier: floor + softplus_β(b−floor). C¹ everywhere, ≈ b away
|
||||
// from the floor, and keeps b ≥ floor > 0 smoothly.
|
||||
// These tests pin the contract of both modes and the default-preservation.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(HyperIdealClamp, SmoothBarrierIsContinuousAndC1AcrossZero)
|
||||
{
|
||||
const auto Hard = HyperIdealScaleClamp::HardJava;
|
||||
const auto Smooth = HyperIdealScaleClamp::SmoothBarrier;
|
||||
auto f = [](double b, HyperIdealScaleClamp mode) {
|
||||
return clamp_hyper_ideal_scale(b, /*variable=*/true, mode);
|
||||
};
|
||||
|
||||
const double eps = 1e-6;
|
||||
|
||||
// HardJava jumps in VALUE at b=0 (floor on the left, 0 on the right).
|
||||
EXPECT_GT(std::abs(f(-eps, Hard) - f(+eps, Hard)), 0.5 * HYPER_IDEAL_SCALE_FLOOR);
|
||||
|
||||
// SmoothBarrier is continuous in value across b=0 …
|
||||
EXPECT_NEAR(f(-eps, Smooth), f(+eps, Smooth), 1e-4);
|
||||
|
||||
// … and C¹: the one-sided slopes across b=0 agree (the HardJava slopes,
|
||||
// 0 on the left and 1 on the right, would not).
|
||||
auto slope = [&](double b0, HyperIdealScaleClamp mode) {
|
||||
return (f(b0 + eps, mode) - f(b0 - eps, mode)) / (2.0 * eps);
|
||||
};
|
||||
const double sL = slope(-1e-3, Smooth);
|
||||
const double sR = slope(+1e-3, Smooth);
|
||||
EXPECT_NEAR(sL, sR, 5e-2) << "SmoothBarrier derivative should be continuous";
|
||||
}
|
||||
|
||||
TEST(HyperIdealClamp, SmoothBarrierStaysAboveFloorAndApproachesIdentity)
|
||||
{
|
||||
const auto Smooth = HyperIdealScaleClamp::SmoothBarrier;
|
||||
auto f = [&](double b) { return clamp_hyper_ideal_scale(b, true, Smooth); };
|
||||
|
||||
// At or above the floor for any input (mathematically > floor; for very
|
||||
// negative b the softplus underflows to 0 in double, giving exactly floor).
|
||||
EXPECT_GE(f(-1e6), HYPER_IDEAL_SCALE_FLOOR);
|
||||
EXPECT_GE(f(-1.0), HYPER_IDEAL_SCALE_FLOOR);
|
||||
EXPECT_GT(f(0.0), 0.0);
|
||||
EXPECT_LT(f(-1e6) - HYPER_IDEAL_SCALE_FLOOR, 1e-9); // converges down to floor
|
||||
|
||||
// ≈ identity well above the floor (the normal operating regime).
|
||||
EXPECT_NEAR(f(1.0), 1.0, 1e-9);
|
||||
EXPECT_NEAR(f(5.0), 5.0, 1e-12);
|
||||
|
||||
// Pinned DOFs are never clamped, regardless of mode.
|
||||
EXPECT_EQ(clamp_hyper_ideal_scale(-3.0, /*variable=*/false, Smooth), -3.0);
|
||||
}
|
||||
|
||||
// Away from the feasibility boundary (all b = 1 ≫ floor), the two modes must
|
||||
// produce the same Hessian — SmoothBarrier only differs near b ≈ floor, so the
|
||||
// default-mode parity results are not perturbed in the normal regime.
|
||||
TEST(HyperIdealClamp, ModesAgreeAwayFromBoundary)
|
||||
{
|
||||
auto mesh = make_tetrahedron();
|
||||
auto m = setup_hyper_ideal_maps(mesh);
|
||||
const int n = assign_all_dof_indices(mesh, m);
|
||||
auto x = natural_x(mesh, m); // b = 1, a = 0.5 — all well above the floor
|
||||
|
||||
auto H_hard = hyper_ideal_hessian_block_fd_sym(
|
||||
mesh, x, m, 1e-5, HyperIdealScaleClamp::HardJava);
|
||||
auto H_soft = hyper_ideal_hessian_block_fd_sym(
|
||||
mesh, x, m, 1e-5, HyperIdealScaleClamp::SmoothBarrier);
|
||||
|
||||
Eigen::MatrixXd Dh(H_hard), Ds(H_soft);
|
||||
EXPECT_LT((Dh - Ds).cwiseAbs().maxCoeff(), 1e-9)
|
||||
<< "clamp modes must agree when every scale is well above the floor";
|
||||
(void)n;
|
||||
}
|
||||
|
||||
@@ -194,6 +194,54 @@ TEST(LawsonHyperIdeal, ConvergenceGoldenVector_JavaXVal)
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// N3 — SmoothBarrier clamp mode converges to the same golden solution
|
||||
//
|
||||
// The C¹ smooth-barrier scale floor is an opt-in alternative to the Java hard
|
||||
// clamp. On this well-posed problem the scales stay well above the floor
|
||||
// throughout the solve, so the barrier is ≈ identity and the converged vector
|
||||
// must match the same golden classes as the HardJava run above — demonstrating
|
||||
// the smooth mode is a drop-in that does not move the solution when the clamp
|
||||
// region is never entered.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
TEST(LawsonHyperIdeal, SmoothBarrierConvergesToGoldenVector)
|
||||
{
|
||||
std::set<Edge_index> original;
|
||||
ConformalMesh m = make_lawson_square_tiled(&original);
|
||||
|
||||
HyperIdealMaps maps = setup_hyper_ideal_maps(m);
|
||||
const int n = assign_all_dof_indices(m, maps);
|
||||
ASSERT_EQ(n, 4 + 18);
|
||||
|
||||
for (auto e : m.edges())
|
||||
if (original.count(e)) maps.theta_e[e] = PI / 2.0;
|
||||
|
||||
std::vector<double> x0(static_cast<std::size_t>(n), 1.0);
|
||||
auto res = newton_hyper_ideal(m, x0, maps, /*tol=*/1e-10, /*max_iter=*/200,
|
||||
/*hess_eps=*/1e-5,
|
||||
HyperIdealScaleClamp::SmoothBarrier);
|
||||
ASSERT_TRUE(res.converged)
|
||||
<< "SmoothBarrier HyperIdeal Newton did not converge; ||G||="
|
||||
<< res.grad_inf_norm;
|
||||
|
||||
constexpr double b_gold = 1.1462158341786262;
|
||||
constexpr double a_orig_gold = 1.7627471737467797;
|
||||
constexpr double a_aux_gold = 2.633915794495759;
|
||||
const double tol = 1e-5;
|
||||
|
||||
for (auto v : m.vertices()) {
|
||||
const int iv = maps.v_idx[v];
|
||||
ASSERT_GE(iv, 0);
|
||||
EXPECT_NEAR(res.x[static_cast<std::size_t>(iv)], b_gold, tol);
|
||||
}
|
||||
for (auto e : m.edges()) {
|
||||
const int ie = maps.e_idx[e];
|
||||
ASSERT_GE(ie, 0);
|
||||
const double gold = original.count(e) ? a_orig_gold : a_aux_gold;
|
||||
EXPECT_NEAR(res.x[static_cast<std::size_t>(ie)], gold, tol);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Branch-points variant — Java HyperIdealConvergenceTest...WithBranchPoints
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user