feat(p1): CLI extensions + quality measures + stereographic layout
Implement Phase-Session P1 quick wins (4 independent additions):
9h.1: Add --tol and --max-iter CLI options to conformallab_core
- Newton solver tolerance [default 1e-8]
- Newton iteration limit [default 200]
- Thread both through run_euclidean / run_spherical / run_hyper_ideal
- Update CLI parameter table in documentation
9h.2: Add -g cp_euclidean and -g inversive_distance geometry routes
- run_cp_euclidean() & run_inversive_distance() pipelines (~60 lines each)
- Face-based DOF assignment for CP-Euclidean
- Vertex-based DOF assignment for Inversive-Distance
- Both integrated into CLI geometry validator (IsMember)
9g.1: Create conformal_quality.hpp with validation measures
- IsothermicityMeasure: metric anisotropy (conformality deviation)
- DiscreteConformalEquivalenceMeasure: length-cross-ratio residuals
- FlippedTriangles: detects inverted/degenerate triangles
- LengthCrossRatio: discrete conformal invariant computation
- ConvergenceUtility: aggregated convergence statistics (max/mean/sum)
- Ported from Java: plugin/visualizer + convergence utilities
- Includes sanity tests validating finite outputs on valid layouts
9d.3: Create stereographic_layout.hpp for S² → ℂ projection
- Stereographic projection from north pole: S² → ℂ ∪ {∞}
- Inverse projection: ℂ → S² for round-trip validation
- Möbius centring: centres the 2-D point cloud at origin
- stereographic_layout(Layout3D) -> Layout2D conversion
- Round-trip tests: south pole, equator, random sphere points
- Tests: projection/inverse consistency, north pole handling
Test results: 336/336 CGAL tests pass (272 pre-existing + 64 new from all phases)
- conformal_quality.cpp: 13 new tests (measures, isothermic, dce, convergence)
- stereographic_layout.cpp: 10 new tests (projection, inverse, round-trip, layout)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -737,3 +737,107 @@ TEST(NewtonCore, Status_LineSearchStalled)
|
||||
EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled);
|
||||
EXPECT_EQ(res.iterations, 0); // H1: no step completed
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// H5 (test-coverage audit, 2026-06-01): degenerate-triangle integration test
|
||||
//
|
||||
// Finding H5: euclidean_hessian.hpp:85-90 returns {0,0,0,false} for degenerate
|
||||
// triangles (triangle inequality violated or area = 0), making the assembled
|
||||
// Hessian singular. This path had no integration test: the behavior on a
|
||||
// near-degenerate mesh was undefined.
|
||||
//
|
||||
// Test strategy: build a mesh with a very thin/sliver triangle (aspect ratio
|
||||
// ~1000:1) so that euclidean_cot_weights returns valid=true but the Hessian
|
||||
// is severely ill-conditioned (the cotangent weights blow up for a near-zero
|
||||
// area). Then feed this through newton_euclidean and characterize the result:
|
||||
// either converges (the SparseQR fallback handles the ill-conditioned H) or
|
||||
// reports a non-Converged status. In either case the solver must not crash,
|
||||
// must not produce NaN in the result, and the behavior is documented.
|
||||
//
|
||||
// We also test the exact-degenerate case (zero-area triangle), where
|
||||
// euclidean_cot_weights explicitly returns valid=false and the Hessian row/col
|
||||
// for those DOFs is zero → the SparseQR fallback must handle it without crash.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
TEST(NewtonSolver, Euclidean_SliverTriangle_CharacterizedBehavior)
|
||||
{
|
||||
// Build a very thin sliver triangle: v0=(0,0), v1=(1,0), v2=(0,1e-4).
|
||||
// Area ≈ 5e-5, aspect ratio ≈ 10000. The cot weights are valid (triangle
|
||||
// inequality holds) but the cotangent at v2 is huge (≈ l01/Area).
|
||||
ConformalMesh mesh;
|
||||
auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0));
|
||||
auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0));
|
||||
auto v2 = mesh.add_vertex(Point3(0.0, 1e-4, 0.0));
|
||||
mesh.add_face(v0, v1, v2);
|
||||
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
// Pin v0; assign DOF indices to v1 and v2.
|
||||
maps.v_idx[v0] = -1;
|
||||
maps.v_idx[v1] = 0;
|
||||
maps.v_idx[v2] = 1;
|
||||
const int n = 2;
|
||||
|
||||
// Natural theta: equilibrium at x* = 0 by construction.
|
||||
set_natural_euclidean_theta(mesh, maps, n);
|
||||
|
||||
std::vector<double> x0(n, 0.0);
|
||||
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100);
|
||||
|
||||
// H5 acceptance criterion: behavior is characterized, not undefined.
|
||||
// The solver must not crash or produce NaN.
|
||||
EXPECT_EQ(static_cast<int>(res.x.size()), n)
|
||||
<< "Result vector must always be populated";
|
||||
for (double xi : res.x)
|
||||
EXPECT_FALSE(std::isnan(xi)) << "NaN in result x — degenerate-triangle path";
|
||||
EXPECT_FALSE(std::isnan(res.grad_inf_norm))
|
||||
<< "NaN in grad_inf_norm — degenerate-triangle path";
|
||||
|
||||
// Document the outcome: the sliver has valid cotangent weights (they are
|
||||
// large but finite), so the Hessian is positive-definite; Newton converges
|
||||
// (possibly via SparseQR for numerical stability).
|
||||
// We tolerate both converged and non-converged outcomes; what matters is
|
||||
// that the result is finite and the status is meaningful.
|
||||
EXPECT_NE(res.status, NewtonStatus::LinearSolverFailed)
|
||||
<< "A sliver triangle should not cause both LDLT and SparseQR to fail;"
|
||||
" the system is still consistent (just ill-conditioned).";
|
||||
}
|
||||
|
||||
TEST(NewtonSolver, Euclidean_ExactDegenerateTriangle_NoCrash)
|
||||
{
|
||||
// Build a degenerate triangle: all three vertices collinear → area = 0.
|
||||
// v0=(0,0), v1=(1,0), v2=(2,0). This forces kahan <= 0 in
|
||||
// euclidean_cot_weights → {0,0,0,false}. The assembled Hessian is the
|
||||
// zero matrix → both LDLT and SparseQR fall through gracefully.
|
||||
ConformalMesh mesh;
|
||||
auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0));
|
||||
auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0));
|
||||
auto v2 = mesh.add_vertex(Point3(2.0, 0.0, 0.0));
|
||||
mesh.add_face(v0, v1, v2);
|
||||
|
||||
auto maps = setup_euclidean_maps(mesh);
|
||||
compute_euclidean_lambda0_from_mesh(mesh, maps);
|
||||
|
||||
maps.v_idx[v0] = -1;
|
||||
maps.v_idx[v1] = 0;
|
||||
maps.v_idx[v2] = 1;
|
||||
const int n = 2;
|
||||
|
||||
// Use zero theta (not natural theta) — we just want to verify no crash.
|
||||
std::vector<double> x0(n, 0.0);
|
||||
|
||||
// H5 acceptance criterion: no crash, no UB, result struct populated.
|
||||
NewtonResult res;
|
||||
ASSERT_NO_THROW(res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/5));
|
||||
|
||||
EXPECT_EQ(static_cast<int>(res.x.size()), n);
|
||||
// A zero Hessian cannot be solved → either solver fails → LinearSolverFailed,
|
||||
// OR SparseQR finds a trivially-zero step and the loop exits via MaxIterations.
|
||||
// Either is an acceptable documented outcome; what must NOT happen is a crash.
|
||||
EXPECT_TRUE(res.status == NewtonStatus::LinearSolverFailed
|
||||
|| res.status == NewtonStatus::MaxIterations
|
||||
|| res.status == NewtonStatus::LineSearchStalled)
|
||||
<< "Exact-degenerate triangle: expected documented failure status, got "
|
||||
<< to_string(res.status);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user