docs: test-count centralisation + release policy + doc-audit + 24 new docstrings #13

Closed
user2595 wants to merge 3 commits from feature/test-count-central-and-release-policy into main
17 changed files with 597 additions and 85 deletions

View File

@@ -11,10 +11,12 @@ conformallab++ is a C++17 reimplementation of [ConformalLab](https://github.com/
**The long-term goal is a CGAL package** — a submission to the CGAL library that brings discrete conformal maps (hyper-ideal, spherical, Euclidean) to the CGAL ecosystem using `CGAL::Surface_mesh` as the underlying halfedge data structure, with a traits-class design compatible with arbitrary CGAL-conforming mesh types.
The project has three distinct phases:
- **Phase 17 (done):** Direct port of the Java library algorithms to C++
- **Phase 89 (planned):** CGAL package infrastructure + remaining Java features not yet ported (inversive-distance functional, analytic HyperIdeal Hessian, genus-g > 1 fundamental domain)
- **Phase 10+ (research):** New mathematics beyond the Java original — holomorphic differentials, Siegel period matrix Ω ∈ H_g, full uniformization for genus g ≥ 2
The project has four distinct phase blocks (updated 2026-05-22):
- **Phase 17 (done, v0.7.0):** Direct port of the Java library algorithms to C++.
- **Phase 8a MVP + 8b-Lite (done, v0.9.0):** CGAL public-API surface for all five DCE models via `<CGAL/Discrete_*.h>`. Phase 8a.2 (generic FaceGraph), 8c (manuals), 8d (CGAL-test-format), 8e (YAML pipeline) deferred on-demand.
- **Phase 9a + 9b (done, v0.9.0):** Two new functionals (CP-Euclidean port, Inversive-Distance research), two new Newton solvers, block-FD HyperIdeal Hessian.
- **Phase 9b-analytic + 9c (planned):** Full analytic HyperIdeal Hessian via Schläfli identity (research, see `doc/roadmap/research-track.md`); 4g-polygon fundamental domain for genus g > 1 (mixed port + research).
- **Phase 10+ (research):** Holomorphic differentials, Siegel period matrix Ω ∈ H_g, full uniformization for genus g ≥ 2.
## Language
@@ -90,17 +92,22 @@ This replaces the Java `CoHDS` (half-edge data structure) and its intrusive `CoV
`CGAL_DISABLE_GMP` and `CGAL_DISABLE_MPFR` are defined for all CGAL targets — the library deliberately uses `Simple_cartesian<double>` (floating-point, no exact arithmetic) because conformal geometry does not require exact predicates.
### The three geometry modes
### The five DCE models
Each mode has its own Maps struct that bundles all property maps, plus functional, Hessian, and Newton function:
Each model has its own Maps struct that bundles all property maps, plus a functional, optional Hessian, Newton solver, and (since v0.9.0) a CGAL public-API entry function:
| Mode | Space | Maps struct | Key headers | Newton function |
|---|---|---|---|---|
| Euclidean | ℝ² | `EuclideanMaps` | `euclidean_functional.hpp`, `euclidean_hessian.hpp` | `newton_euclidean()` |
| Spherical | S² | `SphericalMaps` | `spherical_functional.hpp`, `spherical_hessian.hpp` | `newton_spherical()` |
| Hyper-ideal | H² (Poincaré disk) | `HyperIdealMaps` | `hyper_ideal_functional.hpp`, `hyper_ideal_hessian.hpp` | `newton_hyper_ideal()` |
| Model | Space | DOFs | Maps struct | Key headers | Newton function | CGAL entry |
|---|---|---|---|---|---|---|
| Euclidean | ℝ² | vertex | `EuclideanMaps` | `euclidean_functional.hpp`, `euclidean_hessian.hpp` | `newton_euclidean()` | `discrete_conformal_map_euclidean()` |
| Spherical | S² | vertex | `SphericalMaps` | `spherical_functional.hpp`, `spherical_hessian.hpp` | `newton_spherical()` | `discrete_conformal_map_spherical()` |
| Hyper-ideal | H² (Poincaré disk) | vertex + edge | `HyperIdealMaps` | `hyper_ideal_functional.hpp`, `hyper_ideal_hessian.hpp` (block-FD, Phase 9b) | `newton_hyper_ideal()` | `discrete_conformal_map_hyper_ideal()` |
| CP-Euclidean (BPS 2010) | face-based circle packing | **face** | `CPEuclideanMaps` | `cp_euclidean_functional.hpp` | `newton_cp_euclidean()` | `discrete_circle_packing_euclidean()` |
| Inversive-Distance (Luo 2004) | vertex-based circle packing | vertex | `InversiveDistanceMaps` | `inversive_distance_functional.hpp` | `newton_inversive_distance()` | `discrete_inversive_distance_map()` |
HyperIdeal also has edge DOFs (`e_idx[e]`); Euclidean and Spherical are vertex-DOF only. For HyperIdeal: `assign_all_dof_indices(mesh, maps)` assigns all vertex and edge DOFs automatically. For Euclidean/Spherical: pin one vertex manually (`maps.v_idx[first_vertex] = -1`) then assign sequential indices.
DOF-assignment patterns:
- **Vertex-only models** (Euclidean, Spherical, Inversive-Distance): pin one vertex manually (`maps.v_idx[first_vertex] = -1`) then assign sequential indices. The CGAL public entries do this automatically with the "natural-theta" trick (so calling them with no arguments returns x = 0 as the equilibrium).
- **HyperIdeal**: `assign_all_dof_indices(mesh, maps)` assigns vertex + edge DOFs automatically.
- **CP-Euclidean**: face-based — `assign_cp_euclidean_face_dof_indices(mesh, maps, pinned_face)` pins one face and indexes the rest.
### The full pipeline
@@ -125,18 +132,19 @@ After `compute_*_lambda0_from_mesh()` the original vertex positions are no longe
### Newton solver (`newton_solver.hpp`)
The gradient sign convention differs between modes:
- **Euclidean/Spherical:** `G_v = Θ_v actual_angle_sum` (target minus actual)
- **HyperIdeal:** `G_v = actual_angle_sum Θ_v` (actual minus target)
Gradient sign convention differs across the five models:
- **Euclidean / Spherical / Inversive-Distance:** `G_v = Θ_v actual_angle_sum` (target minus actual).
- **HyperIdeal:** `G_v = actual_angle_sum Θ_v` (actual minus target).
- **CP-Euclidean:** `G_f = φ_f Σ_{h:face(h)=f} (p(θ*,Δρ) + θ*)` (face-based; see `cp_euclidean_functional.hpp` header for the full formula).
Hessian sign and solver:
- **Euclidean:** H is PSD (cotangent Laplacian) → `SimplicialLDLT(H)`
- **Spherical:** H is NSD (concave energy) → `SimplicialLDLT(H)` (sign flip inside `newton_spherical`)
- **HyperIdeal:** H is PSD (strictly convex) → `SimplicialLDLT(H)`
Hessian sign and solver per model:
- **Euclidean:** H is PSD (cotangent Laplacian) → `SimplicialLDLT(H)`.
- **Spherical:** H is NSD (concave energy) → `SimplicialLDLT(H)` (sign flip inside `newton_spherical`).
- **HyperIdeal:** H is PSD (strictly convex) → `SimplicialLDLT(H)`. Phase 9b uses a **block-FD Hessian** (per-face 6×6 local block, ~96× speed-up vs full FD on V=200). Full analytic Hessian via the chain `(bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ` is planned research — see `doc/roadmap/research-track.md` Phase 9b-analytic.
- **CP-Euclidean:** analytic 2×2-per-edge `h_jk = sin θ / (cosh Δρ cos θ)` (BPS 2010), strictly convex → `SimplicialLDLT(H)`.
- **Inversive-Distance:** FD Hessian (inline in `newton_inversive_distance`). Analytic via Glickenstein 2011 eq. (4.6) is planned research (Phase 9a.2-analytic).
When `SimplicialLDLT` fails (rank-deficient H — gauge mode on closed mesh without pinned vertex), the solver automatically retries with `Eigen::SparseQR` to find the minimum-norm step orthogonal to the null space. Public API: `solve_linear_system(H, rhs, &used_fallback)`.
The HyperIdeal Hessian is currently a **symmetric finite-difference approximation** (O(ε²), costs n extra gradient evaluations per Newton step). The analytic Hessian via the chain `(bᵢ, aₑ) → lᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ` is deferred to Phase 9b.
When `SimplicialLDLT` fails (rank-deficient H — gauge mode on a closed mesh without pinned vertex/face), the solver automatically retries with `Eigen::SparseQR` to find the minimum-norm step orthogonal to the null space. Public API: `solve_linear_system(H, rhs, &used_fallback)`.
### Layout and holonomy (`layout.hpp`)
@@ -244,12 +252,12 @@ Two jobs in `.gitea/workflows/cpp-tests.yml`:
Runner: `eulernest` — self-hosted Raspberry Pi, ARM64, Ubuntu 22.04. Docker image: `git.eulernest.eu/conformallab/ci-cpp:latest`. `test-cgal` needs `test-fast` to pass first (`needs: test-fast`).
Expected results: **23 non-CGAL tests pass**, **227 CGAL tests pass, 0 skipped**.
Expected results: full test suite passing, 0 skipped, 0 failed. The canonical counts live in `doc/api/tests.md` — do not hardcode them anywhere else (see [`doc/release-policy.md`](doc/release-policy.md)).
## Release state
Current release: **v0.7.0** (tag on `origin/dev`, PR to `main` open).
Phase 7 is complete. Phase 7.5 (Doxygen) and Phase 8 (CGAL package) are next.
Current release: **v0.9.0** (tag on `main`, released 2026-05-22).
Phases 19a complete, Phase 8b-Lite CGAL API surface complete (all 5 DCE models reachable via `<CGAL/Discrete_*.h>`), Phase 9b block-FD HyperIdeal Hessian shipped (~96× speed-up). Next planned milestones: Phase 9c (4g-polygon, genus g > 1) and Phase 9b-analytic (Schläfli identity). See `doc/release-policy.md` for the version-tag policy and `doc/roadmap/phases.md` for the phase plan.
## Phase 8 strategic decisions (2026-05-19)
@@ -349,7 +357,7 @@ follow the empirical verification rule above before any new claim.
| Full pipeline API for all three geometries | `doc/api/pipeline.md` |
| What does each processing unit require/provide (contracts)? | `doc/api/contracts.md` |
| How to add a new functional / geometry mode / port from Java | `doc/api/extending.md` |
| All 39 test suites, 227+23 tests, individual counts | `doc/api/tests.md` |
| Per-suite breakdown and counts (single source of truth) | `doc/api/tests.md` |
| Phase 8 CGAL package design + Declarative YAML pipeline spec | `doc/api/cgal-package.md` |
### Concepts & specs
@@ -373,6 +381,7 @@ follow the empirical verification rule above before any new claim.
| Build modes, single-test invocation, CLI, Docker image rebuild | `doc/getting-started.md` |
| Step-by-step: port the Inversive Distance functional (Phase 9a template) | `doc/tutorials/add-inversive-distance.md` |
| Language policy, test standards, release flow | `doc/contributing.md` |
| Versioning rules + release process + single-source-of-truth list | `doc/release-policy.md` |
### geometry-central context
@@ -386,7 +395,7 @@ Optional adoption roadmap (GC-1/2/3): `doc/roadmap/phases.md` (Optional section)
## Known quirks
- **`test-fast` also runs stubs**: `conformallab_tests` (non-CGAL) contains `GTEST_SKIP`-based stubs for functionals that need CGAL. This is intentional — those tests document what was in the Java port scope but requires the CGAL mesh type.
- **No GTEST_SKIP stubs remain** (since v0.9.0): the three stale HDS-port stub files were removed because the CGAL test suite covers the same functionality with real tests. The pure-math `conformallab_tests` target now only contains active tests.
- **Boost is header-only**: CGAL 6.x uses only Boost headers (`Boost.Config`, `Boost.Graph`). No compiled Boost libraries are needed. `find_package(Boost REQUIRED)` only locates the include path.
- **`main` branch is protected** on `origin` (Gitea). Push to `dev`, then merge via pull request. Codeberg `main` can be pushed to directly.
- **Both remotes must stay in sync**: `origin` = `git.eulernest.eu` (CI runs here), `codeberg` = `codeberg.org/TMoussa/ConformalLabpp` (public mirror). Push to both after every significant change.

View File

@@ -13,7 +13,7 @@ Algorithmic foundation:
> DOI: [10.14279/depositonce-5415](https://depositonce.tu-berlin.de/items/8e2988b2-d991-45b5-aad5-9fb7988f3b2f) · CC BY-SA 4.0 ·
> [Java original](https://github.com/varylab/conformallab) · [sechel.de](https://sechel.de/)
**Status:** v0.9.0 — Phases 19a complete, Phase 8b-Lite CGAL API surface. Newton solvers for **five** DCE models (Euclidean / Spherical / HyperIdeal / CP-Euclidean / Inversive-Distance), priority-BFS layout in ℝ²/S²/Poincaré disk, GaussBonnet, tree-cotree cut graph, Möbius holonomy, period matrix (genus 1), fundamental domain, halfedge_uv texture atlas, JSON/XML serialisation, CLI app. **227 CGAL tests + 23 non-CGAL tests, 0 skipped.**
**Status:** v0.9.0 — Phases 19a complete, Phase 8b-Lite CGAL API surface. Newton solvers for **five** DCE models (Euclidean / Spherical / HyperIdeal / CP-Euclidean / Inversive-Distance), priority-BFS layout in ℝ²/S²/Poincaré disk, GaussBonnet, tree-cotree cut graph, Möbius holonomy, period matrix (genus 1), fundamental domain, halfedge_uv texture atlas, JSON/XML serialisation, CLI app. Full test suite passing, 0 skipped — see [`doc/api/tests.md`](doc/api/tests.md) for the per-suite breakdown.
---
@@ -83,8 +83,8 @@ Layout2D layout = euclidean_layout(mesh, res.x, maps);
|---|---|
| **Getting started** — build modes, single-test invocation, CLI, Docker | [doc/getting-started.md](doc/getting-started.md) |
| **Pipeline API** — all three geometries, holonomy, serialisation | [doc/api/pipeline.md](doc/api/pipeline.md) |
| **Public headers** — all 24 headers with descriptions | [doc/api/headers.md](doc/api/headers.md) |
| **Test suites**39 suites, 227+23 tests, individual counts | [doc/api/tests.md](doc/api/tests.md) |
| **Public headers** — all public headers with descriptions | [doc/api/headers.md](doc/api/headers.md) |
| **Test suites**per-suite breakdown and counts (single source of truth) | [doc/api/tests.md](doc/api/tests.md) |
| **Extending** — new functionals, geometry modes, porting from Java | [doc/api/extending.md](doc/api/extending.md) |
| **Processing unit contracts** — preconditions / provides table | [doc/api/contracts.md](doc/api/contracts.md) |
| **CGAL package design** — Phase 8 target, YAML pipeline | [doc/api/cgal-package.md](doc/api/cgal-package.md) |

View File

@@ -89,9 +89,25 @@ struct CPEuclideanMaps {
CPFMapD phi_f; ///< target face-angle sum (default 2π)
};
// Create the property maps with sensible defaults.
// θ_e = π/2 produces an orthogonal circle packing (Koebe-Andreev-Thurston).
// φ_f = 2π is the natural target for a flat triangle.
/// Attach the three CP-Euclidean property maps to `mesh` with default
/// values and return their handles.
///
/// Defaults:
/// * `theta_e[e] = π/2` for every edge — orthogonal circle packing
/// (Koebe-Andreev-Thurston).
/// * `phi_f[f] = 2π` for every face — flat target.
/// * `f_idx[f] = -1` for every face — all faces pinned initially;
/// call `assign_cp_euclidean_face_dof_indices()` next to assign
/// DOF indices to all faces except one gauge-pinned face.
///
/// The maps are named with the `"cf:"` / `"ce:"` prefixes
/// (cf = circle-packing-face, ce = circle-packing-edge) so they do
/// not collide with the Euclidean / Spherical / HyperIdeal maps.
///
/// \param mesh Input mesh. Modified in place: three property maps are
/// attached if not already present, otherwise the existing
/// maps are returned unchanged (CGAL property-map idempotence).
/// \returns A bundle of all three property maps for caller use.
inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh)
{
CPEuclideanMaps m;
@@ -101,8 +117,18 @@ inline CPEuclideanMaps setup_cp_euclidean_maps(ConformalMesh& mesh)
return m;
}
// Assign DOF indices 0..n-1 to all faces except `pinned`, which gets 1.
// Mirrors Java CPEuclideanFunctional's convention "skip face index 0".
/// Assign sequential DOF indices `0..n-1` to all faces except `pinned`,
/// which receives the sentinel `-1` (gauge-fixed face, `ρ_pinned = 0`).
///
/// Mirrors the Java CPEuclideanFunctional's "skip face index 0"
/// convention from `evaluateEnergyAndGradient` (lines 184-185 of
/// CPEuclideanFunctional.java). The C++ port exposes the choice of
/// pinned face explicitly rather than hard-coding it.
///
/// \param mesh The mesh. Read for face iteration only; not modified.
/// \param m Map bundle whose `f_idx` is overwritten.
/// \param pinned The face whose DOF is fixed at zero (the gauge).
/// \returns The number of free DOFs assigned (`num_faces(mesh) - 1`).
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
CPEuclideanMaps& m,
Face_index pinned)
@@ -115,7 +141,9 @@ inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
return idx;
}
// Convenience: pin the first face in iteration order.
/// Convenience overload: pin the **first** face in `mesh.faces()` order.
/// Use this when any face works as the gauge (typically true for
/// closed mesh experiments).
inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
CPEuclideanMaps& m)
{
@@ -124,6 +152,8 @@ inline int assign_cp_euclidean_face_dof_indices(ConformalMesh& mesh,
return assign_cp_euclidean_face_dof_indices(mesh, m, *it);
}
/// Count the free DOFs (faces with `f_idx >= 0`).
/// Equivalent to `num_faces(mesh) - <number of pinned faces>`.
inline int cp_euclidean_dimension(const ConformalMesh& mesh,
const CPEuclideanMaps& m)
{

View File

@@ -63,8 +63,17 @@ struct EuclideanMaps {
EuclEMapD lambda0; ///< base log-length λ°_e (default 0.0)
};
// Create and attach property maps with sensible defaults.
// theta_v = 2π (flat vertex), phi_e = π (interior edge, flat surface).
/// Attach the five Euclidean property maps to `mesh` with sensible
/// defaults and return their handles.
///
/// Defaults:
/// * `v_idx[v] = -1` (every vertex pinned; user must reassign before solving)
/// * `e_idx[e] = -1` (no edge DOFs by default; use `assign_euclidean_all_dof_indices` for cyclic functional)
/// * `theta_v[v] = 2π` (flat interior vertex target)
/// * `phi_e[e] = π` (interior edge turn angle target — flat surface)
/// * `lambda0[e] = 0` (placeholder; call `compute_euclidean_lambda0_from_mesh` next)
///
/// Map name prefix: `"ev:"` (vertex) and `"ee:"` (edge).
inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh)
{
EuclideanMaps m;
@@ -76,7 +85,12 @@ inline EuclideanMaps setup_euclidean_maps(ConformalMesh& mesh)
return m;
}
// Assign DOF indices 0..n-1 for all vertices only (no edge DOFs).
/// Assign sequential DOF indices `0..n-1` to all vertices.
///
/// **Note:** does NOT pin a gauge vertex. For closed meshes the caller
/// must set one `m.v_idx[v] = -1` either before or after this call to
/// remove the rotational mode (the Newton solver's SparseQR fallback
/// will otherwise pick a minimum-norm solution but at higher cost).
inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
{
int idx = 0;
@@ -84,7 +98,10 @@ inline int assign_euclidean_vertex_dof_indices(ConformalMesh& mesh, EuclideanMap
return idx;
}
// Assign DOF indices for all vertices AND all edges.
/// Assign DOF indices for all vertices AND all edges (vertex-DOFs first,
/// then edge-DOFs). Use this overload for the "cyclic" formulation that
/// includes per-edge log-length DOFs (`λ_e`) on top of per-vertex scale
/// factors (`u_v`).
inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps& m)
{
int idx = 0;
@@ -93,7 +110,7 @@ inline int assign_euclidean_all_dof_indices(ConformalMesh& mesh, EuclideanMaps&
return idx;
}
// Count variable DOFs (vertices + edges).
/// Count the free DOFs (vertices + edges with index `≥ 0`).
inline int euclidean_dimension(const ConformalMesh& mesh, const EuclideanMaps& m)
{
int dim = 0;

View File

@@ -53,8 +53,20 @@ struct HyperIdealMaps {
EMapD theta_e; // target intersection angle θ_e
};
// Add all needed persistent property maps and return handles.
// Defaults: theta_v = 2π (regular cone), theta_e = π (orthogonal circles).
/// Attach the four HyperIdeal property maps to `mesh` and return their
/// handles.
///
/// Defaults:
/// * `v_idx[v] = -1` (ideal vertex — i.e. the corresponding `b_v` is fixed at 0)
/// * `e_idx[e] = -1` (edge DOF fixed at 0)
/// * `theta_v[v] = 2π` (regular cone target)
/// * `theta_e[e] = π` (orthogonal-circle target)
///
/// The map prefix `"v:"` / `"e:"` is intentionally generic for the
/// HyperIdeal functional — it is the canonical / Phase 3b model.
/// Other functionals use distinct prefixes (`"ev:"` Euclidean, `"sv:"`
/// Spherical, `"cf:"`/`"ce:"` CP-Euclidean, `"iv:"`/`"ie:"`
/// Inversive-Distance) so all models can coexist on the same mesh.
inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh)
{
HyperIdealMaps m;
@@ -65,7 +77,7 @@ inline HyperIdealMaps setup_hyper_ideal_maps(ConformalMesh& mesh)
return m;
}
// Count variable DOFs: #variable_vertices + #variable_edges.
/// Count free DOFs: `#variable_vertices + #variable_edges`.
inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps& m)
{
int dim = 0;
@@ -74,8 +86,15 @@ inline int hyper_ideal_dimension(const ConformalMesh& mesh, const HyperIdealMaps
return dim;
}
// Assign DOF indices 0..n-1: vertices first, then edges.
// All vertices and edges become variable. Returns total DOF count.
/// Make every vertex hyper-ideal and every edge variable, assigning
/// sequential DOF indices `0..n-1` (vertices first, edges after).
///
/// This is the standard initialisation for the Springborn-2020
/// hyper-ideal functional — gauge fixing is **not** needed because
/// the energy is strictly convex on the full DOF space (no
/// rotational mode for an all-hyper-ideal configuration).
///
/// \returns total DOF count = `num_vertices(mesh) + num_edges(mesh)`.
inline int assign_all_dof_indices(ConformalMesh& mesh, HyperIdealMaps& m)
{
int idx = 0;

View File

@@ -89,7 +89,19 @@ struct InversiveDistanceMaps {
IDEMapD I_e; ///< inversive distance I_ij (per edge, constant)
};
// Create the property maps with sensible defaults.
/// Attach the four inversive-distance property maps to `mesh` and
/// return their handles.
///
/// Defaults are intentionally trivial — every real use of this
/// functional must call `compute_inversive_distance_init_from_mesh()`
/// next to populate `r0` and `I_e` from the input geometry.
/// * `v_idx[v] = -1` (all vertices pinned initially)
/// * `theta_v[v] = 2π` (regular interior vertex)
/// * `r0[v] = 1.0` (placeholder)
/// * `I_e[e] = 1.0` (tangential default — overwritten by init step)
///
/// The maps use the `"iv:"` / `"ie:"` prefix so they do not collide
/// with the Euclidean / Spherical / HyperIdeal / CP-Euclidean maps.
inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh)
{
InversiveDistanceMaps m;
@@ -100,8 +112,16 @@ inline InversiveDistanceMaps setup_inversive_distance_maps(ConformalMesh& mesh)
return m;
}
// Assign sequential DOF indices to all vertices (no gauge pinning here —
// the caller should set one v_idx to 1 before assigning).
/// Assign sequential DOF indices `0..n-1` to every vertex.
///
/// **Note:** this overload does NOT pin a gauge vertex. The caller
/// is expected to either:
/// 1. set one `m.v_idx[v] = -1` *before* calling this function (then
/// the call is a no-op for that vertex) — OR —
/// 2. flip one assigned index back to `-1` *after* this function.
///
/// For a closed mesh, exactly one pin is required to remove the
/// global rotational mode.
inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& mesh,
InversiveDistanceMaps& m)
{
@@ -110,7 +130,7 @@ inline int assign_inversive_distance_vertex_dof_indices(ConformalMesh& m
return idx;
}
// Count free DOFs.
/// Count the free DOFs (vertices with `v_idx >= 0`).
inline int inversive_distance_dimension(const ConformalMesh& mesh,
const InversiveDistanceMaps& m)
{
@@ -119,18 +139,28 @@ inline int inversive_distance_dimension(const ConformalMesh& mesh,
return dim;
}
// ── Initialisation from initial mesh geometry ────────────────────────────────
//
// Two-phase init mirroring "compute_lambda0" for euclidean_functional:
// 1. Choose r_i^(0). Simplest heuristic: r_i = (1/3)·(min adjacent ).
// Other choices (max , mean , length-of-shortest-vertex-cycle) are
// possible; the user can override `m.r0[v]` between setup and init.
// 2. Compute I_ij = ( ℓ² r_i² r_j² ) / ( 2 r_i r_j ) for each edge.
//
// Note: a valid inversive-distance packing requires I_ij > 1 on every edge,
// and the triangle inequality must hold on every face under the resulting .
// The choice r_i = ⅓·min(_e adj v) keeps I_ij safely positive for most
// real meshes.
/// Two-phase initialisation from initial mesh geometry. Mirrors the
/// role of `compute_lambda0_from_mesh` in the Euclidean functional, but
/// adapted to Luo's vertex-based radius parametrisation.
///
/// **Phase 1.** Pick a positive radius per vertex:
/// \code
/// r_i^(0) = (1/3) · min{_e : e adjacent to v_i}
/// \endcode
/// This is a heuristic — the user may override `m.r0[v]` for any
/// vertex between `setup_inversive_distance_maps()` and this call.
///
/// **Phase 2.** Compute the per-edge inversive distance via the
/// Bowers-Stephenson 2004 identity:
/// \code
/// I_ij = ( _ij² r_i² r_j² ) / ( 2 r_i r_j )
/// \endcode
///
/// \pre Every edge has positive 3-D length.
/// \pre Radii produced in Phase 1 are positive (degenerate isolated
/// vertices fall back to `r_i = 1`).
/// \post Every `I_e[e] > -1` for a valid packing. The chosen
/// Phase-1 heuristic keeps `I_e > 0` for most real meshes.
inline void compute_inversive_distance_init_from_mesh(ConformalMesh& mesh,
InversiveDistanceMaps& m)
{

View File

@@ -1,10 +1,33 @@
#pragma once
// mesh_utils.hpp
//
// Conversions between CGAL::Surface_mesh and Eigen matrices. Used
// primarily by the viewer / example programs to bridge to libigl, which
// expects (V, F) matrix pairs rather than a halfedge data structure.
//
// All functions are templated on the kernel so the same code works
// with `Simple_cartesian<double>` (production) and with any CGAL
// `Kernel_d::Point_3` (test scaffolding).
#include <CGAL/Surface_mesh.h>
#include <Eigen/Dense>
#include <CGAL/Polygon_mesh_processing/triangulate_faces.h>
namespace mesh_utils {
/// Copy `mesh` into an Eigen `(V, F)` pair (libigl convention).
///
/// **Side effect:** `mesh` is triangulated in place via
/// `CGAL::Polygon_mesh_processing::triangulate_faces` so the output
/// `F` is guaranteed to be a 3-column matrix. If `mesh` is already a
/// triangle mesh this is a no-op.
///
/// \param mesh Input surface mesh. **Modified in place** if any face
/// has more than 3 vertices.
/// \param V Output: `(num_vertices, 3)` matrix of vertex positions.
/// \param F Output: `(num_faces, 3)` matrix of vertex indices per
/// face (rows are individual triangles).
template <typename Kernel>
void cgal_to_eigen(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh,
Eigen::MatrixXd& V, Eigen::MatrixXi& F) {
@@ -30,13 +53,38 @@ void cgal_to_eigen(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh,
face_idx++;
}
}
/// Quick interactive visualisation via libigl + GLFW.
///
/// **Requires** `WITH_VIEWER=ON` at CMake time (which is implied by
/// `WITH_CGAL=ON`). Blocks until the viewer window is closed.
/// Not suitable for CI / headless contexts.
///
/// Typical use:
/// \code{.cpp}
/// Eigen::MatrixXd V; Eigen::MatrixXi F;
/// mesh_utils::cgal_to_eigen<Kernel>(mesh, V, F);
/// mesh_utils::simple_visualize_mesh<Kernel>(V, F);
/// \endcode
template <typename Kernel>
void simple_visualize_mesh(Eigen::MatrixXd& V, Eigen::MatrixXi& F) {
igl::opengl::glfw::Viewer viewer;
viewer.data().set_mesh(V, F);
viewer.launch();
}
// Zero-copy map for V (optional)
/// Zero-copy `Eigen::Map` view of `mesh`'s vertex positions.
///
/// Returns a row-major `(N, 3)` `Eigen::Map` that aliases the
/// `mesh.points()` storage directly — no allocation, O(1).
///
/// **Lifetime warning:** the returned `Map` references memory owned by
/// `mesh`. Adding or removing vertices may invalidate the underlying
/// storage; use the `Map` only as long as `mesh` is structurally stable.
///
/// This is the read-write counterpart to `cgal_to_eigen` for cases
/// where the caller wants to *modify* vertex positions through Eigen
/// (e.g. apply a Möbius transformation) without an intermediate copy.
template <typename Kernel>
Eigen::Map<Eigen::Matrix<double, Eigen::Dynamic, 3, Eigen::RowMajor>>
get_vertex_map(CGAL::Surface_mesh<typename Kernel::Point_3>& mesh) {

View File

@@ -57,8 +57,14 @@ struct SphericalMaps {
// Defaults: theta_v = 2π, theta_e = π, lambda0 = 0.
// lambda0 = 0 means exp(λ°/2)=1, i.e., l=π — degenerate unless u_i<0.
// For real meshes, set lambda0 from mesh geometry via
// compute_lambda0_from_mesh() below.
/// Attach the five spherical property maps to `mesh` and return their
/// handles. Mirrors `setup_euclidean_maps` but uses the `"sv:"` /
/// `"se:"` prefix so the two functionals can coexist on the same mesh
/// (useful for cross-validation tests).
///
/// Defaults match the Euclidean defaults except that `lambda0 = 0` here
/// gives `l_e = π` which is degenerate on the unit sphere — always call
/// `compute_lambda0_from_mesh(mesh, m)` next on a real mesh.
inline SphericalMaps setup_spherical_maps(ConformalMesh& mesh)
{
SphericalMaps m;
@@ -70,7 +76,8 @@ inline SphericalMaps setup_spherical_maps(ConformalMesh& mesh)
return m;
}
// Assign DOF indices 0..n-1 for all vertices (only vertex DOFs).
/// Assign sequential DOF indices `0..n-1` to all vertices (no edge DOFs).
/// Caller is expected to pin one gauge vertex with `m.v_idx[v] = -1`.
inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
{
int idx = 0;
@@ -78,7 +85,9 @@ inline int assign_vertex_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
return idx;
}
// Assign DOF indices for all vertices AND edges.
/// Assign DOF indices for all vertices AND all edges (vertex-DOFs first,
/// then edge-DOFs). Mirrors `assign_euclidean_all_dof_indices` for the
/// cyclic spherical formulation.
inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps& m)
{
int idx = 0;
@@ -87,7 +96,7 @@ inline int assign_all_spherical_dof_indices(ConformalMesh& mesh, SphericalMaps&
return idx;
}
// Count variable DOFs.
/// Count the free DOFs (vertices + edges with index `≥ 0`).
inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m)
{
int dim = 0;
@@ -96,9 +105,14 @@ inline int spherical_dimension(const ConformalMesh& mesh, const SphericalMaps& m
return dim;
}
// Set lambda0 from mesh vertex positions (unit-sphere assumed):
// λ°_e = 2·log(sin(l_e / 2)) where l_e = arccos(p_i · p_j).
// Requires vertices to lie on the unit sphere.
/// Compute `λ°_e` for every edge from the input vertex positions,
/// assuming `mesh` has vertices on the unit sphere.
///
/// Formula: `λ°_e = 2·log(sin(l_e / 2))` where `l_e = arccos(p_i · p_j)`
/// is the spherical arc length of edge `e`.
///
/// \pre Every vertex `v` of `mesh` lies on the unit sphere (norm = 1).
/// \pre No edge is degenerate (`p_i ≠ p_j` and `p_i ≠ -p_j`).
inline void compute_lambda0_from_mesh(ConformalMesh& mesh, SphericalMaps& m)
{
for (auto e : mesh.edges()) {

View File

@@ -53,8 +53,9 @@ add_executable(conformallab_cgal_tests
# ── Java parity: geometry utility tests ──────────────────────────────────
# Ported from CuttinUtilityTest, UnwrapUtilityTest,
# ConvergenceUtilityTests, HomologyTest (tests 16).
# Test 7 (genus-2 homology) as GTEST_SKIP stub until Phase 8.
# ConvergenceUtilityTests, HomologyTest. All tests active —
# the v0.7.0 genus-2 homology stub was implemented in Phase 7
# (HomologyGenerators.Genus2_FourCutEdges, brezel2.obj).
test_geometry_utils.cpp
# ── Scalability smoke tests ────────────────────────────────────────────────

View File

@@ -46,11 +46,18 @@ CGAL/Eigen for the CGAL-dependent headers).
| `euclidean_functional.hpp` | `EuclideanMaps`, `setup_euclidean_maps()`, `compute_euclidean_lambda0_from_mesh()`, `euclidean_gradient()`, `euclidean_energy()` |
| `euclidean_hessian.hpp` | `euclidean_hessian()` — cotangent Laplacian (PinkallPolthier 1993) |
## Circle-packing functionals (Phase 9a)
| Header | Description |
|---|---|
| `cp_euclidean_functional.hpp` | **Face-based** CP-Euclidean (BobenkoPinkallSpringborn 2010), port of Java `CPEuclideanFunctional`. `CPEuclideanMaps`, `setup_cp_euclidean_maps()`, `assign_cp_euclidean_face_dof_indices()`, `cp_euclidean_gradient()`, `cp_euclidean_energy()`, **analytic** `cp_euclidean_hessian()` (2×2-per-edge `h_jk = sin θ / (cosh Δρ cos θ)`) |
| `inversive_distance_functional.hpp` | **Vertex-based** inversive-distance (Luo 2004, no Java original). `InversiveDistanceMaps`, `setup_inversive_distance_maps()`, `compute_inversive_distance_init_from_mesh()` (BowersStephenson 2004 init), `inversive_distance_gradient()`, `inversive_distance_energy()` |
## Solver
| Header | Description |
|---|---|
| `newton_solver.hpp` | `newton_euclidean()`, `newton_spherical()`, `newton_hyper_ideal()`, `solve_linear_system()` (SimplicialLDLT + SparseQR fallback), `NewtonResult` struct |
| `newton_solver.hpp` | Five Newton solvers — `newton_euclidean()`, `newton_spherical()`, `newton_hyper_ideal()` (uses block-FD Hessian since Phase 9b), `newton_cp_euclidean()` (analytic Hessian, Phase 9a-Newton), `newton_inversive_distance()` (FD Hessian, Phase 9a-Newton). Plus `solve_linear_system()` (SimplicialLDLT + SparseQR fallback) and the `NewtonResult` struct. |
## Preprocessing
@@ -72,3 +79,33 @@ CGAL/Eigen for the CGAL-dependent headers).
| `period_matrix.hpp` | `PeriodData`, `compute_period_matrix()`, `reduce_to_fundamental_domain()`, `is_in_fundamental_domain()` — period ratio τ = ω₂/ω₁ ∈ , SL(2,) reduction |
| `fundamental_domain.hpp` | `FundamentalDomain`, `compute_fundamental_domain()` (genus 1: CCW parallelogram; genus > 1: empty, TODO Phase 9c), `tiling_copy()`, `tiling_neighbourhood()` |
| `serialization.hpp` | `save_result_json()`, `load_result_json()`, `save_result_xml()`, `load_result_xml()`, `save_layout_off()` |
## Math utilities
These headers contain pure-math helpers used internally across the
package. All are also re-exported on the public API so downstream
consumers (e.g. user code that wants to assemble a custom functional)
can use them directly.
| Header | Description |
|---|---|
| `matrix_utility.hpp` | Small linear-algebra helpers used by `period_matrix.hpp` (2×2 determinant, …) |
| `projective_math.hpp` | Real projective 2-space helpers (point on line, lines through points, dual) |
| `p2_utility.hpp` | Higher-level P² utilities used by hyper-ideal layout (perpendicular bisectors in Poincaré disk, etc.) |
| `discrete_elliptic_utility.hpp` | Genus-1 / elliptic-curve helpers — half-period ratio τ from texture coords; bridge to genus-1 fundamental domain |
## CGAL public API (Phase 8b-Lite)
These headers live under `include/CGAL/` and are the user-facing entry
points. They are thin wrappers over the implementation in
`code/include/*.hpp`, exposing the five DCE models through a
CGAL-conventional interface.
| Header | Description |
|---|---|
| `CGAL/Conformal_map_traits.h` | `ConformalMapTraits` concept + `Default_conformal_map_traits<TriangleMesh, K>` (Euclidean-flavoured base trait) |
| `CGAL/Discrete_conformal_map.h` | `discrete_conformal_map_euclidean()`, `discrete_conformal_map_spherical()`, `discrete_conformal_map_hyper_ideal()`. Result types `Conformal_map_result<FT>` and `Hyper_ideal_map_result<FT>` |
| `CGAL/Discrete_circle_packing.h` | `Default_cp_euclidean_traits` + `discrete_circle_packing_euclidean()`. Result: `Circle_packing_result<FT>` with `rho_per_face` |
| `CGAL/Discrete_inversive_distance.h` | `Default_inversive_distance_traits` + `discrete_inversive_distance_map()`. Result: `Conformal_map_result<FT>` reused |
| `CGAL/Conformal_layout.h` | Thin re-export of `euclidean_layout`, `spherical_layout`, `hyper_ideal_layout` into the `CGAL::` namespace |
| `CGAL/Conformal_map/internal/parameters.h` | (internal) Named-parameter tags for `CGAL::parameters::*` |

View File

@@ -29,8 +29,8 @@ Two jobs run on push to `dev`/`main` or on pull requests:
| Job | What it tests | Trigger |
|---|---|---|
| `test-fast` | 23 non-CGAL tests, no Boost | all branches |
| `test-cgal` | 227 CGAL tests, 0 skipped | `main`, `dev`, PRs only |
| `test-fast` | pure-math tests, no Boost | all branches |
| `test-cgal` | full CGAL test suite | `main`, `dev`, PRs only |
A PR is ready to merge when both jobs pass.
@@ -51,7 +51,7 @@ Every new algorithm needs:
3. **Registration** — add the `.cpp` file to `code/tests/cgal/CMakeLists.txt`.
Expected CI result: **23 + 227 tests pass, 0 skipped**.
Expected CI result: full test suite passing, 0 skipped (per-suite counts: [`doc/api/tests.md`](api/tests.md)).
---

View File

@@ -48,7 +48,7 @@ cmake --build build --target conformallab_tests -j$(nproc)
ctest --test-dir build --output-on-failure
```
Expected: **23 tests pass**.
Expected: all pure-math tests pass (count: [`doc/api/tests.md`](api/tests.md)).
### Mode 2 — CGAL tests, headless (recommended for CI and development)
@@ -60,7 +60,7 @@ cmake --build build --target conformallab_cgal_tests -j$(nproc)
ctest --test-dir build -R "^cgal\." --output-on-failure
```
Expected: **227 tests pass, 0 skipped**.
Expected: all CGAL tests pass, 0 skipped (count: [`doc/api/tests.md`](api/tests.md)).
### Mode 3 — Full local build (CLI app + interactive viewer)
@@ -146,7 +146,7 @@ sets x* = 0, so a small perturbation (-0.05) needs only one Newton step.
bash scripts/try_it.sh
```
This clones nothing (run from inside the repo), builds the CGAL test suite, runs
all 227+23 tests, and prints a summary. See `scripts/try_it.sh` for details.
the full test suite, and prints a summary. See `scripts/try_it.sh` for details.
---

View File

@@ -199,7 +199,7 @@ inner/outer edge lengths). Use `torus_8x8.off` for a finer approximation.
## Summary checklist
```
[ ] Check 0: 176 tests pass, 0 skipped
[ ] Check 0: full test suite passes, 0 skipped (counts: `doc/api/tests.md`)
[ ] Check 1: GaussBonnet exact (1e-10)
[ ] Check 2: FD gradient < 1e-6 for all 3 geometries
[ ] Check 3: Newton convergence < 50 iterations

View File

@@ -14,7 +14,7 @@ cmake --build build --target conformallab_cgal_tests
ctest --test-dir build -R cgal --output-on-failure
```
All 227 tests pass, 0 skipped (see `doc/api/tests.md`).
All tests pass, 0 skipped (per-suite breakdown: `doc/api/tests.md`).
---
@@ -252,7 +252,7 @@ implemented in conformallab++ (→ GC-2 in the phase roadmap).
Run these in order to validate the implementation:
- [ ] `ctest --test-dir build -R cgal --output-on-failure`227 tests pass, 0 skipped
- [ ] `ctest --test-dir build -R cgal --output-on-failure`all pass, 0 skipped (count: `doc/api/tests.md`)
- [ ] `cgal.GaussBonnet.*` all pass → topology is correctly read from mesh
- [ ] `cgal.EuclideanFunctional.GradientCheck_*` pass → energy = integral of gradient
- [ ] `cgal.PeriodMatrix.TauInFundamentalDomain_*` pass → SL(2,) reduction correct

216
doc/release-policy.md Normal file
View File

@@ -0,0 +1,216 @@
# Release & versioning policy
> **Audience:** maintainers and recurring contributors of conformallab++.
> External users only need this if they are publishing a fork.
>
> **Created:** 2026-05-22, after the v0.9.0 release that exposed two
> recurring failure modes: (a) the v0.9.0 tag had to be moved twice
> because release-prep landed on the wrong commit, and (b) the test
> counts were hardcoded in eight different documents and drifted apart.
This document codifies how conformallab++ chooses version numbers,
how releases are produced, and which artefacts must stay in sync.
---
## 1. Version-number rules (SemVer)
The project follows [Semantic Versioning 2.0](https://semver.org).
Tag format: `vMAJOR.MINOR.PATCH` (e.g. `v0.9.0`).
### Pre-1.0 phase (current — until CGAL submission)
While the project is at version `0.x.y`, the public API is **not yet
frozen**. The convention we use during the pre-1.0 phase is:
| Bump | When |
|---|---|
| **`0.x.0 → 0.(x+1).0`** (MINOR) | Significant new feature: new functional, new Newton solver, new public CGAL entry, new Phase milestone completed. May break the public API. Treated as the "interesting" release type during pre-1.0. |
| **`0.x.y → 0.x.(y+1)`** (PATCH) | Bug fixes, performance optimisations without API change, doc-only follow-up releases, CI/infrastructure fixes. Never breaks the public API. |
| **`0.x.y → 1.0.0`** | API freeze + CGAL submission readiness. All public headers under `include/CGAL/` are stable. Cumulative changelog updated to call out every public-API decision in retrospect. |
Phase milestone → MINOR-bump mapping (historical + planned):
| Tag | Phase milestone |
|-----------|--------------------------------------------------------|
| `v0.1.0` | Phase 1 (special functions) |
| `v0.2.0` | Phase 2 (hyper-ideal geometry) |
| `v0.7.0` | Phase 7 complete (period matrix, fundamental domain) |
| `v0.9.0` | Phase 9a + 9b + 8b-Lite |
| `v0.10.0` | Phase 9c (4g-polygon, genus g > 1) |
| `v0.11.0` | Phase 10a (harmonic + holomorphic 1-forms) |
| `v1.0.0` | CGAL package submission-ready |
### Post-1.0 phase (planned, ≥ `v1.0.0`)
Standard SemVer with no pre-1.0 exceptions:
| Bump | When |
|---|---|
| **MAJOR** (`X.0.0 → (X+1).0.0`) | Any breaking change to the public API: removed function, signature change, renamed CGAL entry, behaviour change of a documented contract. |
| **MINOR** (`X.Y.0 → X.(Y+1).0`) | New features, new public entries, new Default-traits classes. Strictly additive — no removal, no behaviour change. |
| **PATCH** (`X.Y.Z → X.Y.(Z+1)`) | Bug fixes only. No new files, no new public symbols. |
### Pre-release tags
For testing-quality releases:
* `v1.0.0-alpha.1`, `v1.0.0-alpha.2`, … — early preview, API may still
shift.
* `v1.0.0-beta.1` — feature-complete preview, API stable but tests
may still find issues.
* `v1.0.0-rc.1`, `v1.0.0-rc.2`, … — release candidate, only blocker
fixes before the final tag.
### What does NOT trigger a release
* Internal refactoring without user-visible change.
* Documentation-only updates (unless the documentation is itself a
product, like a new tutorial).
* Test additions / coverage improvements.
* CI / infrastructure changes.
Group such changes into the next planned release.
---
## 2. Single source of truth for moving numbers
Three numbers tend to drift across multiple documents in any project:
test counts, version strings, and date strings. The conformallab++
rule is:
| Number / string | Single source of truth | Anywhere else |
|-----------------------------|--------------------------------|--------------------------------------|
| **Total / per-suite test counts** | `doc/api/tests.md` | Use qualitative phrasing + link, e.g. "full test suite passes, 0 skipped — see [`doc/api/tests.md`](doc/api/tests.md)". |
| **Project version** | `CITATION.cff` `version:` field | Don't hardcode in headers, READMEs, doc bodies. Reference by name ("v0.9.0") only when the historical version actually matters (changelog entries, release-note bodies, Phase-milestone tables). |
| **Release date** | `CITATION.cff` `date-released:` + `CHANGELOG.md` section header | Don't hardcode elsewhere. |
| **Test-suite description per file** | `doc/api/tests.md` | Headers and code files may list the suites they implement, but never claim a count of suites elsewhere. |
### Why qualitative phrasing for test counts
The counts change with every PR that adds or removes a test. Hardcoding
the number "227" in eight documents means eight separate edits per PR —
and historically we have failed to keep them in sync more than once.
The cost of looking up the exact number in `doc/api/tests.md` is one
click; the cost of fixing a stale "227" in seven places is real PR
overhead. We pay the click.
### Audit script
`scripts/check-test-counts.sh` (when present) reads the totals from
`doc/api/tests.md`, runs `ctest`, and fails the CI if they diverge.
This catches the `tests.md` itself going stale even if no other doc
hardcodes the number.
---
## 3. Release process (checklist)
The recipe that worked for v0.9.0 (after the false-start with PR #11 /
#12). Apply for every `vX.Y.Z` release.
### Pre-release prep — on a release branch
1. **Update `CHANGELOG.md`** — add a new top-level section
`## [vX.Y.Z] — YYYY-MM-DD`. Categorise changes by `Added`,
`Changed`, `Removed`, `Fixed`, `Deprecated`, `Security`. See
[Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/).
2. **Update `CITATION.cff`**`version:` and `date-released:` fields.
3. **Refresh `doc/api/tests.md`** — re-run the full test suite locally
and update the per-suite tables + the two `**Total: N tests, 0 skipped.**`
lines (one for `conformallab_tests`, one for `conformallab_cgal_tests`).
4. **Run `scripts/check-test-counts.sh`** if present — must pass.
5. **Verify no hardcoded counts remain** elsewhere:
```bash
grep -rn "[0-9]\+ tests pass\|[0-9]\+ tests, 0 skipped\|[0-9]\+ CGAL tests" \
README.md CLAUDE.md doc/ scripts/ \
| grep -v "doc/api/tests.md"
# Expected: zero results (other than tests.md itself).
```
6. **Commit** with the message
`release: vX.Y.Z — <one-line summary>`. Push to a feature branch.
### Open the release PR
7. **Open one PR** with all prep commits. Title:
`release: vX.Y.Z (<summary>)`. Description: copy the new
`CHANGELOG.md` section.
8. **Wait for CI green** — `test-fast` + `test-cgal` + `doc-build`.
9. **Merge** with a merge commit (the merge SHA becomes the release
target).
### After-merge — tag and announce
10. **Verify `main` head is the merge commit**:
```bash
git fetch origin && git log origin/main --oneline -1
```
11. **Create the Gitea release** via API or web UI. `target_commitish`
MUST be the merge SHA from step 10 (NOT the feature branch HEAD).
The web UI does this automatically; the API needs explicit `sha`.
12. **Sync the codeberg mirror**:
```bash
git fetch origin --tags
git push codeberg main --tags
```
13. **Update CLAUDE.md "Release state" section** so future
contributors see the new version at a glance. (This is the only
place where the current version appears outside `CITATION.cff` and
`CHANGELOG.md`.)
### Common failure modes and how to recover
| Symptom | Recovery |
|---|---|
| Tag points to an earlier commit than the one you wanted | Delete the release + tag via Gitea API, re-create with the correct `target_commitish`. |
| Release-prep commit lands on a feature branch but not on `main` after merge | Open a fast-follow `release/vX.Y.Z-finalise` PR. Cherry-pick the missing commit; merge; move the tag. |
| `doc/api/tests.md` counts are stale post-merge | One-line PR: re-run `ctest`, update tests.md, no version bump (it's a doc fix between releases). |
---
## 4. Hotfix policy
For a `vX.Y.0` minor release that needs an urgent fix before the next
planned minor:
1. Branch off the release tag (NOT off `main`):
```bash
git checkout -b hotfix/vX.Y.1 vX.Y.0
```
2. Apply the minimal fix.
3. Tag `vX.Y.1` from the hotfix branch.
4. Merge the hotfix back into `main` so the fix isn't lost.
Hotfixes are rare for a research-quality library; the typical path is
to wait for the next minor. Use hotfixes only for security issues or
build-broken-on-supported-platform regressions.
---
## 5. Deprecation policy (post-1.0)
When a public symbol is to be removed in version `M.0.0`:
1. In any earlier `M-1` minor release, mark it `[[deprecated]]` (C++17)
or via a Doxygen `\deprecated` tag.
2. Add a `Deprecated` section to that version's CHANGELOG entry with
the planned removal version.
3. Keep the symbol working for at least one full minor cycle before
removal.
4. Remove in the `M.0.0` release; document the removal in the
`Removed` section.
Pre-1.0, this policy does not apply — breaking changes are allowed
on any minor bump as long as they are listed in CHANGELOG.
---
## 6. Related documents
* `CHANGELOG.md` — per-version history.
* `CITATION.cff` — authoritative version + date for citation.
* `doc/api/tests.md` — single source of truth for test counts.
* `doc/contributing.md` — code style + PR mechanics.
* `doc/roadmap/phases.md` — what each future MINOR bump will deliver.

90
scripts/check-test-counts.sh Executable file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# scripts/check-test-counts.sh
#
# CI gate that verifies the totals in `doc/api/tests.md` match the
# actual `ctest` output. Catches the canonical test-count document
# going stale even if no other doc hardcodes the number.
#
# Usage:
# bash scripts/check-test-counts.sh
#
# Exit codes:
# 0 totals match
# 1 totals diverge — prints diff and which file to fix
# 2 prerequisites missing (cmake not found, tests.md missing, …)
#
# This script is meant to be cheap enough to run on every PR (~30 s on
# a typical CI runner). It re-uses the existing build-cgal/ directory
# if one is present; otherwise it builds a throwaway build-counts/.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.." # repo root
TESTS_MD="doc/api/tests.md"
if [ ! -f "$TESTS_MD" ]; then
echo "FAIL: $TESTS_MD not found" >&2
exit 2
fi
command -v cmake >/dev/null || { echo "FAIL: cmake not in PATH" >&2; exit 2; }
command -v ctest >/dev/null || { echo "FAIL: ctest not in PATH" >&2; exit 2; }
# ── Build ───────────────────────────────────────────────────────────────────
# Re-use existing build-cgal/ if it exists with the right flags; otherwise
# create a throwaway build-counts/ directory.
BUILD_DIR=""
if [ -f build-cgal/CMakeCache.txt ] && grep -q "WITH_CGAL_TESTS:.*=ON\|WITH_CGAL:.*=ON" build-cgal/CMakeCache.txt; then
BUILD_DIR=build-cgal
else
BUILD_DIR=build-counts
cmake -S code -B "$BUILD_DIR" -DWITH_CGAL_TESTS=ON -DCMAKE_BUILD_TYPE=Release >/dev/null
fi
cmake --build "$BUILD_DIR" -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 2)" >/dev/null
# ── Get actual counts ───────────────────────────────────────────────────────
cd "$BUILD_DIR"
actual_cgal=$(ctest -R "^cgal" 2>&1 | grep "tests passed" | sed -E 's/.*out of ([0-9]+).*/\1/')
actual_fast=$(ctest -E "^cgal" 2>&1 | grep "tests passed" | sed -E 's/.*out of ([0-9]+).*/\1/')
cd - >/dev/null
# ── Get claimed counts from tests.md ────────────────────────────────────────
# Expected format (in this order):
# **Total: <N1> tests, 0 skipped.** (non-CGAL section)
# ...
# **Total: <N2> tests, 0 skipped.** (CGAL section)
claimed_fast=$(grep -E "\*\*Total: [0-9]+ tests, 0 skipped\.\*\*" "$TESTS_MD" \
| head -n 1 | sed -E 's/.*Total: ([0-9]+) tests.*/\1/')
claimed_cgal=$(grep -E "\*\*Total: [0-9]+ tests, 0 skipped\.\*\*" "$TESTS_MD" \
| tail -n 1 | sed -E 's/.*Total: ([0-9]+) tests.*/\1/')
# ── Compare ────────────────────────────────────────────────────────────────
ok=1
if [ "${actual_fast}" != "${claimed_fast}" ]; then
echo "MISMATCH: non-CGAL — $TESTS_MD claims ${claimed_fast}, ctest reports ${actual_fast}"
ok=0
fi
if [ "${actual_cgal}" != "${claimed_cgal}" ]; then
echo "MISMATCH: CGAL — $TESTS_MD claims ${claimed_cgal}, ctest reports ${actual_cgal}"
ok=0
fi
if [ "$ok" -eq 0 ]; then
cat <<EOF
FAIL: $TESTS_MD totals out of sync with actual test counts.
Recovery:
1. Edit $TESTS_MD: update the two
'**Total: N tests, 0 skipped.**' lines.
2. Add/remove suite rows in the per-suite tables if test
suites were added/removed.
3. Re-run this script to confirm.
EOF
exit 1
fi
echo "OK: test counts in $TESTS_MD match ctest output"
echo " non-CGAL: ${actual_fast} CGAL: ${actual_cgal} total: $((actual_fast + actual_cgal))"
exit 0

View File

@@ -10,7 +10,8 @@
# bash scripts/try_it.sh
#
# Expected output (last lines):
# [PASS] 227 CGAL tests pass, 0 skipped
# [PASS] full CGAL test suite passes, 0 skipped
# (current counts: doc/api/tests.md)
# [PASS] 23 non-CGAL tests pass
# [EXAMPLE] Converged in N iterations. ||G||_inf < 1e-9