feat(p1): CLI extensions + quality measures + stereographic layout
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 2m16s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped

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:
Tarik Moussa
2026-06-01 01:25:43 +02:00
parent b57528d92f
commit 135bcf0bba
13 changed files with 1678 additions and 20 deletions

View File

@@ -435,3 +435,145 @@ TEST(Serialization, LoadResultXml_ThrowsOnMalformedSolverAttribute)
EXPECT_THROW(load_result_xml(path, &res), std::runtime_error);
std::filesystem::remove(path);
}
// ════════════════════════════════════════════════════════════════════════════
// V5 (input-validation audit, 2026-06-01): strict XML subset rejection
//
// Finding V5: the hand-rolled XML reader assumed one element per line.
// Reformatted-but-valid XML (attributes on separate lines, etc.) was silently
// mis-read into zeros rather than rejected. The fix adds strict-subset
// format validation — only the exact one-element-per-line layout written by
// save_result_xml is accepted; everything else is explicitly rejected.
//
// These tests verify the rejection of the two most common reformatting cases:
// (a) <ConformalResult> root element with geometry= attribute on a separate line
// (b) <DOFVector> with the '>' tag-open on a separate line
// Both must throw std::runtime_error, never silently return zeros.
// ════════════════════════════════════════════════════════════════════════════
TEST(Serialization, LoadResultXml_RejectsReformattedRootElement)
{
// V5: the <ConformalResult> root element is split across lines — the
// geometry= attribute is on a separate line from the tag name.
// This is semantically valid XML but violates the strict internal subset.
const std::string path = "/tmp/conflab_reformatted_root.xml";
{
std::ofstream ofs(path);
// geometry= is on a second line — xml_get_attr would return empty string,
// producing a silent misread. The V5 fix must detect this and reject it.
ofs << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
<< "<ConformalResult\n" // tag name only — no geometry= here
<< " geometry=\"euclidean\" vertices=\"3\" faces=\"1\">\n"
<< " <Solver converged=\"true\" iterations=\"1\" grad_inf_norm=\"1e-10\"/>\n"
<< " <DOFVector n=\"2\">0.1 0.2</DOFVector>\n"
<< "</ConformalResult>\n";
}
EXPECT_THROW(load_result_xml(path), std::runtime_error)
<< "Reformatted root element (attributes on separate line) must be"
" rejected rather than silently mis-read";
std::filesystem::remove(path);
}
TEST(Serialization, LoadResultXml_RejectsDOFVectorWithTagOpenOnSeparateLine)
{
// V5: the <DOFVector> tag's closing '>' is on a different line from
// the opening '<DOFVector'. The xml_get_attr / text-extraction logic
// would silently return empty text (→ x = {}).
const std::string path = "/tmp/conflab_reformatted_dof.xml";
{
std::ofstream ofs(path);
ofs << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
<< "<ConformalResult geometry=\"euclidean\" vertices=\"3\" faces=\"1\">\n"
<< " <Solver converged=\"true\" iterations=\"1\" grad_inf_norm=\"1e-10\"/>\n"
<< " <DOFVector\n" // tag open on its own line — no '>' here
<< " n=\"2\">0.1 0.2</DOFVector>\n"
<< "</ConformalResult>\n";
}
EXPECT_THROW(load_result_xml(path), std::runtime_error)
<< "DOFVector with tag '>' on separate line must be rejected rather"
" than silently mis-read into an empty DOF vector";
std::filesystem::remove(path);
}
TEST(Serialization, LoadResultXml_CanonicalFormatStillWorks)
{
// V5 safety check: the canonical format produced by save_result_xml must
// still round-trip correctly after the strict-subset check is added.
// (Regression guard: V5 changes must not break valid round-trips.)
auto mesh = make_triangle();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
auto vit = mesh.vertices().begin();
maps.v_idx[*vit++] = -1;
int idx = 0;
for (; vit != mesh.vertices().end(); ++vit) maps.v_idx[*vit] = idx++;
const int n = idx;
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = euclidean_gradient(mesh, x0, maps);
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (iv >= 0) maps.theta_v[v] -= G0[static_cast<std::size_t>(iv)];
}
auto res = newton_euclidean(mesh, x0, maps, 1e-10, 100);
ASSERT_TRUE(res.converged);
const std::string path = "/tmp/conflab_v5_canonical_check.xml";
ASSERT_NO_THROW(save_result_xml(path, res, "euclidean",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces())));
std::string geom;
NewtonResult res2;
ASSERT_NO_THROW({
auto x2 = load_result_xml(path, &res2, &geom);
EXPECT_EQ(geom, "euclidean");
ASSERT_EQ(x2.size(), res.x.size());
for (std::size_t i = 0; i < x2.size(); ++i)
EXPECT_NEAR(x2[i], res.x[i], 1e-12);
});
std::filesystem::remove(path);
}
// ════════════════════════════════════════════════════════════════════════════
// V6 (input-validation audit, 2026-06-01): DOF-vector vs mesh size check
//
// Finding V6: a DOF vector loaded from a file for a *different* mesh had no
// size check — the mismatch only surfaced later (out-of-bounds or wrong
// answer) when x was indexed against the mesh. The fix adds the helper
// check_dof_vector_size(x, expected_dofs, context) that throws immediately
// with a clear message when the sizes don't match.
// ════════════════════════════════════════════════════════════════════════════
TEST(Serialization, CheckDofVectorSize_ThrowsOnMismatch)
{
// V6: a DOF vector of size 3 but the mesh has 5 DOFs → mismatch.
std::vector<double> x = {0.1, 0.2, 0.3};
EXPECT_THROW(check_dof_vector_size(x, 5, "test.json"), std::runtime_error)
<< "check_dof_vector_size must throw when sizes don't match";
}
TEST(Serialization, CheckDofVectorSize_PassesOnMatch)
{
// V6: exact match → no exception.
std::vector<double> x = {0.1, 0.2, 0.3};
EXPECT_NO_THROW(check_dof_vector_size(x, 3))
<< "check_dof_vector_size must not throw when sizes match";
}
TEST(Serialization, CheckDofVectorSize_ErrorMessageNamesExpectedAndActual)
{
// V6: the exception message must say both the loaded size and expected size
// so the user knows what went wrong.
std::vector<double> x(2, 0.0);
try {
check_dof_vector_size(x, 7, "myfile.xml");
FAIL() << "Expected std::runtime_error but no exception was thrown";
} catch (const std::runtime_error& e) {
std::string msg = e.what();
EXPECT_NE(msg.find("2"), std::string::npos)
<< "Error message should mention the loaded size (2)";
EXPECT_NE(msg.find("7"), std::string::npos)
<< "Error message should mention the expected size (7)";
}
}