From 2840437d393078aaeaaa7c654c150088ceedbf12 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 11 Aug 2026 09:12:46 -0700 Subject: [PATCH 1/5] fix(release): harden repository readiness contracts - validate complete reference packages before publishing generated artifacts - bound initializer optimization commands and report actionable failures - add pinned Markdown validation to local and CI workflows - reconcile archival guidance, API contracts, and successor issue routing --- .github/CODE_OF_CONDUCT.md | 26 ++++-- .github/CONTRIBUTING.md | 18 +++-- .github/ISSUE_TEMPLATE/config.yml | 6 ++ .github/ISSUE_TEMPLATE/feature_request.md | 27 ------- .github/workflows/ci.yml | 6 ++ CHANGELOG.md | 14 +++- Justfile | 36 ++++++++- cliff.toml | 2 +- examples/CMakeLists.txt | 5 +- include/Foliated_triangulation.hpp | 7 +- include/Manifold.hpp | 4 +- pyproject.toml | 4 + scripts/generate_reference_fixtures.py | 39 ++++++++- scripts/optimize_initialize.py | 65 ++++++++++++++- .../tests/test_generate_reference_fixtures.py | 40 +++++++++ .../tests/test_justfile_discoverability.py | 14 ++++ scripts/tests/test_optimize_initialize.py | 71 +++++++++++++++- .../tests/test_validate_reference_fixtures.py | 10 +++ scripts/validate_reference_fixtures.py | 81 ++++++++++++------- 19 files changed, 389 insertions(+), 86 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 391f1c4d45..f9656ed98a 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -2,7 +2,9 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level +of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards @@ -24,23 +26,33 @@ Examples of unacceptable behavior by participants include: ## Our Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective +action in response to any instances of unacceptable behavior. -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. ## Scope -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. +Examples include using an official project email address, posting via an official social media account, or acting as an appointed representative at +an online or offline event. Project maintainers may further define and clarify representation of the project or community. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at adam@adamgetchell.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at +[adam@adamgetchell.org](mailto:adam@adamgetchell.org). The project team will review and investigate all complaints and respond as appropriate to the +circumstances. The project team is obligated to maintain confidentiality about the reporter of an incident. Further details of specific enforcement +policies may be posted separately. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by +other members of the project's leadership. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at +[http://contributor-covenant.org/version/1/4][version]. [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 341ba05875..07251ff2dd 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -39,6 +39,9 @@ setup is: - [Just] 1.58.0 or newer; and - [Python] 3.14 and [uv] when running Python-backed checks directly. +Complete local validation also requires [rumdl](https://github.com/rvben/rumdl) 0.2.53 and [typos] +1.49.0. The Justfile reports the exact installation command when either tool is missing. + 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 @@ -49,7 +52,7 @@ For native Windows work, use an x64 [Developer Command Prompt or Developer Power 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 +validation additionally requires [uv] 0.12.3, rumdl 0.2.53, [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. @@ -98,6 +101,7 @@ updating a pull request. The primary recipes are: | `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 markdown-check` | Validate tracked and unignored Markdown sources. | | `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. | @@ -105,7 +109,7 @@ updating a pull request. The primary recipes are: | `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 +`just check` covers C++ and Python formatting, Python lint and types, Markdown, 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. @@ -135,10 +139,9 @@ 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. +The canonical Release build registers 138 CTest entries. The parallel configuration registers 139, +including its replayable parallel launcher. Labels overlap, so use CTest label selection rather than +adding the category counts as though they were disjoint. To rerun the complete supported suite without rebuilding: @@ -156,7 +159,8 @@ 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. +enabled. The compiled C++ API quickstart remains part of the Release integration suite but is +excluded from Debug for the same reason. 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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..89cedad939 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +--- +blank_issues_enabled: false +contact_links: + - name: Feature requests and active development + url: https://github.com/acgetchell/causal-triangulations/issues/new/choose + about: Propose new work in the supported Rust successor. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 0e89d09145..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -## Problem - -Describe the problem this request would solve. CDT++ remains a maintained C++ reference implementation, while broad -new development may belong in the supported -[causal-triangulations](https://github.com/acgetchell/causal-triangulations) successor. Explain why this repository is -the right ownership boundary. - -## Proposed solution - -Describe the behavior you would like and why it belongs in this C++ reference implementation. - -## Alternatives considered - -Describe any alternative solutions or workarounds you considered. - -## Additional context - -Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29f07aa739..afdfac6e1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,7 @@ jobs: echo "ninja=$(just --evaluate ninja_version)" echo "ninja-windows-wheel=$(just --evaluate ninja_windows_wheel_version)" echo "python=$(just --evaluate python_version)" + echo "rumdl=$(just --evaluate rumdl_version)" echo "typos=$(just --evaluate typos_version)" echo "uv=$(just --evaluate uv_version)" } >> "$GITHUB_OUTPUT" @@ -143,6 +144,11 @@ jobs: with: tool: typos@${{ steps.tool-versions.outputs.typos }} + - name: Set up rumdl + uses: taiki-e/install-action@1beb33eee6d086258184383af9a538940be190ed # v2.85.6 + with: + tool: rumdl@${{ steps.tool-versions.outputs.rumdl }} + - name: Set up canonical CI environment with pkgx if: runner.os != 'Windows' uses: pkgxdev/setup@4d4ae97af87ccb39ab8be4e073dea697fef2c6f7 # v5.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aca3dd49b..181ad553bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ CDT++ 1.0.0 is the final planned feature release of the C++23 spherical 2+1-dime - **Compatibility:** The supported source boundary is C++23 with the published GCC, Clang, AppleClang, and MSVC matrix. The project does not promise a stable binary ABI or package-registry distribution. - **Scientific corrections:** The release completes and independently validates all five 2+1D moves, Metropolis-Hastings proposal ratios and atomic transitions, exact-predicate geometry boundaries, run-owned RNG streams, and causal persistence metadata. - **Supported surface:** The default build is headless; CGAL/oneTBB bulk Delaunay operations and the macOS Qt viewer are explicit opt-ins. Historical toroidal and higher-dimensional prototypes are excluded from the supported API. -- **Limitations:** Seeds replay stochastic inputs, not necessarily fresh cospherical CGAL topology. Checkpoints are validated snapshots rather than resumable simulations, and parallelism does not extend to Pachner moves or concurrent manifold access. +- **Limitations:** Seeds replay stochastic inputs, not necessarily fresh cospherical CGAL topology. Resumable checkpoints continue the identical Markov chain only on the recorded producer toolchain; they are restart artifacts rather than portable interchange files. Parallelism does not extend to Pachner moves or concurrent manifold access. - **Migration:** Active development and new work move to [causal-triangulations](https://github.com/acgetchell/causal-triangulations). After the v1.0.0 GitHub and Zenodo handoff, CDT++ enters a maintenance-only stabilization window and becomes read-only only when the owner completes the archival gate. ### Added @@ -27,6 +27,8 @@ CDT++ 1.0.0 is the final planned feature release of the C++23 spherical 2+1-dime - [**breaking**] Expose reported Metropolis transitions [`16d1c76`](https://github.com/acgetchell/CDT-plusplus/commit/16d1c769afc67e06f07639f6fd9de8136974e282) +- Add initial-state loading and checkpoint resume [`e1305d6`](https://github.com/acgetchell/CDT-plusplus/commit/e1305d66663778e3c9bd7e04f8020f10919ecd1a) + ### Changed - Expose shared artifact lifecycle helpers [`2f3ba57`](https://github.com/acgetchell/CDT-plusplus/commit/2f3ba57d425f8ab4f1a0c5da0cedf7876b13eaa5) @@ -39,6 +41,12 @@ CDT++ 1.0.0 is the final planned feature release of the C++23 spherical 2+1-dime - Verify unknown moves preserve transition traces [`70ab238`](https://github.com/acgetchell/CDT-plusplus/commit/70ab238eb07271a7754266407f9f61e28e199b49) +### Documentation + +- Clean up archival documentation references [`703b474`](https://github.com/acgetchell/CDT-plusplus/commit/703b474ab8ba0f6ee86fef7842ad9c5b4b74a3e7) + +- Exclude PCG shim from generated reference [`7e8734f`](https://github.com/acgetchell/CDT-plusplus/commit/7e8734fac7097406eb416df4dd241c6a6b5d9461) + ### Fixed - Serialize Dependabot automation per pull request [`7567372`](https://github.com/acgetchell/CDT-plusplus/commit/7567372f3f11136b4317b66e695e7f2aa7d2dd69) @@ -95,6 +103,10 @@ CDT++ 1.0.0 is the final planned feature release of the C++23 spherical 2+1-dime - Stabilize macOS viewer and coverage gates [`deb24ab`](https://github.com/acgetchell/CDT-plusplus/commit/deb24ab0d7ce179469839a145f344ca1624c1275) +- Harden checkpoint resume contracts [`17bb7fb`](https://github.com/acgetchell/CDT-plusplus/commit/17bb7fb0a687837955b2a8932f028d76d029480b) + +- Enforce portable checkpoint continuation [`95754c4`](https://github.com/acgetchell/CDT-plusplus/commit/95754c430c33d4913f8e7fc30c1775280cc554e6) + ### Maintenance - Gate automatic merges on CodeRabbit review [`8124227`](https://github.com/acgetchell/CDT-plusplus/commit/812422797620c2203c9214af880cb5957af4469b) diff --git a/Justfile b/Justfile index dc4bf20e55..8e15f23b98 100644 --- a/Justfile +++ b/Justfile @@ -28,6 +28,7 @@ primary_binary := if os_family() == "windows" { "out/build/reference/src/cdt.exe python_version := trim(read(".python-version")) reference_fixture_binary := if os_family() == "windows" { "out/build/reference/tests/CDT_reference_fixture.exe" } else { "out/build/reference/tests/CDT_reference_fixture" } rng_benchmark_binary := if os_family() == "windows" { "out/build/reference/tests/CDT_rng_benchmark.exe" } else { "out/build/reference/tests/CDT_rng_benchmark" } +rumdl_version := "0.2.53" typos_version := "1.49.0" uv_version := "0.12.3" viewer_binary := "out/build/viewer/src/cdt-viewer" @@ -145,6 +146,21 @@ _ensure-git-cliff: exit 1 fi +[private] +_ensure-rumdl: + #!/usr/bin/env bash + set -euo pipefail + command -v rumdl >/dev/null || { + echo "rumdl {{ rumdl_version }} is required." >&2 + echo "Install it with: cargo install rumdl --version {{ rumdl_version }} --locked" >&2 + exit 1 + } + actual_version="$(rumdl --version | awk '{print $2}')" + if [[ "$actual_version" != "{{ rumdl_version }}" ]]; then + echo "rumdl {{ rumdl_version }} is required; found $actual_version." >&2 + exit 1 + fi + [private] _ensure-typos: #!/usr/bin/env bash @@ -310,7 +326,7 @@ changelog-unreleased version: _ensure-git-cliff _sync-python-dev # Run fast, non-mutating local validation. [group('workflows')] -check: _justfile-check _format-check _yaml-check _action-lint _zizmor _whitespace-check _cmake-check release-check python-check reference-check semgrep semgrep-test spell-check viewer-check +check: _justfile-check _format-check _yaml-check _action-lint _zizmor _whitespace-check _cmake-check markdown-check release-check python-check reference-check semgrep semgrep-test spell-check viewer-check @echo "Checks complete." # Run the comprehensive pre-commit/pre-push validation gate. @@ -408,6 +424,24 @@ initialize *args: build load input *args: build {{ primary_binary }} --input {{ quote(input) }} {{ args }} +# Check every tracked or unignored Markdown file with the pinned linter. +[group('workflows')] +markdown-check: _ensure-rumdl + #!/usr/bin/env bash + set -euo pipefail + files=() + while IFS= read -r -d '' file; do + if [[ -f "$file" && "$file" != "CHANGELOG.md" ]]; then + files+=("$file") + fi + done < <(git ls-files -co --exclude-standard -z -- '*.md') + if [[ "${#files[@]}" -gt 0 ]]; then + rumdl check --deny-config-warnings -- "${files[@]}" + fi + if [[ -f CHANGELOG.md ]]; then + rumdl check --deny-config-warnings --extend-disable MD013 -- CHANGELOG.md + fi + # Run every non-mutating Python source check. [group('workflows')] python-check: python-format-check python-lint python-typecheck python-support-test python-entrypoint-test diff --git a/cliff.toml b/cliff.toml index 688ba91bd3..9c78bf3987 100644 --- a/cliff.toml +++ b/cliff.toml @@ -23,7 +23,7 @@ body = """ - **Compatibility:** The supported source boundary is C++23 with the published GCC, Clang, AppleClang, and MSVC matrix. The project does not promise a stable binary ABI or package-registry distribution. - **Scientific corrections:** The release completes and independently validates all five 2+1D moves, Metropolis-Hastings proposal ratios and atomic transitions, exact-predicate geometry boundaries, run-owned RNG streams, and causal persistence metadata. - **Supported surface:** The default build is headless; CGAL/oneTBB bulk Delaunay operations and the macOS Qt viewer are explicit opt-ins. Historical toroidal and higher-dimensional prototypes are excluded from the supported API. - - **Limitations:** Seeds replay stochastic inputs, not necessarily fresh cospherical CGAL topology. Checkpoints are validated snapshots rather than resumable simulations, and parallelism does not extend to Pachner moves or concurrent manifold access. + - **Limitations:** Seeds replay stochastic inputs, not necessarily fresh cospherical CGAL topology. Resumable checkpoints continue the identical Markov chain only on the recorded producer toolchain; they are restart artifacts rather than portable interchange files. Parallelism does not extend to Pachner moves or concurrent manifold access. - **Migration:** Active development and new work move to [causal-triangulations](https://github.com/acgetchell/causal-triangulations). After the v1.0.0 GitHub and Zenodo handoff, CDT++ enters a maintenance-only stabilization window and becomes read-only only when the owner completes the archival gate. {% endif %} {% for group, commits in commits | group_by(attribute="group") %} diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 472de4e96d..9ee440c37f 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -17,6 +17,7 @@ if(ENABLE_TESTING) "-DTEST_EXECUTABLE=$" "-DTEST_OUTPUT=${CMAKE_CURRENT_BINARY_DIR}/cpp-api-quickstart.off" -P ${PROJECT_SOURCE_DIR}/cmake/RunCppApiQuickstartTest.cmake) - set_tests_properties(cpp-api-quickstart PROPERTIES LABELS "example;integration" - TIMEOUT 30) + set_tests_properties( + cpp-api-quickstart + PROPERTIES LABELS "example;integration;debug-incompatible" TIMEOUT 30) endif() diff --git a/include/Foliated_triangulation.hpp b/include/Foliated_triangulation.hpp index dd8d9521d9..50fe22a975 100644 --- a/include/Foliated_triangulation.hpp +++ b/include/Foliated_triangulation.hpp @@ -1634,7 +1634,8 @@ namespace cdt::foliated_triangulations /// @param triangulation Delaunay triangulation /// @param initial_radius Radius of first timeslice /// @param foliation_spacing Radial separation between timeslices - /// @pre @p initial_radius and @p foliation_spacing are finite and positive. + /// @pre @p initial_radius is finite and nonnegative; @p foliation_spacing + /// is finite and positive. /// @throws std::invalid_argument if @p triangulation is empty. explicit FoliatedTriangulation( Delaunay triangulation, double const initial_radius = INITIAL_RADIUS, @@ -1712,8 +1713,8 @@ namespace cdt::foliated_triangulations /// FoliatedTriangulation /// @param t_initial_radius Radius of first timeslice /// @param t_foliation_spacing Radial separation between timeslices - /// @pre @p t_initial_radius and @p t_foliation_spacing are finite and - /// positive. + /// @pre @p t_initial_radius is finite and nonnegative; @p + /// t_foliation_spacing is finite and positive. /// @throws std::invalid_argument if @p causal_vertices is empty or contains /// duplicate geometric points. explicit FoliatedTriangulation( diff --git a/include/Manifold.hpp b/include/Manifold.hpp index 8c0458716c..53e3697b21 100644 --- a/include/Manifold.hpp +++ b/include/Manifold.hpp @@ -158,8 +158,8 @@ namespace cdt::manifolds /// @param causal_vertices Causal_vertices to place into the Manifold /// @param t_initial_radius Radius of first timeslice /// @param t_foliation_spacing Radial separation between timeslices - /// @pre @p t_initial_radius and @p t_foliation_spacing are finite and - /// positive. + /// @pre @p t_initial_radius is finite and nonnegative; @p + /// t_foliation_spacing is finite and positive. /// @throws std::invalid_argument if @p causal_vertices is empty or contains /// duplicate geometric points. explicit Manifold(Causal_vertices_t<3> const& causal_vertices, diff --git a/pyproject.toml b/pyproject.toml index 85f4b6dd86..3b0ae43a95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,3 +151,7 @@ indent-style = "space" line-ending = "auto" quote-style = "double" skip-magic-trailing-comma = false + +[tool.rumdl] +disable = ["MD041"] +line-length = 160 diff --git a/scripts/generate_reference_fixtures.py b/scripts/generate_reference_fixtures.py index df35ad8e41..c4ff588ae5 100644 --- a/scripts/generate_reference_fixtures.py +++ b/scripts/generate_reference_fixtures.py @@ -4,6 +4,7 @@ import hashlib import json import os +import shutil import subprocess import sys import tempfile @@ -12,11 +13,13 @@ from pathlib import Path from typing import Any +from scripts import validate_reference_fixtures as reference_validator from scripts.validate_reference_fixtures import ( load_json, parse_json_object, parse_key_value_payload, validate_document, + validate_end_to_end_output, ) ROOT = Path(__file__).resolve().parents[1] @@ -455,6 +458,39 @@ def validate_generated_json( ) +def validate_generated_bounded_run(generated: dict[str, bytes]) -> None: + """Validate the generated bounded transcript before publishing any bytes.""" + relative_path = "reference/raw/v1/end-to-end.txt" + protocol = load_json(ROOT / "reference" / "fixtures" / "v1" / "protocol.json") + try: + output = generated[relative_path].decode("utf-8") + except (KeyError, UnicodeDecodeError) as error: + message = f"generated {relative_path} is missing or is not UTF-8" + raise ValueError(message) from error + validate_end_to_end_output(protocol, output, Path(f"generated {relative_path}")) + + +def validate_generated_package(generated: dict[str, bytes]) -> None: + """Validate a complete staged reference tree before publication.""" + with tempfile.TemporaryDirectory(prefix="cdt-reference-validation-") as temporary: + staged_root = Path(temporary) + staged_reference = staged_root / "reference" + shutil.copytree(ROOT / "reference", staged_reference) + for relative_path, payload in generated.items(): + destination = staged_root / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(payload) + reference_validator.main( + reference_root=staged_reference, + ) + + +def validate_and_publish(generated: dict[str, bytes]) -> None: + """Publish generated artifacts only after complete staged validation.""" + validate_generated_package(generated) + publish_artifacts(generated) + + def publish_artifacts(generated: dict[str, bytes]) -> None: """Write all bytes to sibling temporaries before replacing any artifact.""" pending: list[tuple[Path, Path]] = [] @@ -507,6 +543,7 @@ def regenerate( benchmark_commands, ) validate_platform_identity(fixture_json, scaling_records) + validate_generated_bounded_run(generated) recorded = canonical_timestamp(recorded_at_utc) metadata = RegenerationMetadata(revision, recorded, configured_cmake_version()) reference_manifest = make_reference_manifest(generated, fixture_json, metadata, reference_commands) @@ -516,7 +553,7 @@ def regenerate( validate_generated_json(fixture_json, reference_manifest, scaling_manifest) generated["reference/manifests/v1/macos-arm64.json"] = (json.dumps(reference_manifest, indent=2, ensure_ascii=False) + "\n").encode() generated["reference/manifests/v1/scaling-macos-arm64.json"] = (json.dumps(scaling_manifest, indent=2, ensure_ascii=False) + "\n").encode() - publish_artifacts(generated) + validate_and_publish(generated) print(f"Regenerated reference/raw/v1 and reference/manifests/v1 from {revision}.") diff --git a/scripts/optimize_initialize.py b/scripts/optimize_initialize.py index c3c34b31c1..5fc16bbe2d 100644 --- a/scripts/optimize_initialize.py +++ b/scripts/optimize_initialize.py @@ -2,8 +2,10 @@ import argparse import hashlib +import math import re import shutil +import subprocess import sys from dataclasses import dataclass from importlib.metadata import PackageNotFoundError, version @@ -24,6 +26,8 @@ from collections.abc import Callable, Mapping, Sequence MAX_RANDOM_SEED = (1 << 64) - 1 +DEFAULT_INITIALIZER_TIMEOUT_SECONDS = 900.0 +GIT_TIMEOUT_SECONDS = 30.0 PARAMETER_PAIRS = tuple((initial_radius, spacing) for initial_radius in range(1, 4) for spacing in (1.0, 1.5, 2.0)) @@ -42,6 +46,7 @@ class _SweepConfig: output_directory: Path repository_root: Path seed: int + timeout_seconds: float def _parse_seed(value: str) -> int: @@ -57,6 +62,19 @@ def _parse_seed(value: str) -> int: return seed +def _parse_timeout(value: str) -> float: + """Parse one finite, positive process timeout in seconds.""" + try: + timeout = float(value) + except ValueError as error: + message = "timeout must be a finite, positive number of seconds" + raise argparse.ArgumentTypeError(message) from error + if not math.isfinite(timeout) or timeout <= 0.0: + message = "timeout must be a finite, positive number of seconds" + raise argparse.ArgumentTypeError(message) + return timeout + + def _parse_args(argv: Sequence[str]) -> argparse.Namespace: """Parse the dependency-free local sweep command line.""" parser = argparse.ArgumentParser(description=__doc__) @@ -78,6 +96,12 @@ def _parse_args(argv: Sequence[str]) -> argparse.Namespace: default=92, help="root initializer seed used for every parameter pair (default: 92)", ) + parser.add_argument( + "--timeout-seconds", + type=_parse_timeout, + default=DEFAULT_INITIALIZER_TIMEOUT_SECONDS, + help=f"maximum runtime for each initializer process (default: {DEFAULT_INITIALIZER_TIMEOUT_SECONDS:g})", + ) return parser.parse_args(argv) @@ -141,12 +165,23 @@ def _experiment_provenance(repository_root: Path, initialize_binary: Path) -> di if git is None: message = "Git is required to record initializer source provenance." raise RuntimeError(message) - commit = qx([git, "-C", str(resolved_root), "rev-parse", "HEAD"], text=True).strip() # noqa: S603 + commit = qx( # noqa: S603 + [git, "-C", str(resolved_root), "rev-parse", "HEAD"], + text=True, + stderr=subprocess.PIPE, + timeout=GIT_TIMEOUT_SECONDS, + ).strip() status = qx( # noqa: S603 [git, "-C", str(resolved_root), "status", "--porcelain=v1", "--untracked-files=all"], text=True, + stderr=subprocess.PIPE, + timeout=GIT_TIMEOUT_SECONDS, + ) + tracked_diff = qx( # noqa: S603 + [git, "-C", str(resolved_root), "diff", "--binary", "HEAD"], + stderr=subprocess.PIPE, + timeout=GIT_TIMEOUT_SECONDS, ) - tracked_diff = qx([git, "-C", str(resolved_root), "diff", "--binary", "HEAD"]) # noqa: S603 try: package_version = version(PACKAGE_NAME) except PackageNotFoundError: @@ -251,7 +286,12 @@ def _run_experiments(config: _SweepConfig) -> None: """Run the local parameter sweep with the repository initializer.""" def initializer_runner(command: list[str]) -> str: - return qx(command, text=True) # noqa: S603 - The command is built from validated repository inputs. + return qx( # noqa: S603 - The command is built from validated repository inputs. + command, + text=True, + stderr=subprocess.PIPE, + timeout=config.timeout_seconds, + ) provenance = _experiment_provenance(config.repository_root, config.initialize_binary) _run_parameter_sweep( @@ -263,6 +303,18 @@ def initializer_runner(command: list[str]) -> str: ) +def _format_subprocess_failure(error: subprocess.CalledProcessError | subprocess.TimeoutExpired) -> str: + """Format a bounded subprocess failure without exposing a traceback.""" + command = error.cmd if isinstance(error.cmd, str) else " ".join(str(argument) for argument in error.cmd) + if isinstance(error, subprocess.TimeoutExpired): + return f"command timed out after {error.timeout:g} seconds: {command}" + detail = error.stderr or error.output or "" + if isinstance(detail, bytes): + detail = detail.decode(errors="replace") + suffix = f"\n{detail.strip()}" if detail.strip() else "" + return f"command exited with status {error.returncode}: {command}{suffix}" + + def main(argv: Sequence[str] | None = None) -> int: """Run the parameter sweep from an installed uv entry point.""" args = _parse_args(sys.argv[1:] if argv is None else argv) @@ -279,11 +331,18 @@ def main(argv: Sequence[str] | None = None) -> int: output_directory=output_directory, repository_root=repository_root, seed=args.seed, + timeout_seconds=args.timeout_seconds, ) ) except OutputDirectoryExistsError as error: print(str(error), file=sys.stderr) return 2 + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: + print(f"Initializer optimization failed: {_format_subprocess_failure(error)}", file=sys.stderr) + return 1 + except (OSError, RuntimeError, ValueError) as error: + print(f"Initializer optimization failed: {error}", file=sys.stderr) + return 1 print(f"All done with parameter optimization; canonical local artifacts: {output_directory}") return 0 diff --git a/scripts/tests/test_generate_reference_fixtures.py b/scripts/tests/test_generate_reference_fixtures.py index 65832f8ecf..7150a5b3dc 100644 --- a/scripts/tests/test_generate_reference_fixtures.py +++ b/scripts/tests/test_generate_reference_fixtures.py @@ -98,6 +98,46 @@ def test_producer_paths_are_validated_before_execution(self) -> None: self.assertIn(repr(str(binaries["--fixture-binary"])), str(raised.exception)) produce_raw_artifacts.assert_not_called() + def test_bounded_run_is_validated_before_publication(self) -> None: + """An invalid generated transcript cannot replace tracked artifacts.""" + binaries = {option: Path(path) for option, path in generator.CANONICAL_PRODUCER_PATHS.items()} + generated = {"reference/raw/v1/end-to-end.txt": b"invalid transcript\n"} + + with ( + mock.patch.object(generator, "clean_source_revision", return_value="a" * 40), + mock.patch.object(generator, "executable", side_effect=lambda path: path), + mock.patch.object(generator, "produce_raw_artifacts", return_value=(generated, {}, [])), + mock.patch.object(generator, "validate_platform_identity"), + mock.patch.object(generator, "validate_generated_bounded_run", side_effect=ValueError("invalid bounded run")), + mock.patch.object(generator, "publish_artifacts") as publish_artifacts, + self.assertRaisesRegex(ValueError, "invalid bounded run"), + ): + generator.regenerate( + binaries["--fixture-binary"], + binaries["--cdt-binary"], + binaries["--initialize-binary"], + binaries["--benchmark-binary"], + None, + ) + + publish_artifacts.assert_not_called() + + def test_complete_package_validation_precedes_publication(self) -> None: + """No artifact is published when staged package validation fails.""" + with ( + mock.patch.object(generator, "validate_generated_package", side_effect=ValueError("invalid package")), + mock.patch.object(generator, "publish_artifacts") as publish_artifacts, + self.assertRaisesRegex(ValueError, "invalid package"), + ): + generator.validate_and_publish({}) + + publish_artifacts.assert_not_called() + + def test_staged_package_validator_accepts_the_committed_tree(self) -> None: + """The complete validator can operate against a temporary root.""" + with mock.patch("builtins.print"): + generator.validate_generated_package({}) + def test_cmake_version_comes_from_the_configured_builds(self) -> None: """Manifest provenance uses the CMake recorded in each build cache.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/scripts/tests/test_justfile_discoverability.py b/scripts/tests/test_justfile_discoverability.py index 64fb61b363..5f038ef240 100644 --- a/scripts/tests/test_justfile_discoverability.py +++ b/scripts/tests/test_justfile_discoverability.py @@ -103,6 +103,7 @@ def test_default_gates_include_core_checks(self) -> None: self.assertTrue( { "_justfile-check", + "markdown-check", "python-check", "reference-check", "release-check", @@ -117,6 +118,19 @@ def test_default_gates_include_core_checks(self) -> None: self.assertNotIn("python-experiment-check", recipes) self.assertNotIn("_sync-python-experiments", recipes) + def test_markdown_check_reuses_the_pinned_rumdl_guard(self) -> None: + """Markdown validation must fail clearly when the exact tool is absent.""" + recipes = _just_recipes() + + self.assertIn("_ensure-rumdl", _dependency_names(recipes["markdown-check"])) + self.assertRegex(_run_just("--evaluate", "rumdl_version").stdout.strip(), r"^[0-9]+[.][0-9]+[.][0-9]+$") + + def test_debug_cli_excludes_the_assertion_incompatible_quickstart(self) -> None: + """The Release API example must not abort the focused Debug suite.""" + examples = (REPO_ROOT / "examples" / "CMakeLists.txt").read_text(encoding="utf-8") + + self.assertIn('LABELS "example;integration;debug-incompatible"', examples) + def test_public_recipes_have_one_group_and_a_description(self) -> None: """Every listed recipe should explain its purpose in one stable section.""" for name, recipe in _just_recipes().items(): diff --git a/scripts/tests/test_optimize_initialize.py b/scripts/tests/test_optimize_initialize.py index a38a04589b..d59795bb62 100644 --- a/scripts/tests/test_optimize_initialize.py +++ b/scripts/tests/test_optimize_initialize.py @@ -1,8 +1,11 @@ """Tests for the local initializer optimization support script.""" import argparse +import contextlib import hashlib +import io import json +import subprocess import unittest from pathlib import Path from tempfile import TemporaryDirectory @@ -10,6 +13,8 @@ from scripts.experiment_artifacts import staging_directory_prefix from scripts.optimize_initialize import ( + DEFAULT_INITIALIZER_TIMEOUT_SECONDS, + GIT_TIMEOUT_SECONDS, PARAMETER_PAIRS, _experiment_provenance, _initializer_binary, @@ -17,8 +22,12 @@ _parse_args, _parse_initializer_output, _parse_seed, + _parse_timeout, + _run_experiments, _run_parameter_sweep, + _SweepConfig, _SweepServices, + main, ) @@ -46,6 +55,14 @@ def test_initializer_seed_rejects_values_outside_uint64(self) -> None: with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): _parse_seed(value) + def test_initializer_timeout_is_bounded_and_configurable(self) -> None: + """Every initializer process has a finite positive runtime bound.""" + self.assertEqual(_parse_args([]).timeout_seconds, DEFAULT_INITIALIZER_TIMEOUT_SECONDS) + self.assertEqual(_parse_args(["--timeout-seconds", "12.5"]).timeout_seconds, 12.5) + for value in ("0", "-1", "inf", "nan", "invalid"): + with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): + _parse_timeout(value) + def test_initializer_output_is_parsed(self) -> None: """The sweep extracts both final size and volume profile.""" output = """Timeslice 1 has 12 spacelike faces. @@ -147,7 +164,7 @@ def test_experiment_provenance_hashes_binary_and_source_state(self) -> None: with ( patch("scripts.optimize_initialize.shutil.which", return_value=str(git_binary)), - patch("scripts.optimize_initialize.qx", side_effect=["abc123\n", " M README.md\n", b"tracked diff"]), + patch("scripts.optimize_initialize.qx", side_effect=["abc123\n", " M README.md\n", b"tracked diff"]) as qx, ): provenance = _experiment_provenance(repository_root, initialize_binary) @@ -159,6 +176,10 @@ def test_experiment_provenance_hashes_binary_and_source_state(self) -> None: "sha256": hashlib.sha256(b"exact executable").hexdigest(), }, ) + for call in qx.call_args_list: + with self.subTest(command=call.args[0]): + self.assertEqual(call.kwargs["timeout"], GIT_TIMEOUT_SECONDS) + self.assertEqual(call.kwargs["stderr"], subprocess.PIPE) self.assertEqual( provenance["repository"], { @@ -168,6 +189,54 @@ def test_experiment_provenance_hashes_binary_and_source_state(self) -> None: }, ) + def test_experiment_runner_applies_the_configured_timeout(self) -> None: + """The process service forwards the per-initializer timeout.""" + config = _SweepConfig( + initialize_binary=Path("initialize"), + output_directory=Path("output"), + repository_root=Path("checkout"), + seed=92, + timeout_seconds=12.5, + ) + with ( + patch("scripts.optimize_initialize._experiment_provenance", return_value={}), + patch("scripts.optimize_initialize._run_parameter_sweep") as run_parameter_sweep, + patch("scripts.optimize_initialize.qx", return_value="output") as qx, + ): + _run_experiments(config) + services = run_parameter_sweep.call_args.args[4] + self.assertEqual(services.initializer_runner(["initialize"]), "output") + + qx.assert_called_once_with( + ["initialize"], + text=True, + stderr=subprocess.PIPE, + timeout=12.5, + ) + + def test_main_reports_operational_failures_without_tracebacks(self) -> None: + """Subprocess and local failures produce concise CLI diagnostics.""" + failures = ( + (subprocess.TimeoutExpired(["initialize"], 3), "timed out after 3 seconds"), + (subprocess.CalledProcessError(7, ["initialize"], stderr="producer failed"), "exited with status 7"), + (OSError("cannot execute initializer"), "cannot execute initializer"), + ) + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + initialize_binary = _initializer_binary(root) + initialize_binary.parent.mkdir(parents=True) + initialize_binary.touch() + for failure, expected in failures: + stderr = io.StringIO() + with ( + self.subTest(failure=type(failure).__name__), + patch("scripts.optimize_initialize._run_experiments", side_effect=failure), + contextlib.redirect_stderr(stderr), + ): + self.assertEqual(main(["--repository-root", str(root)]), 1) + self.assertIn(expected, stderr.getvalue()) + self.assertNotIn("Traceback", stderr.getvalue()) + if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_validate_reference_fixtures.py b/scripts/tests/test_validate_reference_fixtures.py index 13f902a990..f08c554a8d 100644 --- a/scripts/tests/test_validate_reference_fixtures.py +++ b/scripts/tests/test_validate_reference_fixtures.py @@ -172,6 +172,16 @@ def test_bounded_run_command_must_match_retained_output(self) -> None: with self.assertRaisesRegex(ValueError, "desired timeslices"): validator.validate_end_to_end(protocol, self.end_to_end_path) + def test_bounded_run_accepts_current_pass_label(self) -> None: + """The current producer wording retains the declared pass count.""" + output = self.end_to_end_path.read_text(encoding="utf-8").replace( + "Number of passes: 1", + "Number of passes to execute: 1", + ) + self.assertIn("Number of passes to execute: 1", output) + + validator.validate_end_to_end_output(self.protocol, output, Path("generated end-to-end.txt")) + def test_bounded_run_f_vector_must_lie_inside_declared_band(self) -> None: """The declared randomized band is executable acceptance data.""" protocol = copy.deepcopy(self.protocol) diff --git a/scripts/validate_reference_fixtures.py b/scripts/validate_reference_fixtures.py index d678e07da2..87aa14cd00 100644 --- a/scripts/validate_reference_fixtures.py +++ b/scripts/validate_reference_fixtures.py @@ -474,7 +474,11 @@ def file_digest(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() -def validate_manifest(manifest: dict[str, Any]) -> None: +def validate_manifest( + manifest: dict[str, Any], + repository_root: Path = ROOT, + reference_root: Path = REFERENCE, +) -> None: """Check that every manifested local artifact exists and matches SHA-256.""" artifact_paths = {artifact["path"] for artifact in manifest["artifacts"]} command_ids = [command["id"] for command in manifest["commands"]] @@ -494,8 +498,8 @@ def validate_manifest(manifest: dict[str, Any]) -> None: raise ValueError(message) for artifact in manifest["artifacts"]: - path = (ROOT / artifact["path"]).resolve() - if not path.is_relative_to(REFERENCE.resolve()): + path = (repository_root / artifact["path"]).resolve() + if not path.is_relative_to(reference_root.resolve()): message = f"manifest artifact escapes the reference directory: {path}" raise ValueError(message) if not path.is_file(): @@ -558,10 +562,10 @@ def fnv1a64(payload: bytes) -> str: return f"{value:016x}" -def validate_persistence() -> None: +def validate_persistence(reference_root: Path = REFERENCE) -> None: """Verify the committed payload against its raw C++ sidecar.""" - payload_path = REFERENCE / "raw" / "v1" / "persistence-v1.off" - metadata_path = REFERENCE / "raw" / "v1" / "persistence-v1.off.meta" + payload_path = reference_root / "raw" / "v1" / "persistence-v1.off" + metadata_path = reference_root / "raw" / "v1" / "persistence-v1.off.meta" payload = payload_path.read_bytes() metadata = dict(line.split("=", maxsplit=1) for line in metadata_path.read_text(encoding="utf-8").splitlines() if "=" in line) if int(metadata["payload.size"]) != len(payload): @@ -606,7 +610,6 @@ def validate_bounded_run_command(bounded_run: dict[str, Any], output: str, path: expected_text = ( f"Number of desired simplices: {command_option(command, '-n', '--simplices')}", f"Number of desired timeslices: {command_option(command, '-t', '--timeslices')}", - f"Number of passes: {command_option(command, '-p', '--passes')}", f"Checkpoint every {command_option(command, '-c', '--checkpoint')} passes.", f"Effective random seed: {command_option(command, '--seed')}", f"Maximum Delaunay threads: {command_option(command, '--threads')}", @@ -616,6 +619,15 @@ def validate_bounded_run_command(bounded_run: dict[str, Any], output: str, path: message = f"{path}: output contradicts declared command field {text!r}" raise ValueError(message) + passes = command_option(command, "-p", "--passes") + pass_records = ( + f"Number of passes: {passes}", + f"Number of passes to execute: {passes}", + ) + if not any(text in output for text in pass_records): + message = f"{path}: output contradicts declared pass count {passes!r}" + raise ValueError(message) + expected_parameters = { "Alpha": float(command_option(command, "-a", "--alpha")), "K": float(command_option(command, "-k")), @@ -636,11 +648,8 @@ def bounded_run_final_f_vector(output: str) -> list[int]: return [int(value) for value in matches[-1]] -def validate_end_to_end(protocol: dict[str, Any], path: Path | None = None) -> None: - """Check bounded-run command provenance, bands, and accounting identities.""" - if path is None: - path = REFERENCE / "raw" / "v1" / "end-to-end.txt" - output = path.read_text(encoding="utf-8") +def validate_end_to_end_output(protocol: dict[str, Any], output: str, path: Path) -> None: + """Check one bounded-run payload's provenance, bands, and accounting.""" bounded_run = protocol["bounded_run"] command = bounded_run["command"] validate_bounded_run_command(bounded_run, output, path) @@ -664,6 +673,13 @@ def validate_end_to_end(protocol: dict[str, Any], path: Path | None = None) -> N raise ValueError(message) +def validate_end_to_end(protocol: dict[str, Any], path: Path | None = None) -> None: + """Check the retained bounded-run record against the reference protocol.""" + if path is None: + path = REFERENCE / "raw" / "v1" / "end-to-end.txt" + validate_end_to_end_output(protocol, path.read_text(encoding="utf-8"), path) + + def parse_key_value_record(path: Path) -> dict[str, str]: """Read one raw benchmark record without changing or normalizing it.""" return parse_key_value_payload(path.read_text(encoding="utf-8"), str(path)) @@ -687,9 +703,9 @@ def parse_key_value_payload(payload: str, source: str) -> dict[str, str]: return result -def validate_scaling_records() -> None: +def validate_scaling_records(reference_root: Path = REFERENCE) -> None: """Verify #88 records share one matched protocol and retain raw samples.""" - records = [parse_key_value_record(REFERENCE / "raw" / "v1" / f"scaling-threads-{threads}.txt") for threads in (1, 2, 4)] + records = [parse_key_value_record(reference_root / "raw" / "v1" / f"scaling-threads-{threads}.txt") for threads in (1, 2, 4)] matched_keys = ( "record.schema", "implementation.revision", @@ -728,21 +744,23 @@ def validate_scaling_records() -> None: def reference_revisions( raw: dict[str, Any], manifests: list[dict[str, Any]], + reference_root: Path = REFERENCE, ) -> set[str]: """Collect every source revision recorded by the reference package.""" return { str(raw["implementation"]["revision"]), *(str(manifest["implementation"]["source_revision"]) for manifest in manifests), - *(parse_key_value_record(REFERENCE / "raw" / "v1" / f"scaling-threads-{threads}.txt")["implementation.revision"] for threads in (1, 2, 4)), + *(parse_key_value_record(reference_root / "raw" / "v1" / f"scaling-threads-{threads}.txt")["implementation.revision"] for threads in (1, 2, 4)), } def validate_provenance_consistency( raw: dict[str, Any], manifests: list[dict[str, Any]], + reference_root: Path = REFERENCE, ) -> str: """Require every artifact family to name the same source revision.""" - revisions = reference_revisions(raw, manifests) + revisions = reference_revisions(raw, manifests, reference_root) if len(revisions) != 1: message = "reference artifacts do not share one source revision" raise ValueError(message) @@ -752,9 +770,10 @@ def validate_provenance_consistency( def validate_clean_provenance( raw: dict[str, Any], manifests: list[dict[str, Any]], + reference_root: Path = REFERENCE, ) -> None: """Require archival artifacts to identify one clean source commit.""" - revision = validate_provenance_consistency(raw, manifests) + revision = validate_provenance_consistency(raw, manifests, reference_root) if revision.endswith("-dirty") or re.fullmatch(r"[0-9a-f]{40}", revision) is None: message = "archival reference artifacts must name one clean Git commit" raise ValueError(message) @@ -884,11 +903,13 @@ def main( # noqa: C901 generated_only: bool = False, provenance_only: bool = False, require_clean_provenance: bool = False, + reference_root: Path = REFERENCE, ) -> int: """Validate schemas, protocol data, raw results, and provenance.""" - fixture_path = REFERENCE / "fixtures" / "v1" / "protocol.json" - result_path = REFERENCE / "raw" / "v1" / "cpp-reference.json" - result_schema = load_json(REFERENCE / "schema" / "result-v1.schema.json") + repository_root = reference_root.parent + fixture_path = reference_root / "fixtures" / "v1" / "protocol.json" + result_path = reference_root / "raw" / "v1" / "cpp-reference.json" + result_schema = load_json(reference_root / "schema" / "result-v1.schema.json") protocol = load_json(fixture_path) raw = load_json(result_path) if generated_only: @@ -899,15 +920,15 @@ def main( # noqa: C901 print("Generated reference fixture matches the committed scientific payload.") return 0 - manifest_paths = sorted((REFERENCE / "manifests" / "v1").glob("*.json")) + manifest_paths = sorted((reference_root / "manifests" / "v1").glob("*.json")) manifests = [load_json(path) for path in manifest_paths] if provenance_only: - validate_clean_provenance(raw, manifests) + validate_clean_provenance(raw, manifests, reference_root) print("Reference artifacts identify one clean source commit.") return 0 - fixture_schema = load_json(REFERENCE / "schema" / "fixture-v1.schema.json") - manifest_schema = load_json(REFERENCE / "schema" / "run-manifest-v1.schema.json") + fixture_schema = load_json(reference_root / "schema" / "fixture-v1.schema.json") + manifest_schema = load_json(reference_root / "schema" / "run-manifest-v1.schema.json") validate_document(protocol, fixture_schema, fixture_path) validate_document(raw, result_schema, result_path) @@ -928,14 +949,14 @@ def main( # noqa: C901 validate_actions(raw, protocol) validate_protocol(protocol, raw) for manifest in manifests: - validate_manifest(manifest) + validate_manifest(manifest, repository_root, reference_root) validate_command_provenance(manifests, protocol) - validate_provenance_consistency(raw, manifests) - validate_persistence() - validate_end_to_end(protocol) - validate_scaling_records() + validate_provenance_consistency(raw, manifests, reference_root) + validate_persistence(reference_root) + validate_end_to_end(protocol, reference_root / "raw" / "v1" / "end-to-end.txt") + validate_scaling_records(reference_root) if require_clean_provenance: - validate_clean_provenance(raw, manifests) + validate_clean_provenance(raw, manifests, reference_root) if fixture_binary is not None: validate_generated_fixture(fixture_binary, raw, result_schema, protocol) print("Reference fixture package is valid.") From 026ba3f20b496923197fb651fb77fa62414a2b3a Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 11 Aug 2026 09:25:52 -0700 Subject: [PATCH 2/5] fix(reproducibility): preserve canonical producer paths on Windows Serialize repository-relative producer paths with stable POSIX separators so Windows regeneration satisfies the platform-neutral manifest contract. --- scripts/generate_reference_fixtures.py | 9 +++++++-- scripts/tests/test_generate_reference_fixtures.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/generate_reference_fixtures.py b/scripts/generate_reference_fixtures.py index c4ff588ae5..0688e1e5a3 100644 --- a/scripts/generate_reference_fixtures.py +++ b/scripts/generate_reference_fixtures.py @@ -10,7 +10,7 @@ import tempfile from dataclasses import dataclass from datetime import UTC, datetime -from pathlib import Path +from pathlib import Path, PurePath from typing import Any from scripts import validate_reference_fixtures as reference_validator @@ -112,11 +112,16 @@ def executable(path: Path) -> Path: return resolved +def portable_path(path: PurePath) -> str: + """Render a repository path with stable separators for manifest metadata.""" + return path.as_posix() + + def display_path(path: Path) -> str: """Render a producer path relative to the repository when possible.""" resolved = path if path.is_absolute() else ROOT / path try: - return str(resolved.resolve().relative_to(ROOT.resolve())) + return portable_path(resolved.resolve().relative_to(ROOT.resolve())) except ValueError: return str(resolved) diff --git a/scripts/tests/test_generate_reference_fixtures.py b/scripts/tests/test_generate_reference_fixtures.py index 7150a5b3dc..053466173b 100644 --- a/scripts/tests/test_generate_reference_fixtures.py +++ b/scripts/tests/test_generate_reference_fixtures.py @@ -2,7 +2,7 @@ import tempfile import unittest -from pathlib import Path +from pathlib import Path, PureWindowsPath from unittest import mock from scripts import generate_reference_fixtures as generator @@ -63,6 +63,15 @@ def test_command_records_must_match_generated_artifacts(self) -> None: with self.assertRaisesRegex(ValueError, "does not match the generated artifacts"): generator.record_commands([{"id": "fixture", "artifacts": []}], []) + def test_producer_paths_use_portable_separators(self) -> None: + """Windows producer paths retain the canonical manifest spelling.""" + path = PureWindowsPath(r"out\build\reference\tests\CDT_reference_fixture") + + self.assertEqual( + generator.portable_path(path), + generator.CANONICAL_PRODUCER_PATHS["--fixture-binary"], + ) + def test_producer_paths_must_use_the_canonical_layout(self) -> None: """A noncanonical producer is rejected with its option and path.""" reference_commands = [ From 38be43de1ec0e9aa181e6e209ce80a3c6b6db7ef Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 11 Aug 2026 10:17:15 -0700 Subject: [PATCH 3/5] fix(release): harden boundary validation and diagnostics - accept origin vertices when the expected foliation radius is zero - retain partial timeout output and require exact fixture pass counts - align checkpoint wording with producer-toolchain limitations --- CHANGELOG.md | 2 +- include/Foliated_triangulation.hpp | 4 ++++ scripts/optimize_initialize.py | 4 ++-- scripts/tests/test_justfile_discoverability.py | 3 +++ scripts/tests/test_optimize_initialize.py | 14 ++++++++++++++ scripts/tests/test_validate_reference_fixtures.py | 11 +++++++++++ scripts/validate_reference_fixtures.py | 3 ++- tests/Foliated_triangulation_test.cpp | 2 ++ 8 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 181ad553bf..c02e32f3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,7 +105,7 @@ CDT++ 1.0.0 is the final planned feature release of the C++23 spherical 2+1-dime - Harden checkpoint resume contracts [`17bb7fb`](https://github.com/acgetchell/CDT-plusplus/commit/17bb7fb0a687837955b2a8932f028d76d029480b) -- Enforce portable checkpoint continuation [`95754c4`](https://github.com/acgetchell/CDT-plusplus/commit/95754c430c33d4913f8e7fc30c1775280cc554e6) +- Limit resumable checkpoint continuation to the recorded producer toolchain [`95754c4`](https://github.com/acgetchell/CDT-plusplus/commit/95754c430c33d4913f8e7fc30c1775280cc554e6) ### Maintenance diff --git a/include/Foliated_triangulation.hpp b/include/Foliated_triangulation.hpp index 50fe22a975..4108b9aad1 100644 --- a/include/Foliated_triangulation.hpp +++ b/include/Foliated_triangulation.hpp @@ -1870,6 +1870,10 @@ namespace cdt::foliated_triangulations auto const actual_radius_squared = squared_radius<3>(t_vertex); auto const radius = expected_radius(t_vertex); auto const expected_radius_squared = std::pow(radius, 2); + if (expected_radius_squared == 0.0) + { + return std::abs(actual_radius_squared) <= TOLERANCE; + } return actual_radius_squared > expected_radius_squared * (1 - TOLERANCE) && actual_radius_squared < expected_radius_squared * (1 + TOLERANCE); diff --git a/scripts/optimize_initialize.py b/scripts/optimize_initialize.py index 5fc16bbe2d..62a3c32f11 100644 --- a/scripts/optimize_initialize.py +++ b/scripts/optimize_initialize.py @@ -306,12 +306,12 @@ def initializer_runner(command: list[str]) -> str: def _format_subprocess_failure(error: subprocess.CalledProcessError | subprocess.TimeoutExpired) -> str: """Format a bounded subprocess failure without exposing a traceback.""" command = error.cmd if isinstance(error.cmd, str) else " ".join(str(argument) for argument in error.cmd) - if isinstance(error, subprocess.TimeoutExpired): - return f"command timed out after {error.timeout:g} seconds: {command}" detail = error.stderr or error.output or "" if isinstance(detail, bytes): detail = detail.decode(errors="replace") suffix = f"\n{detail.strip()}" if detail.strip() else "" + if isinstance(error, subprocess.TimeoutExpired): + return f"command timed out after {error.timeout:g} seconds: {command}{suffix}" return f"command exited with status {error.returncode}: {command}{suffix}" diff --git a/scripts/tests/test_justfile_discoverability.py b/scripts/tests/test_justfile_discoverability.py index 5f038ef240..207e636572 100644 --- a/scripts/tests/test_justfile_discoverability.py +++ b/scripts/tests/test_justfile_discoverability.py @@ -128,8 +128,11 @@ def test_markdown_check_reuses_the_pinned_rumdl_guard(self) -> None: def test_debug_cli_excludes_the_assertion_incompatible_quickstart(self) -> None: """The Release API example must not abort the focused Debug suite.""" examples = (REPO_ROOT / "examples" / "CMakeLists.txt").read_text(encoding="utf-8") + presets = json.loads((REPO_ROOT / "CMakePresets.json").read_text(encoding="utf-8")) + debug_cli = next(preset for preset in presets["testPresets"] if preset["name"] == "debug-cli") self.assertIn('LABELS "example;integration;debug-incompatible"', examples) + self.assertEqual(debug_cli["filter"]["exclude"]["label"], "^debug-incompatible$") def test_public_recipes_have_one_group_and_a_description(self) -> None: """Every listed recipe should explain its purpose in one stable section.""" diff --git a/scripts/tests/test_optimize_initialize.py b/scripts/tests/test_optimize_initialize.py index d59795bb62..6e3e2f16a5 100644 --- a/scripts/tests/test_optimize_initialize.py +++ b/scripts/tests/test_optimize_initialize.py @@ -17,6 +17,7 @@ GIT_TIMEOUT_SECONDS, PARAMETER_PAIRS, _experiment_provenance, + _format_subprocess_failure, _initializer_binary, _initializer_command, _parse_args, @@ -237,6 +238,19 @@ def test_main_reports_operational_failures_without_tracebacks(self) -> None: self.assertIn(expected, stderr.getvalue()) self.assertNotIn("Traceback", stderr.getvalue()) + def test_timeout_failure_retains_partial_output(self) -> None: + """A bounded subprocess retains diagnostics emitted before timeout.""" + failure = subprocess.TimeoutExpired( + ["initialize", "--seed", "92"], + 3, + output=b"partial initializer output", + ) + + message = _format_subprocess_failure(failure) + + self.assertIn("timed out after 3 seconds: initialize --seed 92", message) + self.assertIn("partial initializer output", message) + if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_validate_reference_fixtures.py b/scripts/tests/test_validate_reference_fixtures.py index f08c554a8d..f469607d58 100644 --- a/scripts/tests/test_validate_reference_fixtures.py +++ b/scripts/tests/test_validate_reference_fixtures.py @@ -182,6 +182,17 @@ def test_bounded_run_accepts_current_pass_label(self) -> None: validator.validate_end_to_end_output(self.protocol, output, Path("generated end-to-end.txt")) + def test_bounded_run_rejects_pass_count_prefix(self) -> None: + """A longer pass count cannot satisfy the declared complete record.""" + output = self.end_to_end_path.read_text(encoding="utf-8").replace( + "Number of passes: 1", + "Number of passes: 10", + ) + self.assertIn("Number of passes: 10", output) + + with self.assertRaisesRegex(ValueError, "declared pass count"): + validator.validate_end_to_end_output(self.protocol, output, Path("generated end-to-end.txt")) + def test_bounded_run_f_vector_must_lie_inside_declared_band(self) -> None: """The declared randomized band is executable acceptance data.""" protocol = copy.deepcopy(self.protocol) diff --git a/scripts/validate_reference_fixtures.py b/scripts/validate_reference_fixtures.py index 87aa14cd00..b79bf6d94f 100644 --- a/scripts/validate_reference_fixtures.py +++ b/scripts/validate_reference_fixtures.py @@ -624,7 +624,8 @@ def validate_bounded_run_command(bounded_run: dict[str, Any], output: str, path: f"Number of passes: {passes}", f"Number of passes to execute: {passes}", ) - if not any(text in output for text in pass_records): + output_lines = output.splitlines() + if not any(text in output_lines for text in pass_records): message = f"{path}: output contradicts declared pass count {passes!r}" raise ValueError(message) diff --git a/tests/Foliated_triangulation_test.cpp b/tests/Foliated_triangulation_test.cpp index e7dbe31135..df51539d23 100644 --- a/tests/Foliated_triangulation_test.cpp +++ b/tests/Foliated_triangulation_test.cpp @@ -260,6 +260,8 @@ SCENARIO("FoliatedTriangulation free functions" * { CHECK_EQ(vertex.value()->point(), Point_t<3>{0, 0, 0}); CHECK_EQ(vertex.value()->info(), 1); + CHECK( + triangulation.does_vertex_radius_match_timevalue(vertex.value())); } } WHEN("We choose a point not in the triangulation.") From 5a32b1d5ac1ca460ca78996e8e8b5ce42a5c54cb Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 11 Aug 2026 10:28:14 -0700 Subject: [PATCH 4/5] test(reproducibility): expect portable producer paths on Windows --- scripts/tests/test_generate_reference_fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/test_generate_reference_fixtures.py b/scripts/tests/test_generate_reference_fixtures.py index 053466173b..e5b3770332 100644 --- a/scripts/tests/test_generate_reference_fixtures.py +++ b/scripts/tests/test_generate_reference_fixtures.py @@ -104,7 +104,7 @@ def test_producer_paths_are_validated_before_execution(self) -> None: ) self.assertIn("--fixture-binary", str(raised.exception)) - self.assertIn(repr(str(binaries["--fixture-binary"])), str(raised.exception)) + self.assertIn(repr(generator.portable_path(binaries["--fixture-binary"])), str(raised.exception)) produce_raw_artifacts.assert_not_called() def test_bounded_run_is_validated_before_publication(self) -> None: From 94a0c7700b9b9aa3527863c16a9018ac48f71c13 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 11 Aug 2026 10:47:42 -0700 Subject: [PATCH 5/5] fix(release): preserve complete subprocess diagnostics Combine captured stderr and stdout for initializer failures so timeout and exit-status details are not discarded. --- scripts/optimize_initialize.py | 11 +++--- .../tests/test_generate_reference_fixtures.py | 15 +++++++- scripts/tests/test_optimize_initialize.py | 35 ++++++++++++++----- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/scripts/optimize_initialize.py b/scripts/optimize_initialize.py index 62a3c32f11..aec90e42cb 100644 --- a/scripts/optimize_initialize.py +++ b/scripts/optimize_initialize.py @@ -306,10 +306,13 @@ def initializer_runner(command: list[str]) -> str: def _format_subprocess_failure(error: subprocess.CalledProcessError | subprocess.TimeoutExpired) -> str: """Format a bounded subprocess failure without exposing a traceback.""" command = error.cmd if isinstance(error.cmd, str) else " ".join(str(argument) for argument in error.cmd) - detail = error.stderr or error.output or "" - if isinstance(detail, bytes): - detail = detail.decode(errors="replace") - suffix = f"\n{detail.strip()}" if detail.strip() else "" + details: list[str] = [] + for detail in (error.stderr, error.output): + decoded_detail = detail.decode(errors="replace") if isinstance(detail, bytes) else detail + if decoded_detail and (stripped := decoded_detail.strip()): + details.append(stripped) + combined_detail = "\n".join(details) + suffix = f"\n{combined_detail}" if combined_detail else "" if isinstance(error, subprocess.TimeoutExpired): return f"command timed out after {error.timeout:g} seconds: {command}{suffix}" return f"command exited with status {error.returncode}: {command}{suffix}" diff --git a/scripts/tests/test_generate_reference_fixtures.py b/scripts/tests/test_generate_reference_fixtures.py index e5b3770332..5580fed4fd 100644 --- a/scripts/tests/test_generate_reference_fixtures.py +++ b/scripts/tests/test_generate_reference_fixtures.py @@ -143,10 +143,23 @@ def test_complete_package_validation_precedes_publication(self) -> None: publish_artifacts.assert_not_called() def test_staged_package_validator_accepts_the_committed_tree(self) -> None: - """The complete validator can operate against a temporary root.""" + """The staged validator accepts an unchanged copy of the committed tree.""" with mock.patch("builtins.print"): generator.validate_generated_package({}) + def test_conflicting_staged_artifact_is_rejected_before_publication(self) -> None: + """A generated artifact must agree with its retained manifest.""" + relative_path = "reference/raw/v1/cpp-reference.json" + generated = {relative_path: (generator.ROOT / relative_path).read_bytes() + b"\n"} + + with ( + mock.patch.object(generator, "publish_artifacts") as publish_artifacts, + self.assertRaisesRegex(ValueError, "checksum mismatch"), + ): + generator.validate_and_publish(generated) + + publish_artifacts.assert_not_called() + def test_cmake_version_comes_from_the_configured_builds(self) -> None: """Manifest provenance uses the CMake recorded in each build cache.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/scripts/tests/test_optimize_initialize.py b/scripts/tests/test_optimize_initialize.py index 6e3e2f16a5..b78fe03923 100644 --- a/scripts/tests/test_optimize_initialize.py +++ b/scripts/tests/test_optimize_initialize.py @@ -238,18 +238,35 @@ def test_main_reports_operational_failures_without_tracebacks(self) -> None: self.assertIn(expected, stderr.getvalue()) self.assertNotIn("Traceback", stderr.getvalue()) - def test_timeout_failure_retains_partial_output(self) -> None: - """A bounded subprocess retains diagnostics emitted before timeout.""" - failure = subprocess.TimeoutExpired( - ["initialize", "--seed", "92"], - 3, - output=b"partial initializer output", + def test_subprocess_failures_retain_both_captured_streams(self) -> None: + """Bounded subprocess failures retain diagnostics from both streams.""" + failures = ( + ( + subprocess.CalledProcessError( + 7, + ["initialize", "--seed", "92"], + output="\nstdout diagnostic ", + stderr=" stderr diagnostic\n", + ), + "command exited with status 7: initialize --seed 92", + ), + ( + subprocess.TimeoutExpired( + ["initialize", "--seed", "92"], + 3, + output=b"\nstdout diagnostic ", + stderr=b" stderr diagnostic\n", + ), + "command timed out after 3 seconds: initialize --seed 92", + ), ) - message = _format_subprocess_failure(failure) + for failure, expected in failures: + with self.subTest(failure=type(failure).__name__): + message = _format_subprocess_failure(failure) - self.assertIn("timed out after 3 seconds: initialize --seed 92", message) - self.assertIn("partial initializer output", message) + self.assertIn(expected, message) + self.assertTrue(message.endswith("\nstderr diagnostic\nstdout diagnostic")) if __name__ == "__main__":