# 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 > **Companion documents (no overlap in findings):** > - `external-audit-2026-05-30.md` โ€” port-faithfulness bugs > - `test-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 single `newton_core` refactor can resolve all of them together. --- ## How to read this document in a new session ```bash # 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 by `code/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 `_` 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__maps` IS > 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,132` - `spherical_functional.hpp:99,123` - `hyper_ideal_functional.hpp:107` - `cp_euclidean_functional.hpp:140` - `inversive_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___dof_indices`, `assign__dof_indices` (no geometry), and `assign_all__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___dof_indices` (matches the already-consistent `setup__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___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_mesh` - `spherical_functional.hpp:148` โ€” `compute_lambda0_from_mesh` โš ๏ธ no prefix - `inversive_distance_functional.hpp:187` โ€” `compute_inversive_distance_init_from_mesh` โš ๏ธ `init` not `lambda0` ### 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` โ†’ **keep `init`**. Rationale: this step initializes `I_e` and `r0`, i.e. genuinely more than just ฮปโ‚€, so `init` is the honest verb. The distinction (`lambda0` vs `init`) will be documented in `doc/api/extending.md`. - `compute_euclidean_lambda0_from_mesh` โ†’ unchanged โœ“. - `[[deprecated]]` alias for the spherical rename. ### Acceptance criteria - Spherical compute fn carries the `spherical` prefix. - Naming rationale for `init` vs `lambda0` documented in `doc/api/extending.md`. --- ## FINDING-A3 โ€” ๐ŸŸก `gradient_check` (HyperIdeal) breaks the `_` suffix pattern ### Location - `euclidean_functional.hpp:324` โ€” `gradient_check_euclidean` - `spherical_functional.hpp:367` โ€” `gradient_check_spherical` - `cp_euclidean_functional.hpp:331` โ€” `gradient_check_cp_euclidean` - `inversive_distance_functional.hpp:346` โ€” `gradient_check_inversive_distance` - `hyper_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_`. --- ## 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 ```cpp 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` โ†’ returns `Circle_packing_result` - `Discrete_inversive_distance.h:133,146` โ†’ returns `Conformal_map_result` ### 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_ideal` calls `hyper_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 solver - `newton_solver.hpp:580-608` โ€” `newton_inversive_distance` builds 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. ```cpp // 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. ```cpp // 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 1. **HyperIdeal (cheap win):** switch line 433 to `hyper_ideal_hessian_block_fd_sym`. Verify the existing convergence tests (`test_newton_solver.cpp` HyperIdeal cases, `test_newton_phase9a.cpp`) still pass within tolerance โ€” block-FD is mathematically equivalent up to FD rounding (locality lemma, documented at `hyper_ideal_hessian.hpp:120-128`). 2. **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_ideal` uses 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 in `doc/architecture/`). - Inversive-Distance has a block-FD path; `test_newton_phase9a.cpp` still 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: 1. line 248 โ€” gradient for the convergence check 2. inside `line_search` โ€” gradient at each trial point; the **vector is discarded**, only its norm is kept 3. next iteration's line 248 โ€” recomputes the gradient at the just-accepted point (already computed in step 2) 4. 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_core` template 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 ```cpp 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_dof` computed exactly once per `newton_euclidean` call. - Euclidean tests pass unchanged. --- ## FINDING-B4 โ€” ๐ŸŸก Symbolic factorization rebuilt every iteration ### Location `newton_solver.hpp:73` (`detail::solve_with_fallback`) ### Problem ```cpp Eigen::SimplicialLDLT> 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_core` refactor), calling `analyzePattern` on the first iteration and `factorize` thereafter, 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 in `newton_euclidean`, **never used** (the solve goes through `solve_with_fallback`) - `euclidean_functional.hpp:193,197` โ€” each `euclidean_gradient` call freshly allocates `std::vector G(n)` and `std::vector h_alpha(nh)` ### Problem - The dead `solver` variable 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 ร— 2` fresh heap allocations per Hessian build. ### Fix - Remove the dead `solver` declaration. - 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 1. **B1 (HyperIdeal half)** โ€” one-line switch to `hyper_ideal_hessian_block_fd_sym`; biggest win for the smallest change. Verify tests, benchmark. 2. **B3** โ€” trivial loop hoist. 3. **A1, A2, A3** โ€” low-level renames with `[[deprecated]]` aliases; do together. 4. **A4, A5** โ€” high-level CGAL API politur; coordinate with CHANGELOG (breaking). 5. **newton_core refactor** (test-coverage audit FINDING-H2) โ€” then fold in **B2, B4, B5** in the same pass. 6. **B1 (Inversive-Distance half)** โ€” port a block-FD Hessian; larger effort. ## What is already good (do not "fix") - `setup__maps` is 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_result` carries `sparse_qr_fallback_used` โ€” good observability.