fix(dof-assign): correct "pin before" docs + add gauge-vertex overloads

Finding-D from doc/reviewer/external-audit-2026-05-30.md.

All three DOF-assignment functions iterated over every vertex
unconditionally, overwriting any v_idx set before the call.  The
documentation said "pin before OR after" — the "before" option was
silently wrong (the pin would be overwritten).  For the
inversive-distance variant the doc explicitly said the pre-call pin
would make the function "a no-op for that vertex", which was false.

Changes (three headers):
- euclidean_functional.hpp  assign_euclidean_vertex_dof_indices()
- spherical_functional.hpp  assign_vertex_dof_indices()
- inversive_distance_functional.hpp  assign_inversive_distance_vertex_dof_indices()

For each:
  1. Single-arg overload: doc corrected to "pin AFTER, not before;
     pre-call pins are overwritten"
  2. New two-arg overload accepting a Vertex_index gauge: pins the
     requested vertex (v_idx=-1) in a single pass, preventing the
     user error entirely

Three new GTests in test_euclidean_functional.cpp:
  SingleArg_PinBeforeHasNoEffect        — documents the old pitfall
  TwoArg_GaugeIsPinnedOthersAreSequential — verifies the new overload
  TwoArg_NewtonConvergesWithGaugeOverload — end-to-end correctness

266/266 CGAL tests pass, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-05-31 00:20:43 +02:00
parent bef5a0ceb7
commit cfbbc1b21f
5 changed files with 148 additions and 11 deletions

View File

@@ -645,3 +645,87 @@ TEST(EuclideanFunctional, CyclicHessian_Analytic_MatchesBlockFD_Tetrahedron)
max_sym = std::max(max_sym, std::abs(Ha.coeff(i, j) - Ha.coeff(j, i)));
EXPECT_LT(max_sym, 1e-9) << "analytic cyclic Hessian is not symmetric";
}
// ════════════════════════════════════════════════════════════════════════════
// DOF assignment gauge-vertex overload (Finding-D, external-audit-2026-05-30)
//
// The single-argument assign_euclidean_vertex_dof_indices() overwrites ALL
// v_idx unconditionally — setting a pin *before* the call has no effect.
// The two-argument overload (gauge vertex) pins the requested vertex in a
// single pass. These tests verify:
// (a) single-argument: gauge must be set AFTER, not before.
// (b) two-argument: the gauge vertex gets v_idx = -1, others are sequential.
// (c) Newton converges correctly when the gauge overload is used.
// ════════════════════════════════════════════════════════════════════════════
TEST(EuclideanDOFAssignment, SingleArg_PinBeforeHasNoEffect)
{
// Pin first vertex before the call → the call overwrites it → not pinned.
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
auto first = *mesh.vertices().begin();
maps.v_idx[first] = -1; // set pin BEFORE — should have no effect
assign_euclidean_vertex_dof_indices(mesh, maps);
EXPECT_GE(maps.v_idx[first], 0)
<< "Pre-call pin was overwritten: v_idx[first] must be >= 0 after the call";
EXPECT_EQ(euclidean_dimension(mesh, maps),
static_cast<int>(mesh.number_of_vertices()))
<< "All vertices should be free after single-arg assign";
}
TEST(EuclideanDOFAssignment, TwoArg_GaugeIsPinnedOthersAreSequential)
{
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
auto first = *mesh.vertices().begin();
int n = assign_euclidean_vertex_dof_indices(mesh, maps, first);
EXPECT_EQ(maps.v_idx[first], -1)
<< "Gauge vertex must have v_idx = -1";
EXPECT_EQ(n, static_cast<int>(mesh.number_of_vertices()) - 1)
<< "Returned DOF count must be num_vertices - 1";
EXPECT_EQ(euclidean_dimension(mesh, maps), n)
<< "euclidean_dimension must equal returned count";
// All non-gauge vertices must have distinct indices in [0, n).
std::vector<int> seen;
for (auto v : mesh.vertices()) {
int iv = maps.v_idx[v];
if (v == first) continue;
EXPECT_GE(iv, 0);
EXPECT_LT(iv, n);
seen.push_back(iv);
}
std::sort(seen.begin(), seen.end());
for (int i = 0; i < n; ++i)
EXPECT_EQ(seen[static_cast<std::size_t>(i)], i)
<< "DOF indices must be sequential 0..n-1";
}
TEST(EuclideanDOFAssignment, TwoArg_NewtonConvergesWithGaugeOverload)
{
// End-to-end: use the gauge overload, then run Newton — confirms the
// pinned-vertex DOF layout is consistent with the solver.
auto mesh = make_tetrahedron();
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
auto first = *mesh.vertices().begin();
int n = assign_euclidean_vertex_dof_indices(mesh, maps, first);
// Natural-theta: set targets = actual angle sums at x=0 so x*=0 is the solution.
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, 50);
EXPECT_TRUE(res.converged)
<< "Newton did not converge with gauge-overload DOF assignment";
EXPECT_LT(res.grad_inf_norm, 1e-9);
}