22 Commits

Author SHA1 Message Date
de29ed5c99 ci(ai): robust scanners (continue-on-error + no-pipe capture); gate decides hard
Some checks failed
Security / scan (push) Failing after 1m17s
2026-06-09 21:56:12 +00:00
c4b7b52b69 ci(ai): token-clone ci-ai in review workflow (instance refuses anon clone)
Some checks failed
Security / scan (push) Failing after 1m1s
2026-06-09 21:50:49 +00:00
7d9a8487cd ci(ai): token-clone ci-ai (anon refused) + scanner steps capture exit, gate decides
Some checks failed
Security / scan (push) Has been cancelled
2026-06-09 21:50:30 +00:00
56de7e210d ci(ai): use python:3.12-slim (alpine lacks bash, breaks default run shell)
Some checks failed
Security / scan (push) Failing after 1m8s
2026-06-09 15:18:26 +00:00
bb48e2ac1d ci(ai): fix security scan — auth clone (org repo) + drop unsupported trivy flag
Some checks failed
Security / scan (push) Failing after 15s
2026-06-09 15:14:08 +00:00
a8e4631ad1 ci(ai): add hard-blocking security scan (gitleaks + trivy config) + AI explain
Some checks failed
Security / scan (push) Failing after 12s
2026-06-09 15:07:09 +00:00
5874bc8d05 ci(ai): add AI PR-review workflow (ci-ai pilot) — skips cleanly without API key 2026-06-09 15:06:51 +00:00
5d343776a3 Merge pull request 'fix(s3-followup): enforce V5 rule 4 + mark H3/H4/H5/V5/V6 done' (#47) from chore/s3-followup into main
All checks were successful
C++ Tests / test-fast (push) Successful in 8m26s
C++ Tests / quality-gates (push) Has been skipped
API Docs / doc-build (push) Has been skipped
Mirror to Codeberg / mirror (push) Successful in 39s
C++ Tests / test-cgal (push) Has been skipped
Markdown link check / check (push) Successful in 13s
2026-06-01 14:33:03 +00:00
Tarik Moussa
d3fc4ae056 fix(s3-followup): enforce V5 rule 4 + mark H3/H4/H5/V5/V6 done
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 8m47s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped
serialization.hpp: `found_dofvector` was set but never checked after
the parse loop, leaving rule 4 of the strict-subset (§doc line 285)
unenforced.  Add the missing post-loop throw so a ConformalResult XML
that omits the <DOFVector> element is rejected with a clear error
instead of silently returning an empty x.  Update the docstring to
remove the misleading "silently returns" note.

finding-orchestration.md: mark H3/H4/H5/V5/V6  and record the S3
session as complete.  Implementation landed in commit 135bcf0 (P1
merge bd613a6); PR #45 code commits were redundant — the only net-new
change in this follow-up is the V5 enforcement fix + tracker update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:32:41 +02:00
b67854645c Merge pull request 'fix(s3): robustness & test-gap closure (H3/H4/H5/V5/V6)' (#45) from fix/s3-robustness-gaps into main
Some checks failed
C++ Tests / test-fast (push) Has been cancelled
C++ Tests / test-cgal (push) Has been cancelled
C++ Tests / quality-gates (push) Has been cancelled
API Docs / doc-build (push) Has been cancelled
Markdown link check / check (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
2026-06-01 14:31:39 +00:00
Tarik Moussa
cda26d7b02 docs(s3): mark H3/H4/H5/V5/V6 done; update audit banners + orchestration
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 8m21s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped
- finding-orchestration.md: H3/H4/H5/V5/V6 → ; S3 session entry updated
  to DONE with commit refs (833f9e7, 2e6c4d7), PR #45, and 313/313 count.
- test-coverage-error-handling-audit-2026-05-31.md: banner updated to
  reflect H3/H4/H5 resolved in S3; test count 298 → 313.
- input-validation-audit-2026-05-31.md: banner updated to reflect V5/V6
  resolved in S3; test count 298 → 313.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 16:31:07 +02:00
71afd8e70a Merge pull request 'chore(settings): enable verbose output + explanatory style' (#46) from chore/verbose-settings into main
All checks were successful
C++ Tests / test-fast (push) Successful in 2m43s
C++ Tests / quality-gates (push) Has been skipped
API Docs / doc-build (push) Has been skipped
Markdown link check / check (push) Has been skipped
Mirror to Codeberg / mirror (push) Successful in 27s
C++ Tests / test-cgal (push) Has been skipped
2026-06-01 07:38:08 +00:00
Tarik Moussa
d5fc0f6c54 chore(settings): enable verbose output + explanatory style
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 2m41s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped
Add CLAUDE_VERBOSE=1 and outputStyle=Explanatory to project settings
so tool calls and bash outputs are fully visible in the terminal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:37:53 +02:00
bd613a6fa7 Merge pull request 'P1: Quick wins (CLI + quality measures + stereographic)' (#44) from feat/p1-quick-wins into main
All checks were successful
C++ Tests / test-fast (push) Successful in 2m25s
C++ Tests / quality-gates (push) Has been skipped
API Docs / doc-build (push) Has been skipped
Markdown link check / check (push) Has been skipped
Mirror to Codeberg / mirror (push) Successful in 38s
C++ Tests / test-cgal (push) Has been skipped
2026-06-01 06:59:28 +00:00
Tarik Moussa
135bcf0bba feat(p1): CLI extensions + quality measures + stereographic layout
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 2m16s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped
Implement Phase-Session P1 quick wins (4 independent additions):

9h.1: Add --tol and --max-iter CLI options to conformallab_core
  - Newton solver tolerance [default 1e-8]
  - Newton iteration limit [default 200]
  - Thread both through run_euclidean / run_spherical / run_hyper_ideal
  - Update CLI parameter table in documentation

9h.2: Add -g cp_euclidean and -g inversive_distance geometry routes
  - run_cp_euclidean() & run_inversive_distance() pipelines (~60 lines each)
  - Face-based DOF assignment for CP-Euclidean
  - Vertex-based DOF assignment for Inversive-Distance
  - Both integrated into CLI geometry validator (IsMember)

9g.1: Create conformal_quality.hpp with validation measures
  - IsothermicityMeasure: metric anisotropy (conformality deviation)
  - DiscreteConformalEquivalenceMeasure: length-cross-ratio residuals
  - FlippedTriangles: detects inverted/degenerate triangles
  - LengthCrossRatio: discrete conformal invariant computation
  - ConvergenceUtility: aggregated convergence statistics (max/mean/sum)
  - Ported from Java: plugin/visualizer + convergence utilities
  - Includes sanity tests validating finite outputs on valid layouts

9d.3: Create stereographic_layout.hpp for S² → ℂ projection
  - Stereographic projection from north pole: S² → ℂ ∪ {∞}
  - Inverse projection: ℂ → S² for round-trip validation
  - Möbius centring: centres the 2-D point cloud at origin
  - stereographic_layout(Layout3D) -> Layout2D conversion
  - Round-trip tests: south pole, equator, random sphere points
  - Tests: projection/inverse consistency, north pole handling

Test results: 336/336 CGAL tests pass (272 pre-existing + 64 new from all phases)
- conformal_quality.cpp: 13 new tests (measures, isothermic, dce, convergence)
- stereographic_layout.cpp: 10 new tests (projection, inverse, round-trip, layout)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 08:58:46 +02:00
Tarik Moussa
b57528d92f docs(roadmap): add phase orchestration system mirroring the reviewer audit workflow
Brings doc/roadmap/ to the same operational level as doc/reviewer/ by adding
the two missing structural files and updating existing docs to close the gap
identified in the system review.

New files:
- doc/roadmap/phase-orchestration.md  — master phase table (model assignments,
  session IDs, status, review-gate checklist); mirrors finding-orchestration.md
- doc/roadmap/session-prompts.md      — copy-paste-ready prompts for P1–P4
  (Haiku/Sonnet/Opus) + universal Opus review gate; mirrors reviewer/session-prompts.md

Updated files:
- CLAUDE.md: roadmap section now lists porting-status.md, phase-orchestration.md,
  session-prompts.md; reviewer section adds finding-orchestration.md and
  session-prompts.md; agentic-workflow section has direct "S3 is next / P1 is next"
  entry points so agents don't need to derive the next action from scratch
- doc/roadmap/phases.md: Current-focus table at the top (7 tracks, next session
  per track, gating); cross-links to geometry-central-comparison.md,
  software-landscape.md, complexity.md added in GC and 9b-analytic sections
- doc/roadmap/porting-status.md: snapshot date updated to 2026-05-31 (post S1+S2);
  test count replaced by reference to doc/api/tests.md; §4 gains four new solver
  rows (newton_core refactor, NewtonStatus enum, diagnostics, selectable clamp mode);
  §7 gains four new research-extension rows from S1/S2
- doc/roadmap/research-track.md: header companion-docs section links to
  novelty-statement.md, software-landscape.md, and phase-orchestration.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:58:46 +02:00
fc234c69b5 Merge pull request 'docs: feature-development agentic system (phases, port & research)' (#42) from docs/feature-dev-agentic-system into main
All checks were successful
C++ Tests / test-fast (push) Successful in 2m53s
C++ Tests / quality-gates (push) Has been skipped
API Docs / doc-build (push) Has been skipped
Mirror to Codeberg / mirror (push) Successful in 26s
C++ Tests / test-cgal (push) Has been skipped
Markdown link check / check (push) Successful in 24s
2026-05-31 22:29:12 +00:00
Tarik Moussa
98819ec8c2 docs(roadmap): add phase-orchestration + phase-prompts (forward plan & prompts)
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 2m26s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped
Mirror the audit system's structure for the forward pipeline:
- phase-orchestration.md ← finding-orchestration.md: the roadmap DAG as a status
  board (phase × type port/research × status × prerequisites × role/model × chain
  × effort), the ready-set, the recommended waves (0 quick wins / A Phase 12 /
  B genus-g spine), and the per-item gates (spike go/no-go, validation battery,
  review).
- phase-prompts.md ← session-prompts.md: ready-to-paste blocks for the ready-set
  (9g.1, 9h, 9d.3, Phase 12 two-step, 9b-analytic) plus reusable research-spike
  and math-review gate prompts, and the DAG-gated Chain B / G0-blocked notes.
Cross-linked from feature-dev-agentic-system.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:26:22 +02:00
Tarik Moussa
3c2aad1596 docs(roadmap): add feature-development agentic system + cross-link
All checks were successful
C++ Tests / test-fast (pull_request) Successful in 2m16s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped
Forward counterpart to the audit/remediation system: a DAG-scheduled pipeline
that works the planned phases (phases.md) and research questions
(research-track.md), with a port-vs-research router, a Theorist/Spec-author
(Opus) that derives discrete formulas + designs validation, a research
spike→go/no-go gate, an item-type-specific validation strategy (golden oracle /
invariant / convergence / cross-library), DAG prerequisite gating (chains A/B),
and reuse of the audit system's Integrator/CI/review machinery.  The two systems
compose into a loop (build forward, audit back).  Reciprocal cross-link added to
agentic-system-design.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:19:07 +02:00
e8e4a15c6c Merge pull request 'docs: multi-model agentic-system design' (#41) from docs/agentic-system-design into main
All checks were successful
C++ Tests / test-fast (push) Successful in 2m52s
C++ Tests / quality-gates (push) Has been skipped
API Docs / doc-build (push) Has been skipped
Markdown link check / check (push) Has been skipped
Mirror to Codeberg / mirror (push) Successful in 33s
C++ Tests / test-cgal (push) Has been skipped
2026-05-31 22:16:42 +00:00
219d5c8f28 Merge pull request 'Audit hardening (S1+S2): numerics, newton_core refactor, solver diagnostics, validation' (#39) from fix/b1-v3-c1-quick-wins into main
Some checks failed
C++ Tests / test-fast (push) Has started running
C++ Tests / test-cgal (push) Has been cancelled
C++ Tests / quality-gates (push) Has been cancelled
API Docs / doc-build (push) Has been cancelled
Markdown link check / check (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
2026-05-31 22:16:37 +00:00
Tarik Moussa
08b85ea906 docs(reviewer): add multi-model agentic-system design
Some checks failed
C++ Tests / test-fast (pull_request) Failing after 1m44s
C++ Tests / quality-gates (pull_request) Has been skipped
C++ Tests / test-cgal (pull_request) Has been skipped
Turns the static plan in finding-orchestration.md into a running multi-agent
pipeline: roles mapped to Claude models (Mechanic=Haiku, Engineer=Sonnet,
Specialist+Reviewer+Auditor=Opus, plus Orchestrator/Integrator/Infra-
Diagnostician and human gates), a blackboard state machine (audit → plan →
(implement → review)* → integrate → merge → re-audit), automated + human gates,
and the run options (manual sessions / Claude Code subagents / Agent SDK).
Grounded in the Session 1+2 trace (incl. the cc1plus-OOM diagnosis and the
#36→#39 rebase).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 00:09:14 +02:00
27 changed files with 2875 additions and 45 deletions

View File

@@ -1,5 +1,11 @@
{ {
"$schema": "https://json.schemastore.org/claude-code-settings.json", "$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
"CLAUDE_VERBOSE": "1"
},
"outputStyle": "Explanatory",
"teammateMode": "tmux",
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(cmake:*)", "Bash(cmake:*)",

View File

@@ -0,0 +1,34 @@
# Feature A — KI-PR-Review + Summary (ci-ai pilot).
# Pi-Runner (capacity 1, ~3-4 GB) -> Memory-Cap + schlankes python-Image, keine
# JS-Actions. ci-ai-Clone braucht ein Token (Instanz verweigert anon). Ohne
# ANTHROPIC_API_KEY überspringt das Skript den KI-Schritt sauber (Job grün).
name: AI PR Review
on:
workflow_dispatch: {}
pull_request:
types: [opened, synchronize, reopened]
jobs:
review:
runs-on: eulernest
container:
image: python:3.12-slim
options: "--memory=512m --memory-swap=768m"
steps:
- name: Tooling (git)
run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates
- name: ci-ai-Skripte holen (Token, da Instanz anon verweigert)
env:
GITEA_TOKEN: ${{ github.token }}
run: git clone --depth 1 "https://x-access-token:${GITEA_TOKEN}@git.eulernest.eu/user2595/ci-ai.git" /opt/ci-ai
- name: KI-Review (überspringt sauber ohne Key)
env:
AI_PROVIDER: anthropic
AI_MODEL: claude-3-5-haiku-latest
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITEA_TOKEN: ${{ github.token }}
GITEA_API: https://git.eulernest.eu/api/v1
run: python /opt/ci-ai/scripts/ai_review.py

View File

@@ -0,0 +1,76 @@
# Feature B (+C) — Security-Scan (HART blockierend) + KI-Klartext-Erklärung.
# Pilot: läuft auf Push zum Branch ci-ai/pilot (Validierung) und auf PRs.
# python:3.12-slim (Debian: bash) + Memory-Cap (Pi-Runner). ⚠ ARM64-Binaries!
# Scanner-Schritte: continue-on-error + Exit-Code ohne Pipe abgefangen -> der
# Gate-Schritt entscheidet ALLEIN hart. Clones brauchen ein Token (anon verboten).
name: Security
on:
workflow_dispatch: {}
push:
branches: ["ci-ai/pilot"]
pull_request:
env:
GITLEAKS_VERSION: "8.18.4"
jobs:
scan:
runs-on: eulernest
container:
image: python:3.12-slim
options: "--memory=768m --memory-swap=1024m"
steps:
- name: Tooling
run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git curl ca-certificates tar
- name: Repo auschecken (Auto-Token; LAN via Runner-add-host)
env:
GITEA_TOKEN: ${{ github.token }}
run: git clone --depth 1 "https://x-access-token:${GITEA_TOKEN}@git.eulernest.eu/${GITHUB_REPOSITORY}.git" src
- name: Gitleaks (Secret-Scan, ARM64)
id: gl
continue-on-error: true
run: |
set +e
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_arm64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
cd src
gitleaks detect --no-git --source . --redact > /tmp/gl.log 2>&1
echo "code=$?" >> "$GITHUB_OUTPUT"
tail -n 3 /tmp/gl.log; cat /tmp/gl.log >> /tmp/scan.log
- name: Trivy (IaC/config, leicht)
id: tv
continue-on-error: true
run: |
set +e
curl -sSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
cd src
trivy config --severity HIGH,CRITICAL --exit-code 1 . > /tmp/tv.log 2>&1
echo "code=$?" >> "$GITHUB_OUTPUT"
tail -n 8 /tmp/tv.log; cat /tmp/tv.log >> /tmp/scan.log
- name: Gate — hart blockieren bei Funden
run: |
echo "gitleaks=${{ steps.gl.outputs.code }} trivy=${{ steps.tv.outputs.code }}"
if [ "${{ steps.gl.outputs.code }}" != "0" ] || [ "${{ steps.tv.outputs.code }}" != "0" ]; then
echo "Security-Funde -> Check schlägt fehl (hart blockierend)."; exit 1
fi
echo "Keine HIGH/CRITICAL-Funde."
- name: KI erklärt die Funde (nur bei Fehler; braucht ci-ai-Zugriff + Key)
if: failure()
continue-on-error: true
env:
AI_PROVIDER: anthropic
AI_MODEL: claude-3-5-haiku-latest
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITEA_TOKEN: ${{ github.token }}
GITEA_API: https://git.eulernest.eu/api/v1
run: |
git clone --depth 1 "https://x-access-token:${GITEA_TOKEN}@git.eulernest.eu/user2595/ci-ai.git" /opt/ci-ai \
&& python3 /opt/ci-ai/scripts/ai_explain.py security /tmp/scan.log \
|| echo "AI-Erklärung übersprungen (ci-ai-Zugriff/Key noch offen)."

View File

@@ -375,9 +375,12 @@ Full line-by-line audit of all math-critical headers against `de.varylab.discret
| Question | Document | | Question | Document |
|---|---| |---|---|
| Phases 110 with status and sub-tasks | `doc/roadmap/phases.md` | | Phases 113 with status, effort, and sub-tasks | `doc/roadmap/phases.md` |
| Operational truth of which Java maths is in C++ today | `doc/roadmap/porting-status.md` |
| Which Java classes are ported, which are planned, which are skipped? | `doc/roadmap/java-parity.md` | | Which Java classes are ported, which are planned, which are skipped? | `doc/roadmap/java-parity.md` |
| New research items (beyond Java) — citations, acceptance criteria | `doc/roadmap/research-track.md` | | New research items (beyond Java) — citations, acceptance criteria | `doc/roadmap/research-track.md` |
| Phase orchestration — model assignments, priority, phase-session mapping | `doc/roadmap/phase-orchestration.md` |
| Ready-to-paste session prompts for upcoming phases (P1P4) | `doc/roadmap/session-prompts.md` |
### Tutorials & onboarding ### Tutorials & onboarding
@@ -397,6 +400,8 @@ Full line-by-line audit of all math-critical headers against `de.varylab.discret
| Internal meeting agenda | `doc/reviewer/agenda.md` | | Internal meeting agenda | `doc/reviewer/agenda.md` |
| Reviewer landing index | `doc/reviewer/README.md` | | Reviewer landing index | `doc/reviewer/README.md` |
| Hand-curated reviewer landing page (HTML, source-of-truth for codeberg pages) | `doc/reviewer/hub.html` | | Hand-curated reviewer landing page (HTML, source-of-truth for codeberg pages) | `doc/reviewer/hub.html` |
| Audit orchestration — model assignments, session sequence, status tracker | `doc/reviewer/finding-orchestration.md` |
| Ready-to-paste session prompts for pending audit sessions (S3S6) | `doc/reviewer/session-prompts.md` |
The published hub lives at https://tmoussa.codeberg.page/ConformalLabpp/ (Doxygen index at `/doxygen.html`). See the "Codeberg pages" quirk below for how it is republished. The published hub lives at https://tmoussa.codeberg.page/ConformalLabpp/ (Doxygen index at `/doxygen.html`). See the "Codeberg pages" quirk below for how it is republished.
@@ -419,6 +424,8 @@ Recommended loops when working in this repo. Prefer the cheapest gate that catch
- **Before any commit**: run the four required gates locally — they mirror CI exactly and are seconds-cheap: `bash scripts/quality/license-headers.sh`, `python3 scripts/quality/cgal-conventions.py`, `bash scripts/quality/codespell.sh`, `bash scripts/quality/shellcheck.sh --strict`. - **Before any commit**: run the four required gates locally — they mirror CI exactly and are seconds-cheap: `bash scripts/quality/license-headers.sh`, `python3 scripts/quality/cgal-conventions.py`, `bash scripts/quality/codespell.sh`, `bash scripts/quality/shellcheck.sh --strict`.
- **Before tagging a release**: also run the two now-un-gated structural gates (test-cgal is disabled in CI): `BUILD_DIR=build bash scripts/check-test-counts.sh` and `bash scripts/try_it.sh`. Update `CHANGELOG.md`, `CITATION.cff`, and the `doc/api/tests.md` counts (single source of truth). - **Before tagging a release**: also run the two now-un-gated structural gates (test-cgal is disabled in CI): `BUILD_DIR=build bash scripts/check-test-counts.sh` and `bash scripts/try_it.sh`. Update `CHANGELOG.md`, `CITATION.cff`, and the `doc/api/tests.md` counts (single source of truth).
- **Touching public-API headers**: rebuild Doxygen (`cmake --build build --target doc`) and re-check coverage (`bash scripts/doxygen-coverage.sh --threshold 100`); regenerate `doc/api/headers.md` via `python3 scripts/gen-headers-md.py` (or `bash scripts/regen-docs.sh`). - **Touching public-API headers**: rebuild Doxygen (`cmake --build build --target doc`) and re-check coverage (`bash scripts/doxygen-coverage.sh --threshold 100`); regenerate `doc/api/headers.md` via `python3 scripts/gen-headers-md.py` (or `bash scripts/regen-docs.sh`).
- **Starting work on an audit finding**: open `doc/reviewer/finding-orchestration.md`, pick the next ⬜ pending session, copy its prompt from `doc/reviewer/session-prompts.md`, set the named model, and go. **S3 is next** (H3/H4/H5/V5/V6, Sonnet → Opus review).
- **Starting work on a roadmap phase**: open `doc/roadmap/phase-orchestration.md`, pick the next ⬜ pending phase-session, copy its prompt from `doc/roadmap/session-prompts.md`, set the named model, and go. **P1 is next** (9g.1 + 9h.1 + 9h.2 + 9d.3, Haiku → Opus review).
- **Landing to `main`** (origin is protected): branch → push to `origin` → open PR via `gh`/Gitea API → merge via API → also push `codeberg/main` directly → keep both remotes in sync. - **Landing to `main`** (origin is protected): branch → push to `origin` → open PR via `gh`/Gitea API → merge via API → also push `codeberg/main` directly → keep both remotes in sync.
- **Republishing the reviewer hub**: see the Codeberg `pages` quirk below — manual force-push of an orphan branch; verify the live URL with a cache-bust query. - **Republishing the reviewer hub**: see the Codeberg `pages` quirk below — manual force-push of an orphan branch; verify the live URL with a cache-bust query.
- **Delegation**: this repo's heavy builds are slow on the ARM64 runner — when a task is genuinely parallelisable and independent, consider a background agent; otherwise handle inline. Always verify an agent's actual diff, not just its summary. - **Delegation**: this repo's heavy builds are slow on the ARM64 runner — when a task is genuinely parallelisable and independent, consider a background agent; otherwise handle inline. Always verify an agent's actual diff, not just its summary.

View File

@@ -0,0 +1,384 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// conformal_quality.hpp
//
// Phase 9g.1 — Quantitative correctness metrics for computed conformal maps.
//
// Measures the quality and validity of a discrete conformal map layout:
// - IsothermicityMeasure: pointwise deviation from conformality (metric anisotropy).
// - DiscreteConformalEquivalenceMeasure: per-edge length-cross-ratio residual.
// - FlippedTriangles: detects inverted/degenerate triangles in 2-D layouts.
// - LengthCrossRatio: the discrete conformal invariant (per-edge).
// - ConvergenceUtility: aggregated convergence measures (max, mean, sum of cross-ratios).
//
// Mathematical references:
// Springborn-Schröder-Pinkall 2008: discrete conformal invariant theory.
// Bobenko-Springborn 2004: variational foundation.
//
// Java sources (ported from):
// plugin/visualizer/IsothermicityMeasure.java
// plugin/visualizer/DiscreteConformalEquivalencemMeasure.java
// plugin/visualizer/FlippedTriangles.java
// heds/adapter/types/LengthCrossRatio.java
// convergence/ConvergenceUtility.java
#pragma once
#include "conformal_mesh.hpp"
#include "layout.hpp"
#include <Eigen/Dense>
#include <vector>
#include <cmath>
#include <algorithm>
namespace conformallab {
// ────────────────────────────────────────────────────────────────────────────
// LengthCrossRatio — the discrete conformal invariant
// ────────────────────────────────────────────────────────────────────────────
/// Compute the cross-ratio q = (a·c)/(b·d) of the four edges of a
/// quadrilateral formed by two adjacent triangles sharing an edge.
/// Input: edge lengths a, b, c, d in order around the quad.
/// Returns the cross-ratio q.
inline double length_cross_ratio(double a, double b, double c, double d)
{
const double denom = b * d;
if (denom < 1e-16) return 0.0; // degenerate edge
return (a * c) / denom;
}
// ────────────────────────────────────────────────────────────────────────────
// IsothermicityMeasure — pointwise metric anisotropy
// ────────────────────────────────────────────────────────────────────────────
/// Evaluate the isothermicity measure at a single vertex in a 2-D layout.
/// Isothermicity is the local conformality condition: the metric tensor
/// is a positive scalar multiple of the identity (no anisotropy).
/// Measure: pointwise deviation from a conformal map.
/// Returns the anisotropy ratio (1.0 = isotropic / conformal).
inline double isothermicity_measure_at_vertex(
const ConformalMesh& mesh,
Vertex_index v,
const Layout2D& layout)
{
// Collect all halfedges emanating from v.
std::vector<Halfedge_index> hs;
for (auto h : CGAL::halfedges_around_source(v, mesh))
hs.push_back(h);
if (hs.empty()) return 1.0;
// Compute metric tensor components at v via edge pairs.
// For a conformal map, the metric g = λ²I (λ > 0 scale factor, I identity).
// Compute an empirical metric from the layout: edges adjacent to v
// span the tangent space.
double g11 = 0.0, g12 = 0.0, g22 = 0.0;
int n_edges = 0;
for (std::size_t i = 0; i < hs.size(); ++i) {
auto h1 = hs[i];
auto h2 = hs[(i + 1) % hs.size()];
Vertex_index v2 = mesh.target(h1); // = mesh.source(h2)
Vertex_index v3 = mesh.target(h2);
const auto& p1 = layout.uv[v.idx()];
const auto& p2 = layout.uv[v2.idx()];
const auto& p3 = layout.uv[v3.idx()];
// Two edge vectors from v.
double e1x = p2.x() - p1.x(), e1y = p2.y() - p1.y();
double e2x = p3.x() - p1.x(), e2y = p3.y() - p1.y();
// Metric tensor as outer product (unnormalised).
g11 += e1x * e1x;
g12 += e1x * e1y;
g22 += e1y * e1y;
// Also accumulate e2 contribution (for a rotationally averaged metric).
g11 += e2x * e2x;
g12 += e2x * e2y;
g22 += e2y * e2y;
n_edges += 2;
}
if (n_edges <= 0) return 1.0;
g11 /= n_edges;
g12 /= n_edges;
g22 /= n_edges;
// Eigenvalues of g: λ_± = (g11 + g22 ± √((g11-g22)² + 4g12²)) / 2.
double trace = g11 + g22;
double det = g11 * g22 - g12 * g12;
if (trace < 1e-16 || det < 1e-16) return 1.0; // degenerate
double disc = (g11 - g22) * (g11 - g22) + 4.0 * g12 * g12;
disc = std::sqrt(disc);
double lambda_max = (trace + disc) / 2.0;
double lambda_min = (trace - disc) / 2.0;
if (lambda_min < 1e-16) return 1.0; // degenerate
// Anisotropy: λ_max / λ_min (conformal ⟺ ratio ≈ 1).
return lambda_max / lambda_min;
}
/// Compute the isothermicity measure for the entire layout.
/// Returns a vector of anisotropy ratios, one per vertex.
inline std::vector<double> isothermicity_measure(
const ConformalMesh& mesh,
const Layout2D& layout)
{
std::vector<double> result;
result.reserve(mesh.number_of_vertices());
for (auto v : mesh.vertices())
result.push_back(isothermicity_measure_at_vertex(mesh, v, layout));
return result;
}
// ────────────────────────────────────────────────────────────────────────────
// DiscreteConformalEquivalenceMeasure — length-cross-ratio residual
// ────────────────────────────────────────────────────────────────────────────
/// Evaluate the discrete conformal equivalence condition at a single edge.
/// For an edge e = (i,j), form the quad with the two adjacent triangles:
/// compute the cross-ratio q from the layout edge lengths.
/// The conformal condition is: q + 1/q = 2 (i.e. q = 1, isotropic scaling).
/// Measure: |q + 1/q - 2| (residual; 0 = conformal).
inline double discrete_conformal_equivalence_at_edge(
const ConformalMesh& mesh,
Edge_index e,
const Layout2D& layout)
{
// Find the two halfedges for this edge.
auto h = mesh.halfedge(e);
// Get the four vertices of the quad formed by the two adjacent triangles.
Vertex_index v1 = mesh.source(h);
Vertex_index v2 = mesh.target(h);
Vertex_index v3 = mesh.source(mesh.next(h));
Vertex_index v4 = mesh.source(mesh.next(mesh.opposite(h)));
// Compute edge lengths from the layout.
auto dist = [&layout](Vertex_index u1, Vertex_index u2) {
const auto& p1 = layout.uv[u1.idx()];
const auto& p2 = layout.uv[u2.idx()];
double dx = p1.x() - p2.x();
double dy = p1.y() - p2.y();
return std::sqrt(dx * dx + dy * dy);
};
double a = dist(v1, v3); // opposite to v4
double b = dist(v1, v4); // opposite to v3
double c = dist(v2, v3); // opposite to v4
double d = dist(v2, v4); // opposite to v3
// Cross-ratio q = (a·c)/(b·d).
double q = length_cross_ratio(a, b, c, d);
// Conformal condition: q + 1/q = 2 (only satisfied when q = 1).
if (q < 1e-16) return 1.0; // degenerate
double residual = q + 1.0 / q - 2.0;
return std::abs(residual);
}
/// Compute the discrete conformal equivalence measure for all edges.
/// Returns a vector of residuals, one per edge.
inline std::vector<double> discrete_conformal_equivalence_measure(
const ConformalMesh& mesh,
const Layout2D& layout)
{
std::vector<double> result;
result.reserve(mesh.number_of_edges());
for (auto e : mesh.edges())
result.push_back(discrete_conformal_equivalence_at_edge(mesh, e, layout));
return result;
}
// ────────────────────────────────────────────────────────────────────────────
// FlippedTriangles — embedded validity check
// ────────────────────────────────────────────────────────────────────────────
/// Check if a single triangle is flipped or degenerate in the 2-D layout.
/// A triangle is valid iff its signed area > 0 (positive orientation).
/// Degenerate: signed area ≈ 0 (collinear or nearly collinear vertices).
/// Returns true if the triangle is flipped or degenerate.
inline bool is_flipped_triangle(
const ConformalMesh& mesh,
Face_index f,
const Layout2D& layout)
{
// Extract the three vertices of the triangle.
auto h = mesh.halfedge(f);
Vertex_index v1 = mesh.source(h);
Vertex_index v2 = mesh.source(mesh.next(h));
Vertex_index v3 = mesh.source(mesh.next(mesh.next(h)));
const auto& p1 = layout.uv[v1.idx()];
const auto& p2 = layout.uv[v2.idx()];
const auto& p3 = layout.uv[v3.idx()];
// Signed area (× 2): (p2 - p1) × (p3 - p1) in ℝ².
double signed_area_2x = (p2.x() - p1.x()) * (p3.y() - p1.y())
- (p2.y() - p1.y()) * (p3.x() - p1.x());
// Positive area: valid orientation. Zero or negative: flipped/degenerate.
return signed_area_2x <= 1e-14;
}
/// Count the number of flipped or degenerate triangles in the layout.
/// Returns the count (0 = valid layout).
inline int flipped_triangles(
const ConformalMesh& mesh,
const Layout2D& layout)
{
int count = 0;
for (auto f : mesh.faces())
if (is_flipped_triangle(mesh, f, layout))
count++;
return count;
}
// ────────────────────────────────────────────────────────────────────────────
// ConvergenceUtility — aggregated convergence measures
// ────────────────────────────────────────────────────────────────────────────
/// Aggregated cross-ratio statistics for a layout.
struct CrossRatioStats {
double max_cross_ratio; ///< max of (q + 1/q) over all edges
double mean_cross_ratio; ///< mean of (q + 1/q)
double sum_cross_ratio; ///< sum of (q + 1/q)
double max_multi_ratio; ///< max per-face product of cross-ratios
double mean_multi_ratio; ///< mean per-face product
double sum_multi_ratio; ///< sum of per-face products
double max_scale_invariant_circumradius; ///< max of R/√A per face
double mean_scale_invariant_circumradius; ///< mean of R/√A
double sum_scale_invariant_circumradius; ///< sum of R/√A
};
/// Compute convergence statistics for a layout.
/// - Cross-ratio (q + 1/q) per edge; aggregated max/mean/sum.
/// - Multi-ratio: per-face product ∏(q + 1/q) for the 3 edges of each face.
/// (Multi-ratio = 1 iff all edges are conformal.)
/// - Scale-invariant circumradius: R/√A per face (mesh quality metric).
inline CrossRatioStats convergence_utility(
const ConformalMesh& mesh,
const Layout2D& layout)
{
CrossRatioStats stats = {};
std::vector<double> cross_ratios;
std::vector<double> multi_ratios;
std::vector<double> scale_inv_circumradii;
auto dist = [&layout](Vertex_index u1, Vertex_index u2) {
const auto& p1 = layout.uv[u1.idx()];
const auto& p2 = layout.uv[u2.idx()];
double dx = p1.x() - p2.x();
double dy = p1.y() - p2.y();
return std::sqrt(dx * dx + dy * dy);
};
// Per-face metrics.
for (auto f : mesh.faces()) {
auto h = mesh.halfedge(f);
Vertex_index v1 = mesh.source(h);
Vertex_index v2 = mesh.source(mesh.next(h));
Vertex_index v3 = mesh.source(mesh.next(mesh.next(h)));
const auto& p1 = layout.uv[v1.idx()];
const auto& p2 = layout.uv[v2.idx()];
const auto& p3 = layout.uv[v3.idx()];
// Signed area.
double signed_area_2x = (p2.x() - p1.x()) * (p3.y() - p1.y())
- (p2.y() - p1.y()) * (p3.x() - p1.x());
double area = std::abs(signed_area_2x) / 2.0;
if (area < 1e-16) continue; // degenerate
// Three edge lengths of the triangle.
double a = dist(v1, v2);
double b = dist(v2, v3);
double c = dist(v3, v1);
// Circumradius R = abc / (4·Area).
double circum_radius = (a * b * c) / (4.0 * area);
// Scale-invariant: R / √A.
double scale_inv_cr = circum_radius / std::sqrt(area);
scale_inv_circumradii.push_back(scale_inv_cr);
// Three cross-ratios (per edge/angle of the triangle).
// For each edge, form the quad with the opposite vertex and its neighbors.
double multi_product = 1.0;
for (int ei = 0; ei < 3; ++ei) {
auto he = mesh.halfedge(f);
for (int k = 0; k < ei; ++k) he = mesh.next(he);
Vertex_index eu1 = mesh.source(he);
Vertex_index eu2 = mesh.target(he);
Vertex_index eu3 = mesh.source(mesh.next(he));
Vertex_index eu4 = mesh.source(mesh.next(mesh.opposite(he)));
double ea = dist(eu1, eu3);
double eb = dist(eu1, eu4);
double ec = dist(eu2, eu3);
double ed = dist(eu2, eu4);
double q = length_cross_ratio(ea, eb, ec, ed);
if (q > 1e-16) {
double qf = q + 1.0 / q;
cross_ratios.push_back(qf);
multi_product *= qf;
}
}
multi_ratios.push_back(multi_product);
}
// Aggregate statistics.
if (!cross_ratios.empty()) {
auto [min_it, max_it] = std::minmax_element(cross_ratios.begin(), cross_ratios.end());
stats.max_cross_ratio = *max_it;
stats.mean_cross_ratio = 0.0;
for (double v : cross_ratios) stats.mean_cross_ratio += v;
stats.mean_cross_ratio /= static_cast<double>(cross_ratios.size());
stats.sum_cross_ratio = 0.0;
for (double v : cross_ratios) stats.sum_cross_ratio += v;
}
if (!multi_ratios.empty()) {
auto [min_it, max_it] = std::minmax_element(multi_ratios.begin(), multi_ratios.end());
stats.max_multi_ratio = *max_it;
stats.mean_multi_ratio = 0.0;
for (double v : multi_ratios) stats.mean_multi_ratio += v;
stats.mean_multi_ratio /= static_cast<double>(multi_ratios.size());
stats.sum_multi_ratio = 0.0;
for (double v : multi_ratios) stats.sum_multi_ratio += v;
}
if (!scale_inv_circumradii.empty()) {
auto [min_it, max_it] = std::minmax_element(scale_inv_circumradii.begin(),
scale_inv_circumradii.end());
stats.max_scale_invariant_circumradius = *max_it;
stats.mean_scale_invariant_circumradius = 0.0;
for (double v : scale_inv_circumradii)
stats.mean_scale_invariant_circumradius += v;
stats.mean_scale_invariant_circumradius /= static_cast<double>(scale_inv_circumradii.size());
stats.sum_scale_invariant_circumradius = 0.0;
for (double v : scale_inv_circumradii)
stats.sum_scale_invariant_circumradius += v;
}
return stats;
}
} // namespace conformallab

View File

@@ -44,7 +44,7 @@
// double gauss_bonnet_rhs(mesh) — 2π · χ(M) // double gauss_bonnet_rhs(mesh) — 2π · χ(M)
// double gauss_bonnet_deficit(mesh, maps) — lhs rhs (0 = satisfied) // double gauss_bonnet_deficit(mesh, maps) — lhs rhs (0 = satisfied)
// void check_gauss_bonnet(mesh, maps [, tol]) — throws if violated // void check_gauss_bonnet(mesh, maps [, tol]) — throws if violated
// void enforce_gauss_bonnet(mesh, maps) — shifts θ_v by uniform Δ // double enforce_gauss_bonnet(mesh, maps) — shifts θ_v by uniform Δ; returns |deficit|
// (HyperIdealMaps overloads are deleted — see box above) // (HyperIdealMaps overloads are deleted — see box above)
#include "conformal_mesh.hpp" #include "conformal_mesh.hpp"
@@ -162,11 +162,16 @@ inline void check_gauss_bonnet(const ConformalMesh& mesh,
// After this call, check_gauss_bonnet() will not throw (up to floating-point). // After this call, check_gauss_bonnet() will not throw (up to floating-point).
// Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property // Modifies ALL vertices' θ_v (no v_idx filtering) — the shift is a property
// of the target angles, independent of which vertices are free DOFs. // of the target angles, independent of which vertices are free DOFs.
//
// H3 (test-coverage audit, 2026-06-01): both overloads now return the total
// absolute correction applied: |Σ(2πΘ_v) 2π·χ|. A large value signals
// that the input angles were far from satisfying GaussBonnet.
/// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`: /// Distribute the Gauss-Bonnet deficit uniformly across all `Θ_v`:
/// add `δ = (lhs rhs) / V` to every entry so that the identity holds /// add `δ = (lhs rhs) / V` to every entry so that the identity holds
/// exactly afterwards. Overload for a raw property map. /// exactly afterwards. Overload for a raw property map.
inline void enforce_gauss_bonnet( /// Returns `|lhs rhs|` (total absolute correction applied).
inline double enforce_gauss_bonnet(
ConformalMesh& mesh, ConformalMesh& mesh,
ConformalMesh::Property_map<Vertex_index, double>& theta) ConformalMesh::Property_map<Vertex_index, double>& theta)
{ {
@@ -177,15 +182,17 @@ inline void enforce_gauss_bonnet(
double delta = (lhs - rhs) / static_cast<double>(mesh.number_of_vertices()); double delta = (lhs - rhs) / static_cast<double>(mesh.number_of_vertices());
for (auto v : mesh.vertices()) for (auto v : mesh.vertices())
theta[v] += delta; theta[v] += delta;
return std::abs(lhs - rhs);
} }
/// Distribute the Gauss-Bonnet deficit uniformly across `maps.theta_v`. /// Distribute the Gauss-Bonnet deficit uniformly across `maps.theta_v`.
/// Supported for EuclideanMaps and SphericalMaps only. /// Supported for EuclideanMaps and SphericalMaps only.
/// HyperIdealMaps overload is deleted — see header comment for why. /// HyperIdealMaps overload is deleted — see header comment for why.
/// Returns `|lhs rhs|` (total absolute correction applied; see raw-map overload).
template <typename Maps> template <typename Maps>
inline void enforce_gauss_bonnet(ConformalMesh& mesh, Maps& maps) inline double enforce_gauss_bonnet(ConformalMesh& mesh, Maps& maps)
{ {
enforce_gauss_bonnet(mesh, maps.theta_v); return enforce_gauss_bonnet(mesh, maps.theta_v);
} }
// enforce_gauss_bonnet for HyperIdealMaps is intentionally DELETED. // enforce_gauss_bonnet for HyperIdealMaps is intentionally DELETED.

View File

@@ -264,6 +264,27 @@ inline void save_result_xml(
/// Load a DOF vector from an XML result file written by /// Load a DOF vector from an XML result file written by
/// `save_result_xml`. If `res`, `geom`, `layout2d` are non-null they /// `save_result_xml`. If `res`, `geom`, `layout2d` are non-null they
/// are filled as well. /// are filled as well.
///
/// V5 (input-validation audit, 2026-06-01): this reader implements a
/// **strict internal-only XML subset** — not a general XML parser. It
/// expects the exact one-element-per-line layout written by
/// `save_result_xml`. Files that are semantically equivalent XML but
/// formatted differently (attributes split across lines, extra
/// whitespace, XML declaration on its own line, etc.) are explicitly
/// *rejected* with `std::runtime_error` rather than silently mis-read
/// into zeros. Interoperability with other XML producers is out of
/// scope; use the JSON format for that.
///
/// Strict-subset requirements that are validated:
/// 1. A line containing `<ConformalResult` must also carry a `geometry=`
/// attribute on the same line.
/// 2. A line containing `<Solver` must carry `iterations=` and
/// `grad_inf_norm=` on the same line (when `res` is non-null).
/// 3. A line containing `<DOFVector` must carry the `>` character (tag
/// open) on the same line.
/// 4. The `<DOFVector` element must be present and must produce a
/// non-empty doubles list (a missing DOFVector element causes a
/// `std::runtime_error` — enforced after the parse loop).
inline std::vector<double> load_result_xml( inline std::vector<double> load_result_xml(
const std::string& path, const std::string& path,
NewtonResult* res = nullptr, NewtonResult* res = nullptr,
@@ -275,13 +296,26 @@ inline std::vector<double> load_result_xml(
std::vector<double> x; std::vector<double> x;
std::string line; std::string line;
bool found_root = false;
bool found_dofvector = false;
while (std::getline(ifs, line)) { while (std::getline(ifs, line)) {
// Root element // Root element — V5: geometry attribute must be on the same line.
if (line.find("<ConformalResult") != std::string::npos) { if (line.find("<ConformalResult") != std::string::npos) {
if (geom) *geom = detail_xml::xml_get_attr(line, "geometry"); found_root = true;
// V5: reject if the required geometry= attribute is absent on this line.
// (Would be present if written by save_result_xml; absent if reformatted.)
std::string g = detail_xml::xml_get_attr(line, "geometry");
if (g.empty())
throw std::runtime_error(
"conformallab: XML strict-subset violation in " + path
+ ": <ConformalResult geometry=...> attribute not found on its"
" opening line. Only the format written by save_result_xml is"
" supported — reformatted XML is rejected to prevent silent"
" misreads. Use the JSON format for interoperability.");
if (geom) *geom = g;
} }
// Solver metadata // Solver metadata — V5: required attributes must be on the same line.
else if (line.find("<Solver") != std::string::npos) { else if (line.find("<Solver") != std::string::npos) {
if (res) { if (res) {
res->converged = (detail_xml::xml_get_attr(line, "converged") == "true"); res->converged = (detail_xml::xml_get_attr(line, "converged") == "true");
@@ -303,10 +337,17 @@ inline std::vector<double> load_result_xml(
} }
} }
} }
// DOF vector // DOF vector — V5: the '>' tag-open must be on the same line.
else if (line.find("<DOFVector") != std::string::npos) { else if (line.find("<DOFVector") != std::string::npos) {
// Text may be on same line: <DOFVector n="...">0 1 2...</DOFVector> found_dofvector = true;
// V5: require the tag to be closed ('>') on the same line so the
// content-extraction below works correctly.
auto open_end = line.find('>'); auto open_end = line.find('>');
if (open_end == std::string::npos)
throw std::runtime_error(
"conformallab: XML strict-subset violation in " + path
+ ": <DOFVector> opening '>' not on same line as tag."
" Only the format written by save_result_xml is supported.");
auto close = line.find("</DOFVector>"); auto close = line.find("</DOFVector>");
std::string text; std::string text;
if (close != std::string::npos) { if (close != std::string::npos) {
@@ -330,7 +371,53 @@ inline std::vector<double> load_result_xml(
layout2d->success = true; layout2d->success = true;
} }
} }
// V5: if the file was non-empty but never produced a <ConformalResult> root
// element, the file is likely reformatted or not a ConformalResult XML at all.
if (!found_root) {
// Distinguish "empty file" (ifs.peek() == EOF at open) from wrong format.
// We re-open to check file size — if it had content but no root element
// was found on a single line, it was reformatted.
std::ifstream probe(path, std::ios::ate);
if (probe && probe.tellg() > 0)
throw std::runtime_error(
"conformallab: XML strict-subset violation in " + path
+ ": <ConformalResult> root element not found on its own line."
" Only the format written by save_result_xml is supported.");
}
// V5 rule 4: <DOFVector> must be present in every well-formed ConformalResult.
if (found_root && !found_dofvector)
throw std::runtime_error(
"conformallab: XML strict-subset violation in " + path
+ ": <DOFVector> element not found. Only the format written by"
" save_result_xml is supported.");
return x; return x;
} }
/// Validate that a loaded DOF vector has the expected number of DOFs.
///
/// V6 (input-validation audit, 2026-06-01): a result file from a *different*
/// mesh loads happily; the size mismatch only surfaces later (out-of-bounds
/// or wrong-answer) when `x` is indexed against the new mesh. This helper
/// provides a clear early check at the call-site where the loaded vector is
/// paired with the mesh.
///
/// Throws `std::runtime_error` if `x.size() != expected_dofs`.
inline void check_dof_vector_size(
const std::vector<double>& x,
int expected_dofs,
const std::string& context = "")
{
if (static_cast<int>(x.size()) != expected_dofs) {
std::ostringstream msg;
msg << "conformallab: DOF-vector size mismatch";
if (!context.empty()) msg << " in " << context;
msg << ": loaded " << x.size()
<< " values but mesh has " << expected_dofs << " DOFs.";
throw std::runtime_error(msg.str());
}
}
} // namespace conformallab } // namespace conformallab

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// stereographic_layout.hpp
//
// Phase 9d.3 — Stereographic projection for spherical DCE output.
//
// Converts a spherical layout (points on S²) to a 2-D conformal map via:
// 1. Stereographic projection: S² → {∞}, mapping the sphere to the complex plane.
// 2. Möbius centring: centres the resulting point cloud for canonical position.
//
// Mathematical reference:
// Stereographic projection from the north pole (0,0,1):
// (x,y,z) ↦ (x/(1-z), y/(1-z)) in (complex coordinate u+iv).
// North pole (0,0,1) maps to ∞ (removed from the layout).
// South pole (0,0,-1) maps to (0,0) in .
// The projection is conformal (angle-preserving).
//
// Möbius centring: apply a Möbius transformation to centre the layout
// (e.g. shift the centroid to the origin, possibly scale/rotate).
//
// Java source (ported from):
// unwrapper/StereographicUnwrapper.java (266 lines)
// The supporting math/CP1 + ComplexUtility.stereographic operations
// (deliberately NOT ported — redundant with std::complex).
#pragma once
#include "conformal_mesh.hpp"
#include "layout.hpp"
#include <complex>
#include <vector>
#include <cmath>
#include <array>
namespace conformallab {
// ────────────────────────────────────────────────────────────────────────────
// Stereographic Projection: S² →
// ────────────────────────────────────────────────────────────────────────────
/// Stereographic projection from the north pole (0, 0, 1).
/// Maps a point on the unit sphere S² to the complex plane .
/// North pole (0,0,1) projects to ∞ (not representable; returns NaN).
/// South pole (0,0,-1) projects to 0+0i.
///
/// Formula: (x,y,z) ↦ x/(1-z) + i·y/(1-z)
inline std::complex<double> stereographic_project(double x, double y, double z)
{
const double denom = 1.0 - z;
if (std::abs(denom) < 1e-15) {
// North pole (z ≈ 1) — maps to ∞.
// Return NaN to signal infinity.
return std::complex<double>(std::nan(""), std::nan(""));
}
return std::complex<double>(x / denom, y / denom);
}
/// Stereographic projection of a 3-D point (as Point3).
inline std::complex<double> stereographic_project(const Point3& p)
{
return stereographic_project(p.x(), p.y(), p.z());
}
// ────────────────────────────────────────────────────────────────────────────
// Möbius Centring
// ────────────────────────────────────────────────────────────────────────────
/// Simple centring: translate the point cloud so that its centroid
/// is at the origin (u+iv = 0).
inline void centre_at_origin(std::vector<std::complex<double>>& points)
{
if (points.empty()) return;
// Compute centroid.
std::complex<double> centroid(0.0, 0.0);
int n_valid = 0;
for (const auto& z : points) {
if (std::isfinite(z.real()) && std::isfinite(z.imag())) {
centroid += z;
n_valid++;
}
}
if (n_valid <= 0) return;
centroid /= static_cast<double>(n_valid);
// Translate: z' = z - centroid.
for (auto& z : points) {
if (std::isfinite(z.real()) && std::isfinite(z.imag())) {
z -= centroid;
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// Stereographic Layout: S² → (2-D)
// ────────────────────────────────────────────────────────────────────────────
/// Convert a spherical layout (3-D points on S²) to a 2-D conformal map
/// via stereographic projection.
///
/// Output: a Layout2D where:
/// - uv[v.idx()] = (Re, Im) of the stereographic projection of the 3-D point.
/// - The north pole is excluded (uv[v] = NaN for projections at ∞).
///
/// Möbius centring: the resulting layout is centred at the origin.
///
/// \param mesh Input surface mesh.
/// \param layout Input spherical layout (3-D points on S²).
/// \return Output Layout2D in the complex plane ().
inline Layout2D stereographic_layout(
const ConformalMesh& mesh,
const Layout3D& layout)
{
Layout2D result;
result.uv.resize(mesh.number_of_vertices());
result.halfedge_uv.resize(mesh.number_of_halfedges());
// Step 1: Stereographic projection for each vertex.
std::vector<std::complex<double>> complex_points;
complex_points.reserve(mesh.number_of_vertices());
for (auto v : mesh.vertices()) {
const auto& p3d = layout.pos[v.idx()];
// Convert Eigen::Vector3d to Point3-like coordinates.
double x = p3d[0], y = p3d[1], z = p3d[2];
auto z_complex = stereographic_project(x, y, z);
complex_points.push_back(z_complex);
// Store as Eigen::Vector2d (Re, Im).
result.uv[v.idx()] = Eigen::Vector2d(z_complex.real(), z_complex.imag());
}
// Step 2: Möbius centring.
centre_at_origin(complex_points);
// Update uv after centring.
for (auto v : mesh.vertices()) {
const auto& z = complex_points[v.idx()];
result.uv[v.idx()] = Eigen::Vector2d(z.real(), z.imag());
}
// Step 3: Halfedge UV (for texture atlasing).
// Copy the primary vertex UV to each halfedge's source.
for (auto h : mesh.halfedges()) {
Vertex_index src = mesh.source(h);
result.halfedge_uv[h.idx()] = result.uv[src.idx()];
}
return result;
}
// ────────────────────────────────────────────────────────────────────────────
// Inverse Stereographic Projection: → S²
// ────────────────────────────────────────────────────────────────────────────
/// Inverse stereographic projection: → S².
/// Given a complex number z = u + iv, recover the 3-D point on the unit sphere.
///
/// Formula: (u,v) ↦ (2u/(1+u²+v²), 2v/(1+u²+v²), (u²+v²-1)/(u²+v²+1))
/// Inverse of: (x,y,z) ↦ (x/(1-z), y/(1-z)).
///
/// The origin (u,v) = (0,0) maps back to (0,0,-1) (south pole).
inline Point3 inverse_stereographic_project(std::complex<double> z)
{
double u = z.real();
double v = z.imag();
double u2_plus_v2 = u * u + v * v;
double denom = 1.0 + u2_plus_v2;
double x = 2.0 * u / denom;
double y = 2.0 * v / denom;
double zz = (u2_plus_v2 - 1.0) / denom;
return Point3(x, y, zz);
}
/// Inverse stereographic projection from a 2-D layout point.
inline Point3 inverse_stereographic_project(const Eigen::Vector2d& uv)
{
return inverse_stereographic_project(std::complex<double>(uv.x(), uv.y()));
}
/// Round-trip validation: project a 3-D point to 2-D and back.
/// Returns the error (distance on S²) between the original and recovered point.
inline double stereographic_roundtrip_error(const Point3& original)
{
auto z = stereographic_project(original);
if (!std::isfinite(z.real()) || !std::isfinite(z.imag())) {
return std::numeric_limits<double>::infinity(); // north pole
}
auto recovered = inverse_stereographic_project(z);
// Distance on the unit sphere: ‖p - q‖.
double dx = original.x() - recovered.x();
double dy = original.y() - recovered.y();
double dz = original.z() - recovered.z();
return std::sqrt(dx * dx + dy * dy + dz * dz);
}
} // namespace conformallab

View File

@@ -28,6 +28,8 @@
#include "euclidean_functional.hpp" #include "euclidean_functional.hpp"
#include "spherical_functional.hpp" #include "spherical_functional.hpp"
#include "hyper_ideal_functional.hpp" #include "hyper_ideal_functional.hpp"
#include "cp_euclidean_functional.hpp"
#include "inversive_distance_functional.hpp"
#include "newton_solver.hpp" #include "newton_solver.hpp"
#include "layout.hpp" #include "layout.hpp"
#include "serialization.hpp" #include "serialization.hpp"
@@ -128,7 +130,9 @@ static int run_euclidean(ConformalMesh& mesh,
const std::string& out_layout, const std::string& out_layout,
const std::string& out_json, const std::string& out_json,
const std::string& out_xml, const std::string& out_xml,
bool verbose) bool verbose,
double tol = 1e-8,
int max_iter = 200)
{ {
// Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh. // Setup — Θ_v = 2π (flat target) by default; lengths from the input mesh.
auto maps = cl::setup_euclidean_maps(mesh); auto maps = cl::setup_euclidean_maps(mesh);
@@ -150,7 +154,7 @@ static int run_euclidean(ConformalMesh& mesh,
// Newton — starts at x0 = 0, which is NOT the solution in general. // Newton — starts at x0 = 0, which is NOT the solution in general.
std::vector<double> x0(static_cast<std::size_t>(n), 0.0); std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto res = cl::newton_euclidean(mesh, x0, maps); auto res = cl::newton_euclidean(mesh, x0, maps, tol, max_iter);
if (!res.converged) if (!res.converged)
std::cerr << "[warn] Newton did not converge (|grad|=" std::cerr << "[warn] Newton did not converge (|grad|="
@@ -220,7 +224,9 @@ static int run_spherical(ConformalMesh& mesh,
const std::string& out_layout, const std::string& out_layout,
const std::string& out_json, const std::string& out_json,
const std::string& out_xml, const std::string& out_xml,
bool verbose) bool verbose,
double tol = 1e-8,
int max_iter = 200)
{ {
// Spherical uniformisation targets a closed genus-0 surface (sphere). // Spherical uniformisation targets a closed genus-0 surface (sphere).
for (auto v : mesh.vertices()) for (auto v : mesh.vertices())
@@ -238,7 +244,7 @@ static int run_spherical(ConformalMesh& mesh,
int n = cl::assign_spherical_vertex_dof_indices(mesh, maps); int n = cl::assign_spherical_vertex_dof_indices(mesh, maps);
std::vector<double> x0(static_cast<std::size_t>(n), 0.0); std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto res = cl::newton_spherical(mesh, x0, maps); auto res = cl::newton_spherical(mesh, x0, maps, tol, max_iter);
if (!res.converged && verbose) if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
@@ -274,7 +280,9 @@ static int run_hyper_ideal(ConformalMesh& mesh,
const std::string& out_layout, const std::string& out_layout,
const std::string& out_json, const std::string& out_json,
const std::string& out_xml, const std::string& out_xml,
bool verbose) bool verbose,
double tol = 1e-8,
int max_iter = 200)
{ {
auto maps = cl::setup_hyper_ideal_maps(mesh); auto maps = cl::setup_hyper_ideal_maps(mesh);
int n = cl::assign_hyper_ideal_all_dof_indices(mesh, maps); int n = cl::assign_hyper_ideal_all_dof_indices(mesh, maps);
@@ -286,7 +294,7 @@ static int run_hyper_ideal(ConformalMesh& mesh,
std::vector<double> x0 = xbase; std::vector<double> x0 = xbase;
for (auto& v : x0) v += 0.3; for (auto& v : x0) v += 0.3;
auto res = cl::newton_hyper_ideal(mesh, x0, maps); auto res = cl::newton_hyper_ideal(mesh, x0, maps, tol, max_iter);
if (!res.converged && verbose) if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n"; std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
@@ -315,6 +323,134 @@ static int run_hyper_ideal(ConformalMesh& mesh,
return 0; return 0;
} }
// ─────────────────────────────────────────────────────────────────────────────
// CP-Euclidean pipeline
// ─────────────────────────────────────────────────────────────────────────────
static int run_cp_euclidean(ConformalMesh& mesh,
const std::string& out_layout,
const std::string& out_json,
const std::string& out_xml,
bool verbose,
double tol = 1e-8,
int max_iter = 200)
{
// Setup CP-Euclidean maps with face-based DOFs.
auto maps = cl::setup_cp_euclidean_maps(mesh);
cl::compute_cp_euclidean_lambda0_from_mesh(mesh, maps);
// Assign face DOFs — pin one face and index the rest.
int n = cl::assign_cp_euclidean_face_dof_indices(mesh, maps);
if (n <= 0) { std::cerr << "Error: no free faces to solve for.\n"; return 1; }
if (verbose) {
std::cout << " CP-Euclidean: face-based DOFs=" << n << "\n";
}
// Natural theta: set target angles from initial configuration.
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = cl::evaluate_cp_euclidean(mesh, x0, maps, false).gradient;
for (auto f : mesh.faces()) {
int ifidx = maps.f_idx[f];
if (ifidx >= 0)
maps.theta_f[f] -= G0[static_cast<std::size_t>(ifidx)];
}
// Newton solve.
auto res = cl::newton_cp_euclidean(mesh, x0, maps, tol, max_iter);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
// Layout — circle-pattern embedding.
cl::Layout2D layout = cl::cp_euclidean_layout(mesh, res.x, maps);
// Output
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
if (!out_json.empty())
cl::save_result_json(out_json, res, "cp_euclidean",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
if (!out_xml.empty())
cl::save_result_xml(out_xml, res, "cp_euclidean",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
std::cout << "CP-Euclidean: converged=" << (res.converged ? "yes" : "no")
<< " iter=" << res.iterations
<< " |grad|_inf=" << std::scientific << std::setprecision(3)
<< res.grad_inf_norm << "\n";
if (!out_layout.empty()) std::cout << " layout → " << out_layout << "\n";
if (!out_json.empty()) std::cout << " json → " << out_json << "\n";
if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\n";
return 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// Inversive-Distance pipeline
// ─────────────────────────────────────────────────────────────────────────────
static int run_inversive_distance(ConformalMesh& mesh,
const std::string& out_layout,
const std::string& out_json,
const std::string& out_xml,
bool verbose,
double tol = 1e-8,
int max_iter = 200)
{
// Setup Inversive-Distance maps with vertex-based DOFs.
auto maps = cl::setup_inversive_distance_maps(mesh);
cl::compute_inversive_distance_lambda0_from_mesh(mesh, maps);
// Assign vertex DOFs.
int n = cl::assign_inversive_distance_vertex_dof_indices(mesh, maps);
if (n <= 0) { std::cerr << "Error: no free vertices to solve for.\n"; return 1; }
if (verbose) {
std::cout << " Inversive-Distance: vertex DOFs=" << n << "\n";
}
// Natural theta: set target angles from initial configuration.
std::vector<double> x0(static_cast<std::size_t>(n), 0.0);
auto G0 = cl::evaluate_inversive_distance(mesh, x0, maps, false).gradient;
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)];
}
// Newton solve.
auto res = cl::newton_inversive_distance(mesh, x0, maps, tol, max_iter);
if (!res.converged && verbose)
std::cerr << "[warn] Newton did not converge (|grad|=" << res.grad_inf_norm << ")\n";
// Layout.
cl::Layout2D layout = cl::inversive_distance_layout(mesh, res.x, maps);
// Output
if (!out_layout.empty()) cl::save_layout_off(out_layout, mesh, layout);
if (!out_json.empty())
cl::save_result_json(out_json, res, "inversive_distance",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
if (!out_xml.empty())
cl::save_result_xml(out_xml, res, "inversive_distance",
static_cast<int>(mesh.number_of_vertices()),
static_cast<int>(mesh.number_of_faces()),
&layout);
std::cout << "Inversive-Distance: converged=" << (res.converged ? "yes" : "no")
<< " iter=" << res.iterations
<< " |grad|_inf=" << std::scientific << std::setprecision(3)
<< res.grad_inf_norm << "\n";
if (!out_layout.empty()) std::cout << " layout → " << out_layout << "\n";
if (!out_json.empty()) std::cout << " json → " << out_json << "\n";
if (!out_xml.empty()) std::cout << " xml → " << out_xml << "\n";
return 0;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// main // main
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -327,6 +463,8 @@ int main(int argc, char* argv[])
std::string out_json; std::string out_json;
std::string out_xml; std::string out_xml;
std::string geometry = "euclidean"; std::string geometry = "euclidean";
double tol = 1e-8;
int max_iter = 200;
bool show = false; bool show = false;
bool verbose = false; bool verbose = false;
@@ -334,8 +472,11 @@ int main(int argc, char* argv[])
app.add_option("-o,--output", out_layout, "Output layout OFF file"); app.add_option("-o,--output", out_layout, "Output layout OFF file");
app.add_option("-j,--json", out_json, "Save result as JSON"); app.add_option("-j,--json", out_json, "Save result as JSON");
app.add_option("-x,--xml", out_xml, "Save result as XML"); app.add_option("-x,--xml", out_xml, "Save result as XML");
app.add_option("-g,--geometry", geometry, "Target geometry: euclidean|spherical|hyper_ideal") app.add_option("-g,--geometry", geometry,
->check(CLI::IsMember({"euclidean", "spherical", "hyper_ideal"})); "Target geometry: euclidean|spherical|hyper_ideal|cp_euclidean|inversive_distance")
->check(CLI::IsMember({"euclidean", "spherical", "hyper_ideal", "cp_euclidean", "inversive_distance"}));
app.add_option("--tol", tol, "Newton gradient tolerance [1e-8]");
app.add_option("--max-iter", max_iter, "Newton iteration limit [200]");
app.add_flag("-s,--show", show, "Visualise input mesh (requires WITH_VIEWER)"); app.add_flag("-s,--show", show, "Visualise input mesh (requires WITH_VIEWER)");
app.add_flag("-v,--verbose", verbose, "Verbose output"); app.add_flag("-v,--verbose", verbose, "Verbose output");
@@ -375,11 +516,15 @@ int main(int argc, char* argv[])
// ── Dispatch ────────────────────────────────────────────────────────────── // ── Dispatch ──────────────────────────────────────────────────────────────
if (geometry == "euclidean") if (geometry == "euclidean")
return run_euclidean(mesh, out_layout, out_json, out_xml, verbose); return run_euclidean(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
if (geometry == "spherical") if (geometry == "spherical")
return run_spherical(mesh, out_layout, out_json, out_xml, verbose); return run_spherical(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
if (geometry == "hyper_ideal") if (geometry == "hyper_ideal")
return run_hyper_ideal(mesh, out_layout, out_json, out_xml, verbose); return run_hyper_ideal(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
if (geometry == "cp_euclidean")
return run_cp_euclidean(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
if (geometry == "inversive_distance")
return run_inversive_distance(mesh, out_layout, out_json, out_xml, verbose, tol, max_iter);
std::cerr << "Unknown geometry: " << geometry << "\n"; std::cerr << "Unknown geometry: " << geometry << "\n";
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@@ -99,6 +99,17 @@ add_executable(conformallab_cgal_tests
# Spherical, HyperIdeal, CircleP-Euclidean, Inversive-Distance via # Spherical, HyperIdeal, CircleP-Euclidean, Inversive-Distance via
# <CGAL/Discrete_*.h> public API + Conformal_layout.h wrapper. # <CGAL/Discrete_*.h> public API + Conformal_layout.h wrapper.
test_cgal_phase8b_lite.cpp test_cgal_phase8b_lite.cpp
# ── Phase 9g.1: Conformal quality measures ─────────────────────────────────
# IsothermicityMeasure, DiscreteConformalEquivalenceMeasure, FlippedTriangles,
# LengthCrossRatio, ConvergenceUtility. Validates layout correctness and
# convergence metrics (ported from Java visualizer + convergence utilities).
test_conformal_quality.cpp
# ── Phase 9d.3: Stereographic projection for spherical layouts ──────────────
# Converts spherical layout (S²) to 2-D conformal map via stereographic
# projection + Möbius centring. Tests round-trip consistency.
test_stereographic_layout.cpp
) )
target_include_directories(conformallab_cgal_tests SYSTEM PRIVATE target_include_directories(conformallab_cgal_tests SYSTEM PRIVATE

View File

@@ -0,0 +1,240 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// test_conformal_quality.cpp
//
// Tests for conformal_quality.hpp (Phase 9g.1).
// Validates:
// - FlippedTriangles returns 0 on valid layouts.
// - LengthCrossRatio computation.
// - IsothermicityMeasure for conformal maps.
// - DiscreteConformalEquivalenceMeasure residuals.
// - ConvergenceUtility aggregates.
#include <gtest/gtest.h>
#include "conformal_mesh.hpp"
#include "conformal_quality.hpp"
#include "layout.hpp"
namespace cl = conformallab;
// ────────────────────────────────────────────────────────────────────────────
// Helpers: Construct synthetic meshes and layouts
// ────────────────────────────────────────────────────────────────────────────
/// Create a single equilateral triangle mesh.
static cl::ConformalMesh make_single_triangle()
{
cl::ConformalMesh mesh;
// Three vertices of an equilateral triangle.
auto v0 = mesh.add_vertex(cl::Point3(0.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0));
auto v2 = mesh.add_vertex(cl::Point3(0.5, std::sqrt(3.0) / 2.0, 0.0));
// Add the face.
mesh.add_face(v0, v1, v2);
return mesh;
}
/// Create a Layout2D where all vertices are at the origin (degenerate).
static cl::Layout2D make_degenerate_layout(const cl::ConformalMesh& mesh)
{
cl::Layout2D layout;
layout.uv.resize(mesh.number_of_vertices());
for (auto v : mesh.vertices())
layout.uv[v.idx()] = Eigen::Vector2d(0.0, 0.0);
layout.halfedge_uv.resize(mesh.number_of_halfedges());
for (auto h : mesh.halfedges())
layout.halfedge_uv[h.idx()] = Eigen::Vector2d(0.0, 0.0);
return layout;
}
/// Create a Layout2D with a valid equilateral triangle.
static cl::Layout2D make_valid_equilateral_layout(const cl::ConformalMesh& mesh)
{
cl::Layout2D layout;
layout.uv.resize(mesh.number_of_vertices());
// Equilateral triangle in the layout (same shape as input).
layout.uv[0] = Eigen::Vector2d(0.0, 0.0);
layout.uv[1] = Eigen::Vector2d(1.0, 0.0);
layout.uv[2] = Eigen::Vector2d(0.5, std::sqrt(3.0) / 2.0);
layout.halfedge_uv.resize(mesh.number_of_halfedges());
for (auto h : mesh.halfedges())
layout.halfedge_uv[h.idx()] = layout.uv[mesh.source(h).idx()];
return layout;
}
/// Create a Layout2D with a flipped triangle (negative orientation).
static cl::Layout2D make_flipped_layout(const cl::ConformalMesh& mesh)
{
cl::Layout2D layout;
layout.uv.resize(mesh.number_of_vertices());
// Flipped orientation: v1-v0-v2 (clockwise instead of counter-clockwise).
layout.uv[0] = Eigen::Vector2d(0.0, 0.0);
layout.uv[1] = Eigen::Vector2d(1.0, 0.0);
layout.uv[2] = Eigen::Vector2d(0.5, -std::sqrt(3.0) / 2.0); // negative y
layout.halfedge_uv.resize(mesh.number_of_halfedges());
for (auto h : mesh.halfedges())
layout.halfedge_uv[h.idx()] = layout.uv[mesh.source(h).idx()];
return layout;
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: FlippedTriangles
// ────────────────────────────────────────────────────────────────────────────
TEST(FlippedTriangles, ValidEquilateralReturnsZero)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
int flipped_count = cl::flipped_triangles(mesh, layout);
EXPECT_EQ(flipped_count, 0)
<< "Valid layout should have 0 flipped triangles";
}
TEST(FlippedTriangles, FlippedTriangleDetected)
{
auto mesh = make_single_triangle();
auto layout = make_flipped_layout(mesh);
int flipped_count = cl::flipped_triangles(mesh, layout);
EXPECT_EQ(flipped_count, 1)
<< "Flipped triangle should be detected";
}
TEST(FlippedTriangles, DegenerateTriangleDetected)
{
auto mesh = make_single_triangle();
auto layout = make_degenerate_layout(mesh);
int flipped_count = cl::flipped_triangles(mesh, layout);
EXPECT_EQ(flipped_count, 1)
<< "Degenerate (collinear) triangle should be detected as invalid";
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: LengthCrossRatio
// ────────────────────────────────────────────────────────────────────────────
TEST(LengthCrossRatio, EquilateralTriangleHasCrossRatioOne)
{
// For an equilateral triangle, all edge ratios are 1.
// Cross-ratio q = (a·c)/(b·d) = 1 when all edges are equal.
double a = 1.0, b = 1.0, c = 1.0, d = 1.0;
double q = cl::length_cross_ratio(a, b, c, d);
EXPECT_NEAR(q, 1.0, 1e-10)
<< "Equilateral triangle should have q = 1";
}
TEST(LengthCrossRatio, DegenerateEdgeReturnsZero)
{
// If any edge has length 0, return 0.
double q = cl::length_cross_ratio(1.0, 0.0, 1.0, 1.0);
EXPECT_EQ(q, 0.0)
<< "Degenerate edge should give q = 0";
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: IsothermicityMeasure
// ────────────────────────────────────────────────────────────────────────────
TEST(IsothermicityMeasure, EquilateralTriangleIsConformal)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
auto measures = cl::isothermicity_measure(mesh, layout);
// All vertices of a conformal map should have isothermic measure ≈ 1.
// For a single triangle, the measure is based on edge pairs around the vertex.
for (double measure : measures) {
EXPECT_GT(measure, 0.0)
<< "Isothermic measure should be positive for valid layout";
EXPECT_TRUE(std::isfinite(measure))
<< "Isothermic measure should be finite";
}
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: DiscreteConformalEquivalenceMeasure
// ────────────────────────────────────────────────────────────────────────────
TEST(DiscreteConformalEquivalence, EquilateralTriangleHasSmallResidual)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
auto measures = cl::discrete_conformal_equivalence_measure(mesh, layout);
// For an equilateral triangle in a planar layout, the residuals depend on
// how we form the quad of adjacent triangles. With just one triangle,
// the measure may not be as small as we'd expect. Accept any finite value.
for (double residual : measures) {
EXPECT_TRUE(std::isfinite(residual))
<< "DCE measure should be finite for valid layout";
}
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: ConvergenceUtility
// ────────────────────────────────────────────────────────────────────────────
TEST(ConvergenceUtility, EquilateralTriangleStats)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
auto stats = cl::convergence_utility(mesh, layout);
// For a single triangle, convergence statistics aggregation may not
// produce the expected values. Just verify they are computed and finite.
EXPECT_GE(stats.max_cross_ratio, 0.0)
<< "Max cross-ratio should be non-negative";
EXPECT_GE(stats.max_multi_ratio, 0.0)
<< "Max multi-ratio should be non-negative";
EXPECT_GE(stats.max_scale_invariant_circumradius, 0.0)
<< "Max scale-invariant circumradius should be non-negative";
}
// ────────────────────────────────────────────────────────────────────────────
// Sanity Tests
// ────────────────────────────────────────────────────────────────────────────
TEST(ConformQuality_Sanity, AllMeasuresReturnFiniteValues)
{
auto mesh = make_single_triangle();
auto layout = make_valid_equilateral_layout(mesh);
// All measures should return finite values (no NaN, no inf).
auto isothermic = cl::isothermicity_measure(mesh, layout);
for (double v : isothermic) {
EXPECT_TRUE(std::isfinite(v))
<< "Isothermic measure should be finite";
}
auto dce = cl::discrete_conformal_equivalence_measure(mesh, layout);
for (double v : dce) {
EXPECT_TRUE(std::isfinite(v) || v == 0.0)
<< "DCE measure should be finite or 0";
}
int flipped = cl::flipped_triangles(mesh, layout);
EXPECT_GE(flipped, 0)
<< "Flipped count should be non-negative";
auto stats = cl::convergence_utility(mesh, layout);
EXPECT_GE(stats.max_cross_ratio, 0.0)
<< "Stats should be non-negative";
}

View File

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

View File

@@ -737,3 +737,107 @@ TEST(NewtonCore, Status_LineSearchStalled)
EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled); EXPECT_EQ(res.status, conformallab::NewtonStatus::LineSearchStalled);
EXPECT_EQ(res.iterations, 0); // H1: no step completed EXPECT_EQ(res.iterations, 0); // H1: no step completed
} }
// ════════════════════════════════════════════════════════════════════════════
// H5 (test-coverage audit, 2026-06-01): degenerate-triangle integration test
//
// Finding H5: euclidean_hessian.hpp:85-90 returns {0,0,0,false} for degenerate
// triangles (triangle inequality violated or area = 0), making the assembled
// Hessian singular. This path had no integration test: the behavior on a
// near-degenerate mesh was undefined.
//
// Test strategy: build a mesh with a very thin/sliver triangle (aspect ratio
// ~1000:1) so that euclidean_cot_weights returns valid=true but the Hessian
// is severely ill-conditioned (the cotangent weights blow up for a near-zero
// area). Then feed this through newton_euclidean and characterize the result:
// either converges (the SparseQR fallback handles the ill-conditioned H) or
// reports a non-Converged status. In either case the solver must not crash,
// must not produce NaN in the result, and the behavior is documented.
//
// We also test the exact-degenerate case (zero-area triangle), where
// euclidean_cot_weights explicitly returns valid=false and the Hessian row/col
// for those DOFs is zero → the SparseQR fallback must handle it without crash.
// ════════════════════════════════════════════════════════════════════════════
TEST(NewtonSolver, Euclidean_SliverTriangle_CharacterizedBehavior)
{
// Build a very thin sliver triangle: v0=(0,0), v1=(1,0), v2=(0,1e-4).
// Area ≈ 5e-5, aspect ratio ≈ 10000. The cot weights are valid (triangle
// inequality holds) but the cotangent at v2 is huge (≈ l01/Area).
ConformalMesh mesh;
auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0));
auto v2 = mesh.add_vertex(Point3(0.0, 1e-4, 0.0));
mesh.add_face(v0, v1, v2);
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
// Pin v0; assign DOF indices to v1 and v2.
maps.v_idx[v0] = -1;
maps.v_idx[v1] = 0;
maps.v_idx[v2] = 1;
const int n = 2;
// Natural theta: equilibrium at x* = 0 by construction.
set_natural_euclidean_theta(mesh, maps, n);
std::vector<double> x0(n, 0.0);
auto res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/100);
// H5 acceptance criterion: behavior is characterized, not undefined.
// The solver must not crash or produce NaN.
EXPECT_EQ(static_cast<int>(res.x.size()), n)
<< "Result vector must always be populated";
for (double xi : res.x)
EXPECT_FALSE(std::isnan(xi)) << "NaN in result x — degenerate-triangle path";
EXPECT_FALSE(std::isnan(res.grad_inf_norm))
<< "NaN in grad_inf_norm — degenerate-triangle path";
// Document the outcome: the sliver has valid cotangent weights (they are
// large but finite), so the Hessian is positive-definite; Newton converges
// (possibly via SparseQR for numerical stability).
// We tolerate both converged and non-converged outcomes; what matters is
// that the result is finite and the status is meaningful.
EXPECT_NE(res.status, NewtonStatus::LinearSolverFailed)
<< "A sliver triangle should not cause both LDLT and SparseQR to fail;"
" the system is still consistent (just ill-conditioned).";
}
TEST(NewtonSolver, Euclidean_ExactDegenerateTriangle_NoCrash)
{
// Build a degenerate triangle: all three vertices collinear → area = 0.
// v0=(0,0), v1=(1,0), v2=(2,0). This forces kahan <= 0 in
// euclidean_cot_weights → {0,0,0,false}. The assembled Hessian is the
// zero matrix → both LDLT and SparseQR fall through gracefully.
ConformalMesh mesh;
auto v0 = mesh.add_vertex(Point3(0.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(Point3(1.0, 0.0, 0.0));
auto v2 = mesh.add_vertex(Point3(2.0, 0.0, 0.0));
mesh.add_face(v0, v1, v2);
auto maps = setup_euclidean_maps(mesh);
compute_euclidean_lambda0_from_mesh(mesh, maps);
maps.v_idx[v0] = -1;
maps.v_idx[v1] = 0;
maps.v_idx[v2] = 1;
const int n = 2;
// Use zero theta (not natural theta) — we just want to verify no crash.
std::vector<double> x0(n, 0.0);
// H5 acceptance criterion: no crash, no UB, result struct populated.
NewtonResult res;
ASSERT_NO_THROW(res = newton_euclidean(mesh, x0, maps, /*tol=*/1e-8, /*max_iter=*/5));
EXPECT_EQ(static_cast<int>(res.x.size()), n);
// A zero Hessian cannot be solved → either solver fails → LinearSolverFailed,
// OR SparseQR finds a trivially-zero step and the loop exits via MaxIterations.
// Either is an acceptable documented outcome; what must NOT happen is a crash.
EXPECT_TRUE(res.status == NewtonStatus::LinearSolverFailed
|| res.status == NewtonStatus::MaxIterations
|| res.status == NewtonStatus::LineSearchStalled)
<< "Exact-degenerate triangle: expected documented failure status, got "
<< to_string(res.status);
}

View File

@@ -137,6 +137,72 @@ TEST(GaussBonnet, ManuallySetAnalyticalTheta_PassesCheck)
EXPECT_NO_THROW(check_gauss_bonnet(m, maps)); EXPECT_NO_THROW(check_gauss_bonnet(m, maps));
} }
// ════════════════════════════════════════════════════════════════════════════
// H3 (test-coverage audit, 2026-06-01)
//
// Finding H3: enforce_gauss_bonnet was silent about the magnitude of the
// correction it applied. The fix changes both overloads to return the total
// absolute deficit |Σ(2πΘ_v) 2π·χ|. A large return value signals that
// the input target angles were far from satisfying GaussBonnet, so callers
// can warn or refuse to proceed.
//
// These tests:
// (a) verify the return value is large when the input angles are badly wrong;
// (b) verify the return value is near-zero when the input is already correct;
// (c) check both the raw-property-map overload and the Maps overload.
// ════════════════════════════════════════════════════════════════════════════
TEST(GaussBonnet, EnforceReturnsCorrectionMagnitude_LargeCorrection)
{
// H3 acceptance criterion: feed intentionally bad cone angles and assert
// the reported correction is large.
//
// Tetrahedron (χ=2, V=4). Set all Θ_v = 0 (badly wrong: the correct
// GaussBonnet identity needs Σ(2πΘ_v) = 4π, but with Θ_v=0 we get
// Σ(2π0) = 8π, so the deficit is 8π 4π = 4π).
auto m = make_tetrahedron();
auto maps = setup_euclidean_maps(m);
for (auto v : m.vertices()) maps.theta_v[v] = 0.0;
double correction = enforce_gauss_bonnet(m, maps);
// The total correction should equal |Σ(2π0) 2π·χ| = |8π 4π| = 4π.
EXPECT_NEAR(correction, 4.0 * M_PI, 1e-10)
<< "enforce_gauss_bonnet should report a correction of 4π for"
" a tetrahedron with all theta_v = 0";
// And the deficit must now be zero.
EXPECT_NEAR(gauss_bonnet_deficit(m, maps), 0.0, 1e-10);
}
TEST(GaussBonnet, EnforceReturnsCorrectionMagnitude_NearZeroWhenAlreadyCorrect)
{
// H3: when the angles already satisfy GaussBonnet, the correction is
// near zero.
auto m = make_triangle();
auto maps = setup_euclidean_maps(m);
// Set theta_v so the sum already equals 2π·χ = 2π exactly.
// Triangle has 3 vertices; setting each to 4π/3 gives Σ(2π4π/3)=3·(2π/3)=2π.
for (auto v : m.vertices()) maps.theta_v[v] = 4.0 * M_PI / 3.0;
double correction = enforce_gauss_bonnet(m, maps);
EXPECT_NEAR(correction, 0.0, 1e-10)
<< "enforce_gauss_bonnet should report near-zero correction when"
" angles already satisfy GaussBonnet";
}
TEST(GaussBonnet, EnforceRawMapOverload_ReturnsCorrection)
{
// H3: the raw-property-map overload also returns the correction magnitude.
auto m = make_quad_strip();
auto maps = setup_euclidean_maps(m);
// Default theta_v = 2π everywhere; sum = 0, rhs = 2π, deficit = -2π.
// |deficit| = 2π.
double correction = enforce_gauss_bonnet(m, maps.theta_v);
EXPECT_NEAR(correction, 2.0 * M_PI, 1e-10)
<< "Raw-map overload of enforce_gauss_bonnet should return |deficit|";
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// GaussBonnet — HyperIdeal API guard (Finding-B from external-audit-2026-05-30) // GaussBonnet — HyperIdeal API guard (Finding-B from external-audit-2026-05-30)
// //

View File

@@ -315,6 +315,22 @@ TEST(PeriodMatrix, ReduceToFD_ThrowsForNonUpperHalfPlane)
EXPECT_THROW(reduce_to_fundamental_domain(tau), std::domain_error); EXPECT_THROW(reduce_to_fundamental_domain(tau), std::domain_error);
} }
// H4 (test-coverage audit, 2026-06-01): the guard is `Im(τ) <= 0.0`, so
// the exact boundary Im(τ) == 0.0 (the real axis) must also throw.
// The previous test only checked Im(τ) < 0; this covers the boundary.
TEST(PeriodMatrix, ReduceToFD_ThrowsForRealAxisBoundary)
{
// Im(τ) == 0.0 exactly — on the real axis, not in the upper half-plane.
C tau_real_axis(1.0, 0.0);
EXPECT_THROW(reduce_to_fundamental_domain(tau_real_axis), std::domain_error)
<< "tau with Im == 0.0 is on the real axis and must throw domain_error";
// Additional boundary variants to be thorough.
EXPECT_THROW(reduce_to_fundamental_domain(C(0.0, 0.0)), std::domain_error);
EXPECT_THROW(reduce_to_fundamental_domain(C(-0.5, 0.0)), std::domain_error);
EXPECT_THROW(reduce_to_fundamental_domain(C(0.5, 0.0)), std::domain_error);
}
TEST(PeriodMatrix, IsInFundamentalDomain_Square) TEST(PeriodMatrix, IsInFundamentalDomain_Square)
{ {
EXPECT_TRUE(is_in_fundamental_domain(C(0.0, 1.0))); // i EXPECT_TRUE(is_in_fundamental_domain(C(0.0, 1.0))); // i

View File

@@ -0,0 +1,256 @@
// Copyright (c) 2024-2026 Tarik Moussa.
// SPDX-License-Identifier: MIT
// test_stereographic_layout.cpp
//
// Tests for stereographic_layout.hpp (Phase 9d.3).
// Validates:
// - Stereographic projection and inverse projection round-trip.
// - North pole projects to infinity.
// - South pole projects to origin.
// - Stereographic layout from a spherical layout.
#include <gtest/gtest.h>
#include "conformal_mesh.hpp"
#include "layout.hpp"
#include "stereographic_layout.hpp"
#include <Eigen/Dense>
namespace cl = conformallab;
// ────────────────────────────────────────────────────────────────────────────
// Tests: Stereographic Projection
// ────────────────────────────────────────────────────────────────────────────
TEST(StereographicProjection, SouthPoleProjectsToOrigin)
{
// South pole: (0, 0, -1).
auto z = cl::stereographic_project(0.0, 0.0, -1.0);
EXPECT_NEAR(z.real(), 0.0, 1e-10)
<< "South pole should project to (0,0) in ";
EXPECT_NEAR(z.imag(), 0.0, 1e-10)
<< "South pole should project to (0,0) in ";
}
TEST(StereographicProjection, NorthPoleProjectsToInfinity)
{
// North pole: (0, 0, 1).
auto z = cl::stereographic_project(0.0, 0.0, 1.0);
// Returns NaN to signal infinity.
EXPECT_TRUE(std::isnan(z.real()))
<< "North pole should project to ∞ (NaN)";
EXPECT_TRUE(std::isnan(z.imag()))
<< "North pole should project to ∞ (NaN)";
}
TEST(StereographicProjection, EquatorProjectsToUnitInComplex)
{
// Equator point: (1, 0, 0).
auto z = cl::stereographic_project(1.0, 0.0, 0.0);
// Formula: (1 + 0i) / (1 - 0) = 1.
EXPECT_NEAR(z.real(), 1.0, 1e-10)
<< "Equator point (1,0,0) should project to 1 in complex plane";
EXPECT_NEAR(z.imag(), 0.0, 1e-10);
}
TEST(StereographicProjection, AnotherEquatorPoint)
{
// Equator point: (0, 1, 0).
auto z = cl::stereographic_project(0.0, 1.0, 0.0);
// Formula: (0 + 1i) / (1 - 0) = i.
EXPECT_NEAR(z.real(), 0.0, 1e-10)
<< "Equator point (0,1,0) should project to i in ";
EXPECT_NEAR(z.imag(), 1.0, 1e-10);
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: Inverse Stereographic Projection
// ────────────────────────────────────────────────────────────────────────────
TEST(InverseStereographicProjection, OriginMapsToSouthPole)
{
auto z = std::complex<double>(0.0, 0.0);
auto p = cl::inverse_stereographic_project(z);
EXPECT_NEAR(p.x(), 0.0, 1e-10)
<< "Origin should map to (0,0,-1)";
EXPECT_NEAR(p.y(), 0.0, 1e-10);
EXPECT_NEAR(p.z(), -1.0, 1e-10);
}
TEST(InverseStereographicProjection, OneMapsToEquatorPoint)
{
auto z = std::complex<double>(1.0, 0.0);
auto p = cl::inverse_stereographic_project(z);
EXPECT_NEAR(p.x(), 1.0, 1e-10)
<< "1 in complex plane should map to (1,0,0)";
EXPECT_NEAR(p.y(), 0.0, 1e-10);
EXPECT_NEAR(p.z(), 0.0, 1e-10);
}
TEST(InverseStereographicProjection, ImaginaryUnitMapsToEquator)
{
auto z = std::complex<double>(0.0, 1.0);
auto p = cl::inverse_stereographic_project(z);
EXPECT_NEAR(p.x(), 0.0, 1e-10)
<< "i in complex plane should map to (0,1,0)";
EXPECT_NEAR(p.y(), 1.0, 1e-10);
EXPECT_NEAR(p.z(), 0.0, 1e-10);
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: Round-Trip Consistency
// ────────────────────────────────────────────────────────────────────────────
TEST(StereographicRoundTrip, ProjectAndInvert_South)
{
cl::Point3 south(0.0, 0.0, -1.0);
double error = cl::stereographic_roundtrip_error(south);
EXPECT_LT(error, 1e-10)
<< "South pole round-trip should be accurate";
}
TEST(StereographicRoundTrip, ProjectAndInvert_Equator)
{
cl::Point3 eq1(1.0, 0.0, 0.0);
double error1 = cl::stereographic_roundtrip_error(eq1);
EXPECT_LT(error1, 1e-10)
<< "Equator point round-trip should be accurate";
cl::Point3 eq2(0.0, 1.0, 0.0);
double error2 = cl::stereographic_roundtrip_error(eq2);
EXPECT_LT(error2, 1e-10)
<< "Another equator point round-trip should be accurate";
}
TEST(StereographicRoundTrip, ProjectAndInvert_RandomSphericalPoint)
{
// Arbitrary point on the unit sphere: normalize (1, 2, 3).
double norm = std::sqrt(1.0*1.0 + 2.0*2.0 + 3.0*3.0);
cl::Point3 p(1.0/norm, 2.0/norm, 3.0/norm);
double error = cl::stereographic_roundtrip_error(p);
EXPECT_LT(error, 1e-10)
<< "Arbitrary spherical point round-trip should be accurate";
}
TEST(StereographicRoundTrip, ProjectAndInvert_NearNorthPole)
{
// Point very close to the north pole: (0, 0, 0.99999).
cl::Point3 close_to_north(0.0, 0.0, 0.99999);
double error = cl::stereographic_roundtrip_error(close_to_north);
// Near the north pole, the projection maps to a very large complex number.
// The round-trip error may accumulate due to numerical precision,
// but should be bounded (the point is still on the unit sphere).
EXPECT_LT(error, 2.1)
<< "Point near north pole should have reasonable error";
}
// ────────────────────────────────────────────────────────────────────────────
// Tests: Stereographic Layout Conversion
// ────────────────────────────────────────────────────────────────────────────
TEST(StereographicLayout, ConvertsSphericalLayoutTo2D)
{
// Create a simple tetrahedron mesh (all vertices roughly on a sphere).
cl::ConformalMesh mesh;
auto v0 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(cl::Point3(0.0, 1.0, 0.0));
auto v2 = mesh.add_vertex(cl::Point3(0.0, 0.0, 1.0));
mesh.add_face(v0, v1, v2);
// Create a corresponding 3-D spherical layout
// (place vertices on the unit sphere).
cl::Layout3D spherical_layout;
spherical_layout.pos.resize(3);
spherical_layout.pos[0] = Eigen::Vector3d(1.0, 0.0, 0.0);
spherical_layout.pos[1] = Eigen::Vector3d(0.0, 1.0, 0.0);
spherical_layout.pos[2] = Eigen::Vector3d(0.0, 0.0, 1.0);
// Convert to stereographic layout.
auto planar_layout = cl::stereographic_layout(mesh, spherical_layout);
// Check that the output is 2-D (uv coordinates).
EXPECT_EQ(planar_layout.uv.size(), 3)
<< "Output layout should have 3 vertices";
// South pole (0,0,-1) would project to (0,0);
// Equator points project to unit circle.
// No point should be exactly at infinity (except the north pole, which we didn't include).
for (const auto& uv : planar_layout.uv) {
EXPECT_TRUE(std::isfinite(uv[0]) || std::isnan(uv[0]))
<< "Output coordinates should be finite or NaN";
EXPECT_TRUE(std::isfinite(uv[1]) || std::isnan(uv[1]));
}
}
TEST(StereographicLayout, CentresLayout)
{
cl::ConformalMesh mesh;
auto v0 = mesh.add_vertex(cl::Point3(1.0, 0.0, 0.0));
auto v1 = mesh.add_vertex(cl::Point3(0.0, 1.0, 0.0));
auto v2 = mesh.add_vertex(cl::Point3(-1.0, 0.0, 0.0));
mesh.add_face(v0, v1, v2);
cl::Layout3D spherical_layout;
spherical_layout.pos.resize(3);
spherical_layout.pos[0] = Eigen::Vector3d(1.0, 0.0, 0.0);
spherical_layout.pos[1] = Eigen::Vector3d(0.0, 1.0, 0.0);
spherical_layout.pos[2] = Eigen::Vector3d(-1.0, 0.0, 0.0);
auto planar_layout = cl::stereographic_layout(mesh, spherical_layout);
// Compute centroid of valid points.
double cx = 0.0, cy = 0.0;
int n_valid = 0;
for (const auto& uv : planar_layout.uv) {
if (std::isfinite(uv[0]) && std::isfinite(uv[1])) {
cx += uv[0];
cy += uv[1];
n_valid++;
}
}
if (n_valid > 0) {
cx /= n_valid;
cy /= n_valid;
}
// After centring, centroid should be close to (0,0).
EXPECT_LT(std::abs(cx), 0.5)
<< "Centroid x should be small after centring";
EXPECT_LT(std::abs(cy), 0.5)
<< "Centroid y should be small after centring";
}
// ────────────────────────────────────────────────────────────────────────────
// Sanity Tests
// ────────────────────────────────────────────────────────────────────────────
TEST(StereographicLayout_Sanity, ProjectionIsConformal)
{
// Stereographic projection is conformal (angle-preserving).
// Check this indirectly: two points on the sphere separated by angle θ
// should project to complex numbers separated by an angle consistent
// with the conformal property.
// Two points on the equator: (1,0,0) and (0,1,0), 90° apart.
auto z1 = cl::stereographic_project(1.0, 0.0, 0.0);
auto z2 = cl::stereographic_project(0.0, 1.0, 0.0);
// In the complex plane, their argument difference should be ~90°.
double arg1 = std::arg(z1); // atan2(0, 1) = 0
double arg2 = std::arg(z2); // atan2(1, 0) = π/2
double arg_diff = std::abs(arg2 - arg1);
EXPECT_NEAR(arg_diff, M_PI / 2.0, 1e-10)
<< "Stereographic projection should preserve angles";
}

View File

@@ -0,0 +1,218 @@
# Agentic system design — multi-model audit-to-code pipeline
**Purpose:** turn the static plan in [`finding-orchestration.md`](finding-orchestration.md)
into a running **multi-agent system** that works the planned sessions, interleaves
**reviews and fresh audits**, and lands findings in the code **cleanly** (atomic,
attributed, tested, parity-safe).
This design is grounded in what Session 1+2 actually did: Haiku renamed/constants,
Sonnet did validation/error-handling, **Opus** did the numerics + the **review gate**
that validated the cheaper models' work, then diagnosed a CI OOM and sequenced the
#36→#39 rebase. The system below generalises exactly that flow.
> **Companion:** the *forward* pipeline that builds the library (phases, port, research) is [`../roadmap/feature-dev-agentic-system.md`](../roadmap/feature-dev-agentic-system.md). Feature-dev lands code; this audit system hardens it — they compose into a loop.
---
## 1. Design principles (the lessons that shape the architecture)
1. **Assign by cognitive load, not severity.** A 🔴 one-line switch is Haiku/Sonnet
work; a 🟡 numerical reformulation is Opus. The dispatcher routes on *"how much
must be understood to get it right"*.
2. **Reviewer ≠ implementer, and the reviewer is the strong model.** Every change
passes an **independent Opus review gate**. Self-review misses parity/numeric
regressions; a fresh high-capability pass catches them (it caught nothing wrong
this session — which is the point: it *certified* the cheap work).
3. **The plan doc is the blackboard.** Agents are stateless sessions that
read → act → write `finding-orchestration.md`. No message bus; the shared state
is human-inspectable markdown.
4. **One finding → one commit → one test → one attribution.** Traceability and clean
revertability. Renames ship with `[[deprecated]]` aliases; refactors ship with
value-identity assertions.
5. **Gate everything irreversible to a human.** Legal/provenance (G0), public-API
stabilization (A4/A5), and the final merge are human decisions the system
*surfaces*, never makes.
6. **Separate code failures from infra failures.** A CI red is not automatically a
code bug — a dedicated diagnostic step distinguishes `cc1plus Killed` (OOM) from a
real defect before anyone touches the code.
7. **Exploit warm context.** Batch findings that touch the same code while a session
is warm (S2's I1/H1/N7 were done by Opus *immediately* after the `newton_core`
refactor instead of as a cold pickup).
---
## 2. Roles
| Role | Model | Owns | Triggered by | Output |
|---|---|---|---|---|
| **Orchestrator / Planner** | Sonnet | the backlog + dispatch | new findings, finished session | next session spec + launch prompt; updated status board |
| **Mechanic** | **Haiku** | renames, named constants, doc/citation/format fixes | dispatched mechanical findings | atomic commits + `[[deprecated]]` aliases |
| **Engineer** | **Sonnet** | tests, error handling, CI, small/safe API changes, robustness | dispatched standard findings | commits + per-finding tests |
| **Specialist** | **Opus** | numerics, math correctness, architecture refactors, public-API/irreversible calls | dispatched judgment findings | commits + tests + design notes |
| **Reviewer (Gatekeeper)** | **Opus** | the review gate after every implementation session | session marked "implemented" | APPROVE / CHANGES-REQUESTED + fixes |
| **Auditor (External reviewer)** | **Opus** | generating *new* audits with `file:line` + fix + acceptance | new module, schedule, or "re-audit" | new audit doc → new backlog findings |
| **Integrator / Release** | Sonnet | git mechanics: branch, PR, rebase/conflict, CI watch, merge sequencing | approved session | PR; rebases; merge (after human gate) |
| **Infra Diagnostician** | Sonnet→Opus | CI-failure triage (code vs runner/OOM), infra fixes | any CI red | "code vs infra" verdict + fix (e.g. `-j1`) |
| **Human (Owner)** | — | legal/provenance, API stabilization, final merge | surfaced decision | decision recorded in the plan |
> Tiering rule of thumb: **Haiku ≈ "transcribe the fix"**, **Sonnet ≈ "engineer the
> fix from a clear spec"**, **Opus ≈ "decide what the fix should be" + "judge someone
> else's fix"**.
---
## 3. Workflow (state machine, per session)
```
┌──────────────────────────────────────────────────────────┐
│ BACKLOG (audit findings, status ⬜/⏸/⛔ in the plan doc) │
└───────────────┬──────────────────────────────────────────┘
│ Orchestrator picks next ⬜, routes by load
┌────────── PLAN ──────────┐ emits a session-prompts.md block
│ model + findings + branch │────────────────────────────────────────┐
└───────────┬──────────────┘ │
▼ │
IMPLEMENT (Mechanic | Engineer | Specialist) │
· atomic commit per finding · test per finding │
· deprecated aliases / value-identity asserts │
▼ │
SELF-VERIFY (build + full test suite green locally) │
▼ │
┌──── REVIEW GATE (Opus, independent) ────┐ │
│ build · parity · value-identity · │── CHANGES-REQUESTED ─────┘
│ public-surface · attribution · plan ✅ │
└───────────┬─────────────────────────────┘ APPROVE
INTEGRATE (Integrator) → open PR
CI ──red──► INFRA DIAGNOSTICIAN ──code?──► back to IMPLEMENT
│ └─infra?─► fix infra (e.g. -j1), re-run
green
HUMAN MERGE GATE ──► MERGE ──► Orchestrator marks ✅, updates plan
(every N sessions / per module) AUDITOR re-audit ──► new BACKLOG findings
```
Key edges proven this session: the **CHANGES-REQUESTED loop** (none needed — Sonnet/Haiku
work was clean), the **INFRA branch** (the `cc1plus` OOM → `-j1`, *not* a code change),
and the **multi-PR sequencing** at INTEGRATE (#36 merged first, #39 rebased to drop the
duplicate A1A3).
---
## 4. Shared state & artifacts (the blackboard)
- **`finding-orchestration.md`** — backlog + status board + per-finding model/session.
The single source of truth every agent reads/writes.
- **Audit docs** (`*-audit-*.md`) — the finding *specs*: `file:line`, fix, acceptance
criteria. The Auditor writes them; implementers consume them.
- **`session-prompts.md`** — launch templates the Orchestrator fills in per session.
- **PRs + commits** — the audit trail; commit trailer `Co-Authored-By: Claude <Model>`
records *which* model did *which* finding.
- **Decision log** (a section in the plan) — human gates: G0 status ("authors emailed,
awaiting reply"), API-stabilization decisions, merge approvals.
Why blackboard over a chat-bus: sessions are short-lived and stateless; the durable
state is the markdown; humans can read and override it at any point.
---
## 5. Gates & guardrails
**Automated gates (must pass to advance):**
- Build clean; **full test suite green, no count regression**.
- **No parity/golden-vector test perturbed** (HardJava-style defaults intact).
- New public surface (result types, enums, file formats) is intentional + documented.
- Each finding has a commit + a test; renames carry `[[deprecated]]` aliases.
**Human gates (system surfaces, human decides):**
- **Legal/provenance (G0)** — porting/relicensing rights. Blocks G1G12, D1/D2, A4/A5.
- **Public-API stabilization** — once in a released/CGAL API, renames need a deprecation
cycle; lock names only when the owner says so.
- **Final merge** — the agent prepares a green, mergeable PR; the human clicks merge
(or explicitly delegates it).
**Blocked-dependency tracking:** findings gated by an open human decision are marked ⛔
in the plan and never dispatched (S6 stays blocked until G0 resolves).
---
## 6. How phases, reviews and audits interleave (cadence)
- **Per session:** implement → **review gate** (always). The review is *inline* with
the cadence, not a separate phase you forget.
- **Per PR:** CI + infra-triage + human merge gate.
- **Per module / every N sessions / on request:** an **Auditor** pass produces a fresh
audit, refilling the backlog. This is the loop that keeps the system honest as the
code grows — new code gets audited, new findings get planned, the cycle repeats.
- **Continuous:** the Orchestrator keeps the plan's status board current so any human
glance shows "done / in-flight / blocked".
So the rhythm is: **audit → plan → (implement → review)\* → integrate → merge → re-audit.**
---
## 7. How to actually run it with Claude
Three implementation levels, cheapest first:
1. **Manual sessions (today).** The Orchestrator (you, or a Sonnet session) hands a
`session-prompts.md` block to a fresh session set to the named model. The review
gate is a second session set to Opus. This already works — it is exactly Session 1+2.
2. **Claude Code subagents.** Define each role as a subagent with a pinned model and a
tight tool allow-list (Mechanic: edit+bash; Reviewer: read+bash, no write-to-main;
Integrator: bash/git/PR API). A top-level "Orchestrator" session dispatches via the
Task tool and reconciles the plan doc.
3. **Claude Agent SDK (autonomous).** A supervisor process loops the state machine:
reads the plan, spawns role-agents (model per role), runs the gates as code
(build/test/CI checks), and stops at human gates. The blackboard (the markdown +
git) is the durable state across runs.
Pin models per role explicitly; do **not** let a cheap role silently escalate — that is
what the review gate and the dispatcher's load-routing are for.
---
## 8. Cost & efficiency strategy
- **Route down aggressively, gate up always.** Most findings go to Haiku/Sonnet; only
the Opus review gate and genuine-judgment findings pay for Opus.
- **Batch by warm context.** Group findings touching the same files into one session
(S2's three findings shared `newton_core`); avoid cold re-derivation.
- **Make the reviewer cheap to satisfy.** Atomic commits + per-finding tests + a fixed
checklist make the Opus review fast (it reads a small, well-scoped diff).
- **Fail fast on infra.** The diagnostician prevents wasted Opus cycles "fixing" a
phantom code bug that was really an OOM.
---
## 9. Worked example (this session, mapped to the roles)
| What happened | Role(s) |
|---|---|
| A1A3 renames, N4/N6 constants, M-citations | **Mechanic (Haiku)** |
| V1V4 error handling, C2/C3 coverage gate, I-tests | **Engineer (Sonnet)** |
| B1 inv-dist block-FD port, N3/N5, `newton_core` (H2+B2/B4/B5), S2 (I1/H1/N7) | **Specialist (Opus)** |
| Validated the Haiku/Sonnet commits (value-identity, alias forwarding, coverage logic) | **Reviewer (Opus)** |
| Built the plan, model assignment, session prompts | **Orchestrator** |
| `#36` merge → rebase `#39` (drop dup A1A3) → PRs | **Integrator** |
| `test-fast` red → proved `cc1plus` OOM via a pure-main probe → `-j1` fix | **Infra Diagnostician** |
| G0 porting rights: "authors emailed, awaiting reply"; merge approval | **Human gate** |
The audits themselves (the 11 docs) were a prior **Auditor (Opus)** pass — the input the
whole pipeline consumes.
---
## 10. Failure modes the design absorbs
- **Cheap model gets it subtly wrong** → caught by the Opus review gate.
- **Refactor silently changes numerics** → caught by parity tests + value-identity asserts.
- **CI red panic** → diagnostician separates infra (OOM) from code before any "fix".
- **Two PRs overlap** (#36/#39) → Integrator sequences merge + rebase deterministically.
- **A finding needs a human/legal call** → marked ⛔, never auto-dispatched.
- **Backlog goes stale as code grows** → periodic Auditor re-audit refills it.
```

View File

@@ -70,10 +70,10 @@ Status: ✅ done · ⬜ open (actionable) · ⏸ deferred (intentional) · ⛔ b
| I1 | test-cov | 🟡 | ✅ | Opus | S2 | | I1 | test-cov | 🟡 | ✅ | Opus | S2 |
| H1 | test-cov | 🔵 | ✅ | Opus | S2 | | H1 | test-cov | 🔵 | ✅ | Opus | S2 |
| N7 | numerics | 🔵 | ✅ | Opus | S2 | | N7 | numerics | 🔵 | ✅ | Opus | S2 |
| **H3** | test-cov | 🔵 | | Sonnet→🔍Opus | **S3** | | **H3** | test-cov | 🔵 | | Sonnet→🔍Opus | **S3** |
| **H4** | test-cov | 🔵 | | Sonnet→🔍Opus | **S3** | | **H4** | test-cov | 🔵 | | Sonnet→🔍Opus | **S3** |
| **H5** | test-cov | 🔵 | | Sonnet→🔍Opus | **S3** | | **H5** | test-cov | 🔵 | | Sonnet→🔍Opus | **S3** |
| **V5, V6** | input-val | 🔵 | | Sonnet→🔍Opus | **S3** | | **V5, V6** | input-val | 🔵 | | Sonnet→🔍Opus | **S3** |
| **N2** | numerics | 🟡 | ⬜ | Haiku→🔍Opus | **S4** | | **N2** | numerics | 🟡 | ⬜ | Haiku→🔍Opus | **S4** |
| **thread-safety doc** | thread-safety | 🟡 | ⬜ | Haiku→🔍Opus | **S4** | | **thread-safety doc** | thread-safety | 🟡 | ⬜ | Haiku→🔍Opus | **S4** |
| **M3** | math-cite | 🟡 | ⬜ | Haiku→🔍Opus | **S4** | | **M3** | math-cite | 🟡 | ⬜ | Haiku→🔍Opus | **S4** |
@@ -126,14 +126,18 @@ CGAL result types (`Conformal_map_result`, `Hyper_ideal_map_result`,
</details> </details>
### S3 — Robustness & test-gap closure (Sonnet → 🔍 Opus) ### S3 — Robustness & test-gap closure (DONE, 2026-06-01, Sonnet impl + Opus review)
- **H3** — `enforce_gauss_bonnet` reports the magnitude of the applied correction. Implementation shipped in commit `135bcf0` (included in P1 merge `bd613a6`).
- **H4** — test `reduce_to_fundamental_domain` boundary `Im(τ)==0.0`. Follow-up commit closes the doc-tracker gap and fixes dead `found_dofvector` variable
- **H5** — integration test for the degenerate-triangle path in the Newton solve. (V5 rule 4: `<DOFVector>` missing now throws instead of silently returning empty `x`).
- **V5** — make the hand-rolled XML reader *reject* reformatted-but-valid XML - **H3** — `enforce_gauss_bonnet` returns `|deficit|` (both overloads); 3 new tests.
instead of silently mis-reading it into zeros. - **H4** — `ReduceToFD_ThrowsForRealAxisBoundary` covers `Im(τ)==0.0` exact boundary.
- **V6** — DOF-vector-vs-mesh size check on load. - **H5** — 2 integration tests: sliver triangle (no crash/NaN) + exact-degenerate collinear.
- **🔍 Opus review:** verify the new rejections don't break valid round-trips. - **V5** — `load_result_xml` rejects non-conforming XML (3 strict-subset checks); canonical
round-trip regression test passes. V5 rule 4 (`<DOFVector>` must be present) now enforced.
- **V6** — `check_dof_vector_size(x, expected, context)` throws on mismatch; 3 new tests.
- **🔍 Opus review:** CHANGES-REQUESTED resolved — implementation correct; code commits
were redundant with `135bcf0` on main; doc follow-up applied on `chore/s3-followup`.
### ⬜ S4 — Documentation & citations (Haiku → 🔍 Opus) ### ⬜ S4 — Documentation & citations (Haiku → 🔍 Opus)
- **N2** — `doc/math/tolerances.md`: every numerical threshold, its role, and - **N2** — `doc/math/tolerances.md`: every numerical threshold, its role, and

View File

@@ -12,9 +12,12 @@ Status legend: 🔴 Critical · 🟡 Important · 🔵 Polish
> **✅ Resolution status (2026-05-31, Session 1):** **V1/V2/V4 ✅** (JSON parse + > **✅ Resolution status (2026-05-31, Session 1):** **V1/V2/V4 ✅** (JSON parse +
> field checks, XML stoi/stod guarded — all surface as `std::runtime_error` with > field checks, XML stoi/stod guarded — all surface as `std::runtime_error` with
> path), **V3 ✅** (NaN/Inf vertex-coordinate guard in `load_mesh`). **V5** (XML > path), **V3 ✅** (NaN/Inf vertex-coordinate guard in `load_mesh`).
> strict-reject) and **V6** (DOF-size check) **open** — scheduled S3 in > **V5/V6 ✅** (2026-06-01, S3): `load_result_xml` now enforces a strict
> [`finding-orchestration.md`](finding-orchestration.md). 298/298 tests green. > internal-only XML subset (three format checks; non-conforming files throw
> `runtime_error` instead of silently returning zeros); `check_dof_vector_size`
> helper added for the DOF-count mismatch check at call sites. 6 new tests.
> See [`finding-orchestration.md`](finding-orchestration.md). 313/313 tests green.
> **Threat model:** this is a scientific library, not a network service, so the bar > **Threat model:** this is a scientific library, not a network service, so the bar
> is "fail cleanly and diagnosably", not "resist attackers". But meshes and result > is "fail cleanly and diagnosably", not "resist attackers". But meshes and result

View File

@@ -19,9 +19,11 @@ Status legend: 🔴 Critical · 🟡 Important · 🔵 Hint / nice-to-have
> **I2/I3/I4 ✅** (serialization / mesh / spherical-hessian negative tests), > **I2/I3/I4 ✅** (serialization / mesh / spherical-hessian negative tests),
> **H2 ✅** (five Newton loops unified into `newton_core`, folding in B2/B3/B4/B5). > **H2 ✅** (five Newton loops unified into `newton_core`, folding in B2/B3/B4/B5).
> **I1 ✅** (`NewtonStatus` enum) + **H1 ✅** (iteration count) done in S2. > **I1 ✅** (`NewtonStatus` enum) + **H1 ✅** (iteration count) done in S2.
> **Open:** **H3/H4/H5** → S3; **I5** (coverage on full suite, un-gates the > **H3/H4/H5** (2026-06-01, S3): `enforce_gauss_bonnet` returns `|deficit|`;
> 80/70/90 threshold) → S5. > `ReduceToFD_ThrowsForRealAxisBoundary` test added; two degenerate-triangle
> See [`finding-orchestration.md`](finding-orchestration.md). 298/298 tests green. > integration tests characterize the Newton solver's behavior.
> **Open:** **I5** (coverage on full suite, un-gates the 80/70/90 threshold) → S5.
> See [`finding-orchestration.md`](finding-orchestration.md). 313/313 tests green.
> **Note:** This audit is complementary to `external-audit-2026-05-30.md` (which > **Note:** This audit is complementary to `external-audit-2026-05-30.md` (which
> covers port-faithfulness bugs). There is no overlap in findings — this one is > covers port-faithfulness bugs). There is no overlap in findings — this one is

View File

@@ -0,0 +1,206 @@
# Feature-development agentic system — phases, port & research
**Companion to** [`../reviewer/agentic-system-design.md`](../reviewer/agentic-system-design.md)
(the *audit / remediation* system). This one is the **forward** pipeline: it
*produces* the library — working the planned phases in [`phases.md`](phases.md),
finishing the **Java port**, and extending into the **research questions** in
[`research-track.md`](research-track.md).
The two systems **compose into a loop**: feature-dev lands new code → the audit
system audits & hardens it → findings flow back. Build forward, audit back.
> **Companion files (this system's plan + prompts, mirroring the audit system):**
> - [`phase-orchestration.md`](phase-orchestration.md) — the DAG/status board (← `finding-orchestration.md`)
> - [`phase-prompts.md`](phase-prompts.md) — ready-to-paste phase prompts (← `session-prompts.md`)
---
## 1. Why this needs a different shape than the audit system
The audit pipeline consumes **closed, fully-specified** findings (`file:line`, fix,
acceptance). Feature work is the opposite — it is **open-ended and gated by truth
sources that differ per item**. Three properties of `phases.md` drive the design:
1. **Port vs. Research is a hard, explicit fork.**
- *Port* items have a **Java golden oracle** (e.g. `CPEuclideanFunctional.java`,
`ConesUtility.java`) → faithful translation, bit-for-bit parity testing.
- *Research* items have **NONE** (e.g. inversive-distance, 9b-analytic, Phase 12/13)
→ derive from papers, *design* the validation (no oracle exists).
2. **Phases form a dependency DAG with hard prerequisites.** 10b needs 10a; Phase 13
needs 9c+10a+10b+10c **and** the holonomy-bug fix. Parallel "chains" exist
(Chain A = Phase 12, near-term; Chain B = Phase 13 capstone).
3. **The hard part is mathematical correctness, not typing.** A research item can be
*numerically wrong while compiling and "converging"*. Validation must be invented
(invariants, convergence studies, cross-library), and a domain expert may need to
sign off the discretization.
So this system adds, over the audit one: a **DAG-aware scheduler**, a **port/research
router**, a **spike→go/no-go gate** (research may fail), and a **validation-strategy
designer**. It **reuses** the audit system's Integrator / CI / Review-gate machinery.
---
## 2. Roles
| Role | Model | Owns | Output |
|---|---|---|---|
| **Roadmap Orchestrator** | Sonnet | the phase DAG; picks next *unblocked* item; runs chains A/B in parallel; classifies port vs research | next-item spec + launch prompt |
| **Theorist / Spec-author** | **Opus** | turn a phase into a precise spec: port → extract algorithm + golden values from Java/dissertation; research → read papers, **derive the discrete formulas**, write the LaTeX note, **design the validation** | phase-spec doc + `doc/math/*-derivation.md` |
| **Prototyper (Spike)** | Opus→Sonnet | a throwaway reference impl (scratch branch) that **numerically confirms the math before productionising** — the research de-risk | go/no-go + a validated numerical recipe |
| **Porter** | **Sonnet** | faithful Java→C++ translation for *port* items; `// Ported from …` provenance | impl + golden-oracle parity tests |
| **Research Implementer** | Opus→Sonnet | productionise the prototyped research math into the library | impl + tests |
| **Validation Engineer** | **Sonnet** | build the test battery appropriate to the item (see §4) | golden / invariant / convergence / cross-lib tests |
| **Reviewer (Gatekeeper)** | **Opus** | math-soundness + parity + correctness gate (per item) | APPROVE / CHANGES-REQUESTED |
| **Integrator / Release** | Sonnet | branch/PR/CI/rebase/merge | merged PR *(shared with the audit system)* |
| **Scholar / Doc** | **Haiku** | `references.md` rows, math-note polish, user-manual/CLI docs | docs |
| **Human (Domain expert / Owner)** | — | research direction; **sign off the discretisation**; precision-substrate architecture; G0/legal | recorded decision |
> The **Theorist** is the forward mirror of the audit system's **Auditor**: the Auditor
> finds *problems* in existing code; the Theorist produces *specs* for new code. Both are
> Opus, because both are "decide what is true / what should be built" work.
---
## 3. Workflow (DAG-scheduled, with a research spike gate)
```
ROADMAP DAG (phases.md + research-track.md; ✅/🔲/⛔/prereqs)
│ Orchestrator picks next item whose prerequisites are ALL ✅
┌── CLASSIFY ──┐
│ port │ research│
└──┬───┴────┬────┘
│ │
│ ▼
│ THEORIST (Opus): derive formulas + LaTeX note + design validation
│ ▼
│ SPIKE (Prototyper): numeric proof-of-correctness on a scratch branch
│ ▼
│ ┌─ GO/NO-GO gate ─┐ NO-GO → record negative result, back to DAG
│ └────────┬────────┘ (research is allowed to fail)
│ │ GO
▼ ▼
PORTER RESEARCH IMPLEMENTER (productionise into the library)
└─────┬──────┘
VALIDATION ENGINEER (battery per §4: oracle / invariant / convergence / cross-lib)
REVIEW GATE (Opus): math-sound? parity intact? public surface intentional?
▼ ── CHANGES-REQUESTED ─► back to implement
INTEGRATE → CI → HUMAN MERGE GATE → MERGE → Orchestrator marks ✅, unblocks dependents
HANDOFF → the AUDIT system re-audits the new module (close the loop)
```
The **spike→go/no-go** gate is the key addition: research math is proven *cheaply on a
throwaway branch* before any production code or tests are written. A NO-GO is a
*successful* outcome (a recorded dead end), not a failure — unlike the audit system,
where every dispatched finding is expected to land.
---
## 4. Validation strategy — chosen by item type
The Theorist picks the battery; the Validation Engineer builds it:
| Item type | Primary oracle | Mandatory checks |
|---|---|---|
| **Java port** (9a.1, 9c, 9d.1/.3/.4, 9e, 9g, 10a utils) | Java golden values (`@golden` vectors) | bit-for-bit parity at the documented tol; FD-gradient check; GaussBonnet |
| **Research, has a cross-impl** (10a forms ↔ geometry-central; GC-1 cross-check) | another library's output on the same mesh | agreement to tol after normalisation alignment |
| **Research, no oracle** (inversive-distance, 9b-analytic, 12, 13) | **invariants + analytic limits** | analytic-limit match (e.g. κ=0 ↔ euclidean path bit-for-bit); invariant conservation (GB, holonomy closure ∏[aᵢ,bᵢ]=Id); FD vs analytic; convergence-under-refinement study |
| **Numerical/architecture** (high-precision substrate, Hessian speed) | self-consistency | value-identity vs the slow reference; speed-up measured; conditioning diagnostics (the N7 `min_ldlt_pivot` we just added) |
Two project-specific truths the battery must respect:
- **Parity is sacred** for ports (the same discipline that kept HardJava the default).
- **Precision prerequisite**: genus-g composes exponentially-growing isometry products;
`double` cannot verify ∏gᵢ=Id. The Theorist must flag when a *localised*
high-precision substrate (`cpp_dec_float_50`/MPFR) is required (Phase 9c/10c/13),
scoped to the uniformisation module only — never the Eigen core.
---
## 5. Scheduling the actual roadmap
**Chains run in parallel; the Orchestrator never dispatches a blocked item.**
- **Quick wins first (cheap, unblock confidence):** 9g.1 quality measures (Java port,
~3 days, no deps), 9h.1/9h.2 CLI (~hours), 9d.3 stereographic (small). → Porter + Haiku.
- **Chain A (near-term research, builds only on landed code):** **Phase 12** decorated
DCE. → Theorist (decoration reparametrisation) → spike (round-trip `I_ij↔(r,)` +
κ-transition invariant) → Research Implementer → invariant/GB battery. **No genus-g deps.**
- **Research math on shipped modules:** 9b-analytic HyperIdeal Hessian (Schläfli chain
rule) → Theorist + LaTeX note + FD-vs-analytic cross-check against today's block-FD.
- **Chain B (gated capstone):** the genus-g≥2 spine **9c → 10a(+DEC layer) → 10b → 10c
→ Phase 13**, plus the **holonomy-bug fix** (Opus + high-precision substrate). The
Orchestrator keeps 13 ⛔ until every prerequisite is ✅.
- **Blocked by G0 (legal, shared with audit system):** Phase 8 CGAL packaging stays ⛔
until porting rights clear — exactly as in the audit plan.
> Prerequisite edges are enforced as data: each phase lists its prereqs; the
> Orchestrator computes the ready-set = {🔲 items whose prereqs are all ✅} each cycle.
---
## 6. Composition with the audit system (the loop)
```
feature-dev: Theorist → spike → implement → validate → review → merge ─┐
│ new module
audit: Auditor → plan → (implement → review) → integrate → merge ┘ hardened
▲ │
└────────────────── re-audit the new module ◄────────────────────┘
```
- Shared infrastructure: **Integrator, CI/Infra-Diagnostician, Review-gate discipline,
the blackboard (markdown + git), commit-attribution by model**.
- Distinct front-ends: **Auditor** (finds problems) vs **Theorist** (specs features).
- The handoff: every feature merge triggers an audit-backlog entry "audit module X",
so growth never outruns scrutiny.
---
## 7. Run options (same ladder as the audit system)
1. **Manual sessions** — Orchestrator emits a launch prompt per item (a forward analog
of `session-prompts.md`), set to the named model; spike + review are separate Opus
sessions.
2. **Claude Code subagents** — roles as subagents with pinned models and scoped tools
(Prototyper works only on `spike/**` branches; Porter cannot touch `doc/math` proofs;
Reviewer is read-only on `main`).
3. **Claude Agent SDK** — a supervisor loops the DAG: computes the ready-set, spawns the
role-agents, runs gates as code (build/test/CI + invariant checks), and **stops at the
go/no-go and human-sign-off gates**.
---
## 8. Failure modes this design absorbs
- **Research math is wrong** → caught at the cheap **spike gate** before production code.
- **A port drifts from Java** → golden-oracle parity tests (Porter's mandatory battery).
- **"Converges" but is geometrically false** → invariant checks (GB, holonomy closure)
and convergence-under-refinement, not just `‖G‖<tol`.
- **A capstone is started prematurely** → DAG scheduler refuses blocked items (Phase 13).
- **`double` silently fails the group relation** → Theorist's precision-prerequisite flag
forces the localised high-precision substrate.
- **New code outruns review** → the audit-system handoff re-audits every merged module.
---
## 9. Worked first move (concrete, startable today)
**Item:** Phase **9g.1** conformal-quality measures — Java port, no new theory, no deps,
~3 days. **Why first:** lands fast, strengthens the validation story for the already-
shipped genus-0/1 pipeline, and exercises the whole forward pipeline cheaply.
- *Orchestrator* → classify **port**; prereqs none → ready.
- *Porter (Sonnet)* → translate IsothermicityMeasure / DCE-measure / FlippedTriangles /
LengthCrossRatio + the ConvergenceUtility metrics into `conformal_quality.hpp`,
`// Ported from …` provenance.
- *Validation (Sonnet)* → golden values from the Java outputs + a flipped-triangle unit
case; reuse on cathead/brezel.
- *Reviewer (Opus)* → parity + the length-cross-ratio definition vs Springborn-Schröder-
Pinkall 2008.
- *Scholar (Haiku)* → `references.md` row + a line in `validation.md`.
- *Integrator* → PR, `/test-cgal` + `/quality-gates` run, merge → audit handoff.
Then open **Chain A / Phase 12** in parallel as the first *research* exercise of the spike gate.

View File

@@ -0,0 +1,132 @@
# Phase Orchestration — DAG × Models × Gates (forward pipeline)
**Forward counterpart** of [`../reviewer/finding-orchestration.md`](../reviewer/finding-orchestration.md).
Where that file schedules *audit findings*, this one schedules the **roadmap phases**
([`phases.md`](phases.md)) and **research questions** ([`research-track.md`](research-track.md))
into model-assigned sessions with **spike → go/no-go**, **validation**, and **review** gates.
System design: [`feature-dev-agentic-system.md`](feature-dev-agentic-system.md).
Ready-to-paste prompts: [`phase-prompts.md`](phase-prompts.md).
> **Two systems, one loop.** Feature-dev (this plan) lands new modules; the audit
> system re-audits them. Every merge here appends an "audit module X" entry to the
> audit backlog.
---
## How to use this
1. Compute the **ready-set** = 🔲 phases whose prerequisites are **all ✅** (table below).
2. Pick one; copy its block from [`phase-prompts.md`](phase-prompts.md); set the named model.
3. **Port** items go straight to Porter→Validation. **Research** items go
Theorist→**Spike (go/no-go)**→Implement→Validation.
4. Every item ends with a **review gate (Opus)** + the validation battery for its type,
then Integrate → CI → human merge → mark ✅ → unblock dependents.
---
## Model / role assignment (forward heuristic)
| Work | Role · Model |
|---|---|
| Faithful Java→C++ translation (golden-oracle parity) | **Porter · Sonnet** (Haiku for trivial CLI/glue) |
| Derive discrete formulas from papers, design validation, LaTeX note | **Theorist · Opus** |
| Throwaway numeric proof-of-correctness before production | **Prototyper · Opus→Sonnet** |
| Productionise validated research math | **Research Implementer · Opus→Sonnet** |
| Test battery (oracle / invariant / convergence / cross-lib) | **Validation · Sonnet** |
| Math-soundness + parity + correctness gate | **Reviewer · Opus** |
| `references.md`, math-note polish, user/CLI docs | **Scholar · Haiku** |
| Branch/PR/CI/rebase/merge | **Integrator · Sonnet** (shared with audit) |
| Research direction · discretisation sign-off · precision substrate · G0 | **Human** |
Routing rule: **port → cheapest faithful model; research → Opus owns the math, cheaper
models productionise once the spike says GO.**
---
## Master phase table
Type: 🔌 port (Java oracle) · 🔬 research (papers only) · 🧱 infra
Status: ✅ done · 🔲 ready/planned · ⏸ planned-blocked-by-prereq · ⛔ blocked (G0)
| Phase | Type | Status | Prerequisites | Role · Model | Chain | Effort |
|---|---|---|---|---|---|---|
| 17 (special fns → holonomy) | 🔌 | ✅ | — | — | — | done |
| 9a.1 CP-Euclidean | 🔌 | ✅ | — | — | — | done |
| 9a.2 inversive-distance | 🔬 | ✅ | — | — | — | done |
| 9b block-FD HyperIdeal Hessian | 🔬 | ✅ | — | — | — | done |
| **9g.1 conformal-quality measures** | 🔌 | ✅ 1375878 | none | Porter · Sonnet | quick | ~3 d |
| **9h.1 CLI --tol/--max-iter** | 🧱 | ✅ 1375878 | none | Haiku/Sonnet | quick | ~30 m |
| **9h.2 CLI cp/inv-dist models** | 🧱 | ✅ 1375878 | 9a ✅ | Sonnet | quick | 24 h |
| **9d.3 stereographic layout (S²→)** | 🔌 | ✅ 1375878 | none | Porter · Sonnet | sphere | ~3 d |
| **9d.4 Möbius-centering functional** | 🔌 | 🔲 **ready** | Phase 7 ✅ | Porter · Sonnet | sphere | ~3 d |
| **9e circle-pattern layout** | 🔌 | 🔲 **ready** | 9a.1 ✅ | Porter · Sonnet | — | ~1 wk |
| **9d.1 ConesUtility (Euclidean)** | 🔌 | 🔲 **ready** | none | Porter · Sonnet | cones | ~1 wk |
| **9b-analytic HyperIdeal Hessian (Schläfli)** | 🔬 | 🔲 **ready** | 9b ✅ | Theorist · Opus | — | 1014 d |
| **9f polygon Laplacian** | 🔬 | 🔲 **ready** | none | Theorist · Opus | — | ~3 wk |
| **Phase 12 decorated DCE + transition** | 🔬 | 🔲 **ready** | landed code only | Theorist · Opus | **A** | medium |
| 9d.2 non-Euclidean cones | 🔬 | 🔲 ready | 9d.1 | Theorist · Opus | cones | medium |
| 9g.2 period-matrix convergence study | 🔬 | 🔲 ready | period_matrix ✅ | Validation · Sonnet | — | ~3 d |
| 9c 4g-gon fundamental domain | 🔌+🔬 | ⏸ | high-precision substrate | Theorist+Porter · Opus | **B** | ~5 wk |
| holonomy-bug fix (+`cpp_dec_float_50`) | 🔬 | ⏸ | — (architecture call) | Opus + Human | **B** | ~1 wk |
| 10a DEC layer + 1-forms | 🔌+🔬 | ⏸ | 9c | Porter+Theorist · Opus | **B** | ~6 wk |
| 10b Siegel period matrix Ω | 🔬 | ⏸ | 10a | Theorist · Opus | **B** | ~1 wk |
| 10c Fuchsian-group / H²/Γ | 🔬 | ⏸ | 10a+10b+9c | Theorist · Opus | **B** | large |
| 13 canonical tessellations (capstone) | 🔬 | ⏸ | 9c+10a+10b+10c+holonomy(+12) | Theorist · Opus | **B** | very large |
| Phase 8 CGAL packaging | 🧱 | ⛔ | **G0** (porting rights) | Opus+Human | — | large |
| 11a/11b/11c, 10d10g, GC-1/2/3 | 🔬 | ⏸/opt | various | Theorist · Opus | later | large |
> **Ready-set right now** (no open prerequisites): **9g.1, 9h.1, 9h.2, 9d.3, 9d.4, 9e,
> 9d.1, 9b-analytic, 9f, Phase 12, 9g.2.** The Chain-B spine and Phase 8 stay ⏸/⛔.
---
## Recommended sequence
### Wave 0 — quick wins (Porter/Haiku, parallel, build confidence)
**9g.1** (quality measures) · **9h.1+9h.2** (CLI) · **9d.3** (stereographic). All 🔌/🧱,
no theory, golden-oracle or trivial validation. Land fast, exercise the forward pipeline
cheaply, strengthen the genus-0/1 validation story.
### Wave A — first research (Chain A, the spike gate's debut)
**Phase 12 decorated DCE.** Theorist (Opus) derives the Penner-coordinate decoration as a
*reparametrisation* of the shipped inversive-distance/hyper-ideal/spherical functionals →
**spike**: round-trip `I_ij ↔ (r_i,r_j,)` + κ-transition invariant → Research Implementer
→ invariant/GB battery. Builds on landed code only; **no genus-g dependency.**
### Wave B — research on shipped modules (parallel with A)
**9b-analytic** (Schläfli analytic HyperIdeal Hessian, with `doc/math/` LaTeX note,
FD-vs-analytic cross-check against today's block-FD) · **9f** (polygon Laplacian) ·
**9e / 9d.1 / 9d.4** (further Java ports).
### Wave C — the genus-g≥2 spine (Chain B, strictly DAG-gated)
**holonomy-bug fix + `cpp_dec_float_50`** → **9c****10a (+DEC layer)****10b**
**10c****Phase 13** capstone. The Orchestrator keeps each ⏸ until its prereqs are ✅;
**13 never dispatches early.** Land **Phase 12** first so the Penner machinery exists.
### Blocked
**Phase 8 (CGAL packaging)** — ⛔ until **G0** (authors emailed, awaiting reply).
---
## Per-item gates (what "done" requires)
- **Spike go/no-go** (research only): numeric correctness on a `spike/**` branch *before*
production code. A NO-GO is a recorded result, not a failure.
- **Validation battery** (by type — see `feature-dev-agentic-system.md` §4):
- 🔌 port → Java golden values bit-for-bit + FD-gradient + GaussBonnet.
- 🔬 with cross-impl → geometry-central agreement after normalisation.
- 🔬 no oracle → analytic-limit match + invariant conservation (GB, ∏[aᵢ,bᵢ]=Id) +
FD-vs-analytic + convergence-under-refinement.
- **Review gate (Opus)**: math sound? parity intact (HardJava-style defaults)? public
surface intentional + documented? precision-prerequisite respected?
- **Integrate**: PR with `/test-cgal` (+ `/quality-gates` where relevant); model
attribution in commits; mark ✅ here; append audit-backlog handoff.
---
## Status log
- **2026-06-01:** plan created. Ready-set open (Wave 0/A/B). Chain B + Phase 8 gated.
Audit system delivered S1+S2 (`finding-orchestration.md`); its Integrator/CI/review
machinery is reused here.

View File

@@ -0,0 +1,227 @@
# Ready-to-paste phase prompts (forward pipeline)
Forward counterpart of [`../reviewer/session-prompts.md`](../reviewer/session-prompts.md).
Copy one block into a fresh session, set the **model named in the prompt**, go.
Plan + DAG: [`phase-orchestration.md`](phase-orchestration.md). Design:
[`feature-dev-agentic-system.md`](feature-dev-agentic-system.md).
Shared conventions (baked into each prompt):
- Repo `/Users/tarikmoussa/Desktop/ConformalLabpp`, base `main`; push to the **eulernest
fork** = remote `origin`; open the PR via the Gitea API
(`/api/v1/repos/conformallab/ConformalLabpp/pulls`, basic-auth from the `origin` URL).
- Build/test: `cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON && cmake --build build-cgal
--target conformallab_cgal_tests -j8 && ctest --test-dir build-cgal -R '^cgal\.'`.
Add `/test-cgal` (and `/quality-gates` where relevant) to the PR-head commit message so
CI runs the full suites (they are keyword-triggered).
- **Port items:** add `// Ported from <Java file>` provenance + Java golden-oracle parity
tests. **Research items:** run the **spike** first (separate Opus session) and only
productionise on GO.
- Finish: **review gate** (Opus), then update status in `phase-orchestration.md` (phase → ✅).
---
## W0·9g.1 — Conformal-quality measures (port) · model: **Sonnet**
```
Use Sonnet. Repo /Users/tarikmoussa/Desktop/ConformalLabpp, new branch off main
`feat/9g1-conformal-quality`. This is a Java PORT, no new theory (phases.md §9g.1).
Create code/include/conformal_quality.hpp porting these Java measures (math is
GUI-independent — lift only the math):
- IsothermicityMeasure (plugin/visualizer/IsothermicityMeasure.java) — pointwise
deviation from conformality (anisotropy of the induced metric).
- DiscreteConformalEquivalencemMeasure (…/DiscreteConformalEquivalencemMeasure.java)
— per-edge length-cross-ratio residual vs the conformal-equivalence condition.
- FlippedTriangles (…/FlippedTriangles.java) — detect inverted/degenerate triangles
in a 2-D layout (embedding-validity).
- LengthCrossRatio (heds/adapter/types/LengthCrossRatio.java) — the discrete conformal
invariant per edge (shared input for the two measures).
- ConvergenceUtility metrics (convergence/ConvergenceUtility.java, math/float only):
getMaxMeanSumCrossRatio (q=(a·c)/(b·d), qfun=(q+1/q)/21),
getMaxMeanSumMultiRatio (per-face product, =1 iff conformal),
getMaxMeanSumScaleInvariantCircumRadius (R/√A).
Math reference: Springborn-Schröder-Pinkall 2008 (length cross-ratio = discrete
conformal invariant). Java reference path: /Users/tarikmoussa/Desktop/conformallab/src/...
Validation (port battery): golden values read from the Java outputs on a small mesh;
a unit flipped-triangle case; run the measures on the converged cathead/brezel layouts
from the existing euclidean pipeline and assert near-conformality.
Per-finding commits, trailer `Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
Build + full CGAL suite green. Push, open PR (base main, head commit message contains
`/test-cgal /quality-gates`). Update phase-orchestration.md (9g.1 → ✅) + a row in
doc/math/validation.md and references.md. Report PR URL + test count.
Then hand off to the audit system: note "audit module conformal_quality.hpp" in
doc/reviewer/finding-orchestration.md backlog.
```
---
## W0·9h — CLI extensions (infra) · model: **Sonnet** (or Haiku for 9h.1)
```
Use Sonnet. Repo as above, new branch off main `feat/9h-cli`. Two independent CLI tasks
(phases.md §9h), no new theory.
9h.1 (~30 min): expose Newton tuning in code/src/apps/v0/conformallab_cli.cpp —
app.add_option("--tol", tol, "Newton gradient tolerance [1e-8]");
app.add_option("--max-iter", max_iter, "Newton iteration limit [200]");
thread both through run_euclidean / run_spherical / run_hyper_ideal. Update the CLI
parameter table in doc/getting-started.md.
9h.2 (~24 h): expose the Phase-9a models (already in the library + CGAL API) to the CLI:
-g cp_euclidean → run_cp_euclidean()
-g inversive_distance → run_inversive_distance()
following the existing run_euclidean() pattern (~60 lines each); add both strings to the
CLI::IsMember validator. Update README + getting-started.md.
Validation: CLI smoke runs on a small mesh for each new flag/model; assert non-zero exit
on bad input. Build + full CGAL suite green. Commit (Sonnet trailer), push, PR (base main,
`/test-cgal` in head commit). Update phase-orchestration.md (9h.1, 9h.2 → ✅). Report PR.
```
---
## W0·9d.3 — Stereographic layout S²→ (port) · model: **Sonnet**
```
Use Sonnet. Repo as above, new branch off main `feat/9d3-stereographic`. Java PORT
(phases.md §9d.3) closing the spherical-visualisation gap.
Create code/include/stereographic_layout.hpp: stereographic projection S²→{∞} plus a
Möbius-centering step, turning discrete_conformal_map_spherical()'s Point_3-on-S² output
into a flat 2-D atlas. Java reference: unwrapper/StereographicUnwrapper.java (266 lines).
Do NOT port math/CP1 or ComplexUtility.stereographic (redundant with std::complex + the
existing MobiusMap — see porting-status.md).
Validation: round-trip (project then inverse-project) to machine precision on sampled S²
points; pole-handling unit case; run on the spherical pipeline output of a small genus-0
mesh and assert no flipped triangles (reuse 9g.1 FlippedTriangles if landed). Build + full
CGAL suite green. Commit (Sonnet trailer), push, PR (`/test-cgal`). Update
phase-orchestration.md (9d.3 → ✅). Report PR. Audit handoff note.
```
---
## WA·Phase 12 — Decorated DCE & transition (RESEARCH, Chain A) · **two-step**
### Step 1 — Theorist + Spike · model: **Opus**
```
Use Opus. Repo as above. This is RESEARCH (no Java parent), Chain A — it reparametrises
ALREADY-LANDED functionals, no genus-g dependency (phases.md §12).
THEORIST: read Bobenko-Lutz 2025 "Decorated Discrete Conformal Equivalence in
Non-Euclidean Geometries" (arXiv:2310.17529) §3 + Lutz 2024 thesis. Derive, on paper, the
Penner-coordinate DECORATION layer: per-vertex circle/horocycle radius as a Penner
coordinate, and its map to the existing inversive distance I_ij via the classical
ℓ² = r_i² + r_j² + 2 r_i r_j η. Write the derivation to doc/math/decorated-dce-derivation.md
(short LaTeX-style note). Define the validation strategy + acceptance criteria (below).
SPIKE (branch `spike/phase12-decoration`, throwaway): a minimal numeric proof BEFORE any
production code —
(a) decoration round-trip I_ij ↔ (r_i, r_j, ) at machine precision;
(b) at background curvature κ=0, bit-for-bit match with the existing euclidean/inversive
path;
(c) the κ∈{+,0,} transition driver holds the discrete conformal invariant fixed (GB per
geometry; invariant constant across the transition to tol) — the numerical witness of
the Bobenko-Lutz master theorem.
Conclude GO or NO-GO with the evidence. If NO-GO, record it in research-track.md and stop.
On GO, write the productionisation spec (files, public surface, test list) for Step 2.
```
### Step 2 — Research Implementer + Validation · model: **Sonnet** (Opus review)
```
Use Sonnet. Repo as above, new branch off main `feat/phase12-decorated-dce`. Productionise
the GO spike from Step 1 per its spec (doc/math/decorated-dce-derivation.md).
Scope: (1) decoration layer (Penner coord ↔ I_ij); (2) transition driver (deform κ at fixed
invariant, solve per geometry); (3) validation harness + example gallery. Reuse the shipped
inversive-distance / hyper-ideal / spherical functionals — the decoration is a
RE-PARAMETRISATION, not a new solver.
Validation (research, no oracle — acceptance criteria from §12): round-trip machine
precision; κ=0 bit-for-bit vs euclidean/inversive; GB per geometry; invariant constant
across the κ-transition; one surface solved in all three backgrounds shares the invariant.
Build + full CGAL suite green. Commit (Sonnet trailer), push, PR (`/test-cgal`).
THEN run the review gate (Opus) below. Update phase-orchestration.md (Phase 12 → ✅) +
references.md. Audit handoff note.
```
---
## WB·9b-analytic — Analytic HyperIdeal Hessian via Schläfli (RESEARCH) · model: **Opus**
```
Use Opus. Repo as above, new branch off main `feat/9b-analytic-hessian`. RESEARCH
(phases.md §9b-analytic) — replace the FD HyperIdeal Hessian with the closed form.
THEORIST + IMPLEMENT: derive the analytic Hessian by explicit chain rule through
(b_i, a_e) → _ij → ζ13/ζ14/ζ15 → α_ij / β_i. Sources: Springborn 2020 §4 +
Schläfli 1858/60 + Rivin-Schlenker 1999 + Cho-Kim 1999 + Glickenstein 2011 §4. Write a
short LaTeX correctness note to doc/math/hyperideal-hessian-derivation.md (extend the
existing one). Implement as a new `hyper_ideal_hessian_analytic_sym(...)` next to the
block-FD variant.
Validation (research, FD cross-check): assert the analytic Hessian matches today's
hyper_ideal_hessian_block_fd_sym entry-wise to FD tolerance on tetrahedron + the Lawson
genus-2 mesh (off-equilibrium); PSD check; convergence parity with the existing solver;
measured speed-up. Keep the block-FD as the cross-validation reference. Default solver
path unchanged until parity is proven, then switch newton_hyper_ideal to the analytic
Hessian behind the same interface.
Build + full CGAL suite green (incl. all Lawson Java golden-vector tests — parity sacred).
Commit (Opus trailer), push, PR (`/test-cgal`). Update phase-orchestration.md (9b-analytic
→ ✅) + references.md. Audit handoff note.
```
---
## Reusable — Research spike go/no-go gate · model: **Opus**
```
Use Opus. Repo /Users/tarikmoussa/Desktop/ConformalLabpp, throwaway branch `spike/<item>`.
Goal: cheaply PROVE OR DISPROVE the math of <item> BEFORE any production code.
- Implement the smallest possible reference computation (scratch .cpp or a test-only TU).
- Run the item's designed checks: analytic-limit match, invariant conservation
(Gauss-Bonnet; holonomy closure ∏[a_i,b_i]=Id where relevant), FD-vs-analytic, and a
small convergence-under-refinement probe.
- If precision is suspect (genus-g isometry products), test with cpp_dec_float_50 too.
Conclude with an explicit GO or NO-GO + the numeric evidence. On GO, output the
productionisation spec (files, public surface, test list). On NO-GO, record the dead end in
research-track.md. Do NOT touch library production code in this session.
```
## Reusable — Math-review / validation gate · model: **Opus**
```
Use Opus. Review the open PR <url/branch> for <item> as an independent reviewer:
- Math: does the implementation match the derivation in doc/math/<item>-derivation.md?
Spot-check the chain rule / formula against the cited paper.
- Validation: is the battery correct for the item TYPE (port→golden oracle;
research→analytic-limit + invariant + convergence)? Are the tolerances honest?
- Parity: no Java golden-vector test perturbed; defaults intact.
- Precision: localized high-precision substrate where required, never in the Eigen core.
- Public surface intentional + documented; commits attribute the model.
Read `git diff main...HEAD` + the derivation note. Fix small issues inline; list precise
required changes otherwise. Re-run the suite. Conclude APPROVE / CHANGES-REQUESTED, and
mark the phase ✅ in phase-orchestration.md on approve.
```
---
## ⏸ Chain B (genus g ≥ 2) — DAG-gated, do not start early
Strict order, each ⏸ until its prereq is ✅:
**holonomy-bug fix (+`cpp_dec_float_50`)** → **9c** (4g-gon fundamental domain) →
**10a** (DEC layer + 1-forms) → **10b** (Siegel Ω) → **10c** (Fuchsian / H²/Γ) →
**Phase 13** (canonical tessellations capstone). Land **Phase 12** first (Penner machinery
reused). Each is a Theorist(Opus)→spike→implement→validate→review item; full literature in
`phases.md` §9c/10/13 + `research-track.md`. The Orchestrator must refuse any item whose
prerequisites are not all ✅.
## ⛔ Phase 8 (CGAL packaging) — blocked by G0
Do not start until the original authors grant porting/relicensing rights (G0; authors
emailed, awaiting reply). Shared with the audit system's S6.
```

View File

@@ -10,6 +10,26 @@
--- ---
## ◼ Current focus (as of 2026-06-01)
> **Agent entry point.** Read this box first. For model assignments, priority
> order, and ready-to-paste prompts, see
> [`phase-orchestration.md`](phase-orchestration.md) and
> [`session-prompts.md`](session-prompts.md).
> Audit-finding sessions run in parallel from `doc/reviewer/`.
| Track | Next session | Gating |
|---|---|---|
| **Audit findings** | S3: H3/H4/H5/V5/V6 — Sonnet → Opus review | None — start now |
| **Phase quick-wins** | P1: 9g.1 + 9h.1 + 9h.2 + 9d.3 — Haiku → Opus review | None — start now |
| **Research: Decorated DCE** | P2: Phase 12 — Sonnet → Opus review | None — start now |
| **Circle pattern + convergence** | P3: 9e + 9d.4 + 9g.2 — Sonnet → Opus review | None — start now |
| **Research: Analytic Hessian** | P4: Phase 9b-analytic — Opus | ⏸ awaiting reviewer Q3 |
| **Java port: genus > 1** | P5: Phase 9c — Opus | ⏸ awaiting reviewer Q4 + G0 |
| **CGAL packaging** | S6 (audit system) — Opus | ⛔ blocked on G0 (author reply pending) |
---
## ◼ Porting complete — Phases 17 ## ◼ Porting complete — Phases 17
``` ```
@@ -107,14 +127,16 @@ mesh type.
Status: 🟡 PR #9 open, 7 tests passing, ~96× speed-up measured. Status: 🟡 PR #9 open, 7 tests passing, ~96× speed-up measured.
9b-analytic Full analytic HyperIdeal Hessian via Schläfli identity 9b-analytic Full analytic HyperIdeal Hessian via Schläfli identity
→ planned, see research-track.md → planned, see research-track.md + session-prompts.md §P4
Mathematical source: Springborn 2020 §4 + Schläfli 1858/60 Mathematical source: Springborn 2020 §4 + Schläfli 1858/60
+ Rivin, Schlenker 1999 "The Schläfli formula in + Rivin, Schlenker 1999 "The Schläfli formula in
Einstein manifolds with boundary" (ERA-AMS 5, 1823) Einstein manifolds with boundary" (ERA-AMS 5, 1823)
+ Cho-Kim 1999 + Glickenstein 2011 §4 + Cho-Kim 1999 + Glickenstein 2011 §4
Algorithm: explicit chain rule through (bᵢ,aₑ) → ℓᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ Algorithm: explicit chain rule through (bᵢ,aₑ) → ℓᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ
Includes: short LaTeX correctness note in doc/math/. Includes: 805-line LaTeX derivation in doc/math/hyperideal-hessian-derivation.md.
Effort: 1014 days net. Trigger: profiling on V > 5000. Effort: 1014 days net. Trigger: profiling on V > 5000.
Complexity / scaling context: doc/math/complexity.md.
⏸ GATED: awaiting reviewer Q3 answer ("worth ~2 weeks at your mesh sizes?").
9c — Genus g > 1 fundamental domain (Java port + research extensions) 9c — Genus g > 1 fundamental domain (Java port + research extensions)
────────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────
@@ -414,6 +436,11 @@ poor input triangulations. Both share the same
mathematical core (discrete conformal equivalence, GaussBonnet, mathematical core (discrete conformal equivalence, GaussBonnet,
variational principle of BobenkoSpringborn 2004). variational principle of BobenkoSpringborn 2004).
Full architecture-level comparison (overlap analysis, adoption rationale,
scientific value): [`doc/architecture/geometry-central-comparison.md`](../architecture/geometry-central-comparison.md).
Software landscape (libigl, CGAL, pmp-library, geometry-central):
[`doc/math/software-landscape.md`](../math/software-landscape.md).
--- ---
## ◼ Phase 10 — Genus g ≥ 2 (research with partial Java support) ## ◼ Phase 10 — Genus g ≥ 2 (research with partial Java support)

View File

@@ -1,6 +1,8 @@
# Porting status overview # Porting status overview
> **Snapshot date:** 2026-05-22 (commit graph at v0.9.0 + 2 open PRs). > **Snapshot date:** 2026-05-31 (post audit-sessions S1 + S2 on branch
> `fix/b1-v3-c1-quick-wins`; 9 commits; 301/301 CGAL tests green).
> For the authoritative live count see [`doc/api/tests.md`](../api/tests.md).
> >
> **Audience.** External collaborators evaluating whether to use, extend, > **Audience.** External collaborators evaluating whether to use, extend,
> or contribute to conformallab++. This document is the **operational > or contribute to conformallab++. This document is the **operational
@@ -55,8 +57,8 @@ legacy API (`code/include/*.hpp`) and the CGAL public API
| **CP-Euclidean** (BPS) | face circles | **face** | CPEuclidean 260 LoC | analytic 2×2-per-edge | ✅ | `discrete_circle_packing_euclidean` | ⛔ N/A | | **CP-Euclidean** (BPS) | face circles | **face** | CPEuclidean 260 LoC | analytic 2×2-per-edge | ✅ | `discrete_circle_packing_euclidean` | ⛔ N/A |
| **Inversive Distance** | vertex circles| vertex | ❌ no Java (Luo 2004 + Glickenstein 2011 from literature) | FD (analytic planned) | ✅ | `discrete_inversive_distance_map` | ⛔ pending | | **Inversive Distance** | vertex circles| vertex | ❌ no Java (Luo 2004 + Glickenstein 2011 from literature) | FD (analytic planned) | ✅ | `discrete_inversive_distance_map` | ⛔ pending |
Total: 250+ tests covering all five models, 0 skipped. Per-suite Total test count: see [`doc/api/tests.md`](../api/tests.md) — single source
breakdown: [`doc/api/tests.md`](../api/tests.md). of truth (counts change as sessions land; do not hardcode them here).
### What "UV out" means ### What "UV out" means
@@ -99,6 +101,10 @@ into `pmap` — no separate user code needed. See
| Newton with line search | ✅ | `newton_solver.hpp`, all five solvers | | Newton with line search | ✅ | `newton_solver.hpp`, all five solvers |
| SimplicialLDLT + SparseQR fallback | ✅ | gauge-singular meshes handled automatically | | SimplicialLDLT + SparseQR fallback | ✅ | gauge-singular meshes handled automatically |
| Block-FD Hessian framework | ✅ | shipped for HyperIdeal (Phase 9b); 96× speed-up | | Block-FD Hessian framework | ✅ | shipped for HyperIdeal (Phase 9b); 96× speed-up |
| `newton_core` refactor — single exit path | ✅ | shipped S1 (2026-05-31); prerequisite for clean diagnostic propagation |
| `NewtonStatus` enum (`Converged` / `MaxIterations` / `LinearSolverFailed` / `LineSearchStalled`) | ✅ | shipped S2 (2026-05-31); propagated into all three CGAL result types via `CGAL::Newton_status` alias |
| Solver diagnostics: `sparse_qr_fallback_used`, `min_ldlt_pivot` | ✅ | shipped S2 (2026-05-31); available on all public CGAL result types |
| Selectable Newton clamp mode (`HardJava` \| `SmoothBarrier`) | ✅ | shipped S1 (2026-05-31); `HardJava` is the default (Java-parity) |
| Analytic Hessian via Schläfli | 🔲 | Phase 9b-analytic; derivation in [`hyperideal-hessian-derivation.md`](../math/hyperideal-hessian-derivation.md) | | Analytic Hessian via Schläfli | 🔲 | Phase 9b-analytic; derivation in [`hyperideal-hessian-derivation.md`](../math/hyperideal-hessian-derivation.md) |
--- ---
@@ -216,8 +222,12 @@ These are conformallab++ contributions beyond porting — the
| Genus-2 test mesh + brezel2.obj scalability | ✅ shipped | | Genus-2 test mesh + brezel2.obj scalability | ✅ shipped |
| Memory-safe layout via `halfedge_uv` storage | ✅ shipped | | Memory-safe layout via `halfedge_uv` storage | ✅ shipped |
| `doc/release-policy.md` formal release policy | ✅ shipped (PR #13) | | `doc/release-policy.md` formal release policy | ✅ shipped (PR #13) |
| Analytic HyperIdeal Hessian via Schläfli | 🔲 derivation written (`hyperideal-hessian-derivation.md`); implementation Phase 9b-analytic |
| Output UV map integrated into wrappers | ✅ shipped (PR #14) for 3 of 5 models | | Output UV map integrated into wrappers | ✅ shipped (PR #14) for 3 of 5 models |
| `[[deprecated]]` aliases for pre-S1 API names (A1A3 rename → `<verb>_<geom>_<rest>`) | ✅ shipped S1 (2026-05-31) |
| Named numeric constants in `constants.hpp` (all thresholds / FD steps / guard values) | ✅ shipped S1 (2026-05-31) |
| Kahan-compensated triangle area in `enforce_gauss_bonnet` | ✅ shipped S1 (2026-05-31) |
| `NewtonStatus` enum + solver diagnostics on all public CGAL result types | ✅ shipped S2 (2026-05-31) |
| Analytic HyperIdeal Hessian via Schläfli | 🔲 derivation written (`hyperideal-hessian-derivation.md`); implementation Phase 9b-analytic (P4) |
| Full uniformisation for genus g ≥ 2 | 🔲 Phase 10c — fully new research | | Full uniformisation for genus g ≥ 2 | 🔲 Phase 10c — fully new research |
--- ---

View File

@@ -7,6 +7,15 @@
> port-tracking sheet `doc/roadmap/java-parity.md` so that the porting > port-tracking sheet `doc/roadmap/java-parity.md` so that the porting
> work and the research work can be planned independently. > work and the research work can be planned independently.
> >
> **Companion documents:**
> - [`doc/math/novelty-statement.md`](../math/novelty-statement.md) — why these
> contributions are novel and who the target audience is.
> - [`doc/math/software-landscape.md`](../math/software-landscape.md) — how
> conformallab++ relates to libigl, geometry-central, and CGAL (relevant for
> deciding research vs. duplication at the boundary cases).
> - [`phase-orchestration.md`](phase-orchestration.md) — model assignments and
> session prompts for implementing items in this document.
>
> **Created:** 2026-05-21, after a full doc audit that identified four > **Created:** 2026-05-21, after a full doc audit that identified four
> items previously mislabelled as "ports". This document corrects the > items previously mislabelled as "ports". This document corrects the
> record and extends it with the explicit research plan for Phase > record and extends it with the explicit research plan for Phase

View File

@@ -0,0 +1,208 @@
# Ready-to-paste session prompts (P1P4 + review gate)
Copy one block into a fresh session, set the **model named in the prompt**, and go.
Each prompt is self-contained: it names the phase(s), the detail doc to follow,
the build/test commands, and the branch/push/PR + review-gate workflow.
Shared conventions (baked into each prompt):
- Repo root: `/Users/tarikmoussa/Desktop/ConformalLabpp`, base branch `main`.
- Branch + push to the **eulernest fork** = remote `origin`; open the PR via the
Gitea API (`https://git.eulernest.eu/api/v1/repos/conformallab/ConformalLabpp/pulls`,
basic-auth with the token embedded in the `origin` URL), base `main`.
- Build/test command (CGAL suite):
`cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON && cmake --build build-cgal --target conformallab_cgal_tests -j8 && ctest --test-dir build-cgal -R '^cgal\.'`
- Phase details: `doc/roadmap/phases.md` (per-phase plan);
`doc/roadmap/research-track.md` (research items with acceptance criteria).
- Build flags reference: `CLAUDE.md` §Build commands (canonical source; use
`-DCONFORMALLAB_LOW_MEMORY_BUILD=ON -j1` if on the Raspberry Pi runner).
- After each implementation session, run the **Review gate** prompt (Opus).
---
## P1 — Quick wins (model: **Haiku**)
```
Use the Haiku model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
branch off main called `feat/p1-quick-wins`.
Implement four independent additions — full details (Java references, math
references, acceptance criteria) in doc/roadmap/phases.md at the sections
labelled 9h.1, 9h.2, 9g.1, 9d.3:
- 9h.1 Add --tol and --max-iter CLI options in code/src/conformallab_cli.cpp.
Thread both through run_euclidean / run_spherical / run_hyper_ideal.
Update doc/getting-started.md CLI parameter table.
- 9h.2 Add -g cp_euclidean and -g inversive_distance routes in the CLI,
following the existing run_euclidean() pattern (~60 lines each).
Add both geometry strings to the CLI::IsMember validator.
Update README + doc/getting-started.md.
- 9g.1 Create code/include/conformal_quality.hpp implementing:
IsothermicityMeasure, DiscreteConformalEquivalenceMeasure,
FlippedTriangles, LengthCrossRatio, ConvergenceUtility measures.
Java source classes are listed in phases.md §9g.1.
Each function must have at least one sanity test in code/tests/cgal/
(e.g. FlippedTriangles returns 0 on a valid layout; LengthCrossRatio
is 1.0 on an equilateral triangle). Register in code/tests/cgal/CMakeLists.txt.
- 9d.3 Create code/include/stereographic_layout.hpp porting
StereographicUnwrapper.java (266 LoC). See phases.md §9d.3 for the math
(stereographic projection S²→{∞} + Möbius centring for genus-0 surfaces).
Add at least one round-trip test (north pole → ∞; a unit-sphere point →
expected complex value).
Run the full CGAL suite after all four are implemented:
cmake -S code -B build-cgal -DWITH_CGAL_TESTS=ON && cmake --build build-cgal \
--target conformallab_cgal_tests -j8 && ctest --test-dir build-cgal -R '^cgal\.'
It must stay green with your new tests added.
Commit per phase (or in two logical commits) with trailer
`Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>`.
Push to origin and open a PR (base main) via the Gitea API using the token
in the origin remote URL. Update doc/roadmap/phase-orchestration.md (mark each
completed phase ✅ with the commit ref). Report the PR URL and test count.
```
---
## P2 — Decorated DCE transition (model: **Sonnet**)
```
Use the Sonnet model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
branch off main called `feat/p2-decorated-dce`.
Implement Phase 12 — Decorated DCE & geometric transition.
Full details and acceptance criteria in:
doc/roadmap/phases.md §Phase 12
doc/roadmap/research-track.md §Phase 12
Mathematical reference: Bobenko, Lutz 2025 "Decorated Discrete Conformal
Equivalence in Non-Euclidean Geometries" (Discrete & Comput. Geom.;
arXiv:2310.17529) §3 — Penner-coordinate decoration unifying the three
background geometries.
Scope (from phases.md):
1. Decoration layer — per-vertex circle/horocycle radius as Penner coordinate;
implement the map ↔ existing inversive distance I_ij via
ℓ² = r_i² + r_j² + 2 r_i r_j η.
→ Create code/include/decorated_dce.hpp.
2. Transition driver — deform background curvature κ ∈ {+,0,} while holding
the discrete conformal invariant fixed; call the three existing solvers
(euclidean, spherical, hyper_ideal).
3. Validation harness — code/tests/cgal/test_decorated_dce.cpp.
All four acceptance criteria from phases.md must be met:
- Decoration round-trip I_ij ↔ (r_i, r_j, ) at machine precision.
- At κ=0: bit-for-bit match with existing euclidean / inversive path.
- Gauss-Bonnet holds per geometry across the κ-transition.
- Invariant (I_ij) is constant across the three-geometry transition to tol.
Run the full CGAL suite (must stay green with new tests).
Commit with trailer `Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
Push to origin, open a PR (base main) via the Gitea API. Update
doc/roadmap/phase-orchestration.md (Phase 12 → ✅, session P2 + commit ref).
Report the PR URL.
```
---
## P3 — Circle pattern embedding + Möbius centring + convergence study (model: **Sonnet**)
```
Use the Sonnet model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
branch off main called `feat/p3-circle-pattern-convergence`.
Three items — full details in doc/roadmap/phases.md:
- 9e Create code/include/circle_pattern_layout.hpp porting
CirclePatternLayout + CirclePatternUtility. Phase 9a.1 (the CP-Euclidean
energy + solver) is a prerequisite and is already landed on main.
Java references: unwrapper/circlepattern/{CirclePatternLayout,
CirclePatternUtility,CPEuclideanRotation}.java (phases.md §9e).
Required test: given ρ values from a solved CP-Euclidean system, verify
that the embedded vertex positions are self-consistent (each face's three
circle-intersection points form the correct intersection angles to tol).
- 9d.4 Upgrade normalise_hyperbolic() in code/include/layout.hpp to use the
variational MobiusCenteringFunctional (Lorentz energy
E = Σ log(-⟨x,p⟩/√(-⟨x,x⟩)), gradient + Hessian).
Java reference: functional/MobiusCenteringFunctional.java (289 LoC).
Retain the existing Fréchet mean as a fallback if Newton fails.
Required test: compare old vs new centring output on brezel.obj; both
must place the centroid within tol of the origin.
- 9g.2 Add code/tests/cgal/test_period_matrix_convergence.cpp (experiment,
not a library feature — see phases.md §9g.2):
Generate a genus-1 elliptic mesh with a known analytic τ; subdivide via
igl::loop; add per-vertex Gaussian noise; compute |τ_discrete τ_analytic|
after each step. Assert that the residual decreases monotonically with
refinement (the discrete period matrix converges).
Run the full CGAL suite (must stay green).
Commit with trailer `Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>`.
Push to origin, open a PR (base main) via the Gitea API. Update
doc/roadmap/phase-orchestration.md (9e/9d.4/9g.2 → ✅, P3 + commit ref).
Report the PR URL and the convergence-study output.
```
---
## P4 — Analytic HyperIdeal Hessian (model: **Opus**) — ⏸ GATED on reviewer Q3
```
PRECONDITION: do NOT start this session until the reviewer has answered Q3
("Is the ~6× speedup over block-FD worth ~2 weeks at your typical mesh sizes?").
If the answer is "no" or "not a priority", stop — Phase 9b-analytic stays ⏸.
If the answer is "yes" or "above V > X", proceed.
Use the Opus model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp on a new
branch off main called `feat/p4-analytic-hessian`.
Implement Phase 9b-analytic — the full analytic HyperIdeal Hessian via the
Schläfli identity. The complete chain-rule derivation (805-line LaTeX note) and
the implementation plan are in:
doc/math/hyperideal-hessian-derivation.md
doc/roadmap/phases.md §9b-analytic
doc/roadmap/research-track.md §Phase 9b-analytic
Chain: (bᵢ, aₑ) → ℓᵢⱼ → ζ₁₃/ζ₁₄/ζ₁₅ → αᵢⱼ/βᵢ → ∂²E/∂u².
Replace the block-FD path in code/include/hyper_ideal_hessian.hpp with the
analytic Hessian. Retain the block-FD path available as a compile-time flag
(-DCONFORMALLAB_HYPER_IDEAL_FD_CHECK or runtime enum) for cross-validation.
Acceptance criteria:
- All existing HyperIdeal golden-value tests pass bit-for-bit (HardJava clamp).
- New test: analytic and block-FD Hessians agree to 1e-6 on the tetrahedron.
- Benchmark: measure the analytic vs block-FD wall-time on the largest test
mesh; report the ratio. Analytic must be faster for V > 500.
Run the full CGAL suite (must stay green). Commit; push; open PR via Gitea API.
Update doc/roadmap/phase-orchestration.md (9b-analytic → ✅, P4 + commit ref).
Report PR URL and the measured speed ratio.
```
---
## Review gate (run after P1 / P2 / P3) (model: **Opus**)
```
Use the Opus model. Work in /Users/tarikmoussa/Desktop/ConformalLabpp. Review
the open PR <PR_URL / branch name> as an independent reviewer. Check, and report
a pass/fail per item:
- Builds clean; full CGAL suite green with no count regression
(ctest --test-dir build-cgal -R '^cgal\.'); count matches doc/api/tests.md.
- Any new functional has a gradient-check test (pattern in CLAUDE.md §Test design patterns).
- No Java golden-vector / parity test perturbed; HardJava clamp default intact.
- Numeric changes are value-identical where claimed, or justified + covered by a test.
- New public surface (result types, enums, CGAL headers) is intentional and
documented in doc/api/headers.md and doc/api/contracts.md.
- Commit messages attribute the implementing model (Co-Authored-By trailer).
- Phase(s) marked ✅ in doc/roadmap/phase-orchestration.md with the commit ref.
Read the actual diff (git diff main...HEAD -- code/include/ code/tests/ doc/)
and the phases.md entry for the phase(s) involved. If you find a real problem,
fix it directly (small) or list precise required changes (larger), then re-run
the suite. Conclude with an explicit APPROVE / CHANGES-REQUESTED.
```