diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a30033ef3..341ba0587 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,63 +1,326 @@ # Contributing to CDT++ -CDT++ v1.0.0 is the final planned C++23 feature release. The repository is -preserved as a historical scientific reference and regression oracle for -[causal-triangulations](https://github.com/acgetchell/causal-triangulations), -the supported Rust successor. Active development, new scientific features, -and general support belong in that project. - -Issue [#97](https://github.com/acgetchell/CDT-plusplus/issues/97) owns the -final tag, GitHub release, and Zenodo record verification. Issue -[#155](https://github.com/acgetchell/CDT-plusplus/issues/155) owns the separate -stabilization gate, tracker closure, and eventual repository archival. -Archival is not immediate: the repository remains maintenance-only until the -owner determines that no release blockers remain, after which GitHub will make -it read-only. - -## Final release corrections - -Before archival, pull requests are limited to release-blocking correctness, -reproducibility, security, documentation, and metadata corrections. A -correction must be tied to an existing release issue, preserve the bounded -2+1-dimensional spherical scientific contract unless it fixes a documented -defect, and include deterministic regression evidence when behavior changes. -A critical defect found after v1.0.0 is published must use a new patch release; -the v1.0.0 tag is immutable. - -Run the repository-owned validation appropriate to the correction: - -```bash -just fix -just check -just release-check +CDT++ v1.0.0 is the final planned C++23 feature release. The repository is preserved as a +historical scientific reference. Active development, new scientific features, general support, and +new reports belong in +[causal-triangulations](https://github.com/acgetchell/causal-triangulations), the supported Rust +successor. + +Issue [#97](https://github.com/acgetchell/CDT-plusplus/issues/97) owns the final tag, GitHub release, +and Zenodo record verification. Issue +[#155](https://github.com/acgetchell/CDT-plusplus/issues/155) owns the separate stabilization gate, +tracker closure, and eventual repository archival. Archival is not immediate: the repository remains +maintenance-only until the owner determines that no release blockers remain. + +## Maintenance scope + +Before archival, pull requests are limited to release-blocking correctness, reproducibility, +security, documentation, and metadata corrections. A correction must: + +- be tied to an existing release issue; +- preserve the bounded 2+1-dimensional spherical scientific contract unless it fixes a documented + defect; +- include deterministic regression evidence when behavior changes; +- update the user, scientific, API, or release documentation owned by the affected behavior; and +- preserve the release, citation, reference-fixture, and archival metadata contracts. + +A critical defect found after v1.0.0 is published requires a new patch release; the v1.0.0 tag is +immutable. Do not use this repository for speculative refactors, dependency churn, new platforms, +new topologies or dimensions, broad performance programs, or new scientific capabilities. + +## Environment setup + +The repository uses [CMake] and [Ninja] for [C++23] sources, [vcpkg] manifest mode for C++ dependencies, +[Just] for its command interface, and [uv] for [Python] support scripts. The smallest [pkgx]-assisted Unix +setup is: + +- [Xcode Command Line Tools] on macOS, or a [C++23] compiler and base build environment on Linux; +- [pkgx], which supplies the pinned Unix tool environment ephemerally; +- [Just] 1.58.0 or newer; and +- [Python] 3.14 and [uv] when running Python-backed checks directly. + +Without pkgx, provide [Git], [Bash], [CMake] 4.4.0 or newer, [Ninja], [Python], [GNU M4], [Autoconf], +[Autoconf Archive], [Automake], [GNU Libtool], [Texinfo], and [pkg-config] through the host package manager. +Documentation work also requires [Doxygen] 1.16.1 and [Graphviz] 15.1.0. The build does not require a personal vcpkg +checkout, a fork, a submodule, Docker, or a hosted development environment. + +For native Windows work, use an x64 [Developer Command Prompt or Developer PowerShell] for +[Visual Studio] 2022 17.4 or newer, with [MSVC] 19.34 or newer available. Install [Git for Windows] +and expose [Git Bash] on `PATH`, because the Justfile uses Bash as its recipe shell. The supported build path also +requires [Just] 1.58.0 or newer, [Python] 3.14 with `python.exe` on `PATH`, [CMake] 4.4.0 or newer, and +[Ninja]. The tested Windows cell uses Python 3.14.6, CMake 4.4.1, and Ninja 1.13.0. Complete local +validation additionally requires [uv] 0.12.3, [typos] 1.49.0, and [Go] or [pinact] 4.1.1 for the workflow +policy checks. The CI cell sets `VCPKG_DEFAULT_TRIPLET=x64-windows`; set the same value when a local +vcpkg environment would otherwise select a different triplet. + +Start with the canonical build and locked Python environment: + +```bash +just python-sync +just build +``` + +`just build` creates an ignored `.cache/vcpkg` checkout at the `builtin-baseline` recorded in +`vcpkg.json`, installs the manifest dependencies, builds under `out/build/reference`, and runs the +supported CTest suite. On Unix, `scripts/pkgx-env.sh` can be sourced to expose the same pinned tool +environment and repository-owned `VCPKG_ROOT` in an interactive shell or IDE. The underlying +`./scripts/build.sh` and `scripts\build.bat` entry points are available for native troubleshooting. + +### Tested release matrix + +| CI cell | Host | Compiler | Standard library | Required contract | +| --- | --- | --- | --- | --- | +| Ubuntu GCC | `ubuntu-latest` | [GCC] 16 | libstdc++ | `just ci` and `just build-parallel` | +| Ubuntu Clang | `ubuntu-latest` | [Clang] 22 | libstdc++ | `just ci` and `just build-parallel` | +| macOS AppleClang | `macos-latest` | [Runner AppleClang][Xcode] | libc++ | `just ci` and `just viewer-build` | +| Windows MSVC | `windows-latest`, x64 | [Runner MSVC][MSVC] | MSVC STL | `just ci` | + +Linux compiler packages are pinned by the [`Justfile`](../Justfile). Native macOS and Windows +compilers follow the GitHub-hosted runner images. CMake enforces the minimum compiler floor: GCC +13.3, Clang 22, AppleClang 15, and MSVC 19.34. These are tested release cells, not a support promise +for every host and compiler pairing. + +## Maintainer workflow + +Run the narrowest relevant validator while editing, then the complete contract before opening or +updating a pull request. The primary recipes are: + +| Command | Purpose | +| --- | --- | +| `just build` | Bootstrap, configure, build, and run the headless smoke suite. | +| `just build-debug` | Build production Debug targets and run compatible CLI integration tests. | +| `just build-parallel` | Build and test the opt-in CGAL/oneTBB configuration. | +| `just check` | Fast, non-mutating repository checks. | +| `just ci` | Run the complete local pre-commit and pre-push validation contract. | +| `just clang-tidy` | Analyze project C++ with LLVM 22. | +| `just coverage` | Generate the Linux GCC LCOV and HTML coverage reports. | +| `just docs-check` | Generate and validate documentation without changing the worktree. | +| `just fix` | Format C++, Python, and the Justfile. | +| `just initialize [ARGS]` | Build as needed and generate an initial triangulation. | +| `just load INPUT [ARGS]` | Load an initialized triangulation and start a new CDT move series. | +| `just reference-check` | Validate the committed reference package offline. | +| `just release-check` | Validate synchronized release metadata and citation fields. | +| `just resume CHECKPOINT [ARGS]` | Resume the identical CDT move series from a checkpoint. | +| `just run [ARGS]` | Build as needed and run the primary CDT++ simulation. | +| `just sanitize KIND` | Run the selected Linux sanitizer preset. | +| `just viewer-check` | Validate viewer fixtures, manifests, and the tracked image. | + +`just check` covers C++ and Python formatting, Python lint and types, spelling, release and citation +metadata, YAML, GitHub Actions syntax and security, whitespace, CMake preset parsing, Semgrep policy, +reference-package consistency, and viewer artifacts. `just ci` adds action-pin policy, the supported +build and test contract, regenerated-reference drift checks, and Python package validation. + +The GitHub Actions Ubuntu GCC, Ubuntu Clang, macOS AppleClang, and Windows MSVC cells run `just ci`. +Both Ubuntu cells also run `just build-parallel`; macOS additionally builds and smoke-tests the +viewer. Pull requests have separate coverage, generated-documentation, CodeQL, and sanitizer gates. + +### Project layout + +The primary source and generated-output boundaries are: + +- `.github/` — repository policy, issue templates, and CI workflows; +- `cmake/` — CMake modules and validation helpers; +- `docs/` — source documentation and generated `docs/html/` output; +- `examples/` — compiled public API example; +- `include/` — C++ public and internal headers; +- `out/build/reference/` — ignored canonical headless build directory; +- `reference/` — versioned language-neutral fixtures and canonical records; +- `scripts/` — build, validation, comparison, and release support scripts; +- `src/` — command-line program sources; +- `tests/` — doctest, CTest, fixtures, and policy tests; and +- `viewer/` — versioned render fixtures, manifests, and schemas. + +Do not hand-edit generated files or build output. Update their declared source and regenerate through +the owning Just recipe. + +## Build and test validation + +The canonical Release build runs 135 CTest registrations: 108 doctest scenarios, 25 CLI integration +tests, one compiled C++ API example, and one arithmetic-backend correctness test. The parallel +configuration registers 136 tests: the 108 ordinary doctest scenarios, one parallel launcher with +five scenarios, the same 25 integration tests, the C++ API example, and the arithmetic test. + +To rerun the complete supported suite without rebuilding: + +```bash +ctest --preset reference-smoke +``` + +To run a focused category: + +```bash +ctest --preset reference-smoke -L unit +ctest --preset reference-smoke -L integration +``` + +The Debug build compiles the `cdt` and `initialize` production targets, then runs the 21 +Debug-compatible CTest entries labeled `integration`. It defines `CGAL_NDEBUG` because supported move +paths deliberately traverse invalid intermediate triangulations while keeping CDT++ assertions +enabled. Release remains the canonical complete test configuration. + +For behavior changes, add or update the smallest deterministic unit, integration, reference, or +compiled-example evidence that would have caught the defect. Randomized CGAL topology counts and +benchmark timings are diagnostic evidence, not exact correctness oracles. + +Checkpoint continuation is a scientific correctness boundary. Its tests must demonstrate that an uninterrupted run +and the same run split at a persisted checkpoint retain the identical ordered transition trace, cumulative counters, +and final canonical topology—not merely that a checkpoint can be parsed. + +## Documentation + +The top-level [`README.md`](../README.md) is for people building, running, and consuming CDT++. Keep +maintenance mechanics in this guide and put detailed scientific or API contracts in their owning +pages under `docs/` or `reference/`. + +Validate generated API documentation without changing the worktree: + +```bash just docs-check -just ci -just build-parallel ``` -`just ci` is the supported headless build and test contract. The two Ubuntu -compiler cells also run `just build-parallel`; the macOS cell additionally -runs the opt-in archival viewer. Use `just clang-tidy` for C++ changes and the -relevant `just sanitize asan`, `just sanitize lsan`, or `just sanitize tsan` -workflow for memory, lifetime, undefined-behavior, or concurrency changes. -MemorySanitizer remains experimental because third-party dependencies are not -instrumented. +Generate publishable output under `docs/html/` with: + +```bash +just docs +``` + +Both recipes require Doxygen 1.16.1 and Graphviz 15.1.0 and use pkgx when matching local tools are +unavailable. Doxygen 1.16.1 is the archival pin because 1.17.0 duplicates linked labels, emits broken +alphabetical-index fragments for this repository, and injects an unused Mermaid CDN dependency. +`scripts/validate_generated_site.py` checks the actual HTML, local links and fragments, duplicate IDs +and link labels, and required assets. + +The documentation workflow runs `just docs-check` as the stable pull-request gate. After a successful +`main` validation, a separate least-privilege job runs `just docs` and publishes its output to the +`gh-pages` branch. + +## Static analysis and sanitizers + +Python 3.14 is selected by `.python-version`; uv locks the environment in `uv.lock`. Ruff owns Python +formatting and linting, and ty owns static type checking. Use `just python-check` or +`just python-fix`; both are incorporated into the repository-wide validation recipes. + +The C++ project follows the CppCore Guidelines as enforced by Clang-Tidy. Run the repository-pinned +LLVM 22 configuration with: + +```bash +just clang-tidy +``` + +AddressSanitizer plus UndefinedBehaviorSanitizer, LeakSanitizer, MemorySanitizer, and ThreadSanitizer +share the repository-owned Linux driver and CMake presets: + +```bash +just sanitize asan +just sanitize lsan +just sanitize msan +just sanitize tsan +``` + +MemorySanitizer remains experimental because third-party dependencies are not instrumented. +AddressSanitizer exercises the optional parallel CGAL/oneTBB path; ThreadSanitizer exercises the +default sequential configuration. + +## Coverage + +Coverage reporting requires Linux, GNU GCC and its matching gcov, CMake, Ninja, LCOV 2.5 or newer, +and `genhtml`: + +```bash +CXX=g++ GCOV=gcov just coverage +``` + +The recipe writes `build/coverage.info` and `build/coverage-html/index.html`, retaining only +project-owned `include/` and `src/` paths. It reports line and branch coverage. Function coverage is +disabled because GCC can emit inconsistent function and line records for generated lambda bodies. + +GCC 16 emits three known line-hit and branch-unhit records for templated assignments in +`Utilities.hpp`. The recipe requires that exact warning count so new or removed inconsistencies fail +for review. The Codecov workflow uploads only the filtered tracefile and preserves both reports as a +GitHub Actions artifact; use the LCOV artifact for the independent branch-coverage rate. + +## vcpkg maintenance + +`vcpkg.json` is the C++ dependency source of truth. Its `builtin-baseline` pins the official +`microsoft/vcpkg` registry commit. The repository-local `.cache/vcpkg` checkout is disposable tool +and cache infrastructure and must not be edited or committed. + +To update the baseline intentionally, bootstrap the current checkout, run the upstream baseline +updater, synchronize the independently reviewed bootstrap-tool pins, inspect both diffs, and rebuild: + +```bash +python3 scripts/bootstrap_vcpkg.py +export VCPKG_ROOT="$PWD/.cache/vcpkg" +"$VCPKG_ROOT/vcpkg" x-update-baseline +just sync-vcpkg-tool-pins +./scripts/build.sh +``` + +`just sync-vcpkg-tool-pins` reads the new baseline, fetches that exact upstream commit's tool +metadata, validates the official Windows amd64 and arm64 assets, and atomically updates the release +and SHA-256 pins in `scripts/bootstrap_vcpkg.py`. It leaves existing pins unchanged when an input +cannot be fetched or validated. On Windows, invoke the synchronizer with +`python.exe scripts\sync_vcpkg_tool_pins.py`. + +CodeQL uses a two-phase manual build so third-party implementation findings stay outside CDT++ +results. `just codeql-prepare` installs and configures dependencies before tracing; +`just codeql-build` then compiles only the `cdt` and `initialize` production targets. + +## Submitting a correction + +Open a focused pull request against `main` and identify the blocking release issue. The description +must summarize: + +- the release-blocking defect or inconsistency; +- the correction and why it stays within the maintenance boundary; +- the validators and tests run; +- any scientific, compatibility, reproducibility, security, citation, or archival-metadata impact; + and +- any platform-specific validation that could not be run locally. -Open a focused pull request against `main`, identify the blocking release -issue, and explain any scientific, compatibility, reproducibility, citation, -or archival-metadata impact. All required GitHub Actions checks must pass. +Use a concise conventional title such as `fix:`, `docs:`, `test:`, `build:`, or `ci:`. All required +GitHub Actions checks must pass. Release tags, GitHub releases, Zenodo deposits, and repository +archival remain owned by their dedicated release issues and must not be performed from an ordinary +correction pull request. ## After archival -Do not fork CDT++ merely to continue its retired development line. Use the -active successor for new work. Historical forks remain subject to the BSD -3-Clause license but are not supported by this repository and must not imply -upstream maintenance or compatibility. +Do not fork CDT++ merely to continue its retired development line. Use the active successor for new +work. Historical forks remain subject to the BSD 3-Clause license but are not supported by this +repository and must not imply upstream maintenance or compatibility. -Security and support reporting is defined in -[`SECURITY.md`](../SECURITY.md). The project -[Code of Conduct](CODE_OF_CONDUCT.md) remains part of the preserved project -record. +Security and support reporting is defined in [`SECURITY.md`](../SECURITY.md). The project +[Code of Conduct](CODE_OF_CONDUCT.md) remains part of the preserved project record. Contributors +retain credit through the repository history, `CITATION.cff`, and the Zenodo archive. -Contributors retain credit through the repository history, `CITATION.cff`, -and the Zenodo archive. +[Autoconf]: https://www.gnu.org/software/autoconf/ +[Autoconf Archive]: https://www.gnu.org/software/autoconf-archive/ +[Automake]: https://www.gnu.org/software/automake/ +[Bash]: https://www.gnu.org/software/bash/ +[C++23]: https://en.cppreference.com/w/cpp/23 +[Clang]: https://clang.llvm.org +[CMake]: https://cmake.org +[Developer Command Prompt or Developer PowerShell]: https://learn.microsoft.com/cpp/build/building-on-the-command-line +[Doxygen]: https://www.doxygen.nl +[GCC]: https://gcc.gnu.org +[Git]: https://git-scm.com +[Git Bash]: https://gitforwindows.org +[Git for Windows]: https://gitforwindows.org +[GNU Libtool]: https://www.gnu.org/software/libtool/ +[GNU M4]: https://www.gnu.org/software/m4/ +[Go]: https://go.dev/doc/install +[Graphviz]: https://graphviz.org +[Just]: https://just.systems +[MSVC]: https://learn.microsoft.com/cpp/overview/visual-cpp-in-visual-studio +[Ninja]: https://ninja-build.org +[pkg-config]: https://www.freedesktop.org/wiki/Software/pkg-config/ +[pkgx]: https://pkgx.sh +[pinact]: https://github.com/suzuki-shunsuke/pinact +[Python]: https://www.python.org/downloads/ +[Texinfo]: https://www.gnu.org/software/texinfo/ +[typos]: https://github.com/crate-ci/typos +[uv]: https://docs.astral.sh/uv/ +[Visual Studio]: https://visualstudio.microsoft.com/vs/ +[vcpkg]: https://learn.microsoft.com/vcpkg/ +[Xcode]: https://developer.apple.com/xcode/ +[Xcode Command Line Tools]: https://developer.apple.com/xcode/resources/ diff --git a/Justfile b/Justfile index 6488f59ae..dc4bf20e5 100644 --- a/Justfile +++ b/Justfile @@ -14,6 +14,7 @@ doxygen_version := "1.16.1" gcc_version := "16" git_cliff_version := "2.13.1" graphviz_version := "15.1.0" +initializer_binary := if os_family() == "windows" { "out/build/reference/src/initialize.exe" } else { "out/build/reference/src/initialize" } just_version := "1.58.0" lcov_sha256 := "7e5e5a154bd5f3557659c328cab376764e7abd238bb403c424472c296b175126" lcov_version := "2.5" @@ -397,6 +398,16 @@ fix: _format-fix python-fix just --fmt @echo "Fixes applied." +# Build as needed and generate an initial triangulation. +[group('workflows')] +initialize *args: build + {{ initializer_binary }} {{ args }} + +# Load an initialized triangulation and start a new CDT move series. +[group('workflows')] +load input *args: build + {{ primary_binary }} --input {{ quote(input) }} {{ args }} + # Run every non-mutating Python source check. [group('workflows')] python-check: python-format-check python-lint python-typecheck python-support-test python-entrypoint-test @@ -411,22 +422,6 @@ python-entrypoint-test: _sync-python-dev uv run --no-sync cdt-tag-release --help >/dev/null uv run --no-sync python scripts/sync_vcpkg_tool_pins.py --help >/dev/null -# Build the opt-in macOS CGAL/Qt viewer and run its noninteractive render smoke test. -[group('workflows')] -viewer-build: - {{ if os() == "macos" { "just _build-viewer-unix" } else if os() == "windows" { "cmd.exe //d //c \"echo The archival viewer is not supported on Windows. 1>&2 & exit /b 2\"" } else if os() == "linux" { "sh -c 'echo The archival viewer is not supported on Linux. 1>&2; exit 2'" } else { "sh -c 'echo The archival viewer is supported only on macOS. 1>&2; exit 2'" } }} - -# Validate the tracked viewer fixture, render manifest, and canonical hero image. -[group('workflows')] -viewer-check: _sync-python-dev - uv run --no-sync python scripts/validate_viewer_artifacts.py - -# Regenerate the README hero image from the tracked fixture and render manifest. -[group('workflows')] -viewer-render: viewer-build - {{ viewer_binary }} --manifest {{ quote(viewer_manifest) }} --output {{ quote(viewer_image) }} - just viewer-check - # Apply Ruff lint fixes and formatting to Python source. [group('workflows')] python-fix: _sync-python-dev @@ -533,6 +528,11 @@ reference-regenerate: _sync-python-dev release-check: _sync-python-dev uv run --no-sync python scripts/release_check.py +# Resume the identical CDT move series from a checkpoint. +[group('workflows')] +resume checkpoint *args: build + {{ primary_binary }} --resume {{ quote(checkpoint) }} {{ args }} + # Build as needed and run the primary CDT++ executable. [group('workflows')] run *args: build @@ -619,3 +619,19 @@ update-actions: just _yaml-check just _action-lint just _zizmor + +# Build the opt-in macOS CGAL/Qt viewer and run its noninteractive render smoke test. +[group('workflows')] +viewer-build: + {{ if os() == "macos" { "just _build-viewer-unix" } else if os() == "windows" { "cmd.exe //d //c \"echo The archival viewer is not supported on Windows. 1>&2 & exit /b 2\"" } else if os() == "linux" { "sh -c 'echo The archival viewer is not supported on Linux. 1>&2; exit 2'" } else { "sh -c 'echo The archival viewer is supported only on macOS. 1>&2; exit 2'" } }} + +# Validate the tracked viewer fixture, render manifest, and canonical hero image. +[group('workflows')] +viewer-check: _sync-python-dev + uv run --no-sync python scripts/validate_viewer_artifacts.py + +# Regenerate the README hero image from the tracked fixture and render manifest. +[group('workflows')] +viewer-render: viewer-build + {{ viewer_binary }} --manifest {{ quote(viewer_manifest) }} --output {{ quote(viewer_image) }} + just viewer-check diff --git a/README.md b/README.md index 9b67c709c..e1b1ae73b 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,18 @@ [![Documentation](https://github.com/acgetchell/CDT-plusplus/actions/workflows/doxygen.yml/badge.svg)](https://www.adamgetchell.org/CDT-plusplus/) [![codecov](https://codecov.io/gh/acgetchell/CDT-plusplus/branch/main/graph/badge.svg)](https://codecov.io/gh/acgetchell/CDT-plusplus) +CDT++ is the archival [C++23] implementation of spherical 2+1-dimensional +[Causal Dynamical Triangulations][CDT]. + ![Small foliated Delaunay triangulation](docs/images/S3-7-27528-I1-R1.png "7 timeslices 27528 simplices") This reproducible archival rendering is generated from a tracked triangulation fixture; see the [viewer and visual-artifact contract](docs/viewer.md). -## Archival and maintenance status +## Maintenance status **CDT++ v1.0.0 is the final planned C++23 feature release.** This repository preserves the C++ implementation as a -historical scientific reference and regression oracle. After the release and Zenodo handoff in +historical scientific reference. After the release and Zenodo handoff in [issue #97](https://github.com/acgetchell/CDT-plusplus/issues/97), it will remain maintenance-only during a stabilization window. [Issue #155](https://github.com/acgetchell/CDT-plusplus/issues/155) will archive it only after the repository owner ends the stabilization window and determines that no release blockers remain. It does not @@ -25,39 +28,27 @@ correctness, reproducibility, security, documentation, and metadata corrections. requires a new patch release rather than changing the v1.0.0 tag. For active use, development, and new reports, go to -[causal-triangulations](https://github.com/acgetchell/causal-triangulations), the supported Rust successor. The local -Python comparison harness in this archive only orchestrates independent C++ and Rust executables and analyzes their -declared outputs; it is not a second scientific implementation. See [Security and support](#security-and-support) -for the post-release reporting boundary. - -## Table of contents - -- [CDT-plusplus](README.md) - - [Archival and maintenance status](#archival-and-maintenance-status) - - [Introduction](#introduction) - - [Regression-oracle scope](#regression-oracle-scope) - - [Usage](#usage) - - [Release scope and limitations](#release-scope-and-limitations) - - [Quickstart](#quickstart) - - [Current reference-suite status](#current-reference-suite-status) - - [Setup](#setup) - - [Tested release matrix](#tested-release-matrix) - - [Prerequisites](#prerequisites) - - [Developer workflow](#developer-workflow) - - [vcpkg maintenance](#vcpkg-maintenance) - - [Build](#build) - - [Project Layout](#project-layout) - - [Command-line reference](#command-line-reference) - - [Documentation](#documentation) - - [Citing CDT++](#citing-cdt) - - [Testing](#testing) - - [Static Analysis](#static-analysis) - - [Sanitizers](#sanitizers) - - [Offline Comparison](#offline-comparison) - - [Visualization](#visualization) - - [Security and support](#security-and-support) - - [Contributing](#contributing) - - [Issues](#issues) +[causal-triangulations](https://github.com/acgetchell/causal-triangulations), the supported Rust successor. See +[Security and support](#security-and-support) for the post-release reporting boundary. + +## Contents + +- [Introduction](#introduction) +- [Features](#features) +- [Quickstart](#quickstart) +- [Command-line usage](#command-line-usage) +- [Build requirements](#build-requirements) + - [Tested release matrix](#tested-release-matrix) + - [Prerequisites](#prerequisites) +- [C++ API](#c-api) +- [Release scope and limitations](#release-scope-and-limitations) +- [Reproducibility](#reproducibility) +- [Documentation](#documentation) +- [Visualization](#visualization) +- [Citing CDT++](#citing-cdt) +- [Security and support](#security-and-support) +- [Contributing](#contributing) +- [License](#license) ## Introduction @@ -69,601 +60,328 @@ The implementation uses the [Computational Geometry Algorithms Library][CGAL], [Boost], and [TBB]. Arbitrary-precision numbers and functions are by [MPFR] and [GMP]. [Melissa E. O'Neill's Permuted Congruential Generators][PCG] library provides high-quality RNGs that pass L'Ecuyer's -[TestU01] statistical tests. -[doctest] provides [BDD]/[TDD]. -[vcpkg] provides library management and building. -[Doxygen] provides automated document generation. -The supported C++ namespace and per-header contract are recorded in the -[C++ API boundary](docs/api-boundary.md). -The compiled [C++ API quickstart](docs/cpp-api-quickstart.md) demonstrates -validated construction, ten reported Metropolis proposals with separate -candidate-success and acceptance results, aggregate accounting, and a verified -persistence round trip. -The exact CGAL version, kernel, TDS, metadata, lifetime, TBB, benchmark, and -upgrade policies are recorded in the -[CGAL 6.2 integration contract](docs/cgal-integration.md). -The evidence and migration gates for replacing GMP/MPFR with -Boost.Multiprecision are recorded in the -[arithmetic backend evaluation](docs/arithmetic-backend-evaluation.md). -The opt-in operations, ownership and synchronization rules, replayable stress -inputs, sanitizer boundary, and matched scaling protocol are recorded in the -[multithreaded CGAL contract](docs/multithreading.md). -[{fmt}] provides a safe and fast alternative to `iostream`. -[spdlog] provides fast, multithreaded logging. -Python and JSON Schema provide the local, offline cross-implementation comparison boundary. - -### Regression-oracle scope - -The principal reason to preserve this implementation is its causality-filtering Delaunay construction path in -[`include/Foliated_triangulation.hpp`](https://github.com/acgetchell/CDT-plusplus/blob/main/include/Foliated_triangulation.hpp). `find_invalid_timevalue_cells` classifies -cells from stored vertex time labels, `has_valid_timevalues` provides the predicate, `find_bad_vertex` selects a vertex -responsible for an acausal local configuration, and `fix_timevalues` removes offending vertices through CGAL so the -cavity is retriangulated until the foliation contract is satisfied. - -The deterministic doctest scenario **"Detecting and fixing problems with vertices and cells"** in -[`tests/Foliated_triangulation_test.cpp`](https://github.com/acgetchell/CDT-plusplus/blob/main/tests/Foliated_triangulation_test.cpp) exercises this path with fixed points -and time labels. Its inputs, detected bad vertex, final initialization state, cell counts, and causal classification -are the first comparison fixture for `causal-triangulations`; exact Monte Carlo trajectories are not required to -match. - -The versioned, language-neutral fixtures, canonical C++ results, run -manifests, raw outputs, and Rust consumption rules are published in the -[`reference/`](reference/README.md) package. - -After building, run that fixture directly with: - -```bash -./out/build/reference/tests/CDT_unit_tests \ - --test-case='*Detecting and fixing problems with vertices and cells*' -``` - -## Usage - -The supported build produces `cdt` and `initialize` in -`out/build/reference/src`. Run the primary simulation through Just and pass its -arguments after the recipe name: - -```bash -just run --help -``` - -For troubleshooting, the equivalent direct command is -`./out/build/reference/src/cdt --help`. - -Use `--no-output` for batch, debugging, or scripted runs that should print -results without writing checkpoint or final triangulation files: - -```bash -just run -s -n256 -t4 -a0.6 -k1.1 -l0.1 -p10 -c10 --seed 92 --no-output -``` - -With output enabled, every generated `.off` triangulation is accompanied by a -`.off.meta` provenance manifest containing the effective seed, configuration, -version/toolchain identity, transition-trace fingerprint, and payload checksum. -Checkpoint files are validated snapshots, not resumable simulation states; see -[`docs/reproducibility.md`](docs/reproducibility.md) for the replay and -persistence contract. Same-seed generation replays the random inputs, while -exact transition replay requires an identical starting manifold; CDT++ does -not alter its spherical construction to force CGAL to reproduce one of several -valid cospherical tetrahedralizations. - -- `cdt-viewer` is an opt-in macOS archival target. Build it with `just viewer-build`, regenerate the tracked hero image - with `just viewer-render`, and see the [viewer contract](docs/viewer.md) for its narrower support boundary. -- `initialize` is also used by the dependency-free local parameter sweep described under - [offline comparison](#offline-comparison). - -See the [command-line reference](#command-line-reference) for every option. -Build and dependency instructions begin at [Quickstart](#quickstart). - -## Release scope and limitations - -The v1.0.0 release supports one scientific model: spherical 2+1-dimensional CDT represented by three-dimensional -foliated triangulations. It includes the complete audited `(2,3)`, `(3,2)`, `(2,6)`, `(6,2)`, and `(4,4)` move set, -the Regge action, Metropolis-Hastings evolution, the headless `cdt` and `initialize` programs, deterministic reference -fixtures, an offline C++/Rust comparison harness, and an opt-in macOS archival viewer. - -The release boundary is intentionally narrow: - -- Toroidal slices, dimensions other than three, and 3+1D/4D simulations are unsupported. The retained periodic and - toroidal headers are historical prototypes under `cdt::experimental`, not public v1.0.0 APIs. -- Requested simplex counts drive a monotone spherical population heuristic; randomized post-repair counts are not an - exact topology oracle or a calibrated phase-distribution result. -- A seed replays PCG inputs. Fresh cospherical CGAL construction can still choose another valid tetrahedralization, - so exact fresh topology and cross-toolchain trajectory identity are not promised. -- Checkpoints are validated snapshots, not resumable simulations. Exact transition replay requires an identical - starting manifold and the recorded toolchain contract. -- Optional oneTBB parallelism is limited to eligible CGAL Delaunay insertion and removal. Pachner moves, - Metropolis-Hastings, persistence, and access to one manifold remain sequential and externally serialized. -- CDT++ publishes a C++23 source boundary, not a stable binary ABI or package-registry distribution. - -The detailed evidence and failure boundaries are in the [CGAL integration](docs/cgal-integration.md), -[ergodic-move](docs/ergodic-moves.md), [Metropolis-Hastings](docs/metropolis-hastings.md), -[reproducibility and persistence](docs/reproducibility.md), and [multithreading](docs/multithreading.md) contracts. +[TestU01] statistical tests. [{fmt}] provides a safe and fast alternative to `iostream`, and [spdlog] provides fast, +multithreaded logging. [vcpkg] provides library management and building, and [Doxygen] provides automated document +generation. Python and JSON Schema provide the local, offline cross-implementation comparison boundary. + +The primary `cdt` program generates and evolves spacetime ensembles. The `initialize` program produces initial +foliated triangulations and supports the retained local parameter sweep. The opt-in `cdt-viewer` program renders +tracked triangulations on macOS without adding Qt to the default headless build. + +## Features + +- [x] 3D simplex representation. +- [x] 3D spherical triangulations with 2+1 foliation. +- [x] S3 bulk action and the complete audited `(2,3)`, `(3,2)`, `(2,6)`, `(6,2)`, and `(4,4)` ergodic move set. +- [x] 3D Metropolis-Hastings evolution with separate candidate-success and acceptance results. +- [x] High-quality random number generation with M.E. O'Neill's [PCG] library and named replayable streams. +- [x] Validated CDT++ `.off` persistence with provenance sidecars, payload checksums, geometry and topology + fingerprints, and transition-trace fingerprints. +- [x] Reusable initial-state generation: `initialize` writes a manifested triangulation that `cdt --input` validates + and evolves with a new transition seed. +- [x] Exact checkpoint continuation for interrupted [Slurm] and HPC runs: `cdt --resume` restores the triangulation, + global pass, PCG state, transition trace, and cumulative counters, then continues the identical Markov chain on + the recorded producer toolchain. +- [x] A documented C++23 source API plus the headless `cdt` and `initialize` programs. +- [x] Versioned, language-neutral reference fixtures and a lightweight local/offline C++/Rust comparison harness. +- [x] Optional parallel triangulation with [TBB] for eligible CGAL Delaunay insertion and removal. +- [x] Optional visualization with [Qt] and a reproducible repository-owned hero artifact. +- [x] Cross-platform support on Linux, macOS, and Windows and cross-compiler support on GCC, Clang, AppleClang, and + MSVC through the tested release matrix. ## Quickstart From a fresh checkout, the primary supported headless build, dependency bootstrap, and test path is: ```bash +git clone https://github.com/acgetchell/CDT-plusplus.git +cd CDT-plusplus just build ``` The `build` recipe uses the Release configuration and the pkgx launcher on Unix when pkgx is available, then delegates -to `scripts/build.sh` on Unix or `scripts/build.bat` on Windows. Its -first run creates an ignored `.cache/vcpkg` checkout at the exact `builtin-baseline` recorded in `vcpkg.json`, -bootstraps vcpkg, installs the manifest dependencies, builds in `out/build/reference`, and runs the supported CTest -smoke suite. The first dependency build can take several minutes; subsequent runs reuse both vcpkg dependencies and -CMake/Ninja outputs, so an unchanged build is a no-op apart from configuration and tests. +to `scripts/build.sh` on Unix or `scripts/build.bat` on Windows. Its first run creates an ignored `.cache/vcpkg` +checkout at the exact `builtin-baseline` recorded in `vcpkg.json`, bootstraps vcpkg, installs the manifest +dependencies, builds in `out/build/reference`, and runs the supported CTest smoke suite. The first dependency build +can take several minutes; subsequent runs reuse both vcpkg dependencies and CMake/Ninja outputs, so an unchanged +build is a no-op apart from configuration and tests. -For a production build with CDT++ assertions enabled, use the focused Debug -workflow: +The supported build products are: -```bash -just build-debug +```text +out/build/reference/src/cdt +out/build/reference/src/initialize ``` -The Debug build preset compiles the `cdt` and `initialize` production targets, -then the `debug-cli` test preset runs the 21 Debug-compatible CTest entries -labeled `integration`. It defines `CGAL_NDEBUG` because supported move paths -deliberately traverse invalid intermediate triangulations, while leaving -CDT++'s own assertions enabled. The `cdt` and `cdt-no-output` simulation tests -and the doctest unit suite are excluded because those paths trip project -invariant assertions on the intermediate state. Release remains the canonical -complete test configuration. +The underlying `./scripts/build.sh` and `scripts\build.bat` entry points remain available for troubleshooting and +native Windows use. Direct script invocations must expose CMake 4.4.0 or newer on `PATH`; the canonical pkgx-backed +Just recipes select the tested 4.4.1 toolchain automatically. + +Run `just --list` for the complete list of repository commands and their one-line descriptions. The primary user +entry points are `just initialize`, `just load`, `just resume`, and `just run`. + +## Command-line usage -On Unix, compiler caching is optional. The pkgx launcher supplies the repository-pinned ccache binary when -`CDT_COMPILER_CACHE=ccache`; leaving the variable unset or setting it to `off` preserves the uncached build: +The supported build produces `cdt` and `initialize` in +`out/build/reference/src`. Run either program through Just and pass its +arguments after the recipe name: ```bash -CDT_COMPILER_CACHE=ccache just build +just initialize --help +just run --help ``` -Compiler caching covers project compilation; vcpkg binary caching remains responsible for reusing compiled -third-party packages. +For troubleshooting, the equivalent direct commands are +`./out/build/reference/src/initialize --help` and +`./out/build/reference/src/cdt --help`. -The optional [pkgx](https://pkgx.sh/) entry point supplies the complete Unix developer-tool environment ephemerally -and invokes the same build contract directly: +### Run a simulation + +The supported simulation surface is the spherical, three-dimensional form: ```bash -./scripts/pkgx-build.sh +just run --spherical \ + --simplices 32000 \ + --timeslices 11 \ + --alpha 0.6 \ + --k 1.1 \ + --lambda 0.1 \ + --passes 1000 \ + --seed 92 ``` -To expose the same dependency-build tools to an interactive shell, source the -reusable environment script: +Use `--no-output` for batch, debugging, or scripted runs that should print +results without writing checkpoint or final triangulation files: ```bash -source scripts/pkgx-env.sh +just run -s -n256 -t4 -a0.6 -k1.1 -l0.1 -p10 -c10 --seed 92 --no-output ``` -In CLion, open **Settings | Build, Execution, Deployment | Toolchains**, select -the local toolchain, choose **Add environment | From file**, and select -`scripts/pkgx-env.sh`. The script supplies the pkgx tool environment and points -`VCPKG_ROOT` at the repository-owned `.cache/vcpkg` checkout while keeping CGAL -and the other project libraries under vcpkg control. - -pkgx does not install CGAL or any other project library; those remain owned by the pinned vcpkg manifest. The -underlying `./scripts/build.sh` and `scripts\build.bat` entry points remain available for troubleshooting and native -Windows development. - -### Current reference-suite status - -With the pinned baseline, the reference configuration and build succeed on -macOS with AppleClang. The cross-platform `just build` command runs all 131 -CTest registrations through `scripts/build.sh` on Unix and `scripts/build.bat` -on Windows: 106 doctest scenarios, 23 CLI integration tests, one compiled C++ -API example, and one arithmetic-backend correctness test. The same -`reference-smoke` preset is the -supported local and CI contract; there are no overlapping focused -registrations that can pass while omitting another doctest suite. The current -`parallel` preset also registers 132 tests: 106 ordinary doctest scenarios, one -parallel launcher containing five scenarios, the same 23 CLI integration -tests, the C++ API example, and the arithmetic correctness test. The -parallel-enabled -AddressSanitizer configuration exercises the same replayable stress contract. - -## Setup - -This project uses [CMake]+[Ninja] to build C++23 sources and [vcpkg] manifest mode to manage C++ libraries. The -v1.0.0 release matrix is defined by -[`.github/workflows/ci.yml`](https://github.com/acgetchell/CDT-plusplus/blob/main/.github/workflows/ci.yml) and the -pinned tool versions in the -[`Justfile`](https://github.com/acgetchell/CDT-plusplus/blob/main/Justfile). - -### Tested release matrix - -| CI cell | Host | Compiler | Standard library | Required contract | -| --- | --- | --- | --- | --- | -| Ubuntu GCC | `ubuntu-latest` | GCC 16 | libstdc++ | `just ci` and `just build-parallel` | -| Ubuntu Clang | `ubuntu-latest` | Clang 22 | libstdc++ | `just ci` and `just build-parallel` | -| macOS AppleClang | `macos-latest` | Runner AppleClang | libc++ | `just ci` and `just viewer-build` | -| Windows MSVC | `windows-latest`, x64 | Runner MSVC | MSVC STL | `just ci` | - -Linux compiler packages are pinned by the Justfile. The native macOS and Windows compilers follow the GitHub-hosted -runner images, while CMake enforces the minimum C++23 floor: GCC 13.3, Clang 22, AppleClang 15, and MSVC 19.34. -These are tested release cells, not a claim that every distribution, operating-system version, architecture, or -compiler/standard-library pairing is supported. - -### Prerequisites - -The smallest pkgx-assisted host setup is: - -- Xcode Command Line Tools on macOS, or a C++23 compiler and base build environment on Linux -- pkgx -- Just, used by the recipes and `scripts/pkgx-build.sh` to resolve the repository's tool-version pins -- Python 3.14 for native dependency bootstrap, and uv when checking or running the Python support scripts -- Doxygen 1.16.1 and Graphviz 15.1.0 when checking or generating API documentation; pkgx can supply both +### Generate and load an initial triangulation -The pkgx build and documentation launchers supply their required tools ephemerally, including Git, Bash, CMake, -Ninja, Python, Doxygen, Graphviz, M4, Autoconf, Autoconf Archive, Automake, GNU Libtool, Texinfo, and pkg-config. If -pkgx is not installed, provide these tools conventionally through a package manager such as [Homebrew] or apt: +To generate an initial triangulation once and start a separate CDT run from +that exact state: -- Git -- Bash -- build-essential (Linux only) -- m4 -- automake -- autoconf -- autoconf-archive -- libtool (macOS) or libtool-bin (Linux) -- pkg-config -- texinfo -- ninja (macOS) or ninja-build (Linux) +```bash +just initialize \ + --spherical --simplices 640 --timeslices 4 --output --seed 92 -The build does not require a pre-existing personal vcpkg checkout, a fork, a submodule, Docker, or a hosted -development environment. +just load /path/to/the/generated-file.off \ + --alpha 0.6 --k 1.1 --lambda 0.1 --passes 1000 --seed 93 +``` -### Developer workflow +Keep the generated `.off` and `.off.meta` files together. `cdt --input` +verifies the pair, reconstructs the same initial causal triangulation, and +starts a new Metropolis-Hastings transition stream from the second command's +seed. It accepts only an `initial-triangulation` artifact from `initialize`; +checkpoint and final artifacts cannot start a new chain through `--input`. -The repository-root `Justfile` provides the same small command vocabulary used by the related projects: +`just load` makes the new-series intent explicit. Its general equivalent is +`just run --input PATH`; in both forms, `cdt` validates the manifest's artifact +role rather than asking the Justfile to interpret persistence metadata. -```bash -just check # Fast, non-mutating local checks -just build-debug # Build Debug targets and run compatible CLI integration tests -just build-parallel # Build and test the opt-in CGAL/oneTBB configuration -just codeql-prepare # Configure dependencies before CodeQL tracing -just codeql-build # Build production targets for CodeQL extraction -just fix # Format C++/Python source and the Justfile -just clang-tidy # Analyze C++ with LLVM 22 -just sanitize asan # Build and exercise one Linux sanitizer preset -just build # Bootstrap, configure, build, and smoke-test -just run --help # Build as needed and run cdt with forwarded arguments -just ci # Comprehensive pre-commit/pre-push validation -just docs-check # Validate Doxygen output without changing the worktree -just docs # Generate publishable documentation in docs/html -just coverage # Generate Linux GCC LCOV and HTML coverage reports -just release-check # Validate release metadata and citation fields -just changelog-unreleased vX.Y.Z # Generate a pending release changelog -just tag-check vX.Y.Z # Preview and validate an annotated release tag -just update-actions # Update and repin Actions with pinact, then validate -just sync-vcpkg-tool-pins # Sync the vcpkg tool release and Windows hashes -just python-sync # Install the locked Python development environment -just python-check # Check Python formatting, lint, and types -just python-fix # Apply safe Ruff fixes and formatting -just comparison-run /path/to/rust-fixture out/comparisons/run-1 # Run and retain one comparison -just comparison-analyze out/comparisons/run-1 # Reanalyze without executing C++ or Rust -just spell-check # Check repository text and identifiers for typos -``` +### Resume a checkpoint -`check` covers repository-wide C++ formatting, Python formatting/lint/type checks, spelling, release metadata and -citation fields, YAML, GitHub Actions syntax and security, whitespace, and CMake preset parsing. `ci` adds the pinact policy -check and the supported build/test contract. Documentation validation remains available separately through -`just docs-check`. The GitHub Actions Ubuntu GCC, Ubuntu Clang, macOS -AppleClang, and Windows MSVC jobs all run `just ci`; the two Ubuntu jobs also -run `just build-parallel`, while the macOS job builds and smoke-tests the opt-in -viewer. Pull requests also run the distinct coverage and generated-documentation -gates. The Ubuntu compiler jobs use the pinned pkgx ccache package with a -compiler-specific persistent cache. Sanitizer builds use Release -semantics with sanitizer-provided `-O1 -g` flags, while coverage uses Release -semantics with coverage-provided `-O0 -g` flags; no duplicate full-suite Debug -job is needed. Windows continues to compile with native MSVC; the -locked Python environment supplies `clang-format` only as a source formatter. -Install the developer tools with Homebrew, use equivalent system packages, or -let pkgx supply the Unix environment ephemerally; pkgx remains optional. For -example: +To continue an interrupted run from a checkpoint, keep its `.off` and +`.off.meta` files together and pass the payload to `--resume`: ```bash -just python-sync -pkgx +just.systems@1.58.0 +git-scm.org +cmake.org@4.4.1 +ninja-build.org +python.org just check +just resume /path/to/checkpoint-pass-500.off ``` -All configure paths require CMake 4.4.0 or newer. The Justfile owns the tested -4.4.1 toolchain pin: pkgx-backed recipes remain reproducible at that version, -while direct configure paths accept newer compatible CMake releases. CI is -pkgx-first; because pkgx does not currently publish its CMake and Ninja packages -for Windows, that job uses the exact Justfile pins available as PyPI wheels -through `uv tool install --no-build`. +The saved seed, action parameters, thread limit, checkpoint cadence, complete +PCG state, transition trace, and cumulative move counters are restored. By +default, the run continues to its originally configured total pass count. Use +`--passes TOTAL` to extend that target; `TOTAL` is the global target, not a +number of additional passes. Exact resume requires the same CDT++ source +revision, compiler and standard library, build configuration and parallel +feature, platform, and CGAL version that produced the checkpoint. -[pinact](https://github.com/suzuki-shunsuke/pinact) uses [`.pinact.yaml`](https://github.com/acgetchell/CDT-plusplus/blob/main/.pinact.yaml) to retain immutable action -SHAs, readable release comments, and a seven-day release cooldown. `just update-actions` uses an installed pinact, -Go, or a pkgx-provided Go fallback, then requires `yamllint`, `actionlint`, and `zizmor` to pass. Direct third-party -Python dependencies install only from locked wheels. The uv environment provides `clang-format` and `yamllint`; -actionlint uses its pinned upstream version, and zizmor uses its pinned PyPI wheel through `uvx`. +`just resume` makes the identical-continuation intent explicit. Its general +equivalent is `just run --resume PATH`. -### vcpkg maintenance +### Generate many initial triangulations -`vcpkg.json` is the dependency source of truth. Its `builtin-baseline` pins the official -[`microsoft/vcpkg`](https://github.com/microsoft/vcpkg) registry commit used locally and in CI. The repository-local -`.cache/vcpkg` checkout is disposable tool/cache infrastructure and must not be edited or committed. -The native build entry points delegate checkout provenance, baseline, and executable-integrity validation directly -to `scripts/bootstrap_vcpkg.py`, whose cross-platform fixtures run under `just check`. - -To update dependencies intentionally, bootstrap the current checkout, run the vcpkg baseline updater, synchronize -the independently reviewed tool pins, review both diffs, and then rerun the complete build: +For a collection of random starting states, give each seed its own directory +so timestamped output names cannot collide: ```bash -python3 scripts/bootstrap_vcpkg.py -export VCPKG_ROOT="$PWD/.cache/vcpkg" -"$VCPKG_ROOT/vcpkg" x-update-baseline -just sync-vcpkg-tool-pins -./scripts/build.sh +for seed in $(seq 1 100); do + mkdir -p "out/initial/seed-$seed" + ( + cd "out/initial/seed-$seed" + ../../build/reference/src/initialize \ + --spherical --simplices 640 --timeslices 4 --output --seed "$seed" + ) +done ``` -`just sync-vcpkg-tool-pins` reads the new manifest baseline, fetches that exact upstream commit's tool metadata, -downloads the official Windows amd64 and arm64 release assets, and atomically updates the release and SHA-256 pins in -`scripts/bootstrap_vcpkg.py`. It leaves the existing pins unchanged if any input cannot be fetched or validated, and -only writes when the rendered release and hash assignments differ from the bootstrap source. - -On Windows, invoke the synchronizer with `python.exe scripts\sync_vcpkg_tool_pins.py`; `scripts\build.bat` and -`scripts\fast-build.bat` already invoke the bootstrap implementation directly. - -CI uses the repository's cached-vcpkg action, which derives the vcpkg checkout commit from the same manifest baseline, -stores compiler-specific binary archives through `actions/cache`, and separately caches downloaded source archives. -No separately maintained checkout SHA is required; the bootstrap script retains an independent tool release and -Windows executable hashes as a supply-chain review gate. - -CodeQL keeps third-party implementation findings out of CDT++ results through a two-phase manual build. -`just codeql-prepare` configures the project, installs manifest dependencies before CodeQL starts tracing, and uses a -build directory under the host temporary directory so installed headers are outside the checkout. After CodeQL -initialization, `just codeql-build` compiles only the `cdt` and `initialize` production targets with tests disabled. -The regular `just build` and `just ci` contracts continue to build and run the complete test suite. - -## Build - -Run `just build` from the repository root. It delegates to `./scripts/build.sh` on Unix and `scripts\build.bat` on -Windows; either platform-specific script can itself be run from any working directory for troubleshooting. If -`VCPKG_ROOT` already names the clean official checkout at the manifest baseline, the script respects it; otherwise it -uses the pinned disposable checkout described above. Both scripts invoke the `reference` configure and build presets -followed by the `reference-smoke` test preset; products and tests are isolated under `out/build/reference`, while -`scripts\fast-build.bat` configures the same reference tree and builds only the primary `cdt` target. All entry points -preserve a compatible CMake cache and refresh it only when the selected vcpkg toolchain path changes. Direct script -invocations must expose CMake 4.4.0 or newer on `PATH`; the canonical pkgx-backed `just` recipes select the tested -4.4.1 toolchain automatically. - -The archival Qt viewer is deliberately outside that cross-platform headless contract. On macOS, `just viewer-build` -selects the separate `viewer` preset and vcpkg feature, builds `cdt-viewer` under `out/build/viewer`, and runs its -noninteractive image smoke test. The macOS CI cell runs that same focused recipe after the headless contract; Qt and -Eigen are installed only in the viewer build tree. See -[`docs/viewer.md`](docs/viewer.md) for regeneration, version pins, and portability rules. - -### Project Layout - -The repository-owned source and generated-output boundaries are: - -- .github - GitHub specific settings -- out/build/reference - Ephemeral supported headless build directory -- cmake - Cmake configurations -- docs - Documentation -- external - Includes submodules of external projects (none so far, all using [vcpkg]) -- include - Header files -- scripts - Build, test, and run scripts -- src - Source files -- tests - Unit tests -- viewer - Versioned archival render fixtures, manifests, and schemas - -## Command-line reference +The cross-executable `initialize-to-cdt` CTest exercises this handoff. The +[reference fixture package](reference/README.md) explains how the pair can +serve as the input boundary for a future native `causal-triangulations` +importer without treating the payload as generic mesh OFF. -The supported simulation surface is the spherical, three-dimensional form: - -```bash -just run --spherical \ - --simplices 32000 \ - --timeslices 11 \ - --alpha 0.6 \ - --k 1.1 \ - --lambda 0.1 \ - --passes 1000 \ - --seed 92 -``` +### Options and constraints Run `just run --help` for the executable-owned option list and `just run --version` for the synchronized product version. Long options and their defined short forms are parsed by [Boost.Program_options][program_options]. The legacy parser still names toroidal topology and dimensionality, but runtime validation rejects toroidal input and every dimension other than three; they are not supported release modes. -`--threads` is a maximum concurrency limit for CGAL/oneTBB bulk Delaunay -operations. It defaults to 1. Zero and negative values are rejected. The -canonical reference build accepts only 1; values greater than 1 require the -`parallel` preset. This option does not parallelize Metropolis-Hastings, -Pachner moves, persistence, or concurrent access to one manifold. +`--input` cannot be combined with `--spherical`, `--toroidal`, `--simplices`, `--timeslices`, `--dimensions`, +`--init`, or `--foliate`; those construction values come from the validated artifact. Action parameters, pass and +checkpoint cadence, output control, thread limit, and the new run seed remain run-specific options. + +`--resume` cannot be combined with replacements for the restored seed, action parameters, thread limit, and +checkpoint cadence, or with topology and construction options. An explicitly supplied `--passes TOTAL` may retain +or extend the global target; it cannot be less than the checkpoint's completed pass. Checkpoint numbering and +cadence remain global across the interruption. + +`--threads` is a maximum concurrency limit for CGAL/oneTBB bulk Delaunay operations. It defaults to 1. Zero and +negative values are rejected. The canonical reference build accepts only 1; values greater than 1 require the +`parallel` preset. This option does not parallelize Metropolis-Hastings, Pachner moves, persistence, or concurrent +access to one manifold. With `--dimensions 3`, every spatial slice is two-dimensional and the third dimension is the global time foliation. The accepted runtime boundary requires positive simplex and timeslice counts, finite physical parameters with `alpha > 1/2`, and a positive thread limit. Invalid configurations fail before construction. -## Documentation +With output enabled, every generated `.off` triangulation is accompanied by a `.off.meta` provenance manifest +containing the effective seed, configuration, version/toolchain identity, transition-trace fingerprint, and payload +checksum. Checkpoint manifests additionally preserve the exact transition-engine state and cumulative accounting +needed by `--resume`; see [`docs/reproducibility.md`](docs/reproducibility.md) for the resume, replay, and persistence +contracts. -Online documentation is at . +## Build requirements -The compiled [C++ API quickstart](docs/cpp-api-quickstart.md) is the canonical -end-to-end public API example and is embedded verbatim in the generated site. - -The scientific transition, proposal-ratio, geometry-delta, counter, and -precision contracts are recorded in -[`docs/metropolis-hastings.md`](docs/metropolis-hastings.md). -The cross-language schema, exact-versus-numerical comparison policy, and raw -archival records are documented in -[`reference/README.md`](reference/README.md). -The literature-backed contracts, exact deltas, inverse relationships, and -failure-atomicity rules for the complete 2+1D move set are recorded in -[`docs/ergodic-moves.md`](docs/ergodic-moves.md). -Seed replay, PCG stream ownership, checkpoint metadata, and the parallel stream -policy are recorded in [`docs/reproducibility.md`](docs/reproducibility.md). -The repository-wide scientific bibliography is -[`REFERENCES.md`](REFERENCES.md). - -Validate the generated API documentation without modifying the worktree: +### Tested release matrix -```bash -just docs-check -``` +| CI cell | Host | Compiler | Standard library | Required contract | +| --- | --- | --- | --- | --- | +| Ubuntu GCC | `ubuntu-latest` | [GCC] 16 | libstdc++ | `just ci` and `just build-parallel` | +| Ubuntu Clang | `ubuntu-latest` | [Clang] 22 | libstdc++ | `just ci` and `just build-parallel` | +| macOS AppleClang | `macos-latest` | [Runner AppleClang][Xcode] | libc++ | `just ci` and `just viewer-build` | +| Windows MSVC | `windows-latest`, x64 | [Runner MSVC][MSVC] | MSVC STL | `just ci` | -To generate the same publishable output used by the documentation workflow, run `just docs`; it writes `docs/html/` -only after strict generation and generated-site validation succeed. Both recipes require Doxygen 1.16.1 and Graphviz -15.1.0 and use pkgx ephemerally when matching local tools are unavailable. Doxygen 1.16.1 is the archival pin because -1.17.0 duplicates linked labels, emits broken alphabetical-index fragments for this repository, and injects an unused -Mermaid CDN dependency. `scripts/validate_generated_site.py` preserves that bounded workaround by checking the actual -HTML, local links and fragments, duplicate IDs and link labels, and required assets. `USE_MATHJAX` allows [MathJax] to render LaTeX formulae, and -`HAVE_DOT` enables [GraphViz] diagrams. Documentation validation is intentionally separate from the cross-platform -`just ci` contract. The documentation workflow runs `just docs-check` as the stable `docs` pull-request gate. After a -successful `main` validation, a separate least-privilege job runs `just docs` and publishes its output to the -`gh-pages` branch. +Linux compiler packages are pinned by the Justfile. The native macOS and Windows compilers follow the GitHub-hosted +runner images, while CMake enforces the minimum C++23 floor: GCC 13.3, Clang 22, AppleClang 15, and MSVC 19.34. +These are tested release cells, not a claim that every distribution, operating-system version, architecture, or +compiler/standard-library pairing is supported. -## Citing CDT++ +### Prerequisites -If CDT++ contributes to published work, cite the software using -[`CITATION.cff`](https://github.com/acgetchell/CDT-plusplus/blob/main/CITATION.cff) and cite the scientific methods relevant to the -work from [`REFERENCES.md`](REFERENCES.md). The software citation records the -final release, `1.0.0`, and the all-versions Zenodo concept DOI -[`10.5281/zenodo.21487043`](https://doi.org/10.5281/zenodo.21487043). The concrete v1.0.0 record DOI will be added to -`CITATION.cff` as a version-specific identifier after Zenodo creates the stable deposit during issue #97. - -## Testing - -Run `just build`; it selects `scripts/build.sh` on Unix or `scripts\build.bat` -on Windows, builds the test target, and executes all 131 CTest entries: 106 -doctest scenarios, 23 executable integration tests covering normal CLI use and -invalid-boundary rejection, one compiled C++ API example, and one -arithmetic-backend correctness test -labeled `scientific`. The parallel-enabled AddressSanitizer and `parallel` -configurations register 106 ordinary doctest scenarios, one launcher -containing five scenarios labeled `unit`, `parallel`, and `configuration`, the -same 23 integration tests, the C++ API example, and the arithmetic test, for 132 -CTest entries. -Every process-level test is labeled `integration`, and invalid-input tests also -carry the `cli-boundary` subcategory. Run `just ci` for the complete local -validation gate. - -`just check` also runs the repository-owned Semgrep policy and its annotated -fixtures. Use `just semgrep-test` while changing the rules and `just semgrep` to -scan the real source tree for false positives. - -The doctest executable can also be run directly: +The smallest pkgx-assisted host setup is: -```bash -./out/build/reference/tests/CDT_unit_tests -``` +- [Xcode Command Line Tools] on macOS, or a [C++23] compiler and base build environment on Linux +- [pkgx] +- [Just], used by the recipes and `scripts/pkgx-build.sh` to resolve the repository's tool-version pins +- [Python] 3.14 for native dependency bootstrap, and [uv] when checking or running the Python support scripts -To rerun the complete suite without rebuilding: +The pkgx build launcher supplies its required tools ephemerally, including [Git], [Bash], [CMake], [Ninja], Python, M4, +Autoconf, Autoconf Archive, Automake, GNU Libtool, Texinfo, and pkg-config. If pkgx is not installed, provide these +tools conventionally through a package manager such as [Homebrew] or [apt]. The build does not require a pre-existing +personal vcpkg checkout, a fork, a submodule, Docker, or a hosted development environment. -```bash -ctest --preset reference-smoke -``` +On Windows, use an x64 [Developer Command Prompt or Developer PowerShell] for [Visual Studio] 2022 17.4 or newer, with +[MSVC] 19.34 or newer available. Install [Git for Windows] and expose [Git Bash] on `PATH`, because the Just recipes use +Bash. Native builds also require Just 1.58.0 or newer, Python 3.14 with `python.exe` on `PATH`, CMake 4.4.0 or newer, +and Ninja. The tested Windows cell uses Python 3.14.6, CMake 4.4.1, and Ninja 1.13.0. Run `just build` from the +repository root, or use `scripts\build.bat reference` directly; both bootstrap the repository-pinned vcpkg checkout. -To run a specific test category, use: +Contributor validation, Debug and parallel builds, documentation generation, compiler caching, IDE setup, vcpkg +maintenance, coverage, static analysis, and sanitizer workflows are documented in +[CONTRIBUTING.md](https://github.com/acgetchell/CDT-plusplus/blob/main/.github/CONTRIBUTING.md). -```bash -ctest --preset reference-smoke -L unit -ctest --preset reference-smoke -L integration -``` +## C++ API -In addition to the command line output, you can see detailed results in the -`out/build/reference/Testing` directory generated by CTest. - -### Coverage +The supported C++ namespace and per-header contract are recorded in the +[C++ API boundary](docs/api-boundary.md). CDT++ publishes a C++23 source boundary, not a stable binary ABI or +package-registry distribution. -Coverage reporting is supported on Linux with GNU GCC and its matching gcov, -CMake, Ninja, LCOV 2.5 or newer, and `genhtml`. Distribution packages may -provide an older LCOV that cannot parse coverage from current GCC releases; use -the [upstream LCOV release](https://github.com/linux-test-project/lcov/releases/tag/v2.5) -when necessary. Generate the same reports used by Codecov with: +The compiled [C++ API quickstart](docs/cpp-api-quickstart.md) demonstrates +validated construction, ten reported Metropolis proposals with separate +candidate-success and acceptance results, aggregate accounting, and a verified +persistence round trip. Build and run it with: ```bash -CXX=g++ GCOV=gcov just coverage +cmake --preset reference +cmake --build --preset reference --target CDT_cpp_api_quickstart +./out/build/reference/examples/CDT_cpp_api_quickstart /tmp/cdt-quickstart.off ``` -The command uses an isolated `build/coverage` CMake tree and writes the filtered -LCOV tracefile to `build/coverage.info` and the browsable report to -`build/coverage-html/index.html`. Only project-owned `include/` and `src/` -paths are retained, consistently excluding tests, generated files, system -headers, and vcpkg dependencies. Reports include line and branch coverage; -function coverage is disabled because GCC can emit inconsistent function and -line records for generated lambda bodies. The raw LCOV capture temporarily -retains function records because LCOV 2.5 requires them while filtering GCC 16 -data; the project-only extraction removes them from both published reports. If -coverage collection fails, verify that `g++ -dumpfullversion -dumpversion` and -`gcov --version` report the same major version. - -GCC 16 also emits three known line-hit/branch-unhit records for templated -assignments in `Utilities.hpp`. The coverage recipe keeps LCOV's warnings -visible and requires that exact count during extraction and report generation; -new or removed inconsistencies therefore fail the command for review. - -The Codecov workflow runs this recipe for pull requests and `main`, uploads only `build/coverage.info` with OIDC, and -preserves both reports as a 14-day GitHub Actions artifact for local -diagnosis. If report generation fails, the workflow also preserves the gcov -inputs and CTest diagnostics for seven days. It does not rely on Codecov's -automatic gcov discovery. Codecov retains the LCOV branch detail but counts an -executed line with an uncovered branch as a line hit, keeping its project -percentage comparable to LCOV's line rate; use the LCOV artifact for the -separate branch-coverage rate. - -### Static Analysis - -Python 3.14 is selected by [`.python-version`](https://github.com/acgetchell/CDT-plusplus/blob/main/.python-version), uv locks the environment in `uv.lock`, Ruff owns -Python formatting and linting, and ty owns static type checking. Run `just python-sync` once and then use -`just python-check` or `just python-fix`; both commands are also part of the repository-wide validation recipes. - -This project follows the [CppCore Guidelines][guidelines] as enforced by [ClangTidy]. The repository pins LLVM 22; -run Clang-Tidy through its Just recipe: - -```bash -just clang-tidy -``` +CGAL handles and facet or edge descriptors borrow from the exact triangulation that produced them. Do not use them +with a copied triangulation or after an invalidating topology mutation. `delaunay_snapshot()` instead returns an +owning, detached triangulation suitable for persistence or transfer across an ownership boundary. See the +[multithreaded CGAL contract](docs/multithreading.md) for the full lifetime and synchronization policy. -(Or use your favorite linter plugin for your editor/IDE.) +## Release scope and limitations -### Sanitizers +The v1.0.0 release supports one scientific model: spherical 2+1-dimensional CDT represented by three-dimensional +foliated triangulations. It includes the complete audited `(2,3)`, `(3,2)`, `(2,6)`, `(6,2)`, and `(4,4)` move set, +the Regge action, Metropolis-Hastings evolution, the headless `cdt` and `initialize` programs, deterministic reference +fixtures, an offline C++/Rust comparison harness, and an opt-in macOS archival viewer. -[AddressSanitizer] + [UndefinedBehaviorSanitizer], [LeakSanitizer], [MemorySanitizer], and [ThreadSanitizer] share the -repository-owned Linux driver and CMake presets. Run one locally with `just sanitize asan`, `just sanitize lsan`, -`just sanitize msan`, or `just sanitize tsan`; the GitHub Actions workflows invoke the same commands. MemorySanitizer -remains experimental because third-party dependencies are not instrumented. AddressSanitizer enables the optional -CGAL/TBB path and its parallel contract; ThreadSanitizer exercises the default sequential configuration. +The release boundary is intentionally narrow: -## Offline Comparison +- Toroidal slices, dimensions other than three, and 3+1D/4D simulations are unsupported. The retained periodic and + toroidal headers are historical prototypes under `cdt::experimental`, not public v1.0.0 APIs. +- Requested simplex counts drive a monotone spherical population heuristic; randomized post-repair counts are not an + exact topology oracle or a calibrated phase-distribution result. +- A seed replays PCG inputs. Fresh cospherical CGAL construction can still choose another valid tetrahedralization, + so exact fresh topology and cross-toolchain trajectory identity are not promised. +- Resumable checkpoints continue the identical Markov chain from a completed pass boundary only when loaded by the + recorded CDT++ source revision and toolchain. They are restart artifacts, not portable interchange files. +- Optional oneTBB parallelism is limited to eligible CGAL Delaunay insertion and removal. Pachner moves, + Metropolis-Hastings, persistence, and access to one manifold remain sequential and externally serialized. +- CDT++ publishes a C++23 source boundary, not a stable binary ABI or package-registry distribution. -The [`cdt-compare`](docs/comparison-harness.md) command launches explicit CDT++ and `causal-triangulations` -commands without a shell. Both receive copied versions of the #94 protocol, result schema, and reference manifest. -The harness retains exact stdout, stderr, exit status, executable digest, command, working directory, process time, -and host provenance. It first anchors the live C++ output to #94's committed canonical result, then compares exact -fields and named tolerance-based fields with Rust. Any live C++ transition observations are independently anchored -to the committed #94 protocol before becoming the Rust reference. It classifies -implementation-specific and unsupported fields without interpreting either implementation as ground truth. +The detailed evidence and failure boundaries are in the [CGAL integration](docs/cgal-integration.md), +[ergodic-move](docs/ergodic-moves.md), [Metropolis-Hastings](docs/metropolis-hastings.md), +[reproducibility and persistence](docs/reproducibility.md), and [multithreading](docs/multithreading.md) contracts. -After building CDT++ and a compatible Rust fixture producer, run one bounded comparison and retain it locally: +## Reproducibility -```bash -just comparison-run /absolute/path/to/causal-triangulations-fixture out/comparisons/run-1 -``` +With output enabled, every generated `.off` triangulation is accompanied by a `.off.meta` provenance manifest +containing the effective seed, configuration, version/toolchain identity, transition-trace fingerprint, and payload +checksum. A run started with `--input` also records the source artifact's seed, initialization stream, placement +fingerprint, and topology fingerprint. Resumable checkpoint manifests also contain the complete transition PCG state +and cumulative move accounting. `cdt --resume` validates that state and the recorded producer contract before +continuing. The scientific test suite compares an uninterrupted run with the same run split across checkpoint and +resume, including the ordered transition trace, all move counters, and final canonical topology. Same-seed +generation replays the random inputs, while exact transition replay requires an identical starting manifold; CDT++ +does not alter its spherical construction to force CGAL to reproduce one of several valid cospherical +tetrahedralizations. -Reproduce `summary.json` entirely from the stored raw artifacts, without running either executable: +The versioned, language-neutral fixtures, canonical C++ results, run manifests, raw outputs, and Rust consumption +rules are published in the [`reference/`](reference/README.md) package. That package owns the detailed +cross-implementation comparison and reference-fixture contract. -```bash -just comparison-analyze out/comparisons/run-1 -``` +## Documentation -The bundle under `out/comparisons/run-1` is canonical and is published atomically only after analysis and manifest -creation finish. Preserve its `inputs/`, `raw/`, `manifest.json`, and `summary.json` together. Python validates the -complete artifact inventory and its digests, validates schemas, constructs commands, classifies comparisons, and -renders a small text table; it does not implement topology, action, move-legality, or acceptance rules. See the -[comparison-harness contract](docs/comparison-harness.md) for producer configuration, placeholders, artifact layout, -failure records, and the C++ reference/Rust result boundary. +Online documentation is at . -The retained `cdt-optimize-initialize` command is also entirely local and dependency-free. It writes one directory -per parameter pair under `out/experiments/initialize`, including configuration JSON, raw stdout, a tab-separated -volume profile, metrics, artifact digests, and source/executable provenance. Each invocation requires a nonexistent -output path. Seed `92` is the default; use `--seed` and `--output-directory` for another replayable record. Fresh CGAL -triangulations remain subject to the [reproducibility contract](docs/reproducibility.md). +The compiled [C++ API quickstart](docs/cpp-api-quickstart.md) is the canonical end-to-end public API example and is +embedded verbatim in the generated site. + +- The complete supported public source surface is recorded in the [C++ API boundary](docs/api-boundary.md). +- The scientific transition, proposal-ratio, geometry-delta, counter, and precision contracts are recorded in + [`docs/metropolis-hastings.md`](docs/metropolis-hastings.md). +- The literature-backed contracts, exact deltas, inverse relationships, and failure-atomicity rules for the complete + 2+1D move set are recorded in [`docs/ergodic-moves.md`](docs/ergodic-moves.md). +- Seed replay, PCG stream ownership, checkpoint metadata, and the parallel stream policy are recorded in + [`docs/reproducibility.md`](docs/reproducibility.md). +- The exact CGAL version, kernel, triangulation data structure, metadata, lifetime, TBB, benchmark, and upgrade + policies are recorded in [`docs/cgal-integration.md`](docs/cgal-integration.md). +- The opt-in operations, ownership and synchronization rules, replayable stress inputs, sanitizer boundary, and + matched scaling protocol are recorded in [`docs/multithreading.md`](docs/multithreading.md). +- The initial-state interchange, cross-language comparison, local comparison harness, schemas, and raw archival + records are documented in [`reference/README.md`](reference/README.md). +- The reproducible image, tracked fixture, and macOS renderer boundary are documented in + [`docs/viewer.md`](docs/viewer.md). +- The repository-wide scientific bibliography is [`REFERENCES.md`](REFERENCES.md). + +Documentation generation and validation are documented in +[CONTRIBUTING.md](https://github.com/acgetchell/CDT-plusplus/blob/main/.github/CONTRIBUTING.md). ## Visualization @@ -672,6 +390,12 @@ noninteractive smoke test, exact canonical-image policy, and inventory of histor [viewer and visual-artifact contract](docs/viewer.md). The default build remains headless and does not install Qt or Eigen. +## Citing CDT++ + +If you use CDT++ in your work, please cite it using +[`CITATION.cff`](https://github.com/acgetchell/CDT-plusplus/blob/main/CITATION.cff). The papers and software on which +CDT++ is based are collected in [`REFERENCES.md`](REFERENCES.md). + ## Security and support CDT++ v1.0.0 is an archival scientific reference, not an actively maintained product. The reporting boundary for @@ -683,44 +407,49 @@ archive-specific vulnerabilities and issues that also affect the active successo Active development has moved to [causal-triangulations](https://github.com/acgetchell/causal-triangulations). Before CDT++ is archived, only release-blocking corrections within the maintenance-only stabilization scope are accepted. After archival, GitHub -will make this repository read-only. See [CONTRIBUTING.md] for the correction workflow and -[CODE_OF_CONDUCT.md] for the preserved participation policy. +will make this repository read-only. See +[CONTRIBUTING.md](https://github.com/acgetchell/CDT-plusplus/blob/main/.github/CONTRIBUTING.md) for the correction +scope, environment setup, developer commands, test and documentation validation, dependency maintenance, and pull +request requirements. The preserved participation policy is in +[CODE_OF_CONDUCT.md](https://github.com/acgetchell/CDT-plusplus/blob/main/.github/CODE_OF_CONDUCT.md). -## Issues +## License -The [CDT++ issue tracker](https://github.com/acgetchell/CDT-plusplus/issues) remains available during the -maintenance-only stabilization window. Issue #155 will close or disposition the remaining trackers and archive this -repository after the owner determines that no blockers remain. New development, support, and scientific work belong -in the [causal-triangulations issue tracker](https://github.com/acgetchell/causal-triangulations/issues). +CDT++ is distributed under the +[BSD 3-Clause License](https://github.com/acgetchell/CDT-plusplus/blob/main/LICENSE.md). [CDT]: REFERENCES.md#cdt-framework-2001 [CGAL]: REFERENCES.md#cgal-triangulations -[CMake]: https://cmake.org -[doctest]: https://github.com/doctest/doctest -[guidelines]: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines [Doxygen]: https://www.doxygen.nl [Homebrew]: https://brew.sh +[C++23]: https://en.cppreference.com/w/cpp/23 +[GCC]: https://gcc.gnu.org +[Clang]: https://clang.llvm.org +[Xcode]: https://developer.apple.com/xcode/ +[Xcode Command Line Tools]: https://developer.apple.com/xcode/resources/ +[pkgx]: https://pkgx.sh +[Just]: https://just.systems +[Python]: https://www.python.org/downloads/ +[uv]: https://docs.astral.sh/uv/ +[Git]: https://git-scm.com +[Bash]: https://www.gnu.org/software/bash/ +[CMake]: https://cmake.org [Ninja]: https://ninja-build.org +[apt]: https://ubuntu.com/server/docs/package-management +[Developer Command Prompt or Developer PowerShell]: https://learn.microsoft.com/cpp/build/building-on-the-command-line +[Visual Studio]: https://visualstudio.microsoft.com/vs/ +[MSVC]: https://learn.microsoft.com/cpp/overview/visual-cpp-in-visual-studio +[Git Bash]: https://gitforwindows.org +[Git for Windows]: https://gitforwindows.org +[Slurm]: https://slurm.schedmd.com [program_options]: https://www.boost.org/doc/libs/1_91_0/doc/html/program_options.html -[Mathjax]: https://www.mathjax.org -[GraphViz]: https://www.graphviz.org [MPFR]: https://www.mpfr.org [GMP]: https://gmplib.org [TBB]: https://uxlfoundation.github.io/oneTBB/ [Boost]: https://www.boost.org -[ClangTidy]: https://clang.llvm.org/extra/clang-tidy/ -[BDD]: https://en.wikipedia.org/wiki/Behavior-driven_development -[TDD]: https://en.wikipedia.org/wiki/Test-driven_development [vcpkg]: https://github.com/Microsoft/vcpkg [PCG]: REFERENCES.md#pcg-random-number-generators [TestU01]: https://doi.org/10.1145/1268776.1268777 -[CONTRIBUTING.md]: https://github.com/acgetchell/CDT-plusplus/blob/main/.github/CONTRIBUTING.md -[CODE_OF_CONDUCT.md]: https://github.com/acgetchell/CDT-plusplus/blob/main/.github/CODE_OF_CONDUCT.md [{fmt}]: https://github.com/fmtlib/fmt -[AddressSanitizer]: https://github.com/google/sanitizers/wiki/AddressSanitizer -[LeakSanitizer]: https://github.com/google/sanitizers/wiki/AddressSanitizerLeakSanitizer -[ThreadSanitizer]: https://github.com/google/sanitizers/wiki/ThreadSanitizerCppManual -[MemorySanitizer]: https://github.com/google/sanitizers/wiki/MemorySanitizer -[UndefinedBehaviorSanitizer]: https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html [spdlog]: https://github.com/gabime/spdlog [Qt]: https://www.qt.io diff --git a/cmake/RunCheckpointResumeTest.cmake b/cmake/RunCheckpointResumeTest.cmake new file mode 100644 index 000000000..3d98a6894 --- /dev/null +++ b/cmake/RunCheckpointResumeTest.cmake @@ -0,0 +1,348 @@ +if(NOT DEFINED INITIALIZE_EXECUTABLE OR NOT EXISTS "${INITIALIZE_EXECUTABLE}") + message(FATAL_ERROR "INITIALIZE_EXECUTABLE must name a built initialize program") +endif() +if(NOT DEFINED CDT_EXECUTABLE OR NOT EXISTS "${CDT_EXECUTABLE}") + message(FATAL_ERROR "CDT_EXECUTABLE must name a built cdt program") +endif() +if(NOT DEFINED TEST_ROOT OR TEST_ROOT STREQUAL "") + message(FATAL_ERROR "TEST_ROOT must name the owning test root") +endif() +if(NOT DEFINED TEST_DIRECTORY OR TEST_DIRECTORY STREQUAL "") + message(FATAL_ERROR "TEST_DIRECTORY must name a dedicated test directory") +endif() + +set(normalized_test_root "${TEST_ROOT}") +set(normalized_test_directory "${TEST_DIRECTORY}") +cmake_path(ABSOLUTE_PATH normalized_test_root NORMALIZE) +cmake_path(ABSOLUTE_PATH normalized_test_directory NORMALIZE) +cmake_path( + IS_PREFIX normalized_test_root "${normalized_test_directory}" NORMALIZE + test_directory_is_owned) +if(NOT test_directory_is_owned OR normalized_test_directory STREQUAL normalized_test_root) + message(FATAL_ERROR "TEST_DIRECTORY must be a strict descendant of TEST_ROOT") +endif() + +set(initialize_directory "${normalized_test_directory}/initialize") +set(uninterrupted_directory "${normalized_test_directory}/uninterrupted") +set(resumed_directory "${normalized_test_directory}/resumed") +set(extended_directory "${normalized_test_directory}/extended") +set(rejected_directory "${normalized_test_directory}/rejected") +set(completed_directory "${normalized_test_directory}/completed") +set(corrupt_directory "${normalized_test_directory}/corrupt") +set(thread_limit_directory "${normalized_test_directory}/thread-limit") +file(REMOVE_RECURSE "${normalized_test_directory}") +file(MAKE_DIRECTORY + "${initialize_directory}" + "${uninterrupted_directory}" + "${resumed_directory}" + "${extended_directory}" + "${rejected_directory}" + "${completed_directory}" + "${corrupt_directory}" + "${thread_limit_directory}") + +execute_process( + COMMAND "${INITIALIZE_EXECUTABLE}" -s -n64 -t3 -o --seed 92 + WORKING_DIRECTORY "${initialize_directory}" + RESULT_VARIABLE initialize_result + OUTPUT_VARIABLE initialize_output + ERROR_VARIABLE initialize_error) +if(NOT initialize_result EQUAL 0) + message(FATAL_ERROR "initialize failed:\n${initialize_output}\n${initialize_error}") +endif() +file(GLOB initial_payloads LIST_DIRECTORIES false "${initialize_directory}/*.off") +list(LENGTH initial_payloads initial_payload_count) +if(NOT initial_payload_count EQUAL 1) + message(FATAL_ERROR "Expected one initialized payload, found ${initial_payload_count}") +endif() +list(GET initial_payloads 0 initial_payload) + +execute_process( + COMMAND + "${CDT_EXECUTABLE}" --input "${initial_payload}" -a0.6 -k1.1 -l0.1 + -p4 -c2 --seed 93 + WORKING_DIRECTORY "${uninterrupted_directory}" + RESULT_VARIABLE uninterrupted_result + OUTPUT_VARIABLE uninterrupted_output + ERROR_VARIABLE uninterrupted_error) +if(NOT uninterrupted_result EQUAL 0) + message( + FATAL_ERROR + "Uninterrupted run failed:\n${uninterrupted_output}\n${uninterrupted_error}") +endif() + +file(GLOB pass_two_payloads LIST_DIRECTORIES false "${uninterrupted_directory}/*-pass-2.off") +file(GLOB pass_four_payloads LIST_DIRECTORIES false "${uninterrupted_directory}/*-pass-4.off") +list(LENGTH pass_two_payloads pass_two_count) +list(LENGTH pass_four_payloads pass_four_count) +if(NOT pass_two_count EQUAL 1 OR NOT pass_four_count EQUAL 1) + message( + FATAL_ERROR + "Expected pass-2 and pass-4 checkpoints; found ${pass_two_count} and ${pass_four_count}") +endif() +list(GET pass_two_payloads 0 pass_two_checkpoint) +list(GET pass_four_payloads 0 pass_four_checkpoint) +file(READ "${pass_two_checkpoint}.meta" pass_two_metadata) +foreach( + required + "artifact=checkpoint" + "resume_supported=true" + "configured_passes=4" + "completed_passes=2" + "random.transition_state=" + "moves.proposed=" + "moves.accepted=" + "moves.rejected=" + "moves.attempted=" + "moves.succeeded=" + "moves.failed=") + if(NOT pass_two_metadata MATCHES "${required}") + message(FATAL_ERROR "Checkpoint metadata is missing '${required}':\n${pass_two_metadata}") + endif() +endforeach() + +execute_process( + COMMAND "${CDT_EXECUTABLE}" --resume "${pass_two_checkpoint}" + WORKING_DIRECTORY "${resumed_directory}" + RESULT_VARIABLE resumed_result + OUTPUT_VARIABLE resumed_output + ERROR_VARIABLE resumed_error) +if(NOT resumed_result EQUAL 0) + message(FATAL_ERROR "Resumed run failed:\n${resumed_output}\n${resumed_error}") +endif() +foreach( + expected + "Completed checkpoint passes: 2" + "Target total passes: 4" + "Number of passes to execute: 2" + "=== Pass 3 ===" + "=== Pass 4 ===" + "Writing checkpoint for pass 4") + if(NOT resumed_output MATCHES "${expected}") + message(FATAL_ERROR "Resume output is missing '${expected}':\n${resumed_output}") + endif() +endforeach() +file(GLOB resumed_pass_two_payloads LIST_DIRECTORIES false "${resumed_directory}/*-pass-2.off") +file(GLOB resumed_pass_four_payloads LIST_DIRECTORIES false "${resumed_directory}/*-pass-4.off") +list(LENGTH resumed_pass_two_payloads resumed_pass_two_count) +list(LENGTH resumed_pass_four_payloads resumed_pass_four_count) +if(NOT resumed_pass_two_count EQUAL 0 OR NOT resumed_pass_four_count EQUAL 1) + message( + FATAL_ERROR + "Resume did not preserve global checkpoint numbering: pass-2=${resumed_pass_two_count}, pass-4=${resumed_pass_four_count}") +endif() + +function(find_final_manifest directory output_variable) + file(GLOB candidate_manifests LIST_DIRECTORIES false "${directory}/*.off.meta") + set(final_manifest "") + foreach(candidate IN LISTS candidate_manifests) + file(READ "${candidate}" candidate_metadata) + if(candidate_metadata MATCHES "(^|[\r\n])artifact=final-triangulation([\r\n]|$)") + if(NOT final_manifest STREQUAL "") + message(FATAL_ERROR "Found more than one final manifest in ${directory}") + endif() + set(final_manifest "${candidate}") + endif() + endforeach() + if(final_manifest STREQUAL "") + message(FATAL_ERROR "Did not find a final manifest in ${directory}") + endif() + set("${output_variable}" "${final_manifest}" PARENT_SCOPE) +endfunction() + +find_final_manifest("${uninterrupted_directory}" uninterrupted_final_manifest) +find_final_manifest("${resumed_directory}" resumed_final_manifest) +file(READ "${uninterrupted_final_manifest}" uninterrupted_final_metadata) +file(READ "${resumed_final_manifest}" resumed_final_metadata) + +function(compare_metadata_field field) + string(REPLACE "." "[.]" field_pattern "${field}") + if(NOT uninterrupted_final_metadata MATCHES "(^|[\r\n])${field_pattern}=([^\r\n]+)") + message(FATAL_ERROR "Uninterrupted metadata is missing ${field}") + endif() + set(uninterrupted_value "${CMAKE_MATCH_2}") + if(NOT resumed_final_metadata MATCHES "(^|[\r\n])${field_pattern}=([^\r\n]+)") + message(FATAL_ERROR "Resumed metadata is missing ${field}") + endif() + set(resumed_value "${CMAKE_MATCH_2}") + if(NOT uninterrupted_value STREQUAL resumed_value) + message( + FATAL_ERROR + "Scientific resume mismatch for ${field}: uninterrupted='${uninterrupted_value}', resumed='${resumed_value}'") + endif() +endfunction() + +foreach( + field + "topology.fnv1a64" + "placement.fnv1a64" + "transition_trace.fnv1a64" + "transition_trace.count" + "moves.proposed" + "moves.accepted" + "moves.rejected" + "moves.attempted" + "moves.succeeded" + "moves.failed" + "configured_passes" + "completed_passes" + "random.seed" + "random.transition_stream" + "alpha" + "k" + "lambda") + compare_metadata_field("${field}") +endforeach() + +execute_process( + COMMAND "${CDT_EXECUTABLE}" --resume "${pass_four_checkpoint}" + WORKING_DIRECTORY "${completed_directory}" + RESULT_VARIABLE completed_result + OUTPUT_VARIABLE completed_output + ERROR_VARIABLE completed_error) +if(NOT completed_result EQUAL 0 + OR NOT completed_output MATCHES "no transitions remain") + message( + FATAL_ERROR + "A completed checkpoint did not finalize cleanly:\n${completed_output}\n${completed_error}") +endif() +find_final_manifest("${completed_directory}" completed_final_manifest) + +execute_process( + COMMAND "${CDT_EXECUTABLE}" --resume "${pass_two_checkpoint}" --passes 6 + WORKING_DIRECTORY "${extended_directory}" + RESULT_VARIABLE extended_result + OUTPUT_VARIABLE extended_output + ERROR_VARIABLE extended_error) +if(NOT extended_result EQUAL 0) + message( + FATAL_ERROR + "Extended resume failed with result '${extended_result}':\n${extended_output}\n${extended_error}") +endif() +foreach( + expected + "Completed checkpoint passes: 2" + "Target total passes: 6" + "Number of passes to execute: 4" + "=== Pass 3 ===" + "=== Pass 4 ===" + "=== Pass 5 ===" + "=== Pass 6 ===" + "Writing checkpoint for pass 4" + "Writing checkpoint for pass 6") + if(NOT extended_output MATCHES "${expected}") + message( + FATAL_ERROR + "Extended resume output is missing '${expected}':\n${extended_output}") + endif() +endforeach() +file(GLOB extended_pass_two_payloads LIST_DIRECTORIES false "${extended_directory}/*-pass-2.off") +file(GLOB extended_pass_four_payloads LIST_DIRECTORIES false "${extended_directory}/*-pass-4.off") +file(GLOB extended_pass_six_payloads LIST_DIRECTORIES false "${extended_directory}/*-pass-6.off") +list(LENGTH extended_pass_two_payloads extended_pass_two_count) +list(LENGTH extended_pass_four_payloads extended_pass_four_count) +list(LENGTH extended_pass_six_payloads extended_pass_six_count) +if(NOT extended_pass_two_count EQUAL 0 + OR NOT extended_pass_four_count EQUAL 1 + OR NOT extended_pass_six_count EQUAL 1) + message( + FATAL_ERROR + "Extended resume did not preserve global checkpoint numbering: pass-2=${extended_pass_two_count}, pass-4=${extended_pass_four_count}, pass-6=${extended_pass_six_count}") +endif() +find_final_manifest("${extended_directory}" extended_final_manifest) +file(READ "${extended_final_manifest}" extended_final_metadata) +foreach(required "configured_passes=6" "completed_passes=6") + if(NOT extended_final_metadata MATCHES "(^|[\r\n])${required}([\r\n]|$)") + message( + FATAL_ERROR + "Extended final metadata is missing '${required}':\n${extended_final_metadata}") + endif() +endforeach() + +execute_process( + COMMAND "${CDT_EXECUTABLE}" --resume "${pass_two_checkpoint}" --passes 1 + WORKING_DIRECTORY "${rejected_directory}" + RESULT_VARIABLE rejected_result + OUTPUT_VARIABLE rejected_output + ERROR_VARIABLE rejected_error) +set(rejected_log "${rejected_output}\n${rejected_error}") +if(rejected_result EQUAL 0 + OR NOT rejected_log MATCHES + "Resume target passes must be at least the completed checkpoint pass") + message( + FATAL_ERROR + "cdt accepted a resume target below the completed pass:\n${rejected_log}") +endif() +file(GLOB rejected_artifacts LIST_DIRECTORIES false "${rejected_directory}/*") +if(rejected_artifacts) + list(JOIN rejected_artifacts "\n " rejected_artifact_list) + message( + FATAL_ERROR + "Rejected resume published output artifacts:\n ${rejected_artifact_list}") +endif() + +set(corrupt_checkpoint "${corrupt_directory}/checkpoint.off") +file(COPY_FILE "${pass_two_checkpoint}" "${corrupt_checkpoint}" ONLY_IF_DIFFERENT) +file(COPY_FILE "${pass_two_checkpoint}.meta" "${corrupt_checkpoint}.meta" ONLY_IF_DIFFERENT) +file(READ "${corrupt_checkpoint}.meta" corrupt_metadata) +string( + REGEX REPLACE + "random[.]transition_state=[^\r\n]+" + "random.transition_state=bad" + corrupt_metadata + "${corrupt_metadata}") +file(WRITE "${corrupt_checkpoint}.meta" "${corrupt_metadata}") +execute_process( + COMMAND "${CDT_EXECUTABLE}" --resume "${corrupt_checkpoint}" + WORKING_DIRECTORY "${corrupt_directory}" + RESULT_VARIABLE corrupt_result + OUTPUT_VARIABLE corrupt_output + ERROR_VARIABLE corrupt_error) +set(corrupt_log "${corrupt_output}\n${corrupt_error}") +if(corrupt_result EQUAL 0 OR NOT corrupt_log MATCHES "invalid PCG state") + message(FATAL_ERROR "cdt accepted corrupt resume state:\n${corrupt_log}") +endif() + +execute_process( + COMMAND "${CDT_EXECUTABLE}" --resume "${pass_two_checkpoint}" --seed 94 + WORKING_DIRECTORY "${resumed_directory}" + RESULT_VARIABLE conflict_result + OUTPUT_VARIABLE conflict_output + ERROR_VARIABLE conflict_error) +set(conflict_log "${conflict_output}\n${conflict_error}") +if(conflict_result EQUAL 0 OR NOT conflict_log MATCHES "--resume restores seed") + message(FATAL_ERROR "cdt accepted a conflicting resume seed:\n${conflict_log}") +endif() + +set(thread_limit_checkpoint "${thread_limit_directory}/checkpoint.off") +file(COPY_FILE "${pass_two_checkpoint}" "${thread_limit_checkpoint}" ONLY_IF_DIFFERENT) +file(COPY_FILE "${pass_two_checkpoint}.meta" "${thread_limit_checkpoint}.meta" + ONLY_IF_DIFFERENT) +file(READ "${thread_limit_checkpoint}.meta" thread_limit_metadata) +string( + REGEX REPLACE + "parallel[.]max_threads=[^\r\n]+" + "parallel.max_threads=18446744073709551615" + thread_limit_metadata + "${thread_limit_metadata}") +file(WRITE "${thread_limit_checkpoint}.meta" "${thread_limit_metadata}") +execute_process( + COMMAND "${CDT_EXECUTABLE}" --resume "${thread_limit_checkpoint}" + WORKING_DIRECTORY "${thread_limit_directory}" + RESULT_VARIABLE thread_limit_result + OUTPUT_VARIABLE thread_limit_output + ERROR_VARIABLE thread_limit_error) +set(thread_limit_log "${thread_limit_output}\n${thread_limit_error}") +if(thread_limit_result EQUAL 0 + OR NOT thread_limit_log MATCHES + "Saved thread count exceeds the supported range") + message( + FATAL_ERROR + "cdt accepted an unrepresentable saved thread count:\n${thread_limit_log}") +endif() + +file(GLOB temporary_files LIST_DIRECTORIES false "${normalized_test_directory}/*/*.tmp") +if(temporary_files) + list(JOIN temporary_files "\n " temporary_file_list) + message(FATAL_ERROR "Resume workflow left temporary files behind:\n ${temporary_file_list}") +endif() diff --git a/cmake/RunInitializeToCdtTest.cmake b/cmake/RunInitializeToCdtTest.cmake new file mode 100644 index 000000000..8d66e1dd7 --- /dev/null +++ b/cmake/RunInitializeToCdtTest.cmake @@ -0,0 +1,261 @@ +if(NOT DEFINED INITIALIZE_EXECUTABLE OR NOT EXISTS "${INITIALIZE_EXECUTABLE}") + message(FATAL_ERROR "INITIALIZE_EXECUTABLE must name the built initialize program") +endif() + +if(NOT DEFINED CDT_EXECUTABLE OR NOT EXISTS "${CDT_EXECUTABLE}") + message(FATAL_ERROR "CDT_EXECUTABLE must name the built cdt program") +endif() + +if(NOT DEFINED TEST_ROOT OR TEST_ROOT STREQUAL "") + message(FATAL_ERROR "TEST_ROOT must name the owning test root") +endif() + +if(NOT DEFINED TEST_DIRECTORY OR TEST_DIRECTORY STREQUAL "") + message(FATAL_ERROR "TEST_DIRECTORY must name a dedicated test directory") +endif() + +set(normalized_test_root "${TEST_ROOT}") +set(normalized_test_directory "${TEST_DIRECTORY}") +cmake_path(ABSOLUTE_PATH normalized_test_root NORMALIZE) +cmake_path(ABSOLUTE_PATH normalized_test_directory NORMALIZE) +cmake_path( + IS_PREFIX normalized_test_root "${normalized_test_directory}" NORMALIZE + test_directory_is_owned) +if(NOT test_directory_is_owned OR normalized_test_directory STREQUAL normalized_test_root) + message(FATAL_ERROR "TEST_DIRECTORY must be a strict descendant of TEST_ROOT") +endif() + +set(initialize_directory "${normalized_test_directory}/initialize") +set(cdt_directory "${normalized_test_directory}/cdt") +set(checkpoint_directory "${normalized_test_directory}/checkpoint") +set(unmanifested_directory "${normalized_test_directory}/unmanifested") +file(REMOVE_RECURSE "${normalized_test_directory}") +file(MAKE_DIRECTORY + "${initialize_directory}" + "${cdt_directory}" + "${checkpoint_directory}" + "${unmanifested_directory}") + +execute_process( + COMMAND "${INITIALIZE_EXECUTABLE}" -s -n64 -t3 -o --seed 92 + WORKING_DIRECTORY "${initialize_directory}" + RESULT_VARIABLE initialize_result + OUTPUT_VARIABLE initialize_output + ERROR_VARIABLE initialize_error) +if(NOT initialize_result EQUAL 0) + message( + FATAL_ERROR + "initialize failed:\n${initialize_output}\n${initialize_error}") +endif() + +file(GLOB initial_payloads LIST_DIRECTORIES false "${initialize_directory}/*.off") +file(GLOB initial_manifests LIST_DIRECTORIES false "${initialize_directory}/*.off.meta") +list(LENGTH initial_payloads initial_payload_count) +list(LENGTH initial_manifests initial_manifest_count) +if(NOT initial_payload_count EQUAL 1 OR NOT initial_manifest_count EQUAL 1) + message( + FATAL_ERROR + "initialize must publish exactly one OFF/metadata pair; found ${initial_payload_count} payloads and ${initial_manifest_count} manifests") +endif() +list(GET initial_payloads 0 initial_payload) +list(GET initial_manifests 0 initial_manifest) +if(NOT initial_manifest STREQUAL "${initial_payload}.meta") + message(FATAL_ERROR "initialize metadata is not paired with its payload") +endif() + +file(READ "${initial_manifest}" initial_metadata) +if(NOT initial_metadata MATCHES "(^|[\r\n])artifact=initial-triangulation([\r\n]|$)") + message(FATAL_ERROR "initialize did not publish an initial-triangulation artifact") +endif() +if(NOT initial_metadata MATCHES "(^|[\r\n])topology[.]fnv1a64=([0-9a-f]+)([\r\n]|$)") + message(FATAL_ERROR "initialize metadata is missing its topology fingerprint") +endif() +set(initial_topology_fingerprint "${CMAKE_MATCH_2}") +if(NOT initial_metadata MATCHES "(^|[\r\n])placement[.]fnv1a64=([0-9a-f]+)([\r\n]|$)") + message(FATAL_ERROR "initialize metadata is missing its placement fingerprint") +endif() +set(initial_placement_fingerprint "${CMAKE_MATCH_2}") + +execute_process( + COMMAND "${CDT_EXECUTABLE}" --input "${initial_payload}" -p1 --seed 93 + --no-output + WORKING_DIRECTORY "${cdt_directory}" + RESULT_VARIABLE missing_action_result + OUTPUT_VARIABLE missing_action_output + ERROR_VARIABLE missing_action_error) +set(missing_action_log "${missing_action_output}\n${missing_action_error}") +if(missing_action_result EQUAL 0 + OR NOT missing_action_log MATCHES "Alpha, K, and Lambda must be specified") + message( + FATAL_ERROR + "cdt accepted an initial artifact without action parameters:\n${missing_action_log}") +endif() + +set(unmanifested_payload "${unmanifested_directory}/initial.off") +file(COPY_FILE "${initial_payload}" "${unmanifested_payload}" ONLY_IF_DIFFERENT) +execute_process( + COMMAND + "${CDT_EXECUTABLE}" --input "${unmanifested_payload}" -a0.6 -k1.1 -l0.1 + -p1 --seed 93 --no-output + WORKING_DIRECTORY "${unmanifested_directory}" + RESULT_VARIABLE unmanifested_result + OUTPUT_VARIABLE unmanifested_output + ERROR_VARIABLE unmanifested_error) +set(unmanifested_log "${unmanifested_output}\n${unmanifested_error}") +if(unmanifested_result EQUAL 0 + OR NOT unmanifested_log MATCHES "requires a persistence metadata sidecar") + message( + FATAL_ERROR + "cdt accepted an unmanifested initial payload:\n${unmanifested_output}\n${unmanifested_error}") +endif() + +execute_process( + COMMAND + "${CDT_EXECUTABLE}" --input "${initial_payload}" -s -a0.6 -k1.1 -l0.1 + -p1 --seed 93 --no-output + WORKING_DIRECTORY "${cdt_directory}" + RESULT_VARIABLE conflict_result + OUTPUT_VARIABLE conflict_output + ERROR_VARIABLE conflict_error) +set(conflict_log "${conflict_output}\n${conflict_error}") +if(conflict_result EQUAL 0 + OR NOT conflict_log MATCHES "--input cannot be combined with") + message( + FATAL_ERROR + "cdt accepted conflicting input/construction options:\n${conflict_output}\n${conflict_error}") +endif() + +execute_process( + COMMAND + "${CDT_EXECUTABLE}" --input "${initial_payload}" -a0.6 -k1.1 -l0.1 + -p1 -c10 --seed 93 + WORKING_DIRECTORY "${cdt_directory}" + RESULT_VARIABLE cdt_result + OUTPUT_VARIABLE cdt_output + ERROR_VARIABLE cdt_error) +if(NOT cdt_result EQUAL 0) + message(FATAL_ERROR "cdt input run failed:\n${cdt_output}\n${cdt_error}") +endif() +if(NOT cdt_output MATCHES "Input initial triangulation:") + message(FATAL_ERROR "cdt did not report the loaded initial triangulation") +endif() +if(NOT cdt_output MATCHES "Input initialization seed: 92([\r\n]|$)") + message(FATAL_ERROR "cdt did not report the input initialization seed") +endif() +if(NOT cdt_output MATCHES "Effective random seed: 93([\r\n]|$)") + message(FATAL_ERROR "cdt did not report the new transition-run seed") +endif() + +file(GLOB final_payloads LIST_DIRECTORIES false "${cdt_directory}/*.off") +file(GLOB final_manifests LIST_DIRECTORIES false "${cdt_directory}/*.off.meta") +list(LENGTH final_payloads final_payload_count) +list(LENGTH final_manifests final_manifest_count) +if(NOT final_payload_count EQUAL 1 OR NOT final_manifest_count EQUAL 1) + message( + FATAL_ERROR + "cdt must publish exactly one evolved OFF/metadata pair; found ${final_payload_count} payloads and ${final_manifest_count} manifests") +endif() +list(GET final_payloads 0 final_payload) +list(GET final_manifests 0 final_manifest) +if(NOT final_manifest STREQUAL "${final_payload}.meta") + message(FATAL_ERROR "cdt metadata is not paired with its payload") +endif() + +file(READ "${final_manifest}" final_metadata) +foreach( + required + "artifact=final-triangulation" + "random.seed=93" + "input.artifact=initial-triangulation" + "input.random.seed=92" + "input.random.initialization_stream=0" + "input.placement.fnv1a64=${initial_placement_fingerprint}" + "input.topology.fnv1a64=${initial_topology_fingerprint}" + "transition_trace.fnv1a64=") + if(NOT final_metadata MATCHES "${required}") + message(FATAL_ERROR "Evolved metadata is missing '${required}':\n${final_metadata}") + endif() +endforeach() +if(NOT final_metadata MATCHES "transition_trace[.]count=([1-9][0-9]*)") + message(FATAL_ERROR "The evolved artifact did not record any CDT transitions") +endif() + +execute_process( + COMMAND + "${CDT_EXECUTABLE}" --input "${initial_payload}" -a0.6 -k1.1 -l0.1 + -p1 -c1 --seed 94 + WORKING_DIRECTORY "${checkpoint_directory}" + RESULT_VARIABLE checkpoint_result + OUTPUT_VARIABLE checkpoint_output + ERROR_VARIABLE checkpoint_error) +if(NOT checkpoint_result EQUAL 0) + message( + FATAL_ERROR + "cdt checkpoint run failed:\n${checkpoint_output}\n${checkpoint_error}") +endif() +file(GLOB checkpoint_payloads LIST_DIRECTORIES false + "${checkpoint_directory}/*-pass-1.off") +list(LENGTH checkpoint_payloads checkpoint_payload_count) +if(NOT checkpoint_payload_count EQUAL 1) + message( + FATAL_ERROR + "cdt must publish exactly one pass-1 checkpoint; found ${checkpoint_payload_count}") +endif() +list(GET checkpoint_payloads 0 checkpoint_payload) + +execute_process( + COMMAND + "${CDT_EXECUTABLE}" --input "${checkpoint_payload}" -a0.6 -k1.1 -l0.1 + -p1 --seed 95 --no-output + WORKING_DIRECTORY "${checkpoint_directory}" + RESULT_VARIABLE checkpoint_input_result + OUTPUT_VARIABLE checkpoint_input_output + ERROR_VARIABLE checkpoint_input_error) +set(checkpoint_input_log + "${checkpoint_input_output}\n${checkpoint_input_error}") +if(checkpoint_input_result EQUAL 0 + OR NOT checkpoint_input_log MATCHES "CDT input must be an initial-triangulation artifact") + message( + FATAL_ERROR + "cdt accepted a checkpoint artifact as a new initial state:\n${checkpoint_input_output}\n${checkpoint_input_error}") +endif() + +execute_process( + COMMAND + "${CDT_EXECUTABLE}" --input "${initial_payload}" --resume + "${checkpoint_payload}" -p1 --no-output + WORKING_DIRECTORY "${checkpoint_directory}" + RESULT_VARIABLE conflicting_mode_result + OUTPUT_VARIABLE conflicting_mode_output + ERROR_VARIABLE conflicting_mode_error) +set(conflicting_mode_log + "${conflicting_mode_output}\n${conflicting_mode_error}") +if(conflicting_mode_result EQUAL 0 + OR NOT conflicting_mode_log MATCHES + "--input and --resume are mutually exclusive") + message( + FATAL_ERROR + "cdt accepted both input modes at once:\n${conflicting_mode_log}") +endif() + +execute_process( + COMMAND + "${CDT_EXECUTABLE}" --input "${final_payload}" -a0.6 -k1.1 -l0.1 -p1 + --seed 94 --no-output + WORKING_DIRECTORY "${cdt_directory}" + RESULT_VARIABLE final_input_result + OUTPUT_VARIABLE final_input_output + ERROR_VARIABLE final_input_error) +set(final_input_log "${final_input_output}\n${final_input_error}") +if(final_input_result EQUAL 0 + OR NOT final_input_log MATCHES "CDT input must be an initial-triangulation artifact") + message( + FATAL_ERROR + "cdt accepted a final artifact as a new initial state:\n${final_input_output}\n${final_input_error}") +endif() + +file(GLOB temporary_files LIST_DIRECTORIES false "${normalized_test_directory}/*/*.tmp") +if(temporary_files) + list(JOIN temporary_files "\n " temporary_file_list) + message(FATAL_ERROR "The handoff left temporary files behind:\n ${temporary_file_list}") +endif() diff --git a/docs/api-boundary.md b/docs/api-boundary.md index 360bdb89d..7d06e38af 100644 --- a/docs/api-boundary.md +++ b/docs/api-boundary.md @@ -51,6 +51,19 @@ Tests that name `detail` are deliberate white-box tests for mutation failure-atomicity and malformed-handle rejection. Test access does not promote those declarations into the supported API. +The persistence surface distinguishes three read contracts. +`read_file(path)` remains the general triangulation reader, including +legacy payloads without manifests. `read_initial_triangulation(path)` is the +new-run boundary: it requires a complete manifested +`initial-triangulation`, verifies payload integrity and causal metadata, rejects +checkpoint, final, and coincident-coordinate artifacts, and returns both the +owning triangulation and its initialization provenance in +`Initial_triangulation_artifact`. It does not resume a stochastic stream. +`read_checkpoint(path)` is the exact-restart boundary: it accepts only a +manifested checkpoint with complete PCG state, transition accounting, distinct +vertex coordinates, and a matching producer toolchain, then returns the owning +triangulation and continuation metadata in `Checkpoint_artifact`. + The supported-header compile contract intentionally excludes `experimental`. The CGAL 6.2 audit confirmed that the periodic and d-dimensional prototypes are not production dependencies and do not meet the supported compile contract. diff --git a/docs/ergodic-moves.md b/docs/ergodic-moves.md index 6c165f48a..292e73401 100644 --- a/docs/ergodic-moves.md +++ b/docs/ergodic-moves.md @@ -58,6 +58,11 @@ has different handles; every applicable-move implementation therefore captures stable point values and re-resolves vertices, cells, or edges in the owning candidate immediately before mutation. +Before a checked edge flip, CDT++ also rebinds the CGAL edge descriptor to its +canonical finite incident cell and orders its endpoints by point value. This +makes `(3,2)`, `(6,2)`, and `(4,4)` execution independent of the arbitrary +descriptor owner and orientation recreated by a persistence round trip. + None of these moves is required to preserve the Euclidean empty-sphere (Delaunay) property of the representative coordinates. The scientific state is a valid causal combinatorial triangulation. `tds().is_valid()` establishes the @@ -72,9 +77,9 @@ comparison outside hot move paths. | --- | --- | --- | --- | | `(2,3)` | Equation (58): `(3,1) 1345 + (2,2) 2345 -> (3,1) 1234 + (2,2) 1235 + (2,2) 1245`, or its time reflection. The shared triangle `345` is timelike and the new edge `12` is timelike. | `(0, 0, +1, +2, 0, +1, 0, +1)` | A checked `Triangulation_3::flip(facet)` is attempted only from correctly labelled `(2,2)` cells whose neighbor is a correctly labelled `(3,1)` or `(1,3)` cell and whose opposite vertices lie on adjacent slices. CGAL additionally rejects infinite, nonflippable, or geometrically inverted cavities. | | `(3,2)` | The inverse of equation (58): two `(2,2)` cells and exactly one `(3,1)` or `(1,3)` cell meet at the timelike edge removed by the move. | `(0, 0, -1, -2, 0, -1, 0, -1)` | Before `Triangulation_3::flip(edge)`, CDT++ independently requires three finite incident cells spanning adjacent slices with the exact `2 x (2,2) + 1 x ((3,1) or (1,3))` causal composition. CGAL then rejects hull edges and enforces the total degree-three and geometric flippability contracts. Other degree-three timelike cavities can be combinatorially flippable but causally invalid. | -| `(2,6)` | Equation (56): `(1,3) 1345 + (3,1) 2345` share the spacelike triangle `345`. A new vertex `6` is inserted on the same slice and joined to all five old vertices, producing three tetrahedra above and three below. | `(+1, +3, +2, +8, +2, 0, +2, +4)` | `tds().insert_in_facet()` subdivides the common spacelike facet. The input cells and their metadata must be `(1,3)` and `(3,1)`, the shared vertices must have one time value, and the new star must contain six valid cells. The new point is the facet centroid and receives the facet time. | +| `(2,6)` | Equation (56): `(1,3) 1345 + (3,1) 2345` share the spacelike triangle `345`. A new vertex `6` is inserted on the same slice and joined to all five old vertices, producing three tetrahedra above and three below. | `(+1, +3, +2, +8, +2, 0, +2, +4)` | `tds().insert_in_facet()` subdivides the common spacelike facet. The input cells and their metadata must be `(1,3)` and `(3,1)`, the shared vertices must have one time value, and the new star must contain six valid cells. The new point is the facet centroid and receives the facet time; the move rejects an already occupied centroid before mutation so coordinate-valued locators remain unique. | | `(6,2)` | The inverse of equation (56): a degree-five vertex has six incident cells, exactly three `(3,1)` and three `(1,3)`, with no `(2,2)` cell. | `(-1, -3, -2, -8, -2, 0, -2, -4)` | On a private copy, a checked timelike edge flip reduces the candidate to degree four; `tds().remove_from_maximal_dimension_simplex()` then applies its documented degree-`dimension+1` removal. Exact finite incidence, causal types, metadata, output counts, and output cell types are checked before publication. | -| `(4,4)` | Equation (57): two `(1,3)` and two `(3,1)` tetrahedra form a diamond. The diagonal of the spatial quadrilateral is exchanged; the move is its own inverse. | `(0, 0, 0, 0, 0, 0, 0, 0)` | The pivot must be a spacelike edge with exactly four finite incident cells, two `(3,1)` and two `(1,3)`, all correctly labelled. A checked TDS facet flip creates the new diagonal, then a checked TDS edge flip removes the old one. The composition is performed on a private copy because the transient geometry can fail a `Triangulation_3` geometric flip even when the final abstract diamond is valid. | +| `(4,4)` | Equation (57): two `(1,3)` and two `(3,1)` tetrahedra form a diamond. The diagonal of the spatial quadrilateral is exchanged; the move is its own inverse. | `(0, 0, 0, 0, 0, 0, 0, 0)` | The pivot must be a spacelike edge with exactly four finite incident cells, two `(3,1)` and two `(1,3)`, all correctly labelled. A checked TDS facet flip creates the new diagonal, then a checked TDS edge flip removes the old one. Both descriptors use canonical incident-cell representatives, and edge endpoints use canonical point order, so equivalent reloaded TDS layouts reach the same checked mutation boundary. The composition is performed on a private copy because the transient geometry can fail a `Triangulation_3` geometric flip even when the final abstract diamond is valid. | ## Independent delta derivations @@ -115,8 +120,12 @@ helper. - canonical point/time/simplex representations independent of CGAL handle identity; - exact inverse round trips for `(2,3)/(3,2)`, `(2,6)/(6,2)`, and `(4,4)`; +- equivalent `(4,4)` cavities with different CGAL cell iteration orders and + reversed edge descriptors reaching the same canonical transition; - malformed-handle, stale-metadata, wrong-cavity, non-applicable, and empty-state - rejection checks with canonical failure-atomicity comparisons; and + rejection checks with canonical failure-atomicity comparisons; +- failure-atomic rejection when a `(2,6)` facet centroid already belongs to + another vertex; - valid construction, forbidden default construction, and stale-locator behavior for the applicable-move boundary, including structured failure classification; and diff --git a/docs/reproducibility.md b/docs/reproducibility.md index 667feb917..48ea80b8e 100644 --- a/docs/reproducibility.md +++ b/docs/reproducibility.md @@ -89,12 +89,17 @@ manifest. The manifest records: transitions; - the maximum requested Delaunay thread count; - completed passes and the transition-trace fingerprint; +- when a run starts from `--input`, the initial artifact role, source seed, + initialization stream, placement fingerprint, and topology fingerprint; - a canonical placement fingerprint derived from sorted finite vertices and their timeslices; - a canonical topology fingerprint derived from sorted vertices, causal metadata, and finite-cell incidence; - the CDT++ version, compiler, build configuration, standard library, - operating system, architecture, C++ standard, and CGAL version; + operating system, architecture, C++ standard, source revision, and CGAL + version; +- for a resumable checkpoint, the complete transition PCG state and cumulative + proposed, accepted, rejected, attempted, succeeded, and failed move counts; - the payload byte count and FNV-1a corruption checksum. The triangulation remains a CGAL-readable payload; provenance is in the sidecar @@ -132,16 +137,95 @@ manifest/payload mismatches fail with filesystem diagnostics. FNV-1a protects against accidental truncation or corruption; it does not authenticate files against deliberate modification. -Checkpoints are snapshots only. CDT++ does not currently expose a resume CLI, -and a checkpoint does not serialize mutable PCG engine state or enough runtime -state to continue the identical stream. The manifest explicitly records -`resume_supported=false`. The manifest also records -`fresh_topology_replay_supported=false` and -`transition_replay_requires_identical_start=true`. Starting a second CLI run -from the recorded seed and configuration replays the stochastic inputs, but it -does not override CGAL's non-unique cospherical tetrahedralization. Exact -transition replay is conditional on supplying an identical starting manifold; -it is not checkpoint resume. +### Initial-state handoff + +`initialize --output` publishes an `initial-triangulation` payload and +manifest. `cdt --input PATH` requires both files, verifies the payload checksum, +parses the complete CGAL stream and CDT++ causal trailer, reconciles the +manifest with the parsed state, and reconstructs the foliation and manifold +before attempting a transition. Checkpoint and final artifacts are rejected at +this boundary. Because prepared move locators are coordinate-valued, this +evolution boundary also rejects distinct TDS vertices with coincident +coordinates; generic `read_file` remains available for archival inspection of +such legacy or manually constructed states. + +The input supplies topology, dimension, requested construction counts, +foliation parameters, and the exact starting state. The new `cdt` invocation +supplies its own physical parameters, run cadence, output policy, thread limit, +and root seed. Its transition stream is derived from that new seed; it does not +continue mutable RNG state from `initialize`. Checkpoint and final metadata +carry `input.*` fields identifying the initial artifact seed and stream plus +its placement and topology fingerprints, so the handoff remains auditable +after evolution changes the state. + +The `.off` suffix is historical. The payload is CGAL's version-coupled +triangulation stream followed by CDT++'s causal-data trailer, not a standalone +Geomview mesh. The `.off.meta` file is part of the interchange contract because +it supplies artifact role, foliation, reproducibility, and integrity data that +plain geometry cannot represent. See the +[reference fixture package](../reference/README.md) for the downstream import +boundary. + +### Exact checkpoint continuation + +Simulation checkpoints written by this release are restart artifacts. At each +completed checkpoint pass, CDT++ publishes the triangulation and manifest with +individually atomic replacements. The manifest contains the global completed +pass, configured total-pass target, checkpoint cadence, physical parameters, +thread limit, complete mutable PCG state, ordered transition-trace state and +count, and all cumulative move counters. The manifest records +`resume_supported=true` only when this complete state is present. + +Resume the run with the neighboring pair intact: + +```console +just resume /path/to/checkpoint.off +``` + +By default, `cdt` executes the passes remaining before the saved total target. +`--passes TOTAL` may set a greater global target; it is not an additional-pass +count and cannot precede the checkpoint's completed pass. Pass numbering and +checkpoint cadence remain global, so a run interrupted after pass 500 does not +restart either at pass 1. `--no-output` remains available, but `--resume` +rejects replacement topology, construction, action, seed, thread, and +checkpoint-cadence options. + +Exact continuation is deliberately version-locked. Before returning any +resume state, the reader verifies payload integrity and causal metadata, parses +the complete PCG state, validates counter identities, and requires the current +CDT++ version, source revision, compiler identity and version, build +configuration, parallel-triangulation feature, operating system, architecture, +standard library, and CGAL version to match the producer. A checkpoint is +therefore suitable for restarting an interrupted Slurm/HPC job with the same +built program; it is not a portable interchange format or a promise that +another implementation uses the same random engine representation. Resume also +rejects coincident vertex coordinates, which cannot identify the unique +coordinate-valued move locators required by exact continuation. The `(2,6)` +move prevents current runs from creating that ambiguous state. + +The scientific tests exercise the guarantee at two levels. A transition-level +test restores a checkpoint and compares every subsequent move, outcome, +triangulation, transition trace, and counter update. The end-to-end +`checkpoint-resume` CTest compares an uninterrupted `N`-pass run with the same +run split into `K` passes plus a persisted checkpoint and `N-K` resumed passes. +It requires equal final canonical placement and topology fingerprints, +transition trace and count, and all six cumulative counter groups. The test +also verifies target extension with global checkpoint numbering, rejects a +target below the completed pass, and rejects corrupt PCG state and conflicting +resume options. + +Older checkpoint manifests that record `resume_supported=false` remain +readable as validated snapshots through the general persistence API, but +`cdt --resume` rejects them because they do not contain enough state to prove +the identical continuation. `cdt --input` continues to accept only an +`initial-triangulation` artifact and intentionally starts a new transition +stream from a new seed. + +Every manifest also records `fresh_topology_replay_supported=false` and +`transition_replay_requires_identical_start=true`. Reconstructing a fresh +manifold from only the recorded seed and configuration still does not override +CGAL's non-unique cospherical tetrahedralization; checkpoint continuation +avoids that ambiguity by restoring the persisted manifold itself. The tracked OFF fixture used by the archival renderer is therefore the canonical rendering input. Its recorded seed and producer command reproduce stochastic inputs but are provenance, not a promise that fresh CGAL construction will diff --git a/include/Ergodic_moves_3.hpp b/include/Ergodic_moves_3.hpp index 02c99bbef..cf611a7c3 100644 --- a/include/Ergodic_moves_3.hpp +++ b/include/Ergodic_moves_3.hpp @@ -347,6 +347,30 @@ namespace cdt::ergodic_moves }); } + /// @brief Rebind an edge to its canonical finite incident cell. + /// @details CGAL permits any incident cell to own an edge descriptor, but + /// checked flips can observe that representative. Choosing by canonical + /// cell points makes equivalent in-memory and reloaded triangulations use + /// the same mutation boundary. + [[nodiscard]] inline auto canonical_edge_descriptor( + Delaunay const& triangulation, Edge_handle const& edge) + -> std::optional + { + if (!is_well_formed_edge(edge)) { return std::nullopt; } + auto incident_cells = finite_incident_cells(triangulation, edge); + if (!incident_cells || incident_cells->empty()) { return std::nullopt; } + canonicalize(*incident_cells); + + auto first = edge.first->vertex(edge.second); + auto second = edge.first->vertex(edge.third); + if (point_less(second->point(), first->point())) + { + std::swap(first, second); + } + auto const cell = incident_cells->front(); + return Edge_handle{cell, cell->index(first), cell->index(second)}; + } + [[nodiscard]] inline auto vertex_precedes(Vertex_handle const& left, Vertex_handle const& right) -> bool @@ -783,7 +807,10 @@ namespace cdt::ergodic_moves using enum move_tracker::MoveType; auto const edge = resolve_edge(triangulation, move.m_edge); if (!edge) { return move_error(MoveFailure::STALE_CANDIDATE, THREE_TWO); } - if (!triangulation.flip(edge->first, edge->second, edge->third)) + auto const canonical_edge = canonical_edge_descriptor(triangulation, *edge); + if (!canonical_edge || + !triangulation.flip(canonical_edge->first, canonical_edge->second, + canonical_edge->third)) { return move_error(MoveFailure::EXECUTION_FAILURE, THREE_TWO); } @@ -997,6 +1024,17 @@ namespace cdt::ergodic_moves auto const v_1 = (*bottom)->vertex(first); auto const v_2 = (*bottom)->vertex(second); auto const v_3 = (*bottom)->vertex(third); + std::array face_points{v_1->point(), v_2->point(), v_3->point()}; + std::ranges::sort(face_points, point_less); + auto const center_point = + CGAL::centroid(face_points[0], face_points[1], face_points[2]); + if (std::ranges::any_of(triangulation.finite_vertex_handles(), + [&](auto const& vertex) { + return vertex->point() == center_point; + })) + { + return move_error(MoveFailure::INVARIANT_VIOLATION, TWO_SIX); + } auto const center = triangulation.tds().insert_in_facet(*bottom, common_face_index); @@ -1012,10 +1050,7 @@ namespace cdt::ergodic_moves return move_error(MoveFailure::INVARIANT_VIOLATION, TWO_SIX); } - std::array face_points{v_1->point(), v_2->point(), v_3->point()}; - std::ranges::sort(face_points, point_less); - center->set_point( - CGAL::centroid(face_points[0], face_points[1], face_points[2])); + center->set_point(center_point); center->info() = v_1->info(); if (!post_mutation_validator(static_cast(triangulation)) || @@ -1269,7 +1304,9 @@ namespace cdt::ergodic_moves auto flipped = false; for (auto const& edge : incident_edges) { - if (is_timelike(edge) && tds.flip(edge)) + auto const canonical_edge = + canonical_edge_descriptor(triangulation, edge); + if (is_timelike(edge) && canonical_edge && tds.flip(*canonical_edge)) { flipped = true; break; @@ -1607,17 +1644,18 @@ namespace cdt::ergodic_moves { using enum move_tracker::MoveType; Delaunay triangulation{source_triangulation}; - auto const edge = resolve_edge(triangulation, move.edge_points()); + auto const edge = resolve_edge(triangulation, move.edge_points()); + auto const pivot_from_1 = + resolve_vertex(triangulation, move.edge_points()[0]); + auto const pivot_from_2 = + resolve_vertex(triangulation, move.edge_points()[1]); auto const top = resolve_vertex(triangulation, move.top_point()); auto const bottom = resolve_vertex(triangulation, move.bottom_point()); - if (!edge || !top || !bottom) + if (!edge || !pivot_from_1 || !pivot_from_2 || !top || !bottom) { return move_error(MoveFailure::STALE_CANDIDATE, FOUR_FOUR); } - auto const pivot_from_1 = edge->first->vertex(edge->second); - auto const pivot_from_2 = edge->first->vertex(edge->third); - // A 3D 4-to-4 bistellar move is the composition of CGAL's checked TDS // 2-to-3 facet flip and checked 3-to-2 edge flip. The TDS operations are // intentional: either geometrically checked Triangulation_3 facet flip @@ -1629,28 +1667,46 @@ namespace cdt::ergodic_moves int pivot_from_1_index{}; int pivot_from_2_index{}; int boundary_index{}; - if (!triangulation.is_facet(pivot_from_1, pivot_from_2, *bottom, + if (!triangulation.is_facet(*pivot_from_1, *pivot_from_2, *bottom, boundary_facet_cell, pivot_from_1_index, pivot_from_2_index, boundary_index)) { return move_error(MoveFailure::STALE_CANDIDATE, FOUR_FOUR); } constexpr auto cell_index_sum = 0 + 1 + 2 + 3; - auto const boundary_facet_index = cell_index_sum - pivot_from_1_index - + auto boundary_facet_index = cell_index_sum - pivot_from_1_index - pivot_from_2_index - boundary_index; + auto const other_boundary_cell = + boundary_facet_cell->neighbor(boundary_facet_index); + if (other_boundary_cell == nullptr || + triangulation.is_infinite(boundary_facet_cell) || + triangulation.is_infinite(other_boundary_cell)) + { + return move_error(MoveFailure::EXECUTION_FAILURE, FOUR_FOUR); + } + if (cell_precedes(other_boundary_cell, boundary_facet_cell)) + { + boundary_facet_index = other_boundary_cell->index(boundary_facet_cell); + boundary_facet_cell = other_boundary_cell; + } if (!triangulation.tds().flip( Delaunay::Facet{boundary_facet_cell, boundary_facet_index})) { return move_error(MoveFailure::EXECUTION_FAILURE, FOUR_FOUR); } - Cell_handle old_edge_cell = nullptr; - int old_edge_first_index{}; - int old_edge_second_index{}; - if (!triangulation.is_edge(pivot_from_1, pivot_from_2, old_edge_cell, - old_edge_first_index, old_edge_second_index) || - !triangulation.tds().flip(Delaunay::Edge{ - old_edge_cell, old_edge_first_index, old_edge_second_index})) + auto const old_edge = resolve_edge(triangulation, move.edge_points()); + if (!old_edge) + { + return move_error(MoveFailure::EXECUTION_FAILURE, FOUR_FOUR); + } + auto const canonical_old_edge = + canonical_edge_descriptor(triangulation, *old_edge); + if (!canonical_old_edge) + { + return move_error(MoveFailure::EXECUTION_FAILURE, FOUR_FOUR); + } + if (!triangulation.tds().flip(*canonical_old_edge)) { return move_error(MoveFailure::EXECUTION_FAILURE, FOUR_FOUR); } diff --git a/include/Metropolis.hpp b/include/Metropolis.hpp index 07ae24ec1..a41c02f0a 100644 --- a/include/Metropolis.hpp +++ b/include/Metropolis.hpp @@ -102,7 +102,54 @@ namespace cdt /// @brief Checkpoint events from the latest completed invocation Int_precision m_checkpoint_events{}; - static void record_transition( + /// @brief Passes completed before this invocation, including resumed work. + Int_precision m_completed_passes{}; + + /// @brief Whether the next invocation must begin from restored accounting. + bool m_resume_pending{}; + + [[nodiscard]] static auto to_counts(Counter const& counter) + -> utilities::Move_statistics::Counts + { + utilities::Move_statistics::Counts counts{}; + for (std::size_t index = 0; index < counts.size(); ++index) + { + counts[index] = counter[static_cast(index)]; + } + return counts; + } + + [[nodiscard]] static auto from_counts( + utilities::Move_statistics::Counts const& counts) -> Counter + { + Counter counter; + for (std::size_t index = 0; index < counts.size(); ++index) + { + counter[static_cast(index)] = counts[index]; + } + return counter; + } + + void restore_statistics(utilities::Reproducibility_metadata const& metadata) + { + if (!metadata.move_statistics || !metadata.transition_trace || + !metadata.transition_count) + { + throw std::invalid_argument( + "Checkpoint resume requires cumulative transition statistics."); + } + auto const& saved = *metadata.move_statistics; + m_command_results.attempted = from_counts(saved.attempted); + m_command_results.succeeded = from_counts(saved.succeeded); + m_command_results.failed = from_counts(saved.failed); + m_run_statistics.proposed = from_counts(saved.proposed); + m_run_statistics.accepted = from_counts(saved.accepted); + m_run_statistics.rejected = from_counts(saved.rejected); + m_run_statistics.transition_trace = *metadata.transition_trace; + m_run_statistics.transition_count = *metadata.transition_count; + } + + static void record_transition( RunStatistics& statistics, move_tracker::MoveType const move, ergodic_moves::MoveOutcome const outcome) noexcept { @@ -157,36 +204,74 @@ namespace cdt /// @param random Transition random-number stream owned by this strategy. /// @param reproducibility Optional initialization and requested-state /// provenance to merge with the effective run configuration. + /// @param completed_passes Global passes completed by a restored + /// checkpoint. /// @throws std::invalid_argument If a coupling is non-finite or either - /// cadence value is nonpositive. + /// cadence value is nonpositive, or restored state is incomplete, does not + /// match the global pass target, or does not match the supplied generator. /// @throws std::domain_error If `alpha` is not greater than 1/2. [[maybe_unused]] MoveStrategy( long double const alpha, long double const k, long double const lambda, Int_precision const passes, Int_precision const checkpoint, bool const write_files, cdt::Random random, std::optional reproducibility = - std::nullopt) + std::nullopt, + Int_precision const completed_passes = 0) : m_parameters{s3_action::make_physical_parameters(alpha, k, lambda)} , m_cadence{detail::parse_move_run_cadence(passes, checkpoint, "Metropolis")} , m_write_files{write_files} , m_generator{std::move(random)} - , m_reproducibility{ - reproducibility.value_or(utilities::Reproducibility_metadata{ + , m_reproducibility{reproducibility.value_or( + utilities::Reproducibility_metadata{ .seed = m_generator.seed(), .alpha = alpha, .k = k, .lambda = lambda, .configured_passes = passes, .checkpoint_interval = checkpoint})} + , m_completed_passes{completed_passes} { - m_reproducibility.seed = m_generator.seed(); - m_reproducibility.transition_stream = m_generator.stream(); - m_reproducibility.alpha = m_parameters.alpha(); - m_reproducibility.k = m_parameters.k(); - m_reproducibility.lambda = m_parameters.lambda(); - m_reproducibility.configured_passes = m_cadence.passes(); + if (m_completed_passes < 0) + { + throw std::invalid_argument("Completed passes cannot be negative."); + } + m_reproducibility.alpha = m_parameters.alpha(); + m_reproducibility.k = m_parameters.k(); + m_reproducibility.lambda = m_parameters.lambda(); + auto const total_passes = static_cast(m_completed_passes) + + static_cast(m_cadence.passes()); + if (!std::in_range(total_passes)) + { + throw std::out_of_range( + "Total pass count exceeds the supported range."); + } + if (m_completed_passes > 0 || m_reproducibility.transition_random_state) + { + if (!m_reproducibility.configured_passes || + *m_reproducibility.configured_passes != total_passes || + !m_reproducibility.transition_random_state) + { + throw std::invalid_argument( + "Checkpoint resume state does not match its pass range."); + } + if (m_reproducibility.seed != m_generator.seed() || + m_reproducibility.transition_stream != m_generator.stream() || + *m_reproducibility.transition_random_state != + m_generator.serialized_state()) + { + throw std::invalid_argument( + "Checkpoint resume generator does not match its recorded random state."); + } + restore_statistics(m_reproducibility); + m_resume_pending = true; + } + m_reproducibility.seed = m_generator.seed(); + m_reproducibility.transition_stream = m_generator.stream(); + m_reproducibility.configured_passes = + static_cast(total_passes); m_reproducibility.checkpoint_interval = m_cadence.checkpoint(); + m_reproducibility.transition_random_state.reset(); #ifndef NDEBUG spdlog::debug("{} called.\n", CDT_PRETTY_FUNCTION); #endif @@ -268,7 +353,7 @@ namespace cdt -> utilities::Reproducibility_metadata { return make_reproducibility_metadata(manifold, artifact, completed_passes, - m_run_statistics); + m_command_results, m_run_statistics); } /// @returns The container of trial moves @@ -453,8 +538,9 @@ namespace cdt [[nodiscard]] auto make_reproducibility_metadata( ManifoldType const& manifold, utilities::ArtifactKind const artifact, - Int_precision const completed_passes, - RunStatistics const& statistics) const + Int_precision const completed_passes, + CommandResults const& command_results, + RunStatistics const& statistics) const -> utilities::Reproducibility_metadata { auto metadata = m_reproducibility; @@ -462,6 +548,23 @@ namespace cdt metadata.completed_passes = completed_passes; metadata.transition_trace = statistics.transition_trace; metadata.transition_count = statistics.transition_count; + metadata.move_statistics = utilities::Move_statistics{ + .proposed = to_counts(statistics.proposed), + .accepted = to_counts(statistics.accepted), + .rejected = to_counts(statistics.rejected), + .attempted = to_counts(command_results.attempted), + .succeeded = to_counts(command_results.succeeded), + .failed = to_counts(command_results.failed)}; + if (artifact == utilities::ArtifactKind::CHECKPOINT && + metadata.max_threads && *metadata.max_threads > 0) + { + metadata.transition_random_state = + std::make_shared(m_generator.serialized_state()); + } + else + { + metadata.transition_random_state.reset(); + } utilities::update_reproducibility_state(metadata, manifold); if (metadata.desired_simplices == 0) { @@ -681,10 +784,16 @@ namespace cdt spdlog::debug("{} called.\n", CDT_PRETTY_FUNCTION); #endif - auto initial_statistics = RunStatistics{}; + auto initial_statistics = + m_resume_pending ? m_run_statistics : RunStatistics{}; initial_statistics.geometry = t_manifold.geometry(); - auto result = detail::execute_move_run( - t_manifold, std::move(initial_statistics), m_cadence, + auto initial_command_results = + m_resume_pending ? m_command_results : CommandResults{}; + auto const initial_completed_passes = + m_resume_pending ? m_completed_passes : Int_precision{}; + auto result = detail::execute_move_run( + t_manifold, std::move(initial_command_results), + std::move(initial_statistics), initial_completed_passes, m_cadence, detail::MoveRunIdentity{.algorithm = "Metropolis-Hastings", .seed = seed(), .stream = stream()}, @@ -698,18 +807,21 @@ namespace cdt RunStatistics const& statistics) { print_results(command_results, statistics); }, - [this](ManifoldType const& current, CommandResults const&, - RunStatistics const& statistics, - Int_precision const pass_number) { + [this](ManifoldType const& current, + CommandResults const& command_results, + RunStatistics const& statistics, + Int_precision const pass_number) { utilities::write_file( current, make_reproducibility_metadata( current, utilities::ArtifactKind::CHECKPOINT, - pass_number, statistics)); + pass_number, command_results, statistics)); }); m_command_results = std::move(result.command_results); m_run_statistics = std::move(result.strategy_state); m_checkpoint_events = result.checkpoint_events; + m_completed_passes = initial_completed_passes + m_cadence.passes(); + m_resume_pending = false; return std::move(result.manifold); } diff --git a/include/Move_always.hpp b/include/Move_always.hpp index 95ceee1ae..693796d0d 100644 --- a/include/Move_always.hpp +++ b/include/Move_always.hpp @@ -184,7 +184,7 @@ namespace cdt spdlog::debug("{} called.\n", CDT_PRETTY_FUNCTION); #endif auto result = detail::execute_move_run( - t_manifold, std::monostate{}, m_cadence, + t_manifold, CommandResults{}, std::monostate{}, 0, m_cadence, detail::MoveRunIdentity{ .algorithm = "Move Always", .seed = seed(), .stream = stream()}, m_write_files, diff --git a/include/Move_run.hpp b/include/Move_run.hpp index d24b4ef69..8c67e158b 100644 --- a/include/Move_run.hpp +++ b/include/Move_run.hpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -175,17 +176,24 @@ namespace cdt /// caller, so a reusable strategy never exposes partially reset counters. template - [[nodiscard]] auto execute_move_run(ManifoldType initial, - StrategyState initial_strategy_state, - MoveRunCadence const cadence, - MoveRunIdentity const identity, - bool const writes_files, - ExecutePass execute_pass, Report report, - Checkpoint checkpoint) + [[nodiscard]] auto execute_move_run( + ManifoldType initial, + MoveCommandResults initial_command_results, + StrategyState initial_strategy_state, + Int_precision const completed_passes, MoveRunCadence const cadence, + MoveRunIdentity const identity, bool const writes_files, + ExecutePass execute_pass, Report report, Checkpoint checkpoint) -> MoveRunResult { + if (completed_passes < 0 || + completed_passes > + std::numeric_limits::max() - cadence.passes()) + { + throw std::invalid_argument( + "Completed and configured passes exceed the supported range."); + } auto current = std::move(initial); - auto command_totals = MoveCommandResults{}; + auto command_totals = std::move(initial_command_results); auto strategy_state = std::move(initial_strategy_state); auto checkpoint_events = Int_precision{}; @@ -198,7 +206,7 @@ namespace cdt for (auto pass_index = Int_precision{}; pass_index < cadence.passes(); ++pass_index) { - auto const pass_number = pass_index + 1; + auto const pass_number = completed_passes + pass_index + 1; fmt::print("=== Pass {} ===\n", pass_number); auto const attempts = current.N3(); auto pass = std::invoke(execute_pass, std::move(current), diff --git a/include/Random.hpp b/include/Random.hpp index 041856713..1cb63b07b 100644 --- a/include/Random.hpp +++ b/include/Random.hpp @@ -12,9 +12,33 @@ #include #include +#include #include +#include #include #include +#include +#include +#include +#include + +#if !defined(__SIZEOF_INT128__) || (defined(PCG_FORCE_EMULATED_128BIT_MATH) && \ + PCG_FORCE_EMULATED_128BIT_MATH) +#include "pcg_uint128.hpp" + +/// @cond PCG_COMPATIBILITY +namespace pcg_extras +{ + // PCG's specific_stream::set_stream uses an int literal with its emulated + // 128-bit value. Supply the heterogeneous overload that template deduction + // cannot obtain through uint_x4's converting constructor. + template + [[nodiscard]] auto operator|(uint_x4 const& value, + int const bits) -> uint_x4 + { return value | uint_x4{bits}; } +} // namespace pcg_extras +/// @endcond +#endif #include "pcg_random.hpp" @@ -169,6 +193,48 @@ namespace cdt /// @return A new engine at the beginning of the selected sequence. [[nodiscard]] auto split(RandomStream const stream) const -> Random { return Random{m_seed, stream}; } + + /// @brief Serialize the complete mutable PCG state for exact continuation. + /// @return Locale-independent PCG engine state. + [[nodiscard]] auto serialized_state() const -> std::string + { + std::ostringstream output; + output.imbue(std::locale::classic()); + output << m_engine; + if (!output) + { + throw std::runtime_error("Could not serialize PCG state."); + } + return output.str(); + } + + /// @brief Restore an exact PCG continuation point. + /// @param seed Recorded root seed. + /// @param stream Recorded stream selector. + /// @param state Complete state produced by serialized_state(). + /// @return A generator whose next draw is the saved generator's next draw. + /// @throws std::invalid_argument if the state is malformed, contains + /// trailing data, or selects a different PCG stream. + [[nodiscard]] static auto from_serialized_state( + RandomSeed const seed, RandomStream const stream, + std::string_view const state) -> Random + { + auto restored = Random{seed, stream}; + auto const expected_stream = restored.m_engine.stream(); + std::istringstream input{std::string{state}}; + input.imbue(std::locale::classic()); + input >> restored.m_engine; + if (!input || restored.m_engine.stream() != expected_stream) + { + throw std::invalid_argument("Malformed or mismatched PCG state."); + } + input >> std::ws; + if (!input.eof()) + { + throw std::invalid_argument("PCG state contains trailing data."); + } + return restored; + } }; static_assert(std::uniform_random_bit_generator); diff --git a/include/Utilities.hpp b/include/Utilities.hpp index ddd6d3aaf..dd132820a 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -60,6 +61,7 @@ #include // Global project settings +#include "Move_tracker.hpp" #include "Random.hpp" #include "Settings.hpp" #include "Version.hpp" @@ -100,12 +102,34 @@ namespace cdt::utilities FINAL_TRIANGULATION ///< Final state after the configured move run. }; + /// @brief Cumulative counters needed to preserve observable run state. + struct Move_statistics + { + /// @brief Per-move counts in stable MoveType index order. + using Counts = std::array; + + Counts proposed{}; ///< Raw proposals by move kind. + Counts accepted{}; ///< Accepted proposals by move kind. + Counts rejected{}; ///< Explicit self-transitions by move kind. + Counts attempted{}; ///< Candidate constructions by move kind. + Counts succeeded{}; ///< Successful candidate constructions by move kind. + Counts failed{}; ///< Failed candidate constructions by move kind. + + /// @returns Whether every cumulative counter is equal. + [[nodiscard]] auto operator==(Move_statistics const&) const noexcept + -> bool = default; + }; + /// @brief Provenance recorded next to every stochastic triangulation. - /// @details Checkpoints are deliberately snapshots rather than resumable - /// simulation states: the payload does not serialize mutable RNG state. - /// Payload-derived counts, time bounds, and fingerprints are reconciled - /// with the serialized triangulation before publication. Callers remain - /// responsible for supplying truthful run configuration and RNG provenance. + /// @details A resumable checkpoint couples the triangulation payload to the + /// complete mutable transition-engine state, cumulative transition trace, + /// and move statistics. Older checkpoints without that state remain readable + /// snapshots but cannot be resumed. Payload-derived counts, time bounds, and + /// fingerprints are reconciled with the serialized triangulation before + /// publication. Callers remain responsible for supplying truthful run + /// configuration and RNG provenance. + /// The five `input_*` fields form one optional, all-or-none description of + /// the initial artifact used to start a new transition run. struct Reproducibility_metadata { ArtifactKind artifact{ @@ -137,8 +161,19 @@ namespace cdt::utilities std::optional max_threads; ///< Configured concurrency. std::optional transition_trace; ///< Ordered trace hash. std::optional transition_count; ///< Hashed transitions. - std::optional placement_fingerprint; ///< Coordinate hash. - std::optional topology_fingerprint; ///< Incidence hash. + std::shared_ptr + transition_random_state; ///< Exact PCG state. + std::optional move_statistics; ///< Cumulative counters. + std::optional placement_fingerprint; ///< Coordinate hash. + std::optional topology_fingerprint; ///< Incidence hash. + std::optional input_artifact; ///< Starting artifact role. + std::optional input_seed; ///< Starting artifact seed. + std::optional + input_initialization_stream; ///< Starting initialization stream. + std::optional + input_placement_fingerprint; ///< Starting coordinate hash. + std::optional + input_topology_fingerprint; ///< Starting incidence hash. }; /// @param payload Triangulation payload path. @@ -155,6 +190,12 @@ namespace cdt::utilities { inline constexpr std::string_view CAUSAL_INFO_HEADER{ "cdt-plusplus-causal-info-v2"}; +#if defined(CDT_ENABLE_PARALLEL_TRIANGULATION) && \ + CDT_ENABLE_PARALLEL_TRIANGULATION + inline constexpr bool PARALLEL_TRIANGULATION_ENABLED{true}; +#else + inline constexpr bool PARALLEL_TRIANGULATION_ENABLED{false}; +#endif struct Payload_integrity { @@ -456,6 +497,20 @@ namespace cdt::utilities return false; } + template + void require_distinct_evolution_coordinates( + TriangulationType const& triangulation, + std::filesystem::path const& payload, + std::filesystem::path const& sidecar, std::string_view const operation) + { + if (has_coincident_vertices(triangulation)) + { + throw std::filesystem::filesystem_error( + fmt::format("{} requires distinct vertex coordinates", operation), + payload, sidecar, std::make_error_code(std::errc::not_supported)); + } + } + template [[nodiscard]] auto incidence_topology_records( TriangulationType const& triangulation) -> std::vector @@ -552,8 +607,9 @@ namespace cdt::utilities if constexpr (HAS_CAUSAL_INFO) { // CGAL writes and recreates vertices and cells in container order. - // Persist those payload indices because distinct TDS vertices may be - // geometrically coincident after topological moves. + // Persist those payload indices so generic persistence can retain + // legacy or manually constructed geometrically coincident TDS state. + // CDT evolution boundaries reject that ambiguous locator state. std::vector vertices; vertices.reserve( static_cast(triangulation.number_of_vertices())); @@ -590,18 +646,34 @@ namespace cdt::utilities Reproducibility_metadata const& metadata, Payload_integrity const payload) -> std::string { + auto const resume_supported = + metadata.artifact == ArtifactKind::CHECKPOINT && + static_cast(metadata.transition_random_state); + if (metadata.transition_random_state && !metadata.move_statistics) + { + throw std::invalid_argument( + "Resumable checkpoint metadata requires cumulative move statistics."); + } + if (metadata.transition_random_state && + metadata.artifact != ArtifactKind::CHECKPOINT) + { + throw std::invalid_argument( + "Only checkpoint artifacts may contain resumable PCG state."); + } auto text = fmt::format( "cdt-plusplus-metadata-v1\n" "payload.size={}\n" "payload.fnv1a64={:016x}\n" "artifact={}\n" - "resume_supported=false\n" + "resume_supported={}\n" "fresh_topology_replay_supported=false\n" "transition_replay_requires_identical_start=true\n" "cdt.version={}\n" + "source.revision={}\n" "build.compiler_id={}\n" "build.compiler_version={}\n" "build.configuration={}\n" + "build.parallel_triangulation={}\n" "build.system={}\n" "build.processor={}\n" "build.cxx_standard=23\n" @@ -624,11 +696,13 @@ namespace cdt::utilities "initial_radius={}\n" "foliation_spacing={}\n", payload.size, payload.digest, artifact_name(metadata.artifact), - cdt::VERSION, cdt::BUILD_COMPILER_ID, cdt::BUILD_COMPILER_VERSION, - cdt::BUILD_CONFIGURATION, cdt::BUILD_SYSTEM_NAME, - cdt::BUILD_SYSTEM_PROCESSOR, standard_library_name(), - CGAL_VERSION_STR, metadata.seed, metadata.initialization_stream, - metadata.transition_stream, + resume_supported ? "true" : "false", cdt::VERSION, + cdt::SOURCE_REVISION, cdt::BUILD_COMPILER_ID, + cdt::BUILD_COMPILER_VERSION, cdt::BUILD_CONFIGURATION, + PARALLEL_TRIANGULATION_ENABLED ? "true" : "false", + cdt::BUILD_SYSTEM_NAME, cdt::BUILD_SYSTEM_PROCESSOR, + standard_library_name(), CGAL_VERSION_STR, metadata.seed, + metadata.initialization_stream, metadata.transition_stream, metadata.topology == Topology::SPHERICAL ? "spherical" : "toroidal", metadata.dimension, metadata.desired_simplices, metadata.desired_timeslices, metadata.actual_vertices, @@ -649,12 +723,36 @@ namespace cdt::utilities append_optional("checkpoint_interval", metadata.checkpoint_interval); append_optional("completed_passes", metadata.completed_passes); append_optional("parallel.max_threads", metadata.max_threads); + if (metadata.transition_random_state) + { + text += fmt::format("random.transition_state={}\n", + *metadata.transition_random_state); + } if (metadata.transition_trace) { text += fmt::format("transition_trace.fnv1a64={:016x}\n", *metadata.transition_trace); } append_optional("transition_trace.count", metadata.transition_count); + if (metadata.move_statistics) + { + auto const append_counts = [&text]( + std::string_view const name, + Move_statistics::Counts const& values) { + text += fmt::format("{}={}", name, values.front()); + for (std::size_t index = 1; index < values.size(); ++index) + { + text += fmt::format(",{}", values[index]); + } + text += '\n'; + }; + append_counts("moves.proposed", metadata.move_statistics->proposed); + append_counts("moves.accepted", metadata.move_statistics->accepted); + append_counts("moves.rejected", metadata.move_statistics->rejected); + append_counts("moves.attempted", metadata.move_statistics->attempted); + append_counts("moves.succeeded", metadata.move_statistics->succeeded); + append_counts("moves.failed", metadata.move_statistics->failed); + } if (metadata.placement_fingerprint) { text += fmt::format("placement.fnv1a64={:016x}\n", @@ -665,6 +763,36 @@ namespace cdt::utilities text += fmt::format("topology.fnv1a64={:016x}\n", *metadata.topology_fingerprint); } + auto const input_field_count = + static_cast(metadata.input_artifact.has_value()) + + static_cast(metadata.input_seed.has_value()) + + static_cast(metadata.input_initialization_stream.has_value()) + + static_cast(metadata.input_placement_fingerprint.has_value()) + + static_cast(metadata.input_topology_fingerprint.has_value()); + if (input_field_count != 0 && input_field_count != 5) + { + throw std::invalid_argument( + "Input provenance must contain all five starting-artifact fields."); + } + if (input_field_count == 5) + { + if (*metadata.input_artifact != ArtifactKind::INITIAL_TRIANGULATION) + { + throw std::invalid_argument( + "Input provenance must identify an initial-triangulation " + "artifact."); + } + text += fmt::format( + "input.artifact={}\n" + "input.random.seed={}\n" + "input.random.initialization_stream={}\n" + "input.placement.fnv1a64={:016x}\n" + "input.topology.fnv1a64={:016x}\n", + artifact_name(*metadata.input_artifact), *metadata.input_seed, + *metadata.input_initialization_stream, + *metadata.input_placement_fingerprint, + *metadata.input_topology_fingerprint); + } return text; } @@ -717,6 +845,39 @@ namespace cdt::utilities return value; } + [[nodiscard]] inline auto parse_move_counts( + std::string_view text, std::filesystem::path const& path) + -> Move_statistics::Counts + { + Move_statistics::Counts counts{}; + for (std::size_t index = 0; index < counts.size(); ++index) + { + auto const separator = text.find(','); + auto const is_last = index + 1 == counts.size(); + if (text.empty() || (is_last && separator != std::string_view::npos) || + (!is_last && separator == std::string_view::npos)) + { + throw std::filesystem::filesystem_error( + "Persistence metadata has the wrong number of move counts", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto const token = is_last ? text : text.substr(0, separator); + counts[index] = parse_metadata_integer(token, path); + if (counts[index] < 0) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains a negative move count", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + if (is_last) { text = {}; } + else + { + text.remove_prefix(separator + 1); + } + } + return counts; + } + template [[nodiscard]] auto parse_metadata_floating( std::string_view const text, std::filesystem::path const& path) -> Float @@ -884,22 +1045,43 @@ namespace cdt::utilities struct Parsed_persistence_metadata { - Payload_integrity payload; - ArtifactKind artifact; - cdt::RandomSeed seed; - cdt::RandomStream initialization_stream; - cdt::RandomStream transition_stream; - Topology topology; - Int_precision dimension; - Int_precision actual_vertices; - Int_precision actual_edges; - Int_precision actual_faces; - Int_precision actual_simplices; - Int_precision minimum_timeslice; - Int_precision maximum_timeslice; - std::optional max_threads; - std::uint64_t placement_fingerprint; - std::uint64_t topology_fingerprint; + Payload_integrity payload; + ArtifactKind artifact; + bool resume_supported; + cdt::RandomSeed seed; + cdt::RandomStream initialization_stream; + cdt::RandomStream transition_stream; + Topology topology; + Int_precision dimension; + Int_precision actual_vertices; + Int_precision actual_edges; + Int_precision actual_faces; + Int_precision actual_simplices; + Int_precision minimum_timeslice; + Int_precision maximum_timeslice; + std::optional max_threads; + std::uint64_t placement_fingerprint; + std::uint64_t topology_fingerprint; + Int_precision desired_simplices; + Int_precision desired_timeslices; + double initial_radius; + double foliation_spacing; + std::optional alpha; + std::optional k; + std::optional lambda; + std::optional configured_passes; + std::optional configured_attempts; + std::optional checkpoint_interval; + std::optional completed_passes; + std::optional transition_trace; + std::optional transition_count; + std::optional transition_random_state; + std::optional move_statistics; + std::optional input_artifact; + std::optional input_seed; + std::optional input_initialization_stream; + std::optional input_placement_fingerprint; + std::optional input_topology_fingerprint; }; [[nodiscard]] inline auto read_persistence_metadata( @@ -989,11 +1171,13 @@ namespace cdt::utilities std::make_error_code(std::errc::illegal_byte_sequence)); } } - if (values.at("resume_supported") != "false") + bool resume_supported{}; + if (values.at("resume_supported") == "true") { resume_supported = true; } + else if (values.at("resume_supported") != "false") { throw std::filesystem::filesystem_error( - "This build cannot read resumable checkpoints", path, - std::make_error_code(std::errc::not_supported)); + "Persistence metadata has an invalid resume contract", path, + std::make_error_code(std::errc::illegal_byte_sequence)); } if (values.at("fresh_topology_replay_supported") != "false" || values.at("transition_replay_requires_identical_start") != "true") @@ -1010,27 +1194,23 @@ namespace cdt::utilities std::make_error_code(std::errc::not_supported)); } - ArtifactKind artifact{}; - if (values.at("artifact") == "initial-triangulation") - { - artifact = ArtifactKind::INITIAL_TRIANGULATION; - } - else if (values.at("artifact") == "checkpoint") - { - artifact = ArtifactKind::CHECKPOINT; - } - else if (values.at("artifact") == "final-triangulation") - { - artifact = ArtifactKind::FINAL_TRIANGULATION; - } - else - { + auto const parse_artifact = [&path](std::string_view const value) { + if (value == "initial-triangulation") + { + return ArtifactKind::INITIAL_TRIANGULATION; + } + if (value == "checkpoint") { return ArtifactKind::CHECKPOINT; } + if (value == "final-triangulation") + { + return ArtifactKind::FINAL_TRIANGULATION; + } throw std::filesystem::filesystem_error( "Persistence metadata has an unknown artifact kind", path, std::make_error_code(std::errc::illegal_byte_sequence)); - } + }; + auto const artifact = parse_artifact(values.at("artifact")); - Topology topology{}; + Topology topology{}; if (values.at("topology") == "spherical") { topology = Topology::SPHERICAL; @@ -1090,15 +1270,16 @@ namespace cdt::utilities "Persistence metadata has an incomplete action parameter set", path, std::make_error_code(std::errc::illegal_byte_sequence)); } + std::optional alpha; + std::optional k; + std::optional lambda; if (action_field_count == 3) { - auto const alpha = - parse_metadata_floating(values.at("alpha"), path); - static_cast( - parse_metadata_floating(values.at("k"), path)); - static_cast( - parse_metadata_floating(values.at("lambda"), path)); - if (alpha <= 0.5L) + alpha = parse_metadata_floating(values.at("alpha"), path); + k = parse_metadata_floating(values.at("k"), path); + lambda = + parse_metadata_floating(values.at("lambda"), path); + if (*alpha <= 0.5L) { throw std::filesystem::filesystem_error( "Persistence metadata contains an invalid alpha", path, @@ -1115,20 +1296,29 @@ namespace cdt::utilities "Persistence metadata has an incomplete run configuration", path, std::make_error_code(std::errc::illegal_byte_sequence)); } - if (run_field_count == 2 && - (parse_integer_field("configured_passes") <= 0 || - parse_integer_field("checkpoint_interval") <= 0)) + std::optional configured_passes; + std::optional checkpoint_interval; + if (run_field_count == 2) { - throw std::filesystem::filesystem_error( - "Persistence metadata contains an invalid run configuration", path, - std::make_error_code(std::errc::illegal_byte_sequence)); + configured_passes = parse_integer_field("configured_passes"); + checkpoint_interval = parse_integer_field("checkpoint_interval"); + if (*configured_passes <= 0 || *checkpoint_interval <= 0) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains an invalid run configuration", + path, std::make_error_code(std::errc::illegal_byte_sequence)); + } } - if (values.contains("configured_attempts") && - parse_integer_field("configured_attempts") <= 0) + std::optional configured_attempts; + if (values.contains("configured_attempts")) { - throw std::filesystem::filesystem_error( - "Persistence metadata contains invalid configured attempts", path, - std::make_error_code(std::errc::illegal_byte_sequence)); + configured_attempts = parse_integer_field("configured_attempts"); + if (*configured_attempts <= 0) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains invalid configured attempts", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } } if (artifact == ArtifactKind::CHECKPOINT && @@ -1138,12 +1328,16 @@ namespace cdt::utilities "Checkpoint metadata is missing completed passes", path, std::make_error_code(std::errc::illegal_byte_sequence)); } - if (values.contains("completed_passes") && - parse_integer_field("completed_passes") < 0) + std::optional completed_passes; + if (values.contains("completed_passes")) { - throw std::filesystem::filesystem_error( - "Persistence metadata contains invalid completed passes", path, - std::make_error_code(std::errc::illegal_byte_sequence)); + completed_passes = parse_integer_field("completed_passes"); + if (*completed_passes < 0) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains invalid completed passes", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } } std::optional max_threads; if (auto const field = values.find("parallel.max_threads"); @@ -1167,18 +1361,185 @@ namespace cdt::utilities "Persistence metadata has an incomplete transition trace", path, std::make_error_code(std::errc::illegal_byte_sequence)); } + std::optional transition_trace; + std::optional transition_count; if (transition_field_count == 2) { - static_cast( - parse_unsigned(values.at("transition_trace.fnv1a64"), 16, path)); - static_cast( - parse_unsigned(values.at("transition_trace.count"), 10, path)); + transition_trace = + parse_unsigned(values.at("transition_trace.fnv1a64"), 16, path); + transition_count = + parse_unsigned(values.at("transition_trace.count"), 10, path); + } + + auto const move_field_count = + static_cast(values.contains("moves.proposed")) + + static_cast(values.contains("moves.accepted")) + + static_cast(values.contains("moves.rejected")) + + static_cast(values.contains("moves.attempted")) + + static_cast(values.contains("moves.succeeded")) + + static_cast(values.contains("moves.failed")); + if (move_field_count != 0 && move_field_count != 6) + { + throw std::filesystem::filesystem_error( + "Persistence metadata has incomplete move statistics", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + std::optional move_statistics; + if (move_field_count == 6) + { + move_statistics = Move_statistics{ + .proposed = parse_move_counts(values.at("moves.proposed"), path), + .accepted = parse_move_counts(values.at("moves.accepted"), path), + .rejected = parse_move_counts(values.at("moves.rejected"), path), + .attempted = parse_move_counts(values.at("moves.attempted"), path), + .succeeded = parse_move_counts(values.at("moves.succeeded"), path), + .failed = parse_move_counts(values.at("moves.failed"), path)}; + std::uint64_t proposed_total{}; + for (std::size_t index = 0; index < move_statistics->proposed.size(); + ++index) + { + auto const proposed = + static_cast(move_statistics->proposed[index]); + auto const accepted = + static_cast(move_statistics->accepted[index]); + auto const rejected = + static_cast(move_statistics->rejected[index]); + auto const attempted = + static_cast(move_statistics->attempted[index]); + auto const succeeded = + static_cast(move_statistics->succeeded[index]); + auto const failed = + static_cast(move_statistics->failed[index]); + if (proposed != accepted + rejected || proposed != attempted || + attempted != succeeded + failed) + { + throw std::filesystem::filesystem_error( + "Persistence move statistics violate accounting invariants", + path, std::make_error_code(std::errc::illegal_byte_sequence)); + } + if (proposed_total > + std::numeric_limits::max() - proposed) + { + throw std::filesystem::filesystem_error( + "Persistence transition count exceeds the supported range", + path, std::make_error_code(std::errc::value_too_large)); + } + proposed_total += proposed; + } + if (transition_count && proposed_total != *transition_count) + { + throw std::filesystem::filesystem_error( + "Persistence move statistics do not match the transition count", + path, std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + + std::optional transition_random_state; + if (auto const field = values.find("random.transition_state"); + field != values.end()) + { + transition_random_state = field->second; + } + if (resume_supported) + { + auto const complete_resume_state = + artifact == ArtifactKind::CHECKPOINT && alpha && k && lambda && + configured_passes && checkpoint_interval && completed_passes && + max_threads && transition_trace && transition_count && + transition_random_state && move_statistics; + if (!complete_resume_state || *completed_passes > *configured_passes) + { + throw std::filesystem::filesystem_error( + "Resumable checkpoint metadata is incomplete or inconsistent", + path, std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto const producer_matches = + values.at("cdt.version") == cdt::VERSION && + values.contains("source.revision") && + values.at("source.revision") == cdt::SOURCE_REVISION && + values.at("build.compiler_id") == cdt::BUILD_COMPILER_ID && + values.at("build.compiler_version") == + cdt::BUILD_COMPILER_VERSION && + values.at("build.configuration") == cdt::BUILD_CONFIGURATION && + values.contains("build.parallel_triangulation") && + values.at("build.parallel_triangulation") == + (PARALLEL_TRIANGULATION_ENABLED ? "true" : "false") && + values.at("build.system") == cdt::BUILD_SYSTEM_NAME && + values.at("build.processor") == cdt::BUILD_SYSTEM_PROCESSOR && + values.at("build.standard_library") == standard_library_name() && + values.at("dependency.cgal_version") == CGAL_VERSION_STR; + if (!producer_matches) + { + throw std::filesystem::filesystem_error( + "Exact checkpoint resume requires the recorded producer toolchain", + path, std::make_error_code(std::errc::not_supported)); + } + try + { + static_cast(cdt::Random::from_serialized_state( + cdt::RandomSeed{ + parse_unsigned(values.at("random.seed"), 10, path)}, + cdt::RandomStream{parse_unsigned( + values.at("random.transition_stream"), 10, path)}, + *transition_random_state)); + } + catch (std::invalid_argument const&) + { + throw std::filesystem::filesystem_error( + "Resumable checkpoint contains invalid PCG state", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + else if (transition_random_state) + { + throw std::filesystem::filesystem_error( + "Snapshot metadata contains PCG state without resume support", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + auto const input_field_count = + static_cast(values.contains("input.artifact")) + + static_cast(values.contains("input.random.seed")) + + static_cast( + values.contains("input.random.initialization_stream")) + + static_cast(values.contains("input.placement.fnv1a64")) + + static_cast(values.contains("input.topology.fnv1a64")); + if (input_field_count != 0 && input_field_count != 5) + { + throw std::filesystem::filesystem_error( + "Persistence metadata has incomplete input provenance", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + std::optional input_artifact; + std::optional input_seed; + std::optional input_initialization_stream; + std::optional input_placement_fingerprint; + std::optional input_topology_fingerprint; + if (input_field_count == 5) + { + input_artifact = parse_artifact(values.at("input.artifact")); + if (*input_artifact != ArtifactKind::INITIAL_TRIANGULATION) + { + throw std::filesystem::filesystem_error( + "Persistence input provenance must identify an " + "initial-triangulation artifact", + path, std::make_error_code(std::errc::illegal_byte_sequence)); + } + input_seed = cdt::RandomSeed{ + parse_unsigned(values.at("input.random.seed"), 10, path)}; + input_initialization_stream = cdt::RandomStream{parse_unsigned( + values.at("input.random.initialization_stream"), 10, path)}; + input_placement_fingerprint = + parse_unsigned(values.at("input.placement.fnv1a64"), 16, path); + input_topology_fingerprint = + parse_unsigned(values.at("input.topology.fnv1a64"), 16, path); } return { .payload = {parse_unsigned(values.at("payload.size"), 10, path), parse_unsigned(values.at("payload.fnv1a64"), 16, path)}, .artifact = artifact, + .resume_supported = resume_supported, .seed = cdt::RandomSeed{parse_unsigned(values.at("random.seed"), 10, path)}, .initialization_stream = cdt::RandomStream{parse_unsigned( @@ -1197,10 +1558,74 @@ namespace cdt::utilities .placement_fingerprint = parse_unsigned(values.at("placement.fnv1a64"), 16, path), .topology_fingerprint = - parse_unsigned(values.at("topology.fnv1a64"), 16, path) + parse_unsigned(values.at("topology.fnv1a64"), 16, path), + .desired_simplices = desired_simplices, + .desired_timeslices = desired_timeslices, + .initial_radius = initial_radius, + .foliation_spacing = foliation_spacing, + .alpha = alpha, + .k = k, + .lambda = lambda, + .configured_passes = configured_passes, + .configured_attempts = configured_attempts, + .checkpoint_interval = checkpoint_interval, + .completed_passes = completed_passes, + .transition_trace = transition_trace, + .transition_count = transition_count, + .transition_random_state = transition_random_state, + .move_statistics = move_statistics, + .input_artifact = input_artifact, + .input_seed = input_seed, + .input_initialization_stream = input_initialization_stream, + .input_placement_fingerprint = input_placement_fingerprint, + .input_topology_fingerprint = input_topology_fingerprint }; } + [[nodiscard]] inline auto to_reproducibility_metadata( + Parsed_persistence_metadata const& source) -> Reproducibility_metadata + { + return { + .artifact = source.artifact, + .seed = source.seed, + .initialization_stream = source.initialization_stream, + .transition_stream = source.transition_stream, + .topology = source.topology, + .dimension = source.dimension, + .desired_simplices = source.desired_simplices, + .desired_timeslices = source.desired_timeslices, + .actual_vertices = source.actual_vertices, + .actual_edges = source.actual_edges, + .actual_faces = source.actual_faces, + .actual_simplices = source.actual_simplices, + .minimum_timeslice = source.minimum_timeslice, + .maximum_timeslice = source.maximum_timeslice, + .initial_radius = source.initial_radius, + .foliation_spacing = source.foliation_spacing, + .alpha = source.alpha, + .k = source.k, + .lambda = source.lambda, + .configured_passes = source.configured_passes, + .configured_attempts = source.configured_attempts, + .checkpoint_interval = source.checkpoint_interval, + .completed_passes = source.completed_passes, + .max_threads = source.max_threads, + .transition_trace = source.transition_trace, + .transition_count = source.transition_count, + .transition_random_state = source.transition_random_state + ? std::make_shared( + *source.transition_random_state) + : nullptr, + .move_statistics = source.move_statistics, + .placement_fingerprint = source.placement_fingerprint, + .topology_fingerprint = source.topology_fingerprint, + .input_artifact = source.input_artifact, + .input_seed = source.input_seed, + .input_initialization_stream = source.input_initialization_stream, + .input_placement_fingerprint = source.input_placement_fingerprint, + .input_topology_fingerprint = source.input_topology_fingerprint}; + } + [[nodiscard]] inline auto validate_payload_integrity( std::filesystem::path const& payload) -> std::optional @@ -1739,10 +2164,10 @@ namespace cdt::utilities } // print_delaunay /// @brief Write triangulation to file - /// @details This function writes the Delaunay triangulation in the manifold - /// to an OFF file. http://www.geomview.org/docs/html/OFF.html#OFF Provides - /// strong exception-safety for the destination file. Writes are serialized - /// within the process and validated before an atomic replacement. + /// @details Writes CGAL's native triangulation stream using CDT++'s + /// historical `.off` filename convention. This is not Geomview mesh OFF. + /// Provides strong exception-safety for the destination file. Writes are + /// serialized within the process and validated before an atomic replacement. /// @tparam TriangulationType The type of triangulation /// @param filename The filename to write to /// @param triangulation The triangulation to write @@ -1767,6 +2192,8 @@ namespace cdt::utilities /// @param filename Destination OFF path. /// @param triangulation Triangulation payload to serialize. /// @param metadata Run configuration and stochastic provenance. + /// @throws std::invalid_argument if input provenance is incomplete or does + /// not identify an initial-triangulation artifact. /// @throws std::logic_error for a same-thread reentrant write. /// @throws std::filesystem::filesystem_error if serialization, validation, /// or either replacement fails. @@ -1906,7 +2333,7 @@ namespace cdt::utilities /// @param universe Manifold to serialize. /// @param metadata Complete artifact and stochastic provenance. /// @throws std::invalid_argument if checkpoint metadata omits completed - /// passes. + /// passes or input provenance is incomplete. /// @throws std::logic_error for a same-thread reentrant write. /// @throws std::filesystem::filesystem_error if persistence fails. template @@ -1927,12 +2354,126 @@ namespace cdt::utilities write_file(filename, universe.delaunay_snapshot(), metadata); } + /// @brief A validated triangulation artifact and its provenance. + /// @tparam TriangulationType Persisted triangulation representation. + template + struct Triangulation_artifact + { + TriangulationType triangulation; ///< Validated causal payload. + Reproducibility_metadata metadata; ///< Validated artifact metadata. + }; + + /// @brief A validated initial triangulation and its initialization + /// provenance. + /// @tparam TriangulationType Persisted triangulation representation. + template + using Initial_triangulation_artifact = + Triangulation_artifact; + + /// @brief Read a manifested initial triangulation for a new CDT run. + /// @details This boundary requires an `initial-triangulation` sidecar, + /// verifies payload integrity and causal metadata, and returns the recorded + /// foliation parameters needed to reconstruct the owning manifold. + /// Checkpoints and final triangulations are rejected because this operation + /// starts a new transition stream; it is not checkpoint resume. + /// CDT evolution also requires distinct vertex coordinates because prepared + /// move locators are coordinate-valued. + /// @tparam TriangulationType The type of triangulation. + /// @param filename Initial triangulation payload path. + /// @returns Validated payload and initialization provenance. + /// @throws std::filesystem::filesystem_error if the payload or required + /// sidecar is missing, malformed, inconsistent, or has another artifact role. + template + [[nodiscard]] auto read_initial_triangulation( + std::filesystem::path const& filename) + -> Initial_triangulation_artifact + { + static std::mutex mutex; + fmt::print("Reading initial triangulation from file {}\n", + filename.string()); + std::scoped_lock const lock(mutex); + auto const parsed_metadata = detail::validate_payload_integrity(filename); + auto const sidecar = metadata_filename(filename); + if (!parsed_metadata) + { + throw std::filesystem::filesystem_error( + "Initial triangulation requires a persistence metadata sidecar", + filename, sidecar, + std::make_error_code(std::errc::no_such_file_or_directory)); + } + if (parsed_metadata->artifact != ArtifactKind::INITIAL_TRIANGULATION) + { + throw std::filesystem::filesystem_error( + "CDT input must be an initial-triangulation artifact", filename, + sidecar, std::make_error_code(std::errc::not_supported)); + } + + auto triangulation = detail::parse_payload(filename); + detail::validate_persistence_metadata(*parsed_metadata, triangulation, + filename, sidecar); + detail::require_distinct_evolution_coordinates(triangulation, filename, + sidecar, "CDT input"); + auto metadata = detail::to_reproducibility_metadata(*parsed_metadata); + return {.triangulation = std::move(triangulation), + .metadata = std::move(metadata)}; + } + + /// @brief A validated resumable checkpoint and its complete run state. + /// @tparam TriangulationType Persisted triangulation representation. + template + using Checkpoint_artifact = Triangulation_artifact; + + /// @brief Read a checkpoint that can continue the identical Markov chain. + /// @details The complete manifested pair is validated before any state is + /// returned. Snapshot-only legacy checkpoints, initial states, final states, + /// malformed PCG state, and producer-toolchain mismatches are rejected. + /// Distinct vertex coordinates are required for exact move-locator replay. + /// @tparam TriangulationType The type of triangulation. + /// @param filename Checkpoint payload path. + /// @returns Validated triangulation plus exact stochastic continuation state. + /// @throws std::filesystem::filesystem_error for an invalid or unsupported + /// checkpoint contract. + template + [[nodiscard]] auto read_checkpoint(std::filesystem::path const& filename) + -> Triangulation_artifact + { + static std::mutex mutex; + fmt::print("Reading resumable checkpoint from file {}\n", + filename.string()); + std::scoped_lock const lock(mutex); + auto const parsed_metadata = detail::validate_payload_integrity(filename); + auto const sidecar = metadata_filename(filename); + if (!parsed_metadata) + { + throw std::filesystem::filesystem_error( + "Checkpoint resume requires a persistence metadata sidecar", filename, + sidecar, std::make_error_code(std::errc::no_such_file_or_directory)); + } + if (parsed_metadata->artifact != ArtifactKind::CHECKPOINT || + !parsed_metadata->resume_supported) + { + throw std::filesystem::filesystem_error( + "CDT resume requires a resumable checkpoint artifact", filename, + sidecar, std::make_error_code(std::errc::not_supported)); + } + + auto triangulation = detail::parse_payload(filename); + detail::validate_persistence_metadata(*parsed_metadata, triangulation, + filename, sidecar); + detail::require_distinct_evolution_coordinates(triangulation, filename, + sidecar, "CDT resume"); + auto metadata = detail::to_reproducibility_metadata(*parsed_metadata); + return {.triangulation = std::move(triangulation), + .metadata = std::move(metadata)}; + } + /// @brief Read triangulation from file /// @tparam TriangulationType The type of triangulation /// @param filename The file to read from /// @returns A Delaunay triangulation - /// @throws std::filesystem::filesystem_error if the payload or sidecar is - /// missing, unreadable, malformed, or inconsistent. + /// @throws std::filesystem::filesystem_error if the payload is missing, + /// unreadable, or malformed, or if a present sidecar is unreadable, + /// malformed, or inconsistent with the payload. template [[nodiscard]] auto read_file(std::filesystem::path const& filename) -> TriangulationType diff --git a/reference/README.md b/reference/README.md index 318872bca..57c46f2a3 100644 --- a/reference/README.md +++ b/reference/README.md @@ -6,6 +6,30 @@ independent implementation under comparison, not presumed ground truth. A discrepancy is an investigation target until the protocol or one of the implementations explains it. +## Regression-oracle scope + +The principal reason to preserve this implementation is its causality-filtering Delaunay construction path in +[`Foliated_triangulation.hpp`](https://github.com/acgetchell/CDT-plusplus/blob/main/include/Foliated_triangulation.hpp). +`find_invalid_timevalue_cells` classifies cells from stored vertex time labels, `has_valid_timevalues` provides the +predicate, `find_bad_vertex` selects a vertex responsible for an acausal local configuration, and `fix_timevalues` +removes offending vertices through CGAL so the cavity is retriangulated until the foliation contract is satisfied. + +The deterministic doctest scenario **"Detecting and fixing problems with vertices and cells"** in +[`Foliated_triangulation_test.cpp`](https://github.com/acgetchell/CDT-plusplus/blob/main/tests/Foliated_triangulation_test.cpp) +exercises this path with fixed points and time labels. Its inputs, detected bad vertex, final initialization state, +cell counts, and causal classification are the first comparison fixture for `causal-triangulations`; exact Monte +Carlo trajectories are not required to match. + +The package extends that construction case with the complete move set, action values, Metropolis-Hastings decisions, +persistence records, and one bounded end-to-end run. + +After building, run the construction fixture directly with: + +```console +./out/build/reference/tests/CDT_unit_tests \ + --test-case='*Detecting and fixing problems with vertices and cells*' +``` + ## Package layout - `schema/fixture-v1.schema.json` defines the comparison protocol. @@ -60,6 +84,64 @@ They therefore test proposal preparation, action delta, Hastings factor, and commit/reject behavior without requiring the C++ and Rust implementations to share an RNG engine, allocation order, or container iteration order. +### Initial-triangulation interchange + +This complete command writes the same manifested pair consumed by the second +command: + +```console +just initialize -s -n640 -t4 -o --seed 92 +just load /path/to/generated-file.off -a0.6 -k1.1 -l0.1 -p1000 --seed 93 +``` + +1. the `.off` payload contains CGAL's native triangulation stream followed by + CDT++'s versioned causal-data trailer; and +2. the neighboring `.off.meta` manifest records the artifact role, foliation + parameters, seed and stream provenance, canonical fingerprints, payload + checksum, and producer toolchain. + +Despite the suffix, this is not generic mesh OFF. Plain geometry cannot carry +the vertex time labels, causal cell types, artifact role, or stochastic +provenance required to reconstruct a CDT state. The pair is the archival +interchange boundary and remains coupled to the pinned CDT++/CGAL persistence +contract documented in +[`docs/reproducibility.md`](../docs/reproducibility.md). + +The successor workflow is: + +```text +just initialize -s -n640 -t4 -o --seed 92 + | + +--> initial.off + initial.off.meta + | | + +--> just load PATH +--> causal-triangulations importer + -a0.6 -k1.1 | + -l0.1 -p1000 +--> native Rust triangulation + --seed 93 +``` + +The `causal-triangulations` side should own that importer: validate the complete +pair at its input boundary, reconstruct its native invariant-bearing state, and +then use its own serialization. CDT++ should not add a second lossy converter +or pretend that the CGAL payload alone is portable. Direct Rust import is a +downstream compatibility direction, not a capability claimed by the CDT++ +v1.0.0 release. + +For generating many separately seeded random starting states, invoke +`just initialize` with a different seed in a dedicated directory for each run +and preserve every payload/manifest pair together. The seed replays pre-CGAL +random inputs; the persisted pair, not the seed alone, identifies the exact +post-repair topology to import or evolve. + +CDT++ checkpoints serve a different purpose from this interchange boundary. +`just resume CHECKPOINT.off` can continue the identical CDT++ Markov +chain after an interrupted Slurm/HPC job because the checkpoint sidecar records +mutable PCG state and cumulative transition accounting. That restart contract +is deliberately locked to the recorded CDT++ source revision and producer +toolchain. A successor importer should consume `initial-triangulation` +artifacts for independent evolution, not depend on CDT++'s private checkpoint +engine state. + ## Local comparison harness The repository's [`cdt-compare`](../docs/comparison-harness.md) command copies @@ -72,6 +154,47 @@ protocol, and applies only the exact and named numerical rules declared here. inventory, verifies every retained digest, and reproduces the machine-readable summary without rerunning either implementation. +After building CDT++ and a compatible Rust fixture producer, run one bounded +comparison and retain it locally: + +```console +just comparison-run /absolute/path/to/causal-triangulations-fixture out/comparisons/run-1 +``` + +Reproduce `summary.json` entirely from the stored raw artifacts, without +running either executable: + +```console +just comparison-analyze out/comparisons/run-1 +``` + +The bundle under `out/comparisons/run-1` is published atomically only after +analysis and manifest creation finish. Preserve its `inputs/`, `raw/`, +`manifest.json`, and `summary.json` together. Python validates the artifact +inventory and its digests, validates schemas, constructs commands, classifies +comparisons, and renders a small text table; it does not implement topology, +action, move-legality, or acceptance rules. See the +[comparison-harness contract](../docs/comparison-harness.md) for producer +configuration, placeholders, artifact layout, failure records, and the C++ +reference/Rust result boundary. + +### Local initialization sweep + +The retained `cdt-optimize-initialize` command is also entirely local and +dependency-free. It writes one directory per parameter pair under +`out/experiments/initialize`, including configuration JSON, raw stdout, a +tab-separated volume profile, metrics, artifact digests, and source/executable +provenance. Each invocation requires a nonexistent output path. Seed `92` is +the default; use `--seed` and `--output-directory` for another replayable +record. Fresh CGAL triangulations remain subject to the +[reproducibility contract](../docs/reproducibility.md). + +Inspect its command line with: + +```console +uv run cdt-optimize-initialize --help +``` + ## Regeneration `reference-fixtures` is a quick diagnostic that builds and prints only diff --git a/scripts/validate_generated_site.py b/scripts/validate_generated_site.py index a43ed3000..e02fffb06 100755 --- a/scripts/validate_generated_site.py +++ b/scripts/validate_generated_site.py @@ -38,7 +38,7 @@ "tabs.css", ) REQUIRED_TARGETS = { - "index.html": ("developer-workflow", "documentation", "introduction"), + "index.html": ("contributing", "documentation", "introduction"), "md__c_h_a_n_g_e_l_o_g.html": (), "md__r_e_f_e_r_e_n_c_e_s.html": ("metropolis-hastings-algorithm",), "md__s_e_c_u_r_i_t_y.html": (), diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0ef99b691..69d7d5f4a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -156,6 +156,28 @@ add_test( "-DTEST_ARGUMENTS=-s;-n64;-t3;-o" -P ${PROJECT_SOURCE_DIR}/cmake/RunPersistenceOutputTest.cmake) set_tests_properties(initialize-generated-seed PROPERTIES LABELS "integration;persistence") +add_test( + NAME initialize-to-cdt + COMMAND + ${CMAKE_COMMAND} "-DINITIALIZE_EXECUTABLE=$" + "-DCDT_EXECUTABLE=$" + "-DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}" + "-DTEST_DIRECTORY=${CMAKE_CURRENT_BINARY_DIR}/initialize-to-cdt" + -P ${PROJECT_SOURCE_DIR}/cmake/RunInitializeToCdtTest.cmake) +set_tests_properties(initialize-to-cdt PROPERTIES LABELS + "integration;persistence;debug-incompatible") + +add_test( + NAME checkpoint-resume + COMMAND + ${CMAKE_COMMAND} "-DINITIALIZE_EXECUTABLE=$" + "-DCDT_EXECUTABLE=$" + "-DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}" + "-DTEST_DIRECTORY=${CMAKE_CURRENT_BINARY_DIR}/checkpoint-resume" + -P ${PROJECT_SOURCE_DIR}/cmake/RunCheckpointResumeTest.cmake) +set_tests_properties(checkpoint-resume PROPERTIES LABELS + "integration;persistence;scientific;debug-incompatible") + add_cli_failure_test(initialize-minimum-simplices initialize "Simplices and timeslices must each be at least 2." -s -n1 -t1 -o --seed 92) add_cli_failure_test(initialize-dimensionality initialize "Only three-dimensional triangulations are supported." -s diff --git a/src/cdt.cpp b/src/cdt.cpp index 62761355f..ee5866934 100644 --- a/src/cdt.cpp +++ b/src/cdt.cpp @@ -21,6 +21,8 @@ #include #include +#include +#include #include #include "Runtime_config.hpp" @@ -44,24 +46,20 @@ to the Metropolis algorithm. Specify the number of passes to control how much evolution is desired. Each pass attempts a number of ergodic moves equal to the number of simplices in the simulation. -Usage:./cdt (--spherical | --toroidal) -n SIMPLICES -t TIMESLICES - [-d DIM] - [--init INITIAL RADIUS] - [--foliate FOLIATION SPACING] - [--no-output] - [--seed SEED] - [--threads THREADS] - -k K - --alpha ALPHA - --lambda LAMBDA - [-p PASSES] - [-c CHECKPOINT] +Usage: ./cdt ((--spherical | --toroidal) -n SIMPLICES -t TIMESLICES + [-d DIM] [--init INITIAL RADIUS] [--foliate FOLIATION SPACING] + | --input INITIAL.off) + -k K --alpha ALPHA --lambda LAMBDA [--no-output] [--seed SEED] + [--threads THREADS] [-p PASSES] [-c CHECKPOINT] + ./cdt --resume CHECKPOINT.off [-p TOTAL_PASSES] [--no-output] Optional arguments are in square brackets. Examples: ./cdt --spherical -n 32000 -t 11 --alpha 0.6 -k 1.1 --lambda 0.1 --passes 1000 ./cdt -s -n32000 -t11 -a.6 -k1.1 -l.1 -p1000 --seed 92 +./cdt --input .off -a.6 -k1.1 -l.1 -p1000 --seed 93 +./cdt --resume .off Options)"}; @@ -86,6 +84,8 @@ try long long checkpoint{}; std::uint64_t seed{}; long long threads{}; + std::string input_path; + std::string resume_path; po::options_description description(intro); description.add_options()("help,h", "Show this message")( @@ -100,21 +100,23 @@ try po::value(&initial_radius)->default_value(1.0), "Initial radius")( "foliate,f", po::value(&foliation_spacing)->default_value(1.0), - "Foliation spacing")( + "Foliation spacing")("input", po::value(&input_path), + "Initial-triangulation payload (requires .meta)")( + "resume", po::value(&resume_path), + "Resume the identical Markov chain from a checkpoint")( "no-output", "Do not write checkpoint or final triangulation files")( "seed", po::value(&seed), "Root random seed (default: operating-system entropy)")( "threads", po::value(&threads)->default_value(1), "Maximum worker threads for supported Delaunay operations")( - "alpha,a", po::value(&alpha)->required(), + "alpha,a", po::value(&alpha), "Negative squared geodesic length of 1-d timelike edges")( - "k,k", po::value(&k)->required(), "K = 1/(8*pi*G_newton)")( - "lambda,l", po::value(&lambda)->required(), - "K * Cosmological constant")( + "k,k", po::value(&k), "K = 1/(8*pi*G_newton)")( + "lambda,l", po::value(&lambda), "K * Cosmological constant")( "passes,p", po::value(&passes)->default_value(100), - "Number of passes")("checkpoint,c", - po::value(&checkpoint)->default_value(10), - "Checkpoint every n passes"); + "Total pass target (resume default: saved target)")( + "checkpoint,c", po::value(&checkpoint)->default_value(10), + "Checkpoint every n global passes"); po::variables_map args; po::store(po::parse_command_line(argc, argv, description), args); @@ -132,33 +134,137 @@ try } po::notify(args); - if (!args.count("simplices")) + auto const has_input = args.count("input") != 0; + auto const has_resume = args.count("resume") != 0; + auto const explicitly_supplied = [&args](char const* option) { + auto const value = args.find(option); + return value != args.end() && !value->second.defaulted(); + }; + if (has_input && has_resume) + { + throw invalid_argument("--input and --resume are mutually exclusive."); + } + if ((has_input || has_resume) && + (args.count("spherical") != 0 || args.count("toroidal") != 0 || + args.count("simplices") != 0 || args.count("timeslices") != 0 || + explicitly_supplied("dimensions") || explicitly_supplied("init") || + explicitly_supplied("foliate"))) + { + throw invalid_argument(fmt::format( + "{} cannot be combined with topology or triangulation-construction " + "options.", + has_resume ? "--resume" : "--input")); + } + if (has_resume && + (args.count("seed") != 0 || explicitly_supplied("threads") || + args.count("alpha") != 0 || args.count("k") != 0 || + args.count("lambda") != 0 || explicitly_supplied("checkpoint"))) + { + throw invalid_argument( + "--resume restores seed, threads, action parameters, and checkpoint " + "cadence from the saved run."); + } + if (!has_resume && (args.count("alpha") == 0 || args.count("k") == 0 || + args.count("lambda") == 0)) + { + throw invalid_argument("Alpha, K, and Lambda must be specified."); + } + if (!has_input && !has_resume && !args.count("simplices")) { throw invalid_argument("Number of simplices not specified."); } - if (!args.count("timeslices")) + if (!has_input && !has_resume && !args.count("timeslices")) { throw invalid_argument("Number of timeslices not specified."); } + using Initial_artifact = + utilities::Initial_triangulation_artifact>; + using Resume_artifact = utilities::Checkpoint_artifact>; + std::optional initial_artifact; + std::optional resume_artifact; + if (has_input) + { + initial_artifact.emplace( + utilities::read_initial_triangulation>(input_path)); + } + if (has_resume) + { + resume_artifact.emplace( + utilities::read_checkpoint>(resume_path)); + } + auto root_random = - args.count("seed") != 0 ? cdt::Random{seed} : cdt::Random{}; - auto const triangulation_config = runtime_config::make_triangulation( - args.count("spherical") != 0, args.count("toroidal") != 0, simplices, - timeslices, dimensions, initial_radius, foliation_spacing, - root_random.seed(), threads); + resume_artifact + ? cdt::Random{resume_artifact->metadata.seed} + : (args.count("seed") != 0 ? cdt::Random{seed} : cdt::Random{}); + auto const effective_threads = [&] { + if (!resume_artifact) { return threads; } + auto const saved = *resume_artifact->metadata.max_threads; + if (!std::in_range(saved)) + { + throw out_of_range("Saved thread count exceeds the supported range."); + } + return static_cast(saved); + }(); + auto const triangulation_config = [&] { + if (initial_artifact || resume_artifact) + { + auto const& metadata = initial_artifact ? initial_artifact->metadata + : resume_artifact->metadata; + return runtime_config::make_triangulation( + metadata.topology == Topology::SPHERICAL, + metadata.topology == Topology::TOROIDAL, metadata.desired_simplices, + metadata.desired_timeslices, metadata.dimension, + metadata.initial_radius, metadata.foliation_spacing, + root_random.seed(), effective_threads); + } + return runtime_config::make_triangulation( + args.count("spherical") != 0, args.count("toroidal") != 0, simplices, + timeslices, dimensions, initial_radius, foliation_spacing, + root_random.seed(), effective_threads); + }(); + auto const effective_alpha = + resume_artifact ? *resume_artifact->metadata.alpha : alpha; + auto const effective_k = resume_artifact ? *resume_artifact->metadata.k : k; + auto const effective_lambda = + resume_artifact ? *resume_artifact->metadata.lambda : lambda; + auto const completed_passes = resume_artifact + ? *resume_artifact->metadata.completed_passes + : Int_precision{}; + auto const target_passes = [&] { + if (!resume_artifact) { return passes; } + if (explicitly_supplied("passes")) { return passes; } + return static_cast(*resume_artifact->metadata.configured_passes); + }(); + if (resume_artifact && (!std::in_range(target_passes) || + target_passes < completed_passes)) + { + throw invalid_argument( + "Resume target passes must be at least the completed checkpoint pass."); + } + auto const passes_to_execute = + target_passes - static_cast(completed_passes); + auto const effective_checkpoint = + resume_artifact ? static_cast( + *resume_artifact->metadata.checkpoint_interval) + : checkpoint; auto const config = runtime_config::make_simulation( - triangulation_config, alpha, k, lambda, passes, checkpoint, - !args.count("no-output")); + triangulation_config, effective_alpha, effective_k, effective_lambda, + resume_artifact && passes_to_execute == 0 ? 1 : passes_to_execute, + effective_checkpoint, !args.count("no-output")); #if defined(CDT_ENABLE_PARALLEL_TRIANGULATION) && \ CDT_ENABLE_PARALLEL_TRIANGULATION [[maybe_unused]] oneapi::tbb::global_control thread_limit{ oneapi::tbb::global_control::max_allowed_parallelism, config.triangulation().threads()}; #endif - auto initialization_random = - root_random.split(cdt::random_streams::initialization); - auto transition_random = root_random.split(cdt::random_streams::transitions); + auto transition_random = + resume_artifact ? cdt::Random::from_serialized_state( + resume_artifact->metadata.seed, + resume_artifact->metadata.transition_stream, + *resume_artifact->metadata.transition_random_state) + : root_random.split(cdt::random_streams::transitions); // Display job parameters fmt::print("Topology is {}\n", @@ -172,11 +278,27 @@ try config.triangulation().simplices()); fmt::print("Number of desired timeslices: {}\n", config.triangulation().timeslices()); - fmt::print("Number of passes: {}\n", config.passes()); + fmt::print("Number of passes to execute: {}\n", passes_to_execute); fmt::print("Checkpoint every {} passes.\n", config.checkpoint()); fmt::print("Effective random seed: {}\n", config.triangulation().seed()); fmt::print("Maximum Delaunay threads: {}\n", config.triangulation().threads()); + if (initial_artifact) + { + fmt::print("Input initial triangulation: {}\n", input_path); + fmt::print("Input initialization seed: {}\n", + initial_artifact->metadata.seed); + fmt::print("Input topology fingerprint: {:016x}\n", + *initial_artifact->metadata.topology_fingerprint); + } + if (resume_artifact) + { + fmt::print("Resuming checkpoint: {}\n", resume_path); + fmt::print("Completed checkpoint passes: {}\n", completed_passes); + fmt::print("Target total passes: {}\n", target_passes); + fmt::print("Checkpoint transition count: {}\n", + *resume_artifact->metadata.transition_count); + } fmt::print("=== Parameters ===\n"); fmt::print("Alpha: {}\n", config.alpha()); fmt::print("K: {}\n", config.k()); @@ -187,37 +309,90 @@ try timer.start(); fmt::print("cdt started at {}\n", utilities::current_date_time()); - // Make a triangulation - manifolds::Manifold_3 universe; - - manifolds::Manifold_3 populated_universe( - config.triangulation().simplices(), config.triangulation().timeslices(), - initialization_random, config.triangulation().initial_radius(), - config.triangulation().foliation_spacing()); - swap(populated_universe, universe); - - auto reproducibility = utilities::make_reproducibility_metadata( - universe, config.triangulation().seed(), - utilities::ArtifactKind::FINAL_TRIANGULATION); - reproducibility.desired_simplices = config.triangulation().simplices(); - reproducibility.desired_timeslices = config.triangulation().timeslices(); - reproducibility.alpha = config.alpha(); - reproducibility.k = config.k(); - reproducibility.lambda = config.lambda(); - reproducibility.configured_passes = config.passes(); + // Load an exact checkpoint, load an initial state, or generate a fresh state. + auto universe = [&]() -> manifolds::Manifold_3 { + if (initial_artifact || resume_artifact) + { + auto const& metadata = initial_artifact ? initial_artifact->metadata + : resume_artifact->metadata; + auto triangulation = initial_artifact + ? std::move(initial_artifact->triangulation) + : std::move(resume_artifact->triangulation); + auto manifold = manifolds::Manifold_3{ + foliated_triangulations::FoliatedTriangulation_3{ + std::move(triangulation), metadata.initial_radius, + metadata.foliation_spacing} + }; + if (!manifold.is_correct_with_diagnostics()) + { + throw invalid_argument( + "Input triangulation does not satisfy the CDT manifold contract."); + } + return manifold; + } + auto initialization_random = + root_random.split(cdt::random_streams::initialization); + return manifolds::Manifold_3{ + config.triangulation().simplices(), config.triangulation().timeslices(), + initialization_random, config.triangulation().initial_radius(), + config.triangulation().foliation_spacing()}; + }(); + + auto reproducibility = resume_artifact + ? resume_artifact->metadata + : utilities::make_reproducibility_metadata( + universe, config.triangulation().seed(), + utilities::ArtifactKind::FINAL_TRIANGULATION); + reproducibility.artifact = utilities::ArtifactKind::FINAL_TRIANGULATION; + reproducibility.desired_simplices = config.triangulation().simplices(); + reproducibility.desired_timeslices = config.triangulation().timeslices(); + reproducibility.alpha = config.alpha(); + reproducibility.k = config.k(); + reproducibility.lambda = config.lambda(); + reproducibility.configured_passes = static_cast(target_passes); reproducibility.checkpoint_interval = config.checkpoint(); reproducibility.max_threads = config.triangulation().threads(); - - // Initialize the Metropolis algorithm with complete run provenance. - Metropolis_3 run(config.alpha(), config.k(), config.lambda(), config.passes(), - config.checkpoint(), config.write_files(), - std::move(transition_random), reproducibility); + if (!resume_artifact) + { + reproducibility.input_artifact = + utilities::ArtifactKind::INITIAL_TRIANGULATION; + reproducibility.input_seed = initial_artifact + ? initial_artifact->metadata.seed + : config.triangulation().seed(); + reproducibility.input_initialization_stream = + initial_artifact ? initial_artifact->metadata.initialization_stream + : cdt::random_streams::initialization; + reproducibility.input_placement_fingerprint = + initial_artifact ? initial_artifact->metadata.placement_fingerprint + : reproducibility.placement_fingerprint; + reproducibility.input_topology_fingerprint = + initial_artifact ? initial_artifact->metadata.topology_fingerprint + : reproducibility.topology_fingerprint; + } // Look at triangulation universe.print(); universe.print_details(); universe.print_volume_per_timeslice(); + if (resume_artifact && passes_to_execute == 0) + { + fmt::print( + "Checkpoint already reached the target pass; no transitions remain.\n"); + if (config.write_files()) + { + reproducibility.transition_random_state.reset(); + utilities::write_file(universe, reproducibility); + } + return EXIT_SUCCESS; + } + + // Initialize the Metropolis algorithm with complete run provenance. + Metropolis_3 run(config.alpha(), config.k(), config.lambda(), config.passes(), + config.checkpoint(), config.write_files(), + std::move(transition_random), reproducibility, + completed_passes); + // The main work of the program auto const result = run(universe); @@ -245,7 +420,7 @@ try utilities::write_file( result, run.reproducibility_metadata( result, utilities::ArtifactKind::FINAL_TRIANGULATION, - config.passes())); + static_cast(target_passes))); } return EXIT_SUCCESS; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b3c67cc8f..d79de919b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -57,6 +57,19 @@ target_link_libraries( CDT_random_header_compile_contract PRIVATE project_options project_warnings) +# Exercise PCG's emulated 128-bit state-restoration path on every compiler so +# the Windows/MSVC compatibility boundary remains part of the local contract. +add_executable(CDT_random_emulated_test + ${PROJECT_SOURCE_DIR}/tests/main.cpp + Random_emulated_test.cpp) +target_compile_definitions( + CDT_random_emulated_test + PRIVATE PCG_FORCE_EMULATED_128BIT_MATH=1) +target_compile_features(CDT_random_emulated_test PRIVATE cxx_std_23) +target_link_libraries( + CDT_random_emulated_test + PRIVATE project_options project_warnings doctest::doctest) + # Compile a minimal downstream translation unit without namespace imports or # test-only access to implementation helpers. add_library(CDT_public_api_compile_contract OBJECT Public_api_consumer.cpp) @@ -197,6 +210,10 @@ endif() # precise failure names, this prevents long-lived CGAL/oneTBB state from making # the complete parallel-TDS suite exceed the per-test timeout. include(doctest) +doctest_discover_tests( + CDT_random_emulated_test + TEST_PREFIX "unit-emulated-pcg: " + PROPERTIES LABELS "unit;portability" TIMEOUT "${cdt_unit_test_timeout}") doctest_discover_tests( CDT_unit_tests TEST_PREFIX "unit: " diff --git a/tests/Ergodic_moves_3_audit_test.cpp b/tests/Ergodic_moves_3_audit_test.cpp index a11a0d00c..1ce8c6184 100644 --- a/tests/Ergodic_moves_3_audit_test.cpp +++ b/tests/Ergodic_moves_3_audit_test.cpp @@ -478,6 +478,21 @@ namespace return Manifold{manifolds::make_causal_vertices<3>(vertices, times), 0, 1}; } + [[nodiscard]] auto make_reordered_44_fixture() -> Manifold + { + static constexpr auto inverse_sqrt_2 = 1.0 / std::numbers::sqrt2_v; + std::vector> vertices{ + { 0, 0, 2}, + { 0, -inverse_sqrt_2, inverse_sqrt_2}, + {-inverse_sqrt_2, 0, inverse_sqrt_2}, + { 0, inverse_sqrt_2, inverse_sqrt_2}, + { inverse_sqrt_2, 0, inverse_sqrt_2}, + { 0, 0, 0}, + }; + std::vector const times{2, 1, 1, 1, 1, 0}; + return Manifold{manifolds::make_causal_vertices<3>(vertices, times), 0, 1}; + } + [[nodiscard]] auto finite_incident_cells(Delaunay const& triangulation, Edge_handle const edge) -> std::optional @@ -691,6 +706,46 @@ SCENARIO("Every 2+1D CDT move has the literature-derived local delta" * } } } + + GIVEN("equivalent (4,4) cavities with different insertion orders") + { + auto const original = make_44_fixture(); + auto const reordered = make_reordered_44_fixture(); + REQUIRE_EQ(canonical_state(reordered), canonical_state(original)); + auto triangulation = original.delaunay_snapshot(); + auto const pivot = find_44_pivot(triangulation); + REQUIRE(pivot.has_value()); + auto const reversed = + Edge_handle{pivot->first, pivot->third, pivot->second}; + auto const canonical = + ergodic_moves::detail::canonical_edge_descriptor(triangulation, *pivot); + auto const canonical_reversed = + ergodic_moves::detail::canonical_edge_descriptor(triangulation, + reversed); + REQUIRE(canonical.has_value()); + REQUIRE(canonical_reversed.has_value()); + CHECK(canonical->first == canonical_reversed->first); + CHECK_EQ(canonical->second, canonical_reversed->second); + CHECK_EQ(canonical->third, canonical_reversed->third); + cdt::Random original_random{10645}; + cdt::Random reordered_random{10645}; + + WHEN("the same random (4,4) sequence is applied") + { + auto const original_result = + ergodic_moves::do_44_move(original, original_random); + auto const reordered_result = + ergodic_moves::do_44_move(reordered, reordered_random); + + THEN("canonical flip descriptors produce the same transition") + { + REQUIRE(original_result.has_value()); + REQUIRE(reordered_result.has_value()); + CHECK_EQ(canonical_state(*reordered_result), + canonical_state(*original_result)); + } + } + } } SCENARIO("CDT move rejection is causal and failure-atomic" * @@ -985,6 +1040,58 @@ SCENARIO("CDT move rejection is causal and failure-atomic" * } } + GIVEN("a (2,6) facet whose centroid is already occupied") + { + auto triangulation = make_26_fixture().delaunay_snapshot(); + auto one_three = foliated_triangulations::filter_cells<3>( + foliated_triangulations::collect_cells<3>(triangulation), + CellType::ONE_THREE); + REQUIRE_EQ(one_three.size(), 1); + auto const bottom = one_three.front(); + auto const neighboring_31_index = + ergodic_moves::detail::find_adjacent_31_cell(bottom); + REQUIRE(neighboring_31_index.has_value()); + auto const top = bottom->neighbor(*neighboring_31_index); + REQUIRE(top != nullptr); + auto common_face_index = -1; + REQUIRE(bottom->has_neighbor(top, common_face_index)); + REQUIRE_GE(common_face_index, 0); + + std::array, 3> face_points{}; + auto output = face_points.begin(); + for (auto index = 0; index < 4; ++index) + { + if (index != common_face_index) + { + *output++ = bottom->vertex(index)->point(); + } + } + std::ranges::sort(face_points, ergodic_moves::detail::point_less); + auto const center_point = + CGAL::centroid(face_points[0], face_points[1], face_points[2]); + bottom->vertex(common_face_index)->set_point(center_point); + + auto const prepared = + ergodic_moves::detail::prepare_two_six(triangulation, bottom); + REQUIRE(prepared.has_value()); + auto const before = canonical_triangulation(triangulation); + + WHEN("the prepared move would create a coincident vertex") + { + auto const result = ergodic_moves::detail::execute( + triangulation, *prepared, + ergodic_moves::detail::accept_post_mutation); + + THEN("the coordinate invariant is rejected before mutation") + { + REQUIRE_FALSE(result.has_value()); + CHECK_EQ(result.error().reason(), + ergodic_moves::MoveFailure::INVARIANT_VIOLATION); + CHECK_EQ(canonical_triangulation(triangulation), before); + } + } + } + GIVEN("causal manifolds without the required move incidences") { auto const source_23 = make_23_fixture(); diff --git a/tests/Metropolis_test.cpp b/tests/Metropolis_test.cpp index 6681bfde6..252fe8c75 100644 --- a/tests/Metropolis_test.cpp +++ b/tests/Metropolis_test.cpp @@ -900,6 +900,194 @@ SCENARIO("Metropolis runs replay every transition from an identical start" * CHECK_EQ(first.transition_trace(), replay.transition_trace()); } +SCENARIO("Checkpoint state preserves the identical Markov chain" * + doctest::test_suite("metropolis")) +{ + auto uninterrupted_state = minimal_23_manifold(); + auto checkpointed_state = uninterrupted_state; + constexpr auto seed = cdt::RandomSeed{92}; + constexpr auto total_passes = Int_precision{2}; + constexpr auto checkpoint_interval = Int_precision{1}; + utilities::Reproducibility_metadata checkpoint_provenance{.max_threads = 1}; + Metropolis_3 uninterrupted{ + 0.6L, + 0.0L, + 0.0L, + total_passes, + checkpoint_interval, + false, + cdt::Random{seed, cdt::random_streams::transitions} + }; + Metropolis_3 checkpointed{ + 0.6L, + 0.0L, + 0.0L, + total_passes, + checkpoint_interval, + false, + cdt::Random{seed, cdt::random_streams::transitions}, + checkpoint_provenance + }; + + auto const first_pass_attempts = uninterrupted_state.N3(); + for (auto attempt = Int_precision{}; attempt < first_pass_attempts; ++attempt) + { + auto const uninterrupted_transition = + uninterrupted.attempt_transition(uninterrupted_state); + auto const checkpointed_transition = + checkpointed.attempt_transition(checkpointed_state); + REQUIRE_EQ(uninterrupted_transition, checkpointed_transition); + REQUIRE_EQ(uninterrupted_state.delaunay_snapshot(), + checkpointed_state.delaunay_snapshot()); + } + + auto const checkpoint = checkpointed.reproducibility_metadata( + checkpointed_state, utilities::ArtifactKind::CHECKPOINT, 1); + REQUIRE(checkpoint.transition_random_state); + auto restored_random = cdt::Random::from_serialized_state( + checkpoint.seed, checkpoint.transition_stream, + *checkpoint.transition_random_state); + Metropolis_3 resumed{0.6L, + 0.0L, + 0.0L, + 1, + checkpoint_interval, + false, + std::move(restored_random), + checkpoint, + 1}; + auto resumed_state = checkpointed_state; + + auto const same_counts = [](auto const& lhs, auto const& rhs) { + return std::ranges::equal(lhs.moves_view(), rhs.moves_view()); + }; + auto const second_pass_attempts = uninterrupted_state.N3(); + REQUIRE_EQ(second_pass_attempts, resumed_state.N3()); + for (auto attempt = Int_precision{}; attempt < second_pass_attempts; + ++attempt) + { + auto const uninterrupted_transition = + uninterrupted.attempt_transition(uninterrupted_state); + auto const resumed_transition = resumed.attempt_transition(resumed_state); + + CAPTURE(attempt); + CHECK_EQ(uninterrupted_transition, resumed_transition); + CHECK_EQ(uninterrupted_state.delaunay_snapshot(), + resumed_state.delaunay_snapshot()); + CHECK_EQ(utilities::detail::canonical_topology_fingerprint( + uninterrupted_state.delaunay_snapshot()), + utilities::detail::canonical_topology_fingerprint( + resumed_state.delaunay_snapshot())); + CHECK_EQ(uninterrupted.transition_trace(), resumed.transition_trace()); + CHECK_EQ(uninterrupted.transition_count(), resumed.transition_count()); + CHECK(same_counts(uninterrupted.proposed(), resumed.proposed())); + CHECK(same_counts(uninterrupted.accepted(), resumed.accepted())); + CHECK(same_counts(uninterrupted.rejected(), resumed.rejected())); + CHECK(same_counts(uninterrupted.attempted(), resumed.attempted())); + CHECK(same_counts(uninterrupted.succeeded(), resumed.succeeded())); + CHECK(same_counts(uninterrupted.failed(), resumed.failed())); + } +} + +SCENARIO( + "Metropolis resume construction rejects inconsistent checkpoint state" * + doctest::test_suite("metropolis")) +{ + auto const manifold = minimal_23_manifold(); + constexpr auto seed = cdt::RandomSeed{92}; + utilities::Reproducibility_metadata provenance{.max_threads = 1}; + Metropolis_3 source{ + 0.6L, + 0.0L, + 0.0L, + 1, + 1, + false, + cdt::Random{seed, cdt::random_streams::transitions}, + provenance + }; + auto const checkpoint = source.reproducibility_metadata( + manifold, utilities::ArtifactKind::CHECKPOINT, 0); + REQUIRE(checkpoint.transition_random_state); + + auto const restored_random = [&checkpoint] { + return cdt::Random::from_serialized_state( + checkpoint.seed, checkpoint.transition_stream, + *checkpoint.transition_random_state); + }; + + GIVEN("A checkpoint without cumulative move statistics") + { + auto incomplete = checkpoint; + incomplete.move_statistics.reset(); + + THEN("Construction rejects the incomplete stochastic continuation") + { + CHECK_THROWS_WITH_AS( + Metropolis_3(0.6L, 0.0L, 0.0L, 1, 1, false, restored_random(), + incomplete, 0), + "Checkpoint resume requires cumulative transition statistics.", + std::invalid_argument); + } + } + + GIVEN("A negative completed-pass count") + { + THEN("Construction rejects the invalid checkpoint position") + { + CHECK_THROWS_WITH_AS(Metropolis_3(0.6L, 0.0L, 0.0L, 1, 1, false, + restored_random(), checkpoint, -1), + "Completed passes cannot be negative.", + std::invalid_argument); + } + } + + GIVEN("A completed-pass count whose target would overflow") + { + THEN("Construction rejects the unrepresentable global pass count") + { + CHECK_THROWS_WITH_AS( + Metropolis_3(0.6L, 0.0L, 0.0L, + std::numeric_limits::max(), 1, false, + restored_random(), checkpoint, 1), + "Total pass count exceeds the supported range.", std::out_of_range); + } + } + + GIVEN("A checkpoint whose recorded target differs from the resumed target") + { + THEN("Construction rejects the inconsistent pass range") + { + CHECK_THROWS_WITH_AS( + Metropolis_3(0.6L, 0.0L, 0.0L, 1, 1, false, restored_random(), + checkpoint, 1), + "Checkpoint resume state does not match its pass range.", + std::invalid_argument); + } + } + + GIVEN("A generator that differs from the checkpoint's random continuation") + { + auto const reject_mismatch = [&checkpoint](cdt::Random random) { + CHECK_THROWS_WITH_AS( + Metropolis_3(0.6L, 0.0L, 0.0L, 1, 1, false, std::move(random), + checkpoint, 0), + "Checkpoint resume generator does not match its recorded random state.", + std::invalid_argument); + }; + + THEN("Construction rejects seed, stream, and mutable-state mismatches") + { + reject_mismatch( + cdt::Random{cdt::RandomSeed{93}, checkpoint.transition_stream}); + reject_mismatch(cdt::Random{checkpoint.seed, cdt::RandomStream{17}}); + auto different_state = restored_random(); + static_cast(different_state()); + reject_mismatch(std::move(different_state)); + } + } +} + SCENARIO("Metropolis multi-pass accounting is per invocation" * doctest::test_suite("metropolis")) { @@ -1014,5 +1202,34 @@ SCENARIO("Metropolis provenance is derived from the actual run" * CHECK_EQ(*metadata.checkpoint_interval, 1); } } + + WHEN("Checkpoint provenance is materialized without a thread count") + { + auto const metadata = strategy.reproducibility_metadata( + manifold, utilities::ArtifactKind::CHECKPOINT, 0); + + THEN("The snapshot is not advertised as resumable") + { CHECK_FALSE(metadata.transition_random_state); } + } + + WHEN("Checkpoint provenance is materialized with a zero thread count") + { + supplied.max_threads = 0; + Metropolis_3 zero_thread_strategy{ + 0.6L, + 1.1L, + 0.1L, + 2, + 1, + false, + cdt::Random{seed, transition_stream}, + supplied + }; + auto const metadata = zero_thread_strategy.reproducibility_metadata( + manifold, utilities::ArtifactKind::CHECKPOINT, 0); + + THEN("The snapshot is not advertised as resumable") + { CHECK_FALSE(metadata.transition_random_state); } + } } } diff --git a/tests/Move_run_test.cpp b/tests/Move_run_test.cpp index 3696e46f1..73fae2170 100644 --- a/tests/Move_run_test.cpp +++ b/tests/Move_run_test.cpp @@ -154,21 +154,68 @@ SCENARIO("MoveCommand results are consumed and reset once" * } } +SCENARIO("Shared move-run orchestration rejects invalid global pass ranges" * + doctest::test_suite("move_run")) +{ + auto const cadence = MoveRunCadence::parse(1, 1); + REQUIRE(cadence); + auto const execute = [&cadence](Int_precision const completed_passes) { + return detail::execute_move_run( + ScriptedManifold{}, detail::MoveCommandResults{}, 0, + completed_passes, *cadence, + detail::MoveRunIdentity{.algorithm = "Scripted", + .seed = RandomSeed{103}, + .stream = RandomStream{7}}, + false, + [](ScriptedManifold current, int state, Int_precision) { + return detail::MovePassResult{ + .manifold = current, + .command_results = {}, + .strategy_state = state}; + }, + [](ScriptedManifold const&, + detail::MoveCommandResults const&, int const&) {}, + [](ScriptedManifold const&, + detail::MoveCommandResults const&, int const&, + Int_precision) {}); + }; + + WHEN("The completed count is negative or would overflow the total") + { + THEN("Both invalid checkpoint positions are rejected before execution") + { + CHECK_THROWS_WITH_AS( + static_cast(execute(-1)), + "Completed and configured passes exceed the supported range.", + std::invalid_argument); + CHECK_THROWS_WITH_AS( + static_cast(execute(std::numeric_limits::max())), + "Completed and configured passes exceed the supported range.", + std::invalid_argument); + } + } +} + SCENARIO("Shared move-run orchestration accumulates pass deltas once" * doctest::test_suite("move_run")) { - GIVEN("A three-pass cadence with reporting and checkpoint collectors.") + GIVEN("Restored counters, two completed passes, and a three-pass cadence.") { auto const cadence = MoveRunCadence::parse(3, 2); REQUIRE(cadence); - std::vector reports; - std::vector checkpoints; - std::vector checkpoint_attempts; + std::vector reports; + std::vector checkpoints; + std::vector checkpoint_attempts; + detail::MoveCommandResults initial_results; + constexpr auto move = move_tracker::MoveType::TWO_THREE; + initial_results.attempted[move] = 7; + initial_results.succeeded[move] = 4; + initial_results.failed[move] = 3; WHEN("The shared runner executes scripted pass deltas.") { auto result = detail::execute_move_run( - ScriptedManifold{}, 0, *cadence, + ScriptedManifold{}, initial_results, 0, 2, *cadence, detail::MoveRunIdentity{.algorithm = "Scripted", .seed = RandomSeed{103}, .stream = RandomStream{7}}, @@ -204,13 +251,13 @@ SCENARIO("Shared move-run orchestration accumulates pass deltas once" * { CHECK_EQ(result.manifold.N3(), 5); CHECK_EQ(result.strategy_state, 3); - CHECK_EQ(result.command_results.attempted.total(), 9); - CHECK_EQ(result.command_results.succeeded.total(), 6); - CHECK_EQ(result.command_results.failed.total(), 3); + CHECK_EQ(result.command_results.attempted.total(), 16); + CHECK_EQ(result.command_results.succeeded.total(), 10); + CHECK_EQ(result.command_results.failed.total(), 6); CHECK_EQ(result.checkpoint_events, 1); - CHECK_EQ(reports, std::vector{5, 9}); - CHECK_EQ(checkpoints, std::vector{2}); - CHECK_EQ(checkpoint_attempts, std::vector{5}); + CHECK_EQ(reports, std::vector{12, 16}); + CHECK_EQ(checkpoints, std::vector{4}); + CHECK_EQ(checkpoint_attempts, std::vector{12}); } } } @@ -230,7 +277,8 @@ SCENARIO( WHEN("The shared runner executes two passes.") { auto result = detail::execute_move_run( - ScriptedManifold{}, 0, *cadence, + ScriptedManifold{}, detail::MoveCommandResults{}, 0, + 0, *cadence, detail::MoveRunIdentity{.algorithm = "Scripted", .seed = RandomSeed{103}, .stream = RandomStream{7}}, diff --git a/tests/Random_emulated_test.cpp b/tests/Random_emulated_test.cpp new file mode 100644 index 000000000..0d703b164 --- /dev/null +++ b/tests/Random_emulated_test.cpp @@ -0,0 +1,41 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL + + Copyright © 2026 Adam Getchell + ******************************************************************************/ + +/// @file Random_emulated_test.cpp +/// @brief Exact PCG continuation with emulated 128-bit arithmetic + +#include + +#include "pcg_random.hpp" +#include "Random.hpp" + +SCENARIO("Emulated 128-bit PCG state resumes at the exact next draw" * + doctest::test_suite("random")) +{ + constexpr auto seed = cdt::RandomSeed{92}; + constexpr auto stream = cdt::random_streams::transitions; + cdt::Random uninterrupted{seed, stream}; + for (auto sample = 0; sample < 37; ++sample) + { + static_cast(uninterrupted()); + } + + WHEN("The complete emulated engine state is serialized and restored") + { + auto resumed = cdt::Random::from_serialized_state( + seed, stream, uninterrupted.serialized_state()); + + THEN("Every subsequent draw remains on the same PCG sequence") + { + for (auto sample = 0; sample < 256; ++sample) + { + CHECK_EQ(resumed(), uninterrupted()); + } + CHECK_EQ(resumed.seed(), seed); + CHECK_EQ(resumed.stream(), stream); + } + } +} diff --git a/tests/Random_test.cpp b/tests/Random_test.cpp index e21d50749..43fd00e66 100644 --- a/tests/Random_test.cpp +++ b/tests/Random_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include "Foliated_triangulation.hpp" #include "Utilities.hpp" @@ -67,6 +68,52 @@ SCENARIO("PCG runs are reproducible and independently split" * } } +SCENARIO("PCG mutable state round-trips at an exact continuation point" * + doctest::test_suite("random")) +{ + constexpr auto seed = cdt::RandomSeed{92}; + constexpr auto stream = cdt::random_streams::transitions; + cdt::Random original{seed, stream}; + for (auto sample = 0; sample < 37; ++sample) + { + static_cast(original()); + } + + WHEN("The complete engine state is serialized and restored") + { + auto const state = original.serialized_state(); + auto restored = cdt::Random::from_serialized_state(seed, stream, state); + + THEN("Every subsequent draw remains on the identical PCG sequence") + { + for (auto sample = 0; sample < 256; ++sample) + { + CHECK_EQ(restored(), original()); + } + CHECK_EQ(restored.seed(), seed); + CHECK_EQ(restored.stream(), stream); + } + } + + WHEN("The state is malformed, trailing, or assigned to another stream") + { + auto const state = original.serialized_state(); + + THEN("Restoration rejects it before publishing a generator") + { + CHECK_THROWS_AS(static_cast(cdt::Random::from_serialized_state( + seed, stream, "bad")), + std::invalid_argument); + CHECK_THROWS_AS(static_cast(cdt::Random::from_serialized_state( + seed, stream, state + " trailing")), + std::invalid_argument); + CHECK_THROWS_AS(static_cast(cdt::Random::from_serialized_state( + seed, cdt::RandomStream{7}, state)), + std::invalid_argument); + } + } +} + SCENARIO("Distinct seeds have pinned PCG transition prefixes" * doctest::test_suite("random")) { diff --git a/tests/Utilities_test.cpp b/tests/Utilities_test.cpp index 34d3ddda0..5f3e49394 100644 --- a/tests/Utilities_test.cpp +++ b/tests/Utilities_test.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -167,6 +168,64 @@ namespace std::ofstream output{filename, std::ios::trunc}; output << contents; } + + void remove_metadata_field(std::filesystem::path const& filename, + std::string_view const field) + { + std::ifstream input{filename}; + std::string contents{std::istreambuf_iterator{input}, + std::istreambuf_iterator{}}; + auto const prefix = std::string{field} + '='; + auto const start = contents.find(prefix); + if (start == std::string::npos) + { + throw std::runtime_error{"Metadata field not found"}; + } + auto const end = contents.find('\n', start); + contents.erase(start, end == std::string::npos ? end : end - start + 1); + std::ofstream output{filename, std::ios::trunc}; + output << contents; + } + + template + void check_filesystem_error(Operation operation, + std::errc const expected_code, + std::string_view const expected_message) + { + try + { + operation(); + FAIL_CHECK("Malformed persistence metadata was accepted."); + } + catch (std::filesystem::filesystem_error const& error) + { + CHECK_EQ(error.code(), std::make_error_code(expected_code)); + CHECK(std::string_view{error.what()}.contains(expected_message)); + } + } + + [[nodiscard]] auto make_resumable_checkpoint_metadata( + manifolds::Manifold_3 const& manifold) -> Reproducibility_metadata + { + auto metadata = make_reproducibility_metadata(manifold, cdt::RandomSeed{92}, + ArtifactKind::CHECKPOINT); + metadata.desired_simplices = 64; + metadata.desired_timeslices = 3; + metadata.alpha = 0.6L; + metadata.k = 1.1L; + metadata.lambda = 0.1L; + metadata.configured_passes = 2; + metadata.checkpoint_interval = 1; + metadata.completed_passes = 1; + metadata.max_threads = 1; + metadata.transition_trace = 14695981039346656037ULL; + metadata.transition_count = 0; + metadata.transition_random_state = std::make_shared( + cdt::Random{cdt::RandomSeed{92}, cdt::random_streams::transitions} + .serialized_state()); + metadata.move_statistics = Move_statistics{}; + return metadata; + } } // namespace SCENARIO("Various string/stream/time utilities" * @@ -489,6 +548,39 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * CHECK(restored_cell == restored.finite_cell_handles().end()); } } + WHEN("A manifested CDT input contains coincident vertices") + { + TemporaryDirectory const directory; + auto const filename = directory.file("coincident-input.off"); + auto annotated = manifold.delaunay_snapshot(); + auto const vertices = annotated.finite_vertex_handles(); + auto first = vertices.begin(); + REQUIRE(first != vertices.end()); + auto second = std::next(first); + REQUIRE(second != vertices.end()); + (*second)->set_point((*first)->point()); + auto const metadata = make_reproducibility_metadata( + manifold, cdt::RandomSeed{92}, ArtifactKind::INITIAL_TRIANGULATION); + write_file(filename, annotated, metadata); + + THEN("generic persistence retains it but CDT evolution rejects it") + { + CHECK_NOTHROW(static_cast(read_file>(filename))); + try + { + static_cast( + read_initial_triangulation>(filename)); + FAIL_CHECK("CDT input accepted ambiguous coordinate locators."); + } + catch (std::filesystem::filesystem_error const& error) + { + CHECK_EQ(error.code(), + std::make_error_code(std::errc::not_supported)); + CHECK(std::string_view{error.what()}.contains( + "distinct vertex coordinates")); + } + } + } WHEN( "Coincident vertices exchange causal labels across distinct cell stars") { @@ -576,6 +668,12 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * metadata.max_threads = 4; metadata.transition_trace = 0x1234; metadata.transition_count = 17; + metadata.input_artifact = ArtifactKind::INITIAL_TRIANGULATION; + metadata.input_seed = cdt::RandomSeed{91}; + metadata.input_initialization_stream = + cdt::random_streams::initialization; + metadata.input_placement_fingerprint = 0x5678; + metadata.input_topology_fingerprint = 0x9abc; write_file(filename, manifold.delaunay_snapshot(), metadata); auto const sidecar = metadata_filename(filename); @@ -608,12 +706,33 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * CHECK_NE(contents.find("parallel.max_threads=4"), std::string::npos); CHECK_NE(contents.find("transition_trace.fnv1a64=0000000000001234"), std::string::npos); + CHECK_NE(contents.find("input.artifact=initial-triangulation"), + std::string::npos); + CHECK_NE(contents.find("input.random.seed=91"), std::string::npos); + CHECK_NE(contents.find("input.random.initialization_stream=0"), + std::string::npos); + CHECK_NE(contents.find("input.placement.fnv1a64=0000000000005678"), + std::string::npos); + CHECK_NE(contents.find("input.topology.fnv1a64=0000000000009abc"), + std::string::npos); CHECK_NE(contents.find("placement.fnv1a64="), std::string::npos); CHECK_NE(contents.find("topology.fnv1a64="), std::string::npos); auto const parsed_metadata = utilities::detail::read_persistence_metadata(sidecar); REQUIRE(parsed_metadata.max_threads.has_value()); CHECK_EQ(*parsed_metadata.max_threads, 4); + REQUIRE(parsed_metadata.input_artifact.has_value()); + CHECK_EQ(*parsed_metadata.input_artifact, + ArtifactKind::INITIAL_TRIANGULATION); + REQUIRE(parsed_metadata.input_seed.has_value()); + CHECK_EQ(*parsed_metadata.input_seed, cdt::RandomSeed{91}); + REQUIRE(parsed_metadata.input_initialization_stream.has_value()); + CHECK_EQ(*parsed_metadata.input_initialization_stream, + cdt::random_streams::initialization); + REQUIRE(parsed_metadata.input_placement_fingerprint.has_value()); + CHECK_EQ(*parsed_metadata.input_placement_fingerprint, 0x5678); + REQUIRE(parsed_metadata.input_topology_fingerprint.has_value()); + CHECK_EQ(*parsed_metadata.input_topology_fingerprint, 0x9abc); CHECK_NOTHROW(static_cast(read_file>(filename))); auto payload_temporary = filename; payload_temporary += ".tmp"; @@ -641,6 +760,152 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * CHECK_FALSE(parsed_metadata.max_threads.has_value()); } } + WHEN("A checkpoint contains complete stochastic continuation state") + { + TemporaryDirectory const directory; + auto const filename = directory.file("resumable.off"); + auto metadata = make_resumable_checkpoint_metadata(manifold); + + write_file(filename, manifold.delaunay_snapshot(), metadata); + + THEN("The checkpoint advertises and restores exact resume state") + { + std::ifstream input{metadata_filename(filename)}; + std::string const contents{std::istreambuf_iterator{input}, + std::istreambuf_iterator{}}; + CHECK_NE(contents.find("resume_supported=true"), std::string::npos); + CHECK_NE(contents.find("random.transition_state="), std::string::npos); + CHECK_NE(contents.find("moves.proposed=0,0,0,0,0"), std::string::npos); + + auto restored = read_checkpoint>(filename); + REQUIRE(restored.metadata.transition_random_state); + REQUIRE(restored.metadata.move_statistics); + CHECK_EQ(*restored.metadata.transition_random_state, + *metadata.transition_random_state); + CHECK_EQ(*restored.metadata.move_statistics, *metadata.move_statistics); + CHECK_EQ(restored.triangulation, manifold.delaunay_snapshot()); + } + } + WHEN("Checkpoint continuation state omits cumulative move statistics") + { + TemporaryDirectory const directory; + auto const filename = directory.file("incomplete-resume.off"); + auto metadata = make_resumable_checkpoint_metadata(manifold); + metadata.move_statistics.reset(); + + THEN("Publication rejects the incomplete resume contract atomically") + { + CHECK_THROWS_WITH_AS( + write_file(filename, manifold.delaunay_snapshot(), metadata), + "Resumable checkpoint metadata requires cumulative move statistics.", + std::invalid_argument); + CHECK_FALSE(std::filesystem::exists(filename)); + CHECK_FALSE(std::filesystem::exists(metadata_filename(filename))); + } + } + WHEN("A final artifact contains checkpoint continuation state") + { + TemporaryDirectory const directory; + auto const filename = directory.file("final-resume-state.off"); + auto metadata = make_resumable_checkpoint_metadata(manifold); + metadata.artifact = ArtifactKind::FINAL_TRIANGULATION; + + THEN("Publication rejects the artifact-role mismatch atomically") + { + CHECK_THROWS_WITH_AS( + write_file(filename, manifold.delaunay_snapshot(), metadata), + "Only checkpoint artifacts may contain resumable PCG state.", + std::invalid_argument); + CHECK_FALSE(std::filesystem::exists(filename)); + CHECK_FALSE(std::filesystem::exists(metadata_filename(filename))); + } + } + WHEN("A resumable checkpoint contains coincident vertices") + { + TemporaryDirectory const directory; + auto const filename = directory.file("coincident-checkpoint.off"); + auto annotated = manifold.delaunay_snapshot(); + auto const vertices = annotated.finite_vertex_handles(); + auto first = vertices.begin(); + REQUIRE(first != vertices.end()); + auto second = std::next(first); + REQUIRE(second != vertices.end()); + (*second)->set_point((*first)->point()); + auto metadata = make_resumable_checkpoint_metadata(manifold); + + write_file(filename, annotated, metadata); + + THEN("generic persistence retains it but checkpoint resume rejects it") + { + CHECK_NOTHROW(static_cast(read_file>(filename))); + try + { + static_cast(read_checkpoint>(filename)); + FAIL_CHECK("CDT resume accepted ambiguous coordinate locators."); + } + catch (std::filesystem::filesystem_error const& error) + { + CHECK_EQ(error.code(), + std::make_error_code(std::errc::not_supported)); + CHECK(std::string_view{error.what()}.contains( + "distinct vertex coordinates")); + } + } + } + WHEN("Input provenance contains only part of the required field group") + { + TemporaryDirectory const directory; + auto const filename = directory.file("partial-input.off"); + auto metadata = make_reproducibility_metadata( + manifold, cdt::RandomSeed{92}, ArtifactKind::FINAL_TRIANGULATION); + metadata.input_artifact = ArtifactKind::INITIAL_TRIANGULATION; + + THEN("The writer rejects it without publishing a partial artifact") + { + CHECK_THROWS_AS( + write_file(filename, manifold.delaunay_snapshot(), metadata), + std::invalid_argument); + CHECK_FALSE(std::filesystem::exists(filename)); + CHECK_FALSE(std::filesystem::exists(metadata_filename(filename))); + } + } + WHEN("Complete input provenance names a non-initial artifact") + { + TemporaryDirectory const directory; + auto metadata = make_reproducibility_metadata( + manifold, cdt::RandomSeed{92}, ArtifactKind::FINAL_TRIANGULATION); + metadata.input_seed = cdt::RandomSeed{91}; + metadata.input_initialization_stream = + cdt::random_streams::initialization; + metadata.input_placement_fingerprint = 0x5678; + metadata.input_topology_fingerprint = 0x9abc; + + THEN( + "The writer rejects checkpoint and final input roles before " + "publishing any artifact") + { + for (auto const role : + {ArtifactKind::CHECKPOINT, ArtifactKind::FINAL_TRIANGULATION}) + { + auto const filename = directory.file(role == ArtifactKind::CHECKPOINT + ? "checkpoint-input.off" + : "final-input.off"); + metadata.input_artifact = role; + + CHECK_THROWS_AS( + write_file(filename, manifold.delaunay_snapshot(), metadata), + std::invalid_argument); + CHECK_FALSE(std::filesystem::exists(filename)); + CHECK_FALSE(std::filesystem::exists(metadata_filename(filename))); + auto payload_temporary = filename; + payload_temporary += ".tmp"; + auto metadata_temporary = metadata_filename(filename); + metadata_temporary += ".tmp"; + CHECK_FALSE(std::filesystem::exists(payload_temporary)); + CHECK_FALSE(std::filesystem::exists(metadata_temporary)); + } + } + } } } @@ -784,6 +1049,43 @@ SCENARIO("File serialization reports complete failures" * } } + WHEN("Complete input provenance names a non-initial artifact.") + { + auto const filename = directory.file("wrong-input-role.off"); + auto metadata = make_reproducibility_metadata( + manifold, cdt::RandomSeed{92}, ArtifactKind::CHECKPOINT); + metadata.completed_passes = 2; + metadata.input_artifact = ArtifactKind::INITIAL_TRIANGULATION; + metadata.input_seed = cdt::RandomSeed{91}; + metadata.input_initialization_stream = + cdt::random_streams::initialization; + metadata.input_placement_fingerprint = 0x5678; + metadata.input_topology_fingerprint = 0x9abc; + write_file(filename, triangulation, metadata); + + THEN("The parser rejects checkpoint and final input roles as malformed") + { + for (auto const replacement : {std::string_view{"checkpoint"}, + std::string_view{"final-triangulation"}}) + { + replace_metadata_field(metadata_filename(filename), "input.artifact", + replacement); + try + { + static_cast(read_file>(filename)); + FAIL_CHECK("A non-initial input provenance role was accepted."); + } + catch (std::filesystem::filesystem_error const& error) + { + CHECK_EQ(error.code(), + std::make_error_code(std::errc::illegal_byte_sequence)); + CHECK(std::string_view{error.what()}.contains( + "initial-triangulation artifact")); + } + } + } + } + WHEN("A payload-derived topology fingerprint is changed in the sidecar.") { auto const filename = directory.file("changed-topology-fingerprint.off"); @@ -881,6 +1183,164 @@ SCENARIO("File serialization reports complete failures" * } } + WHEN("Resumable metadata fields violate their persistence contracts.") + { + struct MetadataMutation + { + std::string_view field; + std::string_view replacement; + std::errc code; + std::string_view message; + }; + std::array const mutations{ + {MetadataMutation{"resume_supported", "maybe", + std::errc::illegal_byte_sequence, + "invalid resume contract"}, + MetadataMutation{"configured_passes", "0", + std::errc::illegal_byte_sequence, + "invalid run configuration"}, + MetadataMutation{"completed_passes", "-1", + std::errc::illegal_byte_sequence, + "invalid completed passes"}, + MetadataMutation{"moves.proposed", "0,0", + std::errc::illegal_byte_sequence, + "wrong number of move counts"}, + MetadataMutation{"moves.proposed", "-1,0,0,0,0", + std::errc::illegal_byte_sequence, + "negative move count"}, + MetadataMutation{"moves.proposed", "1,0,0,0,0", + std::errc::illegal_byte_sequence, + "move statistics violate accounting invariants"}, + MetadataMutation{ + "transition_trace.count", "1", std::errc::illegal_byte_sequence, + "move statistics do not match the transition count"}, + MetadataMutation{"build.compiler_id", "different-compiler", + std::errc::not_supported, + "recorded producer toolchain"}, + MetadataMutation{"resume_supported", "false", + std::errc::illegal_byte_sequence, + "PCG state without resume support"}} + }; + auto index = std::size_t{}; + + THEN("Each malformed contract is rejected with its specific diagnosis") + { + for (auto const& mutation : mutations) + { + CAPTURE(mutation.field); + CAPTURE(mutation.replacement); + auto const filename = + directory.file(fmt::format("invalid-resume-{}.off", index++)); + auto const metadata = make_resumable_checkpoint_metadata(manifold); + write_file(filename, triangulation, metadata); + replace_metadata_field(metadata_filename(filename), mutation.field, + mutation.replacement); + + check_filesystem_error( + [&filename] { + static_cast(read_file>(filename)); + }, + mutation.code, mutation.message); + } + } + } + + WHEN("Resumable metadata omits a required continuation field.") + { + auto const filename = directory.file("missing-resume-field.off"); + auto const metadata = make_resumable_checkpoint_metadata(manifold); + write_file(filename, triangulation, metadata); + remove_metadata_field(metadata_filename(filename), + "parallel.max_threads"); + + THEN("The incomplete checkpoint is rejected before payload use") + { + check_filesystem_error( + [&filename] { + static_cast(read_file>(filename)); + }, + std::errc::illegal_byte_sequence, + "Resumable checkpoint metadata is incomplete or inconsistent"); + } + } + + WHEN("Resumable metadata contains only part of its move statistics.") + { + auto const filename = directory.file("partial-move-statistics.off"); + auto const metadata = make_resumable_checkpoint_metadata(manifold); + write_file(filename, triangulation, metadata); + remove_metadata_field(metadata_filename(filename), "moves.failed"); + + THEN("The partial accounting record is rejected") + { + check_filesystem_error( + [&filename] { + static_cast(read_file>(filename)); + }, + std::errc::illegal_byte_sequence, "incomplete move statistics"); + } + } + + WHEN("Persisted input provenance is incomplete.") + { + auto const filename = directory.file("partial-persisted-input.off"); + auto metadata = make_resumable_checkpoint_metadata(manifold); + metadata.input_artifact = ArtifactKind::INITIAL_TRIANGULATION; + metadata.input_seed = cdt::RandomSeed{91}; + metadata.input_initialization_stream = + cdt::random_streams::initialization; + metadata.input_placement_fingerprint = 0x5678; + metadata.input_topology_fingerprint = 0x9abc; + write_file(filename, triangulation, metadata); + remove_metadata_field(metadata_filename(filename), + "input.topology.fnv1a64"); + + THEN("The partial source identity is rejected") + { + check_filesystem_error( + [&filename] { + static_cast(read_file>(filename)); + }, + std::errc::illegal_byte_sequence, "incomplete input provenance"); + } + } + + WHEN("A checkpoint payload has no metadata sidecar.") + { + auto const filename = directory.file("unmanifested-checkpoint.off"); + write_file(filename, triangulation); + + THEN("The checkpoint reader requires the manifested pair") + { + check_filesystem_error( + [&filename] { + static_cast(read_checkpoint>(filename)); + }, + std::errc::no_such_file_or_directory, + "Checkpoint resume requires a persistence metadata sidecar"); + } + } + + WHEN("A checkpoint is a validated snapshot without continuation state.") + { + auto const filename = directory.file("snapshot-checkpoint.off"); + auto metadata = make_reproducibility_metadata( + manifold, cdt::RandomSeed{92}, ArtifactKind::CHECKPOINT); + metadata.completed_passes = 1; + write_file(filename, triangulation, metadata); + + THEN("Generic persistence accepts it but exact resume does not") + { + CHECK_NOTHROW(static_cast(read_file>(filename))); + check_filesystem_error( + [&filename] { + static_cast(read_checkpoint>(filename)); + }, + std::errc::not_supported, + "CDT resume requires a resumable checkpoint artifact"); + } + } + WHEN("Caller-supplied payload-derived provenance is stale.") { auto const filename = directory.file("reconciled-provenance.off");