Session 1 (2026-05-31) resolved 26 findings across Sonnet/Haiku/Opus; 298/298 CGAL tests green. Bring the audit docs in line with reality: - README.md: G0 banner now records that the original authors were contacted by email about porting/relicensing rights (awaiting reply); headline-status cells updated per finding; new "Session 1 — implemented" record; link to the plan. - Per-audit resolution banners (api-performance, numerical-stability, input-validation, test-coverage, math-citation, thread-safety) + G0-blocked banners (cgal-submission, dependency-license). - NEW finding-orchestration.md: the meta-plan mapping every finding to a session, an implementing model (Haiku/Sonnet/Opus by the "how much must be understood" rule), and an Opus review gate after each implementation session. Records S1 (done) and lays out S2–S6 so the next session can be picked up cold. Docs only — no code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
API-Consistency & Performance Audit — ConformalLabpp
Date: 2026-05-31
Auditor: External reviewer (Claude Opus 4.8)
Scope: Low-level API (code/include/*.hpp), high-level CGAL API (code/include/CGAL/), Newton-solver performance
Focus: Public-API naming consistency and runtime performance — NOT port-faithfulness or test coverage.
This document is self-contained. A new session can pick up any finding below and act on it without prior context. Each finding includes:
- exact file path + line numbers (verified by direct file read)
- a minimal reproduction of the problematic code
- the recommended fix
- acceptance criteria for "done"
Status legend: 🔴 Critical · 🟡 Important · 🔵 Hint / nice-to-have
✅ Resolution status (2026-05-31, Session 1): A1–A3 ✅ (renamed with
[[deprecated]]aliases), B1 ✅ both halves (HyperIdeal + Inversive-Distance block-FD), B2/B3/B4/B5 ✅ (folded into thenewton_corerefactor — see test-coverage H2). A4/A5 deferred until the G0/G1 licensing outcome (public CGAL surface). 298/298 tests green. Plan:finding-orchestration.md.
Companion documents (no overlap in findings):
external-audit-2026-05-30.md— port-faithfulness bugstest-coverage-error-handling-audit-2026-05-31.md— test gaps + error handling- this file — API consistency + performance
Cross-reference: performance findings B2/B3/B5 below share root cause with
FINDING-H2("five near-identical Newton loops") in the test-coverage audit. A singlenewton_corerefactor can resolve all of them together.
How to read this document in a new session
# Build the CGAL suite (the API + solvers live behind WITH_CGAL_TESTS)
cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON
cmake --build build-cgal --target conformallab_cgal_tests -j$(nproc)
# Baseline before any change
ctest --test-dir build-cgal -R '^cgal\.' --output-on-failure
# Expected: 246 passed, 0 failed
For performance findings, the smoke meshes used by the project are:
cathead.obj(open, F≈248)brezel.obj(genus-1, F≈13824)brezel2.obj(genus-2) exercised bycode/tests/cgal/test_scalability_smoke.cpp.
Summary table
| ID | Sev | Title | Primary location |
|---|---|---|---|
| B1 | 🔴 | newton_hyper_ideal uses full-FD Hessian; the 96–1166× faster block-FD exists but is unused. Inversive-Distance has no fast path at all |
newton_solver.hpp:433, :580 |
| A1 | 🔴 | assign_*_dof_indices: three different naming conventions across the 5 functionals |
5 functional headers |
| B2 | 🟡 | line_search discards the gradient at the accepted point → ~1 redundant full gradient eval per Newton iteration (×5 solvers) |
newton_solver.hpp |
| B3 | 🟡 | has_edge_dof O(E) scan runs every Newton iteration (layout is loop-invariant) |
newton_solver.hpp:264 |
| A4 | 🟡 | High-level CGAL entry points have inconsistent suffix (_map only on one) |
code/include/CGAL/*.h |
| A5 | 🟡 | The two circle-packing models return different result types | Discrete_circle_packing.h, Discrete_inversive_distance.h |
| A2 | 🟡 | compute_*_from_mesh: inconsistent prefix + verb (lambda0 vs init) |
3 functional headers |
| A3 | 🟡 | gradient_check (HyperIdeal) lacks the _<geom> suffix the other 4 have |
hyper_ideal_functional.hpp:470 |
| B4 | 🟡 | SimplicialLDLT symbolic factorization rebuilt every iteration (sparsity pattern is constant) | newton_solver.hpp:73 |
| B5 | 🔵 | Dead SimplicialLDLT solver; variable + per-call gradient heap allocation |
newton_solver.hpp:244, euclidean_functional.hpp:193 |
Part A — API consistency
Context: the project has two API layers. The high-level CGAL layer (
CGAL::discrete_*+ named parameters) intentionally follows CGAL conventions — that is correct and not a finding by itself. The low-level free-function layer (setup_*,assign_*,*_gradient) is the one used directly by tests and internal callers, and it is internally inconsistent.setup_<geom>_mapsIS uniform across all five functionals, which proves the convention is achievable — it simply was not held for the other verbs.
FINDING-A1 — 🔴 assign_*_dof_indices: three conventions for one operation
Location
euclidean_functional.hpp:107,132spherical_functional.hpp:99,123hyper_ideal_functional.hpp:107cp_euclidean_functional.hpp:140inversive_distance_functional.hpp:133
Problem
| Functional | Vertex variant | All variant |
|---|---|---|
| Euclidean | assign_euclidean_vertex_dof_indices |
assign_euclidean_all_dof_indices |
| Spherical | assign_vertex_dof_indices ⚠️ no prefix |
assign_all_spherical_dof_indices ⚠️ infix |
| HyperIdeal | — | assign_all_dof_indices ⚠️ no prefix |
| CP-Euclidean | assign_cp_euclidean_face_dof_indices |
— |
| InversiveDist | assign_inversive_distance_vertex_dof_indices |
— |
Three patterns: assign_<geom>_<scope>_dof_indices, assign_<scope>_dof_indices
(no geometry), and assign_all_<geom>_dof_indices (geometry in the middle).
The prefix-less assign_vertex_dof_indices (spherical) and assign_all_dof_indices
(hyper-ideal) read as generic but are geometry-specific, which is misleading at
the call site (e.g. test_newton_solver.cpp:81 calls assign_vertex_dof_indices).
Fix — DECIDED (2026-05-31)
Standardize on assign_<geom>_<scope>_dof_indices (matches the already-consistent
setup_<geom>_maps). Confirmed renames:
| Old | New |
|---|---|
assign_vertex_dof_indices (spherical) |
assign_spherical_vertex_dof_indices |
assign_all_spherical_dof_indices |
assign_spherical_all_dof_indices |
assign_all_dof_indices (hyper-ideal) |
assign_hyper_ideal_all_dof_indices |
assign_euclidean_vertex_dof_indices |
unchanged ✓ |
assign_euclidean_all_dof_indices |
unchanged ✓ |
assign_cp_euclidean_face_dof_indices |
unchanged ✓ |
assign_inversive_distance_vertex_dof_indices |
unchanged ✓ |
Keep the old names as [[deprecated]] inline wrappers for one release so existing
call sites (tests, examples) keep compiling, then update all call sites.
Acceptance criteria
- All five functionals follow
assign_<geom>_<scope>_dof_indices. - Old names exist as
[[deprecated]]aliases (or all call sites updated in the same PR). - Full CGAL suite still 246/246.
FINDING-A2 — 🟡 compute_*_from_mesh: inconsistent prefix and verb
Location
euclidean_functional.hpp:152—compute_euclidean_lambda0_from_meshspherical_functional.hpp:148—compute_lambda0_from_mesh⚠️ no prefixinversive_distance_functional.hpp:187—compute_inversive_distance_init_from_mesh⚠️initnotlambda0
Problem
compute_lambda0_from_mesh (spherical) has no geometry prefix and is used directly
in test_newton_solver.cpp:80,99. Inversive-Distance uses the verb init instead
of lambda0 for the conceptually parallel step.
Fix — DECIDED (2026-05-31)
compute_lambda0_from_mesh→compute_spherical_lambda0_from_mesh(add prefix).compute_inversive_distance_init_from_mesh→ keepinit. Rationale: this step initializesI_eandr0, i.e. genuinely more than just λ₀, soinitis the honest verb. The distinction (lambda0vsinit) will be documented indoc/api/extending.md.compute_euclidean_lambda0_from_mesh→ unchanged ✓.[[deprecated]]alias for the spherical rename.
Acceptance criteria
- Spherical compute fn carries the
sphericalprefix. - Naming rationale for
initvslambda0documented indoc/api/extending.md.
FINDING-A3 — 🟡 gradient_check (HyperIdeal) breaks the _<geom> suffix pattern
Location
euclidean_functional.hpp:324—gradient_check_euclideanspherical_functional.hpp:367—gradient_check_sphericalcp_euclidean_functional.hpp:331—gradient_check_cp_euclideaninversive_distance_functional.hpp:346—gradient_check_inversive_distancehyper_ideal_functional.hpp:470—gradient_check⚠️ no suffix
Fix — DECIDED (2026-05-31)
Rename gradient_check → gradient_check_hyper_ideal with a [[deprecated]] alias.
The other four (gradient_check_euclidean/_spherical/_cp_euclidean/_inversive_distance)
are unchanged ✓.
Acceptance criteria
- All five follow
gradient_check_<geom>.
FINDING-A4 — 🟡 High-level CGAL entry points: inconsistent suffix
Location
code/include/CGAL/Discrete_conformal_map.h, Discrete_circle_packing.h,
Discrete_inversive_distance.h
Problem
CGAL::discrete_conformal_map_euclidean // no trailing word
CGAL::discrete_conformal_map_spherical
CGAL::discrete_conformal_map_hyper_ideal
CGAL::discrete_circle_packing_euclidean // no _map
CGAL::discrete_inversive_distance_map // ⚠️ trailing _map
discrete_inversive_distance_map ends in _map; discrete_circle_packing_euclidean
does not — yet both are circle-packing models.
Fix — DECIDED (2026-05-31)
discrete_inversive_distance_map → discrete_inversive_distance_euclidean (drop
the trailing _map, add the geometry word), so it matches
discrete_circle_packing_euclidean. Because this is the public CGAL API, treat
it as a breaking change: add the new name, mark the old [[deprecated]], announce in
CHANGELOG.md.
⚠️ Sequencing: A4 touches the public CGAL surface. Do this rename only after the G0/G1 license/provenance outcome is known (see CGAL audit) — there is no point stabilizing a public API on a package whose release status is unresolved. A1–A3 are internal and can proceed now.
Acceptance criteria
- The two circle-packing entry points share a naming shape
(
discrete_*_euclidean). - CHANGELOG documents the rename; deprecated alias provided.
FINDING-A5 — 🟡 The two circle-packing models return different result types
Location
Discrete_circle_packing.h:100→ returnsCircle_packing_result<FT>Discrete_inversive_distance.h:133,146→ returnsConformal_map_result<FT>
Problem
CP-Euclidean and Inversive-Distance are both circle-packing models, but
discrete_circle_packing_euclidean returns Circle_packing_result while
discrete_inversive_distance_map returns Conformal_map_result. A user switching
between the two packers must rewrite their result-handling code, even though the
semantics (per-element radii + Newton diagnostics) are the same.
Fix — DECIDED (2026-05-31)
Option (a): both circle-packing entry points return Circle_packing_result.
discrete_inversive_distance_* switches from Conformal_map_result to
Circle_packing_result (semantically truthful — both produce circle radii). The
unified-result option (b) is rejected as over-engineered.
⚠️ Same sequencing as A4 — public-API change, do after the G0/G1 outcome.
Acceptance criteria
- The two packing entry points return
Circle_packing_result. - If a field is renamed (e.g.
u_per_vertex→radius_per_*), document it in CHANGELOG.
Part B — Performance
FINDING-B1 — 🔴 Fast Hessian paths exist but the solvers don't use them
Location
newton_solver.hpp:433—newton_hyper_idealcallshyper_ideal_hessian_sym(...)(the full-FD O(n·F) version)hyper_ideal_hessian.hpp:220—hyper_ideal_hessian_block_fd_sym(...)(the fast block-FD version) exists and is tested but is never called by the solvernewton_solver.hpp:580-608—newton_inversive_distancebuilds a full-FD Hessian inline (2 gradient evals per DOF), with no fast path at all
Problem
HyperIdeal: the solver uses the slow full-FD Hessian even though a block-FD
version with a documented 33× speedup on cathead, ~1166× on brezel already
exists (hyper_ideal_hessian.hpp:130-136). The fast code is implemented, tested
(test_hyper_ideal_hessian.cpp), and simply not wired into the solver.
// newton_solver.hpp:433 — current (slow):
auto H = hyper_ideal_hessian_sym(mesh, x, m, hess_eps); // full-FD, O(n·F)
// available (fast, same result up to FD noise):
auto H = hyper_ideal_hessian_block_fd_sym(mesh, x, m, hess_eps); // block-FD
Inversive-Distance: the inline build_hessian lambda runs 2n full gradient
evaluations per Hessian (each O(F)), i.e. O(n·F) per Newton iteration — quadratic
in mesh size. No block-FD or analytic path exists yet.
// newton_solver.hpp:585-601 — current:
for (int j = 0; j < n; ++j) {
auto Gp = inversive_distance_gradient(mesh, xp, m); // O(F)
auto Gm = inversive_distance_gradient(mesh, xm, m); // O(F) → 2n total per Hessian
...
}
Fix
- HyperIdeal (cheap win): switch line 433 to
hyper_ideal_hessian_block_fd_sym. Verify the existing convergence tests (test_newton_solver.cppHyperIdeal cases,test_newton_phase9a.cpp) still pass within tolerance — block-FD is mathematically equivalent up to FD rounding (locality lemma, documented athyper_ideal_hessian.hpp:120-128). - Inversive-Distance: port a block-FD Hessian mirroring the HyperIdeal one
(the gradient is also face-decomposable). Track the analytic Glickenstein-2011
Hessian separately as already noted in
doc/roadmap/research-track.md.
Acceptance criteria
newton_hyper_idealuses block-FD; HyperIdeal convergence tests still pass.- A timing assertion or smoke test demonstrates the speedup on
brezel.obj(or at minimum, the change is benchmarked and recorded indoc/architecture/). - Inversive-Distance has a block-FD path;
test_newton_phase9a.cppstill passes.
FINDING-B2 — 🟡 line_search discards the gradient at the accepted point
Location
newton_solver.hpp — detail::line_search (lines 140-201) and all 5 solver loops
(e.g. Euclidean lines 248, 277-280, 287)
Problem
Per Newton iteration the gradient over the whole mesh is computed redundantly:
- line 248 — gradient for the convergence check
- inside
line_search— gradient at each trial point; the vector is discarded, only its norm is kept - next iteration's line 248 — recomputes the gradient at the just-accepted point (already computed in step 2)
- line 287 (
G_final) — recomputes once more after the loop
That is roughly one redundant full-mesh gradient per iteration plus one at the end.
Fix
Have line_search return (via out-param) the gradient vector at the accepted point,
and feed it into the next iteration's convergence check instead of recomputing. The
end-of-loop G_final can reuse the last accepted gradient too.
Best done as part of the
newton_coretemplate refactor (test-coverage audit FINDING-H2) so the fix lands once instead of five times.
Acceptance criteria
- Gradient evaluations per iteration drop by ~1 (instrument with a counter in a test).
- All convergence tests pass unchanged.
FINDING-B3 — 🟡 has_edge_dof scan runs every Newton iteration
Location
newton_solver.hpp:264-268 (inside the newton_euclidean iteration loop)
Problem
bool has_edge_dof = false;
for (auto e : mesh.edges()) // O(E), runs EVERY iteration
if (m.e_idx[e] >= 0) { has_edge_dof = true; break; }
The DOF layout (m.e_idx) does not change during the solve, so this scan is
loop-invariant. Over up to 200 iterations the mesh edge set is scanned 200× for no
reason.
Fix
Hoist the scan above the for (iter...) loop; compute has_edge_dof once.
Acceptance criteria
has_edge_dofcomputed exactly once pernewton_euclideancall.- Euclidean tests pass unchanged.
FINDING-B4 — 🟡 Symbolic factorization rebuilt every iteration
Location
newton_solver.hpp:73 (detail::solve_with_fallback)
Problem
Eigen::SimplicialLDLT<Eigen::SparseMatrix<double>> ldlt(A); // analyze + factorize
A fresh SimplicialLDLT is constructed each iteration, which re-runs the symbolic
analysis (fill-reducing reordering / COLAMD). But the sparsity pattern of the
Hessian is constant across iterations (only the numeric values change). Eigen
supports analyzePattern() once + factorize() per iteration, skipping the
repeated symbolic step.
Fix
This needs a structural change: solve_with_fallback is stateless, so it cannot
cache the analysis. Either:
- thread a persistent solver object through the Newton loop (cleanest with the
newton_corerefactor), callinganalyzePatternon the first iteration andfactorizethereafter, or - key a cached symbolic factorization on the matrix sparsity pattern.
Keep the SparseQR fallback for the singular-pattern case (the pattern there is also constant, so the same caching applies).
Acceptance criteria
- Symbolic analysis runs once per solve, not once per iteration.
- All Newton + fallback tests (incl. the 3 SparseQR tests) pass.
- Benchmarked on
brezel.obj; improvement recorded.
FINDING-B5 — 🔵 Dead variable + per-call gradient allocation
Location
newton_solver.hpp:244—Eigen::SimplicialLDLT<...> solver;declared innewton_euclidean, never used (the solve goes throughsolve_with_fallback)euclidean_functional.hpp:193,197— eacheuclidean_gradientcall freshly allocatesstd::vector<double> G(n)andstd::vector<double> h_alpha(nh)
Problem
- The dead
solvervariable is harmless but misleading (suggests reuse that doesn't happen — directly relevant once B4 is addressed). - In the FD Hessian (B1) the per-call allocation means
2n × 2fresh heap allocations per Hessian build.
Fix
- Remove the dead
solverdeclaration. - When addressing B1/B2, pass reusable scratch buffers (
G,h_alpha) into the gradient functions, or provide an overload that writes into a caller-owned buffer.
Acceptance criteria
- Dead variable removed (compiles clean with
-Wunused). - (Optional, with B1/B2) gradient functions support buffer reuse; allocation count in the FD Hessian path drops measurably.
Suggested order of work
- B1 (HyperIdeal half) — one-line switch to
hyper_ideal_hessian_block_fd_sym; biggest win for the smallest change. Verify tests, benchmark. - B3 — trivial loop hoist.
- A1, A2, A3 — low-level renames with
[[deprecated]]aliases; do together. - A4, A5 — high-level CGAL API politur; coordinate with CHANGELOG (breaking).
- newton_core refactor (test-coverage audit FINDING-H2) — then fold in B2, B4, B5 in the same pass.
- B1 (Inversive-Distance half) — port a block-FD Hessian; larger effort.
What is already good (do not "fix")
setup_<geom>_mapsis uniform across all five functionals — the consistency target is proven achievable.- The block-FD HyperIdeal Hessian is correct, tested, and well-documented — it just needs to be wired into the solver (B1).
- The high-level CGAL API correctly uses CGAL named parameters and
Kernel_traits— idiomatic for a CGAL package. Conformal_map_resultcarriessparse_qr_fallback_used— good observability.