From ce90551ff28368235844b8fe0b068c050f529ab1 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 21 Jul 2026 16:11:23 -0700 Subject: [PATCH 1/2] feat(reproducibility): persist verifiable stochastic run provenance - Record effective seeds, named PCG streams, build identity, transition traces, causal metadata, state fingerprints, and payload checksums. - Atomically publish validated OFF payload/manifest pairs and reject malformed, corrupt, or semantically inconsistent persisted state. - Canonically map proposal draws for identical-start replay while preserving spherical placement and CGAL triangulation freedom. - Document checkpoint and fresh-topology replay limitations. Closes #93 --- CMakeLists.txt | 4 +- README.md | 15 +- cmake/RunCdtNoOutputTest.cmake | 25 +- cmake/RunPersistenceOutputTest.cmake | 98 ++ cmake/Version.hpp.in | 12 + docs/reproducibility.md | 129 ++- include/Ergodic_moves_3.hpp | 169 +++- include/Metropolis.hpp | 139 ++- include/Runtime_config.hpp | 51 +- include/Utilities.hpp | 1388 ++++++++++++++++++++++++-- src/CMakeLists.txt | 43 +- src/cdt.cpp | 35 +- src/initialize.cpp | 19 +- tests/Ergodic_moves_3_test.cpp | 27 + tests/Metropolis_test.cpp | 61 +- tests/Random_test.cpp | 22 + tests/Runtime_config_test.cpp | 9 + tests/Utilities_test.cpp | 282 ++++++ 18 files changed, 2361 insertions(+), 167 deletions(-) create mode 100644 cmake/RunPersistenceOutputTest.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 4fe1111d8..9318000d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,10 +30,10 @@ project( # the product version used by executables and release metadata. set(CDT_VERSION_SUFFIX "-rc1") set(CDT_VERSION "${PROJECT_VERSION}${CDT_VERSION_SUFFIX}") -configure_file(cmake/Version.hpp.in "${PROJECT_BINARY_DIR}/Version.hpp" @ONLY) # Project settings include(cmake/StandardProjectSettings.cmake) +configure_file(cmake/Version.hpp.in "${PROJECT_BINARY_DIR}/Version.hpp" @ONLY) # Prevent in source builds include(cmake/PreventInSourceBuilds.cmake) @@ -41,6 +41,8 @@ include(cmake/PreventInSourceBuilds.cmake) # Link this 'library' to set the c++ standard / compile-time options requested add_library(project_options INTERFACE) target_compile_features(project_options INTERFACE cxx_std_23) +target_compile_definitions( + project_options INTERFACE CDT_BUILD_CONFIGURATION="$") if(CMAKE_CXX_COMPILER_ID MATCHES ".*Clang") option(ENABLE_BUILD_WITH_TIME_TRACE "Enable -ftime-trace to generate time tracing .json files on clang" OFF) diff --git a/README.md b/README.md index 91264b014..3af373e67 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,16 @@ triangulation files: just run -s -n256 -t4 -a0.6 -k1.1 -l0.1 -p10 -c10 --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 currently disabled and will be restored as an opt-in v1.0.0 target by [#98](https://github.com/acgetchell/CDT-plusplus/issues/98) - `initialize` is used by [CometML] to run [parameter optimization](#optimizing-parameters) @@ -471,8 +481,9 @@ uv run --locked --group experiments cdt-mnist-experiment Run these commands from the repository root. Set `COMET_API_KEY` before starting the parameter optimization; use `--repository-root` when invoking it from another directory. The optimizer uses seed `92` by default for every -parameter pair so results can be compared and replayed; pass `--seed SEED` to select and record another root seed. The -experiment results are then available in Comet. +parameter pair so stochastic inputs and provenance can be compared; pass `--seed SEED` to select and record another +root seed. Fresh CGAL triangulations are subject to the limits in the +[reproducibility contract](docs/reproducibility.md). The experiment results are then available in Comet. Migration of these legacy scripts to Python 3.14, PyTorch, and the current Comet API is tracked by [#104](https://github.com/acgetchell/CDT-plusplus/issues/104). diff --git a/cmake/RunCdtNoOutputTest.cmake b/cmake/RunCdtNoOutputTest.cmake index 688c33323..b5c884b5c 100644 --- a/cmake/RunCdtNoOutputTest.cmake +++ b/cmake/RunCdtNoOutputTest.cmake @@ -2,10 +2,25 @@ if(NOT DEFINED CDT_EXECUTABLE OR NOT EXISTS "${CDT_EXECUTABLE}") message(FATAL_ERROR "CDT_EXECUTABLE must name the built cdt executable") endif() -if(NOT DEFINED TEST_DIRECTORY OR TEST_DIRECTORY STREQUAL "" OR TEST_DIRECTORY STREQUAL "/") +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() + execute_process( COMMAND "${CDT_EXECUTABLE}" --help RESULT_VARIABLE help_result @@ -18,12 +33,12 @@ if(NOT help_output MATCHES "--no-output" OR NOT help_output MATCHES "--seed") message(FATAL_ERROR "cdt --help does not document --no-output and --seed") endif() -file(REMOVE_RECURSE "${TEST_DIRECTORY}") -file(MAKE_DIRECTORY "${TEST_DIRECTORY}") +file(REMOVE_RECURSE "${normalized_test_directory}") +file(MAKE_DIRECTORY "${normalized_test_directory}") execute_process( COMMAND "${CDT_EXECUTABLE}" -s -n64 -t3 -a0.6 -k1.1 -l0.1 -p1 -c1 --no-output --seed 92 - WORKING_DIRECTORY "${TEST_DIRECTORY}" + WORKING_DIRECTORY "${normalized_test_directory}" RESULT_VARIABLE run_result OUTPUT_VARIABLE run_output ERROR_VARIABLE run_error) @@ -37,7 +52,7 @@ if(NOT run_output MATCHES "Effective random seed: 92") message(FATAL_ERROR "cdt did not report the requested effective seed:\n${run_output}") endif() -file(GLOB_RECURSE generated_files LIST_DIRECTORIES false "${TEST_DIRECTORY}/*") +file(GLOB_RECURSE generated_files LIST_DIRECTORIES false "${normalized_test_directory}/*") if(generated_files) list(JOIN generated_files "\n " generated_file_list) message(FATAL_ERROR "cdt --no-output created files:\n ${generated_file_list}") diff --git a/cmake/RunPersistenceOutputTest.cmake b/cmake/RunPersistenceOutputTest.cmake new file mode 100644 index 000000000..a500ec5d6 --- /dev/null +++ b/cmake/RunPersistenceOutputTest.cmake @@ -0,0 +1,98 @@ +if(NOT DEFINED TEST_EXECUTABLE OR NOT EXISTS "${TEST_EXECUTABLE}") + message(FATAL_ERROR "TEST_EXECUTABLE must name a built executable") +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() + +if(NOT DEFINED EXPECTED_ARTIFACT OR EXPECTED_ARTIFACT STREQUAL "") + message(FATAL_ERROR "EXPECTED_ARTIFACT must name the persisted artifact kind") +endif() + +file(REMOVE_RECURSE "${normalized_test_directory}") +file(MAKE_DIRECTORY "${normalized_test_directory}") + +execute_process( + COMMAND "${TEST_EXECUTABLE}" ${TEST_ARGUMENTS} + WORKING_DIRECTORY "${normalized_test_directory}" + RESULT_VARIABLE run_result + OUTPUT_VARIABLE run_output + ERROR_VARIABLE run_error) +if(NOT run_result EQUAL 0) + message(FATAL_ERROR "Persistence command failed:\n${run_output}\n${run_error}") +endif() +if(NOT run_output MATCHES "Effective random seed: ([0-9]+)") + message(FATAL_ERROR "Command did not report the requested seed:\n${run_output}") +endif() +set(reported_seed "${CMAKE_MATCH_1}") +if(DEFINED EXPECTED_SEED AND NOT reported_seed STREQUAL EXPECTED_SEED) + message(FATAL_ERROR "Command reported seed ${reported_seed}, expected ${EXPECTED_SEED}") +endif() + +file(GLOB payloads LIST_DIRECTORIES false "${normalized_test_directory}/*.off") +file(GLOB manifests LIST_DIRECTORIES false "${normalized_test_directory}/*.off.meta") +list(LENGTH payloads payload_count) +list(LENGTH manifests manifest_count) +if(NOT payload_count EQUAL 1 OR NOT manifest_count EQUAL 1) + message( + FATAL_ERROR + "Expected one payload and one manifest, found ${payload_count} payloads and ${manifest_count} manifests") +endif() + +list(GET payloads 0 payload) +list(GET manifests 0 manifest) +get_filename_component(payload_name "${payload}" NAME) +if(payload_name MATCHES ":") + message(FATAL_ERROR "Generated filename is not Windows-portable: ${payload_name}") +endif() +if(NOT manifest STREQUAL "${payload}.meta") + message(FATAL_ERROR "Manifest is not paired with its payload: ${manifest}") +endif() + +file(READ "${manifest}" metadata) +foreach( + required + "cdt-plusplus-metadata-v1" + "artifact=${EXPECTED_ARTIFACT}" + "resume_supported=false" + "fresh_topology_replay_supported=false" + "transition_replay_requires_identical_start=true" + "random.seed=${reported_seed}" + "payload.size=" + "payload.fnv1a64=" + "placement.fnv1a64=" + "topology.fnv1a64=" + "cdt.version=" + "build.configuration=" + "dependency.cgal_version=") +if(NOT metadata MATCHES "${required}") + message(FATAL_ERROR "Manifest is missing '${required}':\n${metadata}") + endif() +endforeach() +if(EXPECTED_ARTIFACT STREQUAL "final-triangulation" + AND (NOT metadata MATCHES "transition_trace.fnv1a64=" + OR NOT metadata MATCHES "transition_trace.count=")) + message(FATAL_ERROR "Simulation manifest does not record its transition trace:\n${metadata}") +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 "Persistence left temporary files behind:\n ${temporary_file_list}") +endif() diff --git a/cmake/Version.hpp.in b/cmake/Version.hpp.in index 027b9a9ad..9af7d5389 100644 --- a/cmake/Version.hpp.in +++ b/cmake/Version.hpp.in @@ -9,9 +9,21 @@ #include +#ifndef CDT_BUILD_CONFIGURATION +#define CDT_BUILD_CONFIGURATION "unknown" +#endif + namespace cdt { inline constexpr std::string_view VERSION{"@CDT_VERSION@"}; +inline constexpr std::string_view BUILD_COMPILER_ID{"@CMAKE_CXX_COMPILER_ID@"}; +inline constexpr std::string_view BUILD_COMPILER_VERSION{ + "@CMAKE_CXX_COMPILER_VERSION@"}; +inline constexpr std::string_view BUILD_CONFIGURATION{ + CDT_BUILD_CONFIGURATION}; +inline constexpr std::string_view BUILD_SYSTEM_NAME{"@CMAKE_SYSTEM_NAME@"}; +inline constexpr std::string_view BUILD_SYSTEM_PROCESSOR{ + "@CMAKE_SYSTEM_PROCESSOR@"}; } // namespace cdt #endif // CDT_VERSION_HPP diff --git a/docs/reproducibility.md b/docs/reproducibility.md index 581026222..837cf1ec7 100644 --- a/docs/reproducibility.md +++ b/docs/reproducibility.md @@ -9,10 +9,11 @@ subsystems: Pass `--seed SEED` to `cdt` or `initialize` to replay the random inputs to a run. Without that option, the command obtains operating-system entropy once -and prints the effective seed. Checkpoint and final OFF filenames also include -`seed-SEED`; simulation checkpoints include `pass-PASS`. The seed is metadata -in the filename rather than extra OFF content so files remain readable by -standard CGAL triangulation parsers. +and prints the effective seed. The validated runtime configuration retains that +effective seed rather than the pre-parse default. Checkpoint and final payload +filenames include `seed-SEED`; simulation checkpoints also include +`pass-PASS`. Filename timestamps use UTC and replace the colon separators that +are invalid in Windows filenames. ```console ./out/build/reference/src/cdt -s -n640 -t4 -a0.6 -k1.1 -l0.1 -p10 --seed 92 @@ -26,13 +27,114 @@ engine construction outside `Random.hpp`. The supported spherical CGAL point generator receives a single seed derived from its caller-owned initialization stream rather than using CGAL's hidden default generator. -The reproducibility guarantee is exact for PCG draws and generated -initialization points. Given the same starting manifold, the complete -Metropolis transition sequence and counters also replay exactly. A freshly -constructed triangulation is not promised to have byte-identical topology -across CGAL versions, platforms, or builds: points on the same spherical layer -are cospherical, so CGAL may choose a different valid tetrahedralization when -resolving geometric ties. +Stream consumption is sequential and defined at the subsystem boundary. The +initialization stream supplies the one seed used to construct CGAL's spherical +point generator. The generated vertices retain the existing exact spherical +placement and CGAL range-insertion path; reproducibility does not perturb their +radii or change foliation repair policy. For each Metropolis attempt, the +transition stream selects a +move type, draws the acceptance variate, and then performs the selected raw-site +selection and any candidate shuffles. A pass captures the current simplex count +before its first attempt. Failed candidate construction is an explicit +self-transition and does not retry a different site. The output metadata +records an FNV-1a fingerprint of the ordered move/outcome trace—candidate +failure, acceptance, or Metropolis-Hastings rejection—and its transition count. +The fingerprint is a compact replay diagnostic, not a cryptographic proof. + +The reproducibility guarantee is exact for PCG draws and the ordered, +pre-CGAL initialization point sequence on the recorded supported toolchain. +Given an identical starting manifold, the complete Metropolis transition +sequence and counters also replay exactly. Canonical proposal ordering only +maps random draws to the same uniformly selected candidate; it does not change +the candidate set or proposal probabilities. + +A freshly constructed triangulation is deliberately not promised to have +identical topology or a matching post-repair vertex set, even in separate +processes using the same toolchain. Points on each spherical layer are +cospherical, so CGAL may choose a different valid tetrahedralization when +resolving geometric ties. Foliation repair operates on that tetrahedralization +and may consequently remove different vertices. CDT++ does not perturb point +radii, replace CGAL's range insertion, or change foliation repair policy solely +to force fresh-topology replay. + +The placement fingerprint describes the finite vertices that survive CGAL +construction and foliation repair. Along with the topology fingerprint and +actual counts, it makes output-state differences visible; it is not a +fingerprint of the pre-CGAL generated point sequence. Exact generation replay +is tested directly before insertion. + +## Persistence contract + +Each new stochastic `.off` payload has a neighboring `.off.meta` text +manifest. The manifest records: + +- the root seed, PCG engine, and named stream identifiers; +- the explicit limits on fresh-topology and transition replay; +- requested and actual topology dimensions and counts; +- foliation parameters and, for simulations, the action parameters and pass + cadence; +- completed passes and the transition-trace 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 cells; +- the CDT++ version, compiler, build configuration, standard library, + operating system, architecture, C++ standard, and CGAL version; +- the payload byte count and FNV-1a corruption checksum. + +The triangulation remains a CGAL-readable payload; provenance is in the sidecar +rather than prepended to the CGAL stream. Because CGAL's native triangulation +stream omits `info()` fields, CDT++ appends a versioned, canonically ordered +causal-data trailer that preserves every finite vertex timeslice and cell type. +Legacy streams without this trailer remain topology-readable. Before +publication, CDT++ serializes with round-trip floating-point precision to a +temporary file, flushes and closes it, parses the complete CGAL stream and +causal trailer, rejects any other trailing data, validates its triangulation +data structure, and requires the parsed dimension, incidence counts, causal +metadata, and canonical topology fingerprint to match the source. It similarly +writes and reparses the complete typed manifest. Payload-derived dimensions, +incidence counts, time bounds, placement fingerprint, and topology fingerprint +are derived from the serialized state rather than trusted from caller-supplied +metadata. The manifest is published first and the payload second using +same-directory atomic replacement (`rename` on POSIX and `MoveFileExW` on +Windows). If a process is interrupted between the two replacements, their +checksum mismatch is detectable rather than silently pairing a payload with +stale provenance. + +Reads of a manifested payload verify the size and checksum before parsing, then +repeat the complete-input and causal-metadata checks and compare every +payload-derived manifest field with the parsed state. Evolved CDT states are +abstract causal triangulations and need not retain the Euclidean Delaunay +empty-sphere property after Pachner moves, so integrity validation requires a +valid CGAL triangulation data structure without imposing Delaunayhood. Legacy +files without a manifest remain readable, but naturally have no checksum or +provenance guarantee; legacy CGAL streams also lack the causal `info()` data +that older CDT++ versions never serialized. +Malformed manifests, truncated payloads, trailing input, invalid topology, and +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. + +Payload parsing is supported for the repository's declared build matrix and +pinned dependency set. Exact PCG prefixes replay on the same supported +toolchain; transition traces replay when the starting manifold is identical. +The manifest makes fresh-construction and cross-toolchain differences +diagnosable, but it does not promise matching fresh topology, post-repair +placement, counts, or payload bytes. CGAL may also serialize the same abstract +topology in a different handle iteration order, so equality of a persisted +state is defined by the canonical topology fingerprint and reproducibility +fields rather than raw payload byte order. ## Parallel stream policy @@ -56,5 +158,6 @@ just benchmark-rng 10000 The diagnostic reports both durations and their ratio. It is intentionally not a pass/fail CI test because operating-system entropy latency and runner load are -machine-dependent; replay and distribution boundaries remain correctness tests -in `just ci`. +machine-dependent; RNG-prefix, pre-CGAL point-generation, identical-start +transition replay, and distribution boundaries remain correctness tests in +`just ci`. diff --git a/include/Ergodic_moves_3.hpp b/include/Ergodic_moves_3.hpp index 94cadb469..02089a240 100644 --- a/include/Ergodic_moves_3.hpp +++ b/include/Ergodic_moves_3.hpp @@ -19,12 +19,14 @@ #ifndef CDT_PLUSPLUS_ERGODIC_MOVES_3_HPP #define CDT_PLUSPLUS_ERGODIC_MOVES_3_HPP +#include #include #include #include #include #include #include +#include #include "Manifold.hpp" #include "Move_tracker.hpp" @@ -108,7 +110,73 @@ namespace ergodic_moves return incident_cells; } - /// Select exactly one raw proposal site uniformly. + [[nodiscard]] inline auto point_less(Point_t<3> const& left, + Point_t<3> const& right) -> bool + { return CGAL::lexicographically_xyz_smaller(left, right); } + + [[nodiscard]] inline auto canonical_cell_points(Cell_handle const& cell) + -> std::array, 4> + { + std::array points{cell->vertex(0)->point(), cell->vertex(1)->point(), + cell->vertex(2)->point(), cell->vertex(3)->point()}; + std::ranges::sort(points, point_less); + return points; + } + + [[nodiscard]] inline auto canonical_edge_points(Edge_handle const& edge) + -> std::array, 2> + { + std::array points{edge.first->vertex(edge.second)->point(), + edge.first->vertex(edge.third)->point()}; + std::ranges::sort(points, point_less); + return points; + } + + [[nodiscard]] inline auto cell_precedes(Cell_handle const& left, + Cell_handle const& right) -> bool + { + auto const left_points = canonical_cell_points(left); + auto const right_points = canonical_cell_points(right); + return std::lexicographical_compare( + left_points.begin(), left_points.end(), right_points.begin(), + right_points.end(), point_less); + } + + [[nodiscard]] inline auto edge_precedes(Edge_handle const& left, + Edge_handle const& right) -> bool + { + auto const left_points = canonical_edge_points(left); + auto const right_points = canonical_edge_points(right); + return std::lexicographical_compare( + left_points.begin(), left_points.end(), right_points.begin(), + right_points.end(), point_less); + } + + inline void canonicalize(Cell_container& cells) + { std::ranges::sort(cells, cell_precedes); } + + inline void canonicalize(Edge_container& edges) + { std::ranges::sort(edges, edge_precedes); } + + inline void canonicalize(Vertex_container& vertices) + { + std::ranges::sort(vertices, [](auto const& left, auto const& right) { + return point_less(left->point(), right->point()); + }); + } + + [[nodiscard]] inline auto vertex_precedes(Vertex_handle const& left, + Vertex_handle const& right) + -> bool + { + if (left->info() != right->info()) + { + return left->info() < right->info(); + } + return point_less(left->point(), right->point()); + } + + /// Select exactly one raw proposal site uniformly in container order. template [[nodiscard]] inline auto random_element(Container const& candidates, Generator& generator) @@ -119,6 +187,48 @@ namespace ergodic_moves 0, candidates.size() - 1}; return candidates[distribution(generator)]; } + + /// Select the same canonical rank as sorting followed by indexed selection, + /// without sorting the complete proposal domain. + template + [[nodiscard]] inline auto canonical_random_element(Container& candidates, + Generator& generator, + Comparator comparator) + -> std::optional + { + if (candidates.empty()) { return std::nullopt; } + std::uniform_int_distribution distribution{ + 0, candidates.size() - 1}; + auto const index = distribution(generator); + auto const nth = candidates.begin() + + static_cast(index); + std::ranges::nth_element(candidates, nth, comparator); + return candidates[index]; + } + + template + [[nodiscard]] inline auto canonical_random_element(Cell_container& cells, + Generator& generator) + -> std::optional + { return canonical_random_element(cells, generator, cell_precedes); } + + template + [[nodiscard]] inline auto canonical_random_element(Edge_container& edges, + Generator& generator) + -> std::optional + { return canonical_random_element(edges, generator, edge_precedes); } + + template + [[nodiscard]] inline auto canonical_random_element( + Vertex_container& vertices, Generator& generator) + -> std::optional + { + return canonical_random_element( + vertices, generator, [](auto const& left, auto const& right) { + return point_less(left->point(), right->point()); + }); + } } // namespace detail /// @brief Perform a null move @@ -147,9 +257,14 @@ namespace ergodic_moves { return false; } + std::array facet_indices{0, 1, 2, 3}; + std::ranges::sort(facet_indices, [&](auto const left, auto const right) { + return detail::point_less(to_be_moved->vertex(left)->point(), + to_be_moved->vertex(right)->point()); + }); auto flipped = false; // Try every facet of the (2,2) cell - for (auto i = 0; i < 4; ++i) + for (auto const i : facet_indices) { auto const neighbor = to_be_moved->neighbor(i); if (triangulation.is_infinite(neighbor)) { continue; } @@ -209,6 +324,7 @@ namespace ergodic_moves auto two_two = foliated_triangulations::filter_cells<3>( foliated_triangulations::collect_cells<3>(triangulation), Cell_type::TWO_TWO); + detail::canonicalize(two_two); // Shuffle the container to create a random sequence of (2,2) cells std::ranges::shuffle(two_two, generator); // Try a (2,3) move on successive cells in the sequence @@ -235,7 +351,7 @@ namespace ergodic_moves auto two_two = foliated_triangulations::filter_cells<3>( foliated_triangulations::collect_cells<3>(triangulation), Cell_type::TWO_TWO); - auto const candidate = detail::random_element(two_two, generator); + auto const candidate = detail::canonical_random_element(two_two, generator); if (candidate && try_23_move(triangulation, *candidate)) { return detail::make_manifold(std::move(triangulation), t_manifold); @@ -319,6 +435,7 @@ namespace ergodic_moves Delaunay triangulation{t_manifold.delaunay_snapshot()}; auto timelike_edges = foliated_triangulations::filter_edges<3>( foliated_triangulations::collect_edges<3>(triangulation), true); + detail::canonicalize(timelike_edges); // Shuffle the container to create a random sequence of edges std::ranges::shuffle(timelike_edges, generator); // Try a (3,2) move on successive timelike edges in the sequence @@ -343,7 +460,8 @@ namespace ergodic_moves auto triangulation = t_manifold.delaunay_snapshot(); auto timelike_edges = foliated_triangulations::filter_edges<3>( foliated_triangulations::collect_edges<3>(triangulation), true); - auto const candidate = detail::random_element(timelike_edges, generator); + auto const candidate = + detail::canonical_random_element(timelike_edges, generator); if (candidate && try_32_move(triangulation, *candidate)) { return detail::make_manifold(std::move(triangulation), t_manifold); @@ -366,6 +484,7 @@ namespace ergodic_moves { return std::nullopt; } + std::vector candidates; for (auto i = 0; i < 4; ++i) { auto const neighbor = t_cell->neighbor(i); @@ -373,10 +492,20 @@ namespace ergodic_moves foliated_triangulations::expected_cell_type<3>(neighbor) == Cell_type::THREE_ONE) { - return std::make_optional(i); + candidates.emplace_back(i); } } - return std::nullopt; + if (candidates.empty()) { return std::nullopt; } + std::ranges::sort(candidates, [&](auto const left, auto const right) { + auto const left_points = + detail::canonical_cell_points(t_cell->neighbor(left)); + auto const right_points = + detail::canonical_cell_points(t_cell->neighbor(right)); + return std::lexicographical_compare( + left_points.begin(), left_points.end(), right_points.begin(), + right_points.end(), detail::point_less); + }); + return candidates.front(); } // find_26_move() namespace detail @@ -406,7 +535,8 @@ namespace ergodic_moves Cell_type::ONE_THREE); if (only_first_site) { - auto const candidate = detail::random_element(one_three, generator); + auto const candidate = + detail::canonical_random_element(one_three, generator); if (!candidate) { return std::unexpected("No (2,6) proposal site is available.\n"); @@ -415,6 +545,7 @@ namespace ergodic_moves } else { + detail::canonicalize(one_three); // Shuffle the container to pick a random sequence of (1,3) cells to // try. std::ranges::shuffle(one_three, generator); @@ -486,8 +617,10 @@ namespace ergodic_moves } // Now assign a geometric point to the center vertex + std::array face_points{v_1->point(), v_2->point(), v_3->point()}; + std::ranges::sort(face_points, detail::point_less); auto const center_point = - CGAL::centroid(v_1->point(), v_2->point(), v_3->point()); + CGAL::centroid(face_points[0], face_points[1], face_points[2]); v_center->set_point(center_point); // Assign a timevalue to the new vertex @@ -662,6 +795,7 @@ namespace ergodic_moves Edge_container incident_edges; triangulation.finite_incident_edges(candidate, std::back_inserter(incident_edges)); + detail::canonicalize(incident_edges); std::ranges::shuffle(incident_edges, generator); auto const is_timelike = [](Edge_handle const& edge) { @@ -747,6 +881,7 @@ namespace ergodic_moves { auto triangulation = t_manifold.delaunay_snapshot(); auto vertices = foliated_triangulations::collect_vertices<3>(triangulation); + detail::canonicalize(vertices); // Shuffle the container to create a random sequence of vertices std::ranges::shuffle(vertices, generator); // Try a (6,2) move on successive vertices in the sequence @@ -770,7 +905,8 @@ namespace ergodic_moves { auto triangulation = t_manifold.delaunay_snapshot(); auto vertices = foliated_triangulations::collect_vertices<3>(triangulation); - auto const candidate = detail::random_element(vertices, generator); + auto const candidate = + detail::canonical_random_element(vertices, generator); if (candidate && is_62_movable(triangulation, *candidate)) { if (auto moved = try_62_move(triangulation, *candidate, generator)) @@ -1070,6 +1206,7 @@ namespace ergodic_moves auto triangulation = t_manifold.delaunay_snapshot(); auto spacelike_edges = foliated_triangulations::filter_edges<3>( foliated_triangulations::collect_edges<3>(triangulation), false); + detail::canonicalize(spacelike_edges); // Shuffle the container to pick a random sequence of edges to try std::ranges::shuffle(spacelike_edges, generator); for (auto const& edge : spacelike_edges) @@ -1096,11 +1233,11 @@ namespace ergodic_moves if (vertex != v1 && vertex != v2) { // Use timevalue to determine if it's a top or bottom vertex - if (top == nullptr || vertex->info() > top->info()) + if (top == nullptr || detail::vertex_precedes(top, vertex)) { top = vertex; } - if (bottom == nullptr || vertex->info() < bottom->info()) + if (bottom == nullptr || detail::vertex_precedes(vertex, bottom)) { bottom = vertex; } @@ -1136,7 +1273,8 @@ namespace ergodic_moves auto triangulation = t_manifold.delaunay_snapshot(); auto spacelike_edges = foliated_triangulations::filter_edges<3>( foliated_triangulations::collect_edges<3>(triangulation), false); - auto const candidate = detail::random_element(spacelike_edges, generator); + auto const candidate = + detail::canonical_random_element(spacelike_edges, generator); if (!candidate) { return std::unexpected( @@ -1160,8 +1298,11 @@ namespace ergodic_moves { auto const vertex = cell->vertex(index); if (vertex == v1 || vertex == v2) { continue; } - if (top == nullptr || vertex->info() > top->info()) { top = vertex; } - if (bottom == nullptr || vertex->info() < bottom->info()) + if (top == nullptr || detail::vertex_precedes(top, vertex)) + { + top = vertex; + } + if (bottom == nullptr || detail::vertex_precedes(vertex, bottom)) { bottom = vertex; } diff --git a/include/Metropolis.hpp b/include/Metropolis.hpp index cfa2d6894..a1e24eeee 100644 --- a/include/Metropolis.hpp +++ b/include/Metropolis.hpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -28,6 +29,7 @@ #include "Move_strategy.hpp" #include "Random.hpp" #include "S3Action.hpp" +#include "Utilities.hpp" /// @brief Metropolis-Hastings algorithm strategy /// @details The Metropolis-Hastings algorithm is a Markov Chain Monte Carlo @@ -75,7 +77,17 @@ class MoveStrategy Geometry m_geometry; /// @brief Run-owned random engine used for move, site, and acceptance draws - cdt::Random m_generator; + cdt::Random m_generator{ + cdt::Random{}.split(cdt::random_streams::transitions)}; + + /// @brief Immutable run provenance, refreshed with state at each output. + utilities::Reproducibility_metadata m_reproducibility; + + /// @brief Compact deterministic fingerprint of ordered transition outcomes. + std::uint64_t m_transition_trace{14695981039346656037ULL}; + + /// @brief Number of transition records incorporated into the fingerprint. + std::uint64_t m_transition_count{}; /// @brief The number of move types and raw sites proposed /// @details This equals accepted moves + rejected moves. @@ -100,9 +112,32 @@ class MoveStrategy /// @brief The number of inapplicable or invalid candidate constructions Counter m_failed_moves; + enum class Transition_outcome : std::uint8_t + { + CANDIDATE_FAILED, + ACCEPTED, + REJECTED + }; + + void record_transition(move_tracker::move_type const move, + Transition_outcome const outcome) noexcept + { + auto const append = [this](std::uint8_t const value) { + m_transition_trace ^= value; + m_transition_trace *= 1099511628211ULL; + }; + append(static_cast(move)); + append(static_cast(outcome)); + ++m_transition_count; + } + public: - /// @brief Default ctor - MoveStrategy() = default; + /// @brief Construct a default strategy on the named transition stream. + MoveStrategy() + { + m_reproducibility.seed = m_generator.seed(); + m_reproducibility.transition_stream = m_generator.stream(); + } /// @brief Metropolis function object constructor /// @details Setup of runtime job parameters. @@ -118,26 +153,50 @@ class MoveStrategy Int_precision const passes, Int_precision const checkpoint, bool const write_files = true) - : MoveStrategy{Alpha, K, Lambda, passes, - checkpoint, write_files, cdt::Random{}} + : MoveStrategy{Alpha, + K, + Lambda, + passes, + checkpoint, + write_files, + cdt::Random{}.split(cdt::random_streams::transitions)} {} /// @brief Construct a run from an already selected PCG stream. - [[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) + /// @details Seed, transition-stream, action, and cadence provenance are + /// derived from the actual constructor arguments. Caller metadata supplies + /// initialization and requested-state context only. + [[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) : m_passes(passes) , m_checkpoint{checkpoint} , m_write_files{write_files} , m_generator{std::move(random)} + , m_reproducibility{ + reproducibility.value_or(utilities::Reproducibility_metadata{ + .seed = m_generator.seed(), + .alpha = Alpha, + .k = K, + .lambda = Lambda, + .configured_passes = passes, + .checkpoint_interval = checkpoint})} { auto const parameters = s3_action::make_physical_parameters(Alpha, K, Lambda); - m_Alpha = parameters.alpha; - m_K = parameters.k; - m_Lambda = parameters.lambda; + m_Alpha = parameters.alpha; + m_K = parameters.k; + m_Lambda = parameters.lambda; + m_reproducibility.seed = m_generator.seed(); + m_reproducibility.transition_stream = m_generator.stream(); + m_reproducibility.alpha = m_Alpha; + m_reproducibility.k = m_K; + m_reproducibility.lambda = m_Lambda; + m_reproducibility.configured_passes = m_passes; + m_reproducibility.checkpoint_interval = m_checkpoint; if (m_passes <= 0) { throw std::invalid_argument{"Metropolis passes must be positive"}; @@ -157,8 +216,15 @@ class MoveStrategy long double const Lambda, Int_precision const passes, Int_precision const checkpoint, bool const write_files, std::uint64_t const seed) - : MoveStrategy{Alpha, K, Lambda, passes, - checkpoint, write_files, cdt::Random{seed}} + : MoveStrategy{ + Alpha, + K, + Lambda, + passes, + checkpoint, + write_files, + cdt::Random{seed, cdt::random_streams::transitions} + } {} /// @returns The length of the timelike edge @@ -185,6 +251,37 @@ class MoveStrategy /// @returns The PCG stream selector used for transitions. [[nodiscard]] auto stream() const noexcept { return m_generator.stream(); } + /// @returns FNV-1a fingerprint of the ordered move/outcome transition trace. + [[nodiscard]] auto transition_trace() const noexcept + { return m_transition_trace; } + + /// @returns Number of transitions represented by transition_trace(). + [[nodiscard]] auto transition_count() const noexcept + { return m_transition_count; } + + /// @brief Materialize output provenance for the supplied canonical state. + [[nodiscard]] auto reproducibility_metadata( + ManifoldType const& manifold, utilities::Artifact_kind const artifact, + Int_precision const completed_passes) const + -> utilities::Reproducibility_metadata + { + auto metadata = m_reproducibility; + metadata.artifact = artifact; + metadata.completed_passes = completed_passes; + metadata.transition_trace = m_transition_trace; + metadata.transition_count = m_transition_count; + utilities::update_reproducibility_state(metadata, manifold); + if (metadata.desired_simplices == 0) + { + metadata.desired_simplices = manifold.N3(); + } + if (metadata.desired_timeslices == 0) + { + metadata.desired_timeslices = manifold.max_time(); + } + return metadata; + } + /// @returns The container of trial moves auto get_proposed() const { return m_proposed_moves; } @@ -348,6 +445,7 @@ class MoveStrategy { ++m_failed_moves[move]; ++m_rejected_moves[move]; + record_transition(move, Transition_outcome::CANDIDATE_FAILED); return false; } @@ -359,10 +457,12 @@ class MoveStrategy swap(*candidate, current); m_geometry = current.get_geometry(); ++m_accepted_moves[move]; + record_transition(move, Transition_outcome::ACCEPTED); return true; } ++m_rejected_moves[move]; + record_transition(move, Transition_outcome::REJECTED); return false; } @@ -389,8 +489,10 @@ class MoveStrategy m_attempted_moves.reset(); m_succeeded_moves.reset(); m_failed_moves.reset(); + m_transition_trace = 14695981039346656037ULL; + m_transition_count = 0; - auto current = t_manifold; + auto current = t_manifold; initialize(current); std::uniform_real_distribution acceptance_draw{0.0L, 1.0L}; @@ -413,7 +515,10 @@ class MoveStrategy if (m_write_files) { fmt::print("Writing to file.\n"); - utilities::write_file(current, m_generator.seed(), pass_number); + utilities::write_file( + current, + reproducibility_metadata( + current, utilities::Artifact_kind::CHECKPOINT, pass_number)); } } } diff --git a/include/Runtime_config.hpp b/include/Runtime_config.hpp index ffaeced31..47ca8c542 100644 --- a/include/Runtime_config.hpp +++ b/include/Runtime_config.hpp @@ -28,27 +28,31 @@ namespace runtime_config friend auto make_triangulation(bool spherical, bool toroidal, long long simplices, long long timeslices, long long dimensions, double initial_radius, - double foliation_spacing) -> Triangulation; - - topology_type m_topology; - Int_precision m_simplices; - Int_precision m_timeslices; - Int_precision m_dimensions; - double m_initial_radius; - double m_foliation_spacing; - - explicit Triangulation(topology_type const topology, - Int_precision const simplices, - Int_precision const timeslices, - Int_precision const dimensions, - double const initial_radius, - double const foliation_spacing) noexcept + double foliation_spacing, + cdt::Random_seed seed) -> Triangulation; + + topology_type m_topology; + Int_precision m_simplices; + Int_precision m_timeslices; + Int_precision m_dimensions; + double m_initial_radius; + double m_foliation_spacing; + cdt::Random_seed m_seed; + + explicit Triangulation(topology_type const topology, + Int_precision const simplices, + Int_precision const timeslices, + Int_precision const dimensions, + double const initial_radius, + double const foliation_spacing, + cdt::Random_seed const seed) noexcept : m_topology{topology} , m_simplices{simplices} , m_timeslices{timeslices} , m_dimensions{dimensions} , m_initial_radius{initial_radius} , m_foliation_spacing{foliation_spacing} + , m_seed{seed} {} public: @@ -75,6 +79,9 @@ namespace runtime_config [[nodiscard]] auto foliation_spacing() const noexcept -> double { return m_foliation_spacing; } + + [[nodiscard]] auto seed() const noexcept -> cdt::Random_seed + { return m_seed; } }; /// Complete validated configuration for the Metropolis simulation. @@ -222,8 +229,8 @@ namespace runtime_config [[nodiscard]] inline auto make_triangulation( bool const spherical, bool const toroidal, long long const simplices, long long const timeslices, long long const dimensions, - double const initial_radius, double const foliation_spacing) - -> Triangulation + double const initial_radius, double const foliation_spacing, + cdt::Random_seed const seed = 0) -> Triangulation { auto const topology = detail::select_topology(spherical, toroidal); auto const checked_simplices = @@ -260,9 +267,13 @@ namespace runtime_config [[maybe_unused]] auto const population = detail::make_generated_population( checked_simplices, checked_timeslices, checked_initial_radius, checked_foliation_spacing); - return Triangulation{ - topology, checked_simplices, checked_timeslices, - checked_dimensions, checked_initial_radius, checked_foliation_spacing}; + return Triangulation{topology, + checked_simplices, + checked_timeslices, + checked_dimensions, + checked_initial_radius, + checked_foliation_spacing, + seed}; } /// Validate the complete simulation configuration. diff --git a/include/Utilities.hpp b/include/Utilities.hpp index 6d5b647f7..5e7c87cda 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -11,15 +11,32 @@ #ifndef INCLUDE_UTILITIES_HPP_ #define INCLUDE_UTILITIES_HPP_ +#include + #include +#include +#include +#include +#include +#include #include #include #include +#include +#include +#include +#include #include +#include #include #include +#include #include #include +#include +#include +#include +#include // H. Hinnant date and time library #include @@ -44,6 +61,7 @@ // Global project settings #include "Random.hpp" #include "Settings.hpp" +#include "Version.hpp" enum class topology_type { @@ -68,8 +86,875 @@ inline auto operator<<(std::ostream& t_os, topology_type const& t_topology) namespace utilities { + enum class Artifact_kind + { + INITIAL_TRIANGULATION, + CHECKPOINT, + FINAL_TRIANGULATION + }; + + /// @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. + struct Reproducibility_metadata + { + Artifact_kind artifact{Artifact_kind::FINAL_TRIANGULATION}; + cdt::Random_seed seed{}; + cdt::Random_stream initialization_stream{ + cdt::random_streams::initialization}; + cdt::Random_stream transition_stream{cdt::random_streams::transitions}; + topology_type topology{topology_type::SPHERICAL}; + Int_precision dimension{}; + Int_precision desired_simplices{}; + Int_precision desired_timeslices{}; + Int_precision actual_vertices{}; + Int_precision actual_edges{}; + Int_precision actual_faces{}; + Int_precision actual_simplices{}; + Int_precision minimum_timeslice{}; + Int_precision maximum_timeslice{}; + double initial_radius{}; + double foliation_spacing{}; + std::optional alpha; + std::optional k; + std::optional lambda; + std::optional configured_passes; + std::optional checkpoint_interval; + std::optional completed_passes; + std::optional transition_trace; + std::optional transition_count; + std::optional placement_fingerprint; + std::optional topology_fingerprint; + }; + + [[nodiscard]] inline auto metadata_filename( + std::filesystem::path const& payload) -> std::filesystem::path + { + auto metadata = payload; + metadata += ".meta"; + return metadata; + } + namespace detail { + inline std::string_view constexpr CAUSAL_INFO_HEADER{ + "cdt-plusplus-causal-info-v1"}; + + struct Payload_integrity + { + std::uint64_t size{}; + std::uint64_t digest{}; + }; + + [[nodiscard]] inline auto artifact_name(Artifact_kind const artifact) + -> std::string_view + { + switch (artifact) + { + case Artifact_kind::INITIAL_TRIANGULATION: + return "initial-triangulation"; + case Artifact_kind::CHECKPOINT: return "checkpoint"; + case Artifact_kind::FINAL_TRIANGULATION: return "final-triangulation"; + } + return "unknown"; + } + + [[nodiscard]] inline auto standard_library_name() -> std::string + { +#if defined(_LIBCPP_VERSION) + return fmt::format("libc++-{}", _LIBCPP_VERSION); +#elif defined(__GLIBCXX__) + return fmt::format("libstdc++-{}", __GLIBCXX__); +#elif defined(_MSVC_STL_VERSION) + return fmt::format("msvc-stl-{}", _MSVC_STL_VERSION); +#else + return "unknown"; +#endif + } + + [[nodiscard]] inline auto payload_integrity( + std::filesystem::path const& filename) -> Payload_integrity + { + std::ifstream input(filename, std::ios::in | std::ios::binary); + if (!input.is_open()) + { + throw std::filesystem::filesystem_error( + "Could not open payload for integrity validation", filename, + std::make_error_code(std::errc::bad_file_descriptor)); + } + + std::uint64_t digest{14695981039346656037ULL}; + std::uint64_t size{}; + std::array buffer{}; + while (input) + { + input.read(buffer.data(), static_cast(buffer.size())); + auto const count = input.gcount(); + for (std::streamsize index = 0; index < count; ++index) + { + digest ^= static_cast( + buffer[static_cast(index)]); + digest *= 1099511628211ULL; + } + size += static_cast(count); + } + if (!input.eof()) + { + throw std::filesystem::filesystem_error( + "Could not read payload for integrity validation", filename, + std::make_error_code(std::errc::io_error)); + } + return {size, digest}; + } + + [[nodiscard]] inline auto point_key(auto const& point) -> std::string + { + return fmt::format("{:.17g},{:.17g},{:.17g}", CGAL::to_double(point.x()), + CGAL::to_double(point.y()), + CGAL::to_double(point.z())); + } + + [[nodiscard]] inline auto cell_key(auto const& cell) -> std::string + { + std::array points{point_key(cell->vertex(0)->point()), + point_key(cell->vertex(1)->point()), + point_key(cell->vertex(2)->point()), + point_key(cell->vertex(3)->point())}; + std::ranges::sort(points); + return fmt::format("{};{};{};{}", points[0], points[1], points[2], + points[3]); + } + + template + inline bool constexpr HAS_CAUSAL_INFO = + requires(TriangulationType const& triangulation) { + triangulation.finite_vertices_begin(); + triangulation.finite_vertices_end(); + triangulation.finite_cells_begin(); + triangulation.finite_cells_end(); + triangulation.number_of_vertices(); + triangulation.number_of_finite_cells(); + }; + + template + [[nodiscard]] auto vertex_records(TriangulationType const& triangulation) + -> std::vector + { + std::vector records; + records.reserve( + static_cast(triangulation.number_of_vertices())); + for (auto vertex = triangulation.finite_vertices_begin(); + vertex != triangulation.finite_vertices_end(); ++vertex) + { + records.emplace_back( + fmt::format("v:{}:{}", point_key(vertex->point()), vertex->info())); + } + std::ranges::sort(records); + return records; + } + + [[nodiscard]] inline auto fingerprint_records( + std::vector const& records) -> std::uint64_t + { + std::uint64_t digest{14695981039346656037ULL}; + for (auto const& record : records) + { + for (auto const byte : record) + { + digest ^= static_cast(byte); + digest *= 1099511628211ULL; + } + digest ^= 0xFFU; + digest *= 1099511628211ULL; + } + return digest; + } + + template + [[nodiscard]] auto canonical_placement_fingerprint( + TriangulationType const& triangulation) -> std::uint64_t + { return fingerprint_records(vertex_records(triangulation)); } + + template + [[nodiscard]] auto canonical_topology_fingerprint( + TriangulationType const& triangulation) -> std::uint64_t + { + auto records = vertex_records(triangulation); + records.reserve( + static_cast(triangulation.number_of_vertices() + + triangulation.number_of_finite_cells())); + for (auto cell = triangulation.finite_cells_begin(); + cell != triangulation.finite_cells_end(); ++cell) + { + records.emplace_back( + fmt::format("c:{}:{}", cell_key(cell), cell->info())); + } + std::ranges::sort(records); + return fingerprint_records(records); + } + + template + void write_causal_info(std::ostream& output, + TriangulationType const& triangulation) + { + if constexpr (HAS_CAUSAL_INFO) + { + std::vector vertices; + vertices.reserve( + static_cast(triangulation.number_of_vertices())); + for (auto vertex = triangulation.finite_vertices_begin(); + vertex != triangulation.finite_vertices_end(); ++vertex) + { + vertices.emplace_back( + fmt::format("{}|{}", point_key(vertex->point()), vertex->info())); + } + std::ranges::sort(vertices); + + std::vector cells; + cells.reserve( + static_cast(triangulation.number_of_finite_cells())); + for (auto cell = triangulation.finite_cells_begin(); + cell != triangulation.finite_cells_end(); ++cell) + { + cells.emplace_back( + fmt::format("{}|{}", cell_key(cell), cell->info())); + } + std::ranges::sort(cells); + + output << '\n' << CAUSAL_INFO_HEADER << '\n'; + output << "vertices=" << vertices.size() << '\n'; + for (auto const& record : vertices) + { + output << "v=" << record << '\n'; + } + output << "cells=" << cells.size() << '\n'; + for (auto const& record : cells) { output << "c=" << record << '\n'; } + } + } + + [[nodiscard]] inline auto metadata_text( + Reproducibility_metadata const& metadata, + Payload_integrity const payload) -> std::string + { + auto text = fmt::format( + "cdt-plusplus-metadata-v1\n" + "payload.size={}\n" + "payload.fnv1a64={:016x}\n" + "artifact={}\n" + "resume_supported=false\n" + "fresh_topology_replay_supported=false\n" + "transition_replay_requires_identical_start=true\n" + "cdt.version={}\n" + "build.compiler_id={}\n" + "build.compiler_version={}\n" + "build.configuration={}\n" + "build.system={}\n" + "build.processor={}\n" + "build.cxx_standard=23\n" + "build.standard_library={}\n" + "dependency.cgal_version={}\n" + "random.engine=pcg64\n" + "random.seed={}\n" + "random.initialization_stream={}\n" + "random.transition_stream={}\n" + "topology={}\n" + "dimension={}\n" + "desired.simplices={}\n" + "desired.timeslices={}\n" + "actual.vertices={}\n" + "actual.edges={}\n" + "actual.faces={}\n" + "actual.simplices={}\n" + "actual.minimum_timeslice={}\n" + "actual.maximum_timeslice={}\n" + "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, + metadata.topology == topology_type::SPHERICAL ? "spherical" + : "toroidal", + metadata.dimension, metadata.desired_simplices, + metadata.desired_timeslices, metadata.actual_vertices, + metadata.actual_edges, metadata.actual_faces, + metadata.actual_simplices, metadata.minimum_timeslice, + metadata.maximum_timeslice, metadata.initial_radius, + metadata.foliation_spacing); + + auto append_optional = [&text](std::string_view const name, + auto const& value) { + if (value) { text += fmt::format("{}={}\n", name, *value); } + }; + append_optional("alpha", metadata.alpha); + append_optional("k", metadata.k); + append_optional("lambda", metadata.lambda); + append_optional("configured_passes", metadata.configured_passes); + append_optional("checkpoint_interval", metadata.checkpoint_interval); + append_optional("completed_passes", metadata.completed_passes); + 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.placement_fingerprint) + { + text += fmt::format("placement.fnv1a64={:016x}\n", + *metadata.placement_fingerprint); + } + if (metadata.topology_fingerprint) + { + text += fmt::format("topology.fnv1a64={:016x}\n", + *metadata.topology_fingerprint); + } + return text; + } + + [[nodiscard]] inline auto parse_unsigned(std::string_view const text, + int const base, + std::filesystem::path const& path) + -> std::uint64_t + { + std::uint64_t value{}; + auto const [end, error] = + std::from_chars(text.data(), text.data() + text.size(), value, base); + if (error != std::errc{} || end != text.data() + text.size()) + { + throw std::filesystem::filesystem_error( + "Malformed persistence metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + return value; + } + + [[nodiscard]] inline auto parse_info(std::string_view const text, + std::filesystem::path const& path) + -> Int_precision + { + Int_precision value{}; + auto const [end, error] = + std::from_chars(text.data(), text.data() + text.size(), value); + if (error != std::errc{} || end != text.data() + text.size()) + { + throw std::filesystem::filesystem_error( + "Malformed causal triangulation metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + return value; + } + + [[nodiscard]] inline auto parse_metadata_integer( + std::string_view const text, std::filesystem::path const& path) + -> Int_precision + { + Int_precision value{}; + auto const [end, error] = + std::from_chars(text.data(), text.data() + text.size(), value); + if (error != std::errc{} || end != text.data() + text.size()) + { + throw std::filesystem::filesystem_error( + "Malformed persistence metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + return value; + } + + template + [[nodiscard]] auto parse_metadata_floating( + std::string_view const text, std::filesystem::path const& path) -> Float + { + Float value{}; + std::istringstream input{std::string{text}}; + input.imbue(std::locale::classic()); + input >> value; + if (input.fail()) + { + throw std::filesystem::filesystem_error( + "Malformed persistence metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + input >> std::ws; + if (!input.eof() || !std::isfinite(value)) + { + throw std::filesystem::filesystem_error( + "Malformed persistence metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + return value; + } + + [[nodiscard]] inline auto parse_record(std::string const& line, + std::string_view const prefix, + std::filesystem::path const& path) + -> std::pair + { + if (!line.starts_with(prefix)) + { + throw std::filesystem::filesystem_error( + "Malformed causal triangulation metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto const record = std::string_view{line}.substr(prefix.size()); + auto const separator = record.rfind('|'); + if (separator == std::string_view::npos || separator == 0 || + separator + 1 == record.size()) + { + throw std::filesystem::filesystem_error( + "Malformed causal triangulation metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + return {std::string{record.substr(0, separator)}, + parse_info(record.substr(separator + 1), path)}; + } + + [[nodiscard]] inline auto parse_count_line( + std::string const& line, std::string_view const prefix, + std::filesystem::path const& path) -> std::uint64_t + { + if (!line.starts_with(prefix)) + { + throw std::filesystem::filesystem_error( + "Malformed causal triangulation metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + return parse_unsigned(std::string_view{line}.substr(prefix.size()), 10, + path); + } + + template + void read_causal_info(std::istream& input, TriangulationType& triangulation, + std::filesystem::path const& path) + { + std::string line; + if (!std::getline(input, line) || line != CAUSAL_INFO_HEADER) + { + throw std::filesystem::filesystem_error( + "Unexpected trailing data after triangulation", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + if (!std::getline(input, line)) + { + throw std::filesystem::filesystem_error( + "Truncated causal triangulation metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto const vertex_count = parse_count_line(line, "vertices=", path); + if (vertex_count != + static_cast(triangulation.number_of_vertices())) + { + throw std::filesystem::filesystem_error( + "Causal vertex metadata count does not match triangulation", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + std::map vertex_info; + for (std::uint64_t index = 0; index < vertex_count; ++index) + { + if (!std::getline(input, line)) + { + throw std::filesystem::filesystem_error( + "Truncated causal vertex metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto [key, value] = parse_record(line, "v=", path); + if (!vertex_info.emplace(std::move(key), value).second) + { + throw std::filesystem::filesystem_error( + "Duplicate causal vertex metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + + if (!std::getline(input, line)) + { + throw std::filesystem::filesystem_error( + "Truncated causal triangulation metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto const cell_count = parse_count_line(line, "cells=", path); + if (cell_count != + static_cast(triangulation.number_of_finite_cells())) + { + throw std::filesystem::filesystem_error( + "Causal cell metadata count does not match triangulation", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + std::map cell_info; + for (std::uint64_t index = 0; index < cell_count; ++index) + { + if (!std::getline(input, line)) + { + throw std::filesystem::filesystem_error( + "Truncated causal cell metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto [key, value] = parse_record(line, "c=", path); + if (!cell_info.emplace(std::move(key), value).second) + { + throw std::filesystem::filesystem_error( + "Duplicate causal cell metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + + for (auto vertex = triangulation.finite_vertices_begin(); + vertex != triangulation.finite_vertices_end(); ++vertex) + { + auto const found = vertex_info.find(point_key(vertex->point())); + if (found == vertex_info.end()) + { + throw std::filesystem::filesystem_error( + "Causal vertex metadata does not match triangulation", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + vertex->info() = found->second; + vertex_info.erase(found); + } + for (auto cell = triangulation.finite_cells_begin(); + cell != triangulation.finite_cells_end(); ++cell) + { + auto const found = cell_info.find(cell_key(cell)); + if (found == cell_info.end()) + { + throw std::filesystem::filesystem_error( + "Causal cell metadata does not match triangulation", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + cell->info() = found->second; + cell_info.erase(found); + } + if (!vertex_info.empty() || !cell_info.empty()) + { + throw std::filesystem::filesystem_error( + "Causal metadata contains records outside the triangulation", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + + struct Parsed_persistence_metadata + { + Payload_integrity payload; + Artifact_kind artifact; + cdt::Random_seed seed; + cdt::Random_stream initialization_stream; + cdt::Random_stream transition_stream; + topology_type 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::uint64_t placement_fingerprint; + std::uint64_t topology_fingerprint; + }; + + [[nodiscard]] inline auto read_persistence_metadata( + std::filesystem::path const& path) -> Parsed_persistence_metadata + { + std::ifstream input(path); + if (!input.is_open()) + { + throw std::filesystem::filesystem_error( + "Could not open persistence metadata", path, + std::make_error_code(std::errc::bad_file_descriptor)); + } + + std::string line; + if (!std::getline(input, line) || line != "cdt-plusplus-metadata-v1") + { + throw std::filesystem::filesystem_error( + "Unsupported or malformed persistence metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + std::map values; + while (std::getline(input, line)) + { + auto const separator = line.find('='); + if (separator == std::string::npos || separator == 0 || + separator + 1 == line.size()) + { + throw std::filesystem::filesystem_error( + "Malformed persistence metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto [unused, inserted] = values.emplace(line.substr(0, separator), + line.substr(separator + 1)); + if (!inserted) + { + throw std::filesystem::filesystem_error( + "Duplicate persistence metadata field", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + if (!input.eof()) + { + throw std::filesystem::filesystem_error( + "Could not read persistence metadata", path, + std::make_error_code(std::errc::io_error)); + } + + for (auto const required : {"payload.size", + "payload.fnv1a64", + "artifact", + "resume_supported", + "fresh_topology_replay_supported", + "transition_replay_requires_identical_start", + "cdt.version", + "build.compiler_id", + "build.compiler_version", + "build.configuration", + "build.system", + "build.processor", + "build.cxx_standard", + "build.standard_library", + "dependency.cgal_version", + "random.engine", + "random.seed", + "random.initialization_stream", + "random.transition_stream", + "topology", + "dimension", + "desired.simplices", + "desired.timeslices", + "actual.vertices", + "actual.edges", + "actual.faces", + "actual.simplices", + "actual.minimum_timeslice", + "actual.maximum_timeslice", + "initial_radius", + "foliation_spacing", + "placement.fnv1a64", + "topology.fnv1a64"}) + { + if (!values.contains(required)) + { + throw std::filesystem::filesystem_error( + "Persistence metadata is missing a required field", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + 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)); + } + if (values.at("fresh_topology_replay_supported") != "false" || + values.at("transition_replay_requires_identical_start") != "true") + { + throw std::filesystem::filesystem_error( + "Unsupported persistence replay contract", path, + std::make_error_code(std::errc::not_supported)); + } + if (values.at("random.engine") != "pcg64" || + values.at("build.cxx_standard") != "23") + { + throw std::filesystem::filesystem_error( + "Unsupported persistence metadata", path, + std::make_error_code(std::errc::not_supported)); + } + + Artifact_kind artifact{}; + if (values.at("artifact") == "initial-triangulation") + { + artifact = Artifact_kind::INITIAL_TRIANGULATION; + } + else if (values.at("artifact") == "checkpoint") + { + artifact = Artifact_kind::CHECKPOINT; + } + else if (values.at("artifact") == "final-triangulation") + { + artifact = Artifact_kind::FINAL_TRIANGULATION; + } + else + { + throw std::filesystem::filesystem_error( + "Persistence metadata has an unknown artifact kind", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + topology_type topology{}; + if (values.at("topology") == "spherical") + { + topology = topology_type::SPHERICAL; + } + else if (values.at("topology") == "toroidal") + { + topology = topology_type::TOROIDAL; + } + else + { + throw std::filesystem::filesystem_error( + "Persistence metadata has an unknown topology", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + auto const parse_integer_field = [&](std::string const& name) { + return parse_metadata_integer(values.at(name), path); + }; + auto const dimension = parse_integer_field("dimension"); + auto const desired_simplices = parse_integer_field("desired.simplices"); + auto const desired_timeslices = parse_integer_field("desired.timeslices"); + auto const actual_vertices = parse_integer_field("actual.vertices"); + auto const actual_edges = parse_integer_field("actual.edges"); + auto const actual_faces = parse_integer_field("actual.faces"); + auto const actual_simplices = parse_integer_field("actual.simplices"); + auto const minimum_timeslice = + parse_integer_field("actual.minimum_timeslice"); + auto const maximum_timeslice = + parse_integer_field("actual.maximum_timeslice"); + if (dimension <= 0 || desired_simplices < 0 || desired_timeslices < 0 || + actual_vertices < 0 || actual_edges < 0 || actual_faces < 0 || + actual_simplices < 0 || minimum_timeslice > maximum_timeslice) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains invalid state dimensions", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + auto const initial_radius = + parse_metadata_floating(values.at("initial_radius"), path); + auto const foliation_spacing = + parse_metadata_floating(values.at("foliation_spacing"), path); + if (initial_radius < 0.0 || foliation_spacing <= 0.0) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains invalid foliation parameters", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + auto const action_field_count = + static_cast(values.contains("alpha")) + + static_cast(values.contains("k")) + + static_cast(values.contains("lambda")); + if (action_field_count != 0 && action_field_count != 3) + { + throw std::filesystem::filesystem_error( + "Persistence metadata has an incomplete action parameter set", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + 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) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains an invalid alpha", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + + auto const run_field_count = + static_cast(values.contains("configured_passes")) + + static_cast(values.contains("checkpoint_interval")); + if (run_field_count != 0 && run_field_count != 2) + { + throw std::filesystem::filesystem_error( + "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)) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains an invalid run configuration", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + if (artifact == Artifact_kind::CHECKPOINT && + !values.contains("completed_passes")) + { + throw std::filesystem::filesystem_error( + "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) + { + throw std::filesystem::filesystem_error( + "Persistence metadata contains invalid completed passes", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + + auto const transition_field_count = + static_cast(values.contains("transition_trace.fnv1a64")) + + static_cast(values.contains("transition_trace.count")); + if (transition_field_count != 0 && transition_field_count != 2) + { + throw std::filesystem::filesystem_error( + "Persistence metadata has an incomplete transition trace", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + 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)); + } + + return { + .payload = {parse_unsigned(values.at("payload.size"), 10, path), + parse_unsigned(values.at("payload.fnv1a64"), 16, path)}, + .artifact = artifact, + .seed = parse_unsigned(values.at("random.seed"), 10, path), + .initialization_stream = parse_unsigned( + values.at("random.initialization_stream"), 10, path), + .transition_stream = + parse_unsigned(values.at("random.transition_stream"), 10, path), + .topology = topology, + .dimension = dimension, + .actual_vertices = actual_vertices, + .actual_edges = actual_edges, + .actual_faces = actual_faces, + .actual_simplices = actual_simplices, + .minimum_timeslice = minimum_timeslice, + .maximum_timeslice = maximum_timeslice, + .placement_fingerprint = + parse_unsigned(values.at("placement.fnv1a64"), 16, path), + .topology_fingerprint = + parse_unsigned(values.at("topology.fnv1a64"), 16, path) + }; + } + + [[nodiscard]] inline auto validate_payload_integrity( + std::filesystem::path const& payload) + -> std::optional + { + auto const metadata = metadata_filename(payload); + if (!std::filesystem::exists(metadata)) { return std::nullopt; } + auto const expected = read_persistence_metadata(metadata); + auto const actual = payload_integrity(payload); + if (expected.payload.size != actual.size || + expected.payload.digest != actual.digest) + { + throw std::filesystem::filesystem_error( + "Triangulation payload does not match its persistence metadata", + payload, metadata, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + return expected; + } + [[nodiscard]] inline auto write_file_mutex() -> std::mutex& { static std::mutex mutex; @@ -124,6 +1009,338 @@ namespace utilities } #endif } + + template + [[nodiscard]] auto parse_payload(std::filesystem::path const& filename) + -> TriangulationType + { + std::ifstream file(filename, std::ios::in); + if (!file.is_open()) + { + throw std::filesystem::filesystem_error( + "Could not open file for reading", filename, + std::make_error_code(std::errc::bad_file_descriptor)); + } + TriangulationType triangulation; + file >> triangulation; + if (!file) + { + throw std::filesystem::filesystem_error( + "Could not parse triangulation", filename, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + file >> std::ws; + if (!file.eof()) + { + if constexpr (HAS_CAUSAL_INFO) + { + read_causal_info(file, triangulation, filename); + file >> std::ws; + } + } + if (!file.eof()) + { + throw std::filesystem::filesystem_error( + "Unexpected trailing data after triangulation", filename, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + if constexpr (requires(TriangulationType const& value) { + { value.tds().is_valid() } -> std::convertible_to; + }) + { + if (!triangulation.tds().is_valid()) + { + throw std::filesystem::filesystem_error( + "Parsed triangulation data structure failed its integrity check", + filename, std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + // Evolved CDT states are valid abstract causal triangulations but need + // not satisfy the Euclidean Delaunay empty-sphere property. For CGAL + // triangulations the TDS check above is therefore the authoritative + // integrity check. Use a type's broader validator only when it does not + // expose a distinct triangulation data structure. + if constexpr ( + !requires(TriangulationType const& value) { + { value.tds().is_valid() } -> std::convertible_to; + } && + requires(TriangulationType const& value) { + { value.is_valid() } -> std::convertible_to; + }) + { + if (!triangulation.is_valid()) + { + throw std::filesystem::filesystem_error( + "Parsed triangulation failed its integrity check", filename, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + return triangulation; + } + + template + void reconcile_payload_metadata(Reproducibility_metadata& metadata, + TriangulationType const& triangulation) + { + if constexpr (requires(TriangulationType const& value) { + value.dimension(); + value.number_of_vertices(); + value.number_of_finite_edges(); + value.number_of_finite_facets(); + value.number_of_finite_cells(); + }) + { + metadata.dimension = + gsl::narrow(triangulation.dimension()); + metadata.actual_vertices = + gsl::narrow(triangulation.number_of_vertices()); + metadata.actual_edges = + gsl::narrow(triangulation.number_of_finite_edges()); + metadata.actual_faces = + gsl::narrow(triangulation.number_of_finite_facets()); + metadata.actual_simplices = + gsl::narrow(triangulation.number_of_finite_cells()); + } + if constexpr (HAS_CAUSAL_INFO) + { + if (triangulation.number_of_vertices() == 0) + { + metadata.minimum_timeslice = 0; + metadata.maximum_timeslice = 0; + } + else + { + auto vertex = triangulation.finite_vertices_begin(); + auto minimum_timeslice = static_cast(vertex->info()); + auto maximum_timeslice = minimum_timeslice; + for (++vertex; vertex != triangulation.finite_vertices_end(); + ++vertex) + { + auto const time = static_cast(vertex->info()); + minimum_timeslice = std::min(minimum_timeslice, time); + maximum_timeslice = std::max(maximum_timeslice, time); + } + metadata.minimum_timeslice = minimum_timeslice; + metadata.maximum_timeslice = maximum_timeslice; + } + metadata.placement_fingerprint = + canonical_placement_fingerprint(triangulation); + metadata.topology_fingerprint = + canonical_topology_fingerprint(triangulation); + } + } + + template + void validate_persistence_metadata( + Parsed_persistence_metadata const& metadata, + TriangulationType const& triangulation, + std::filesystem::path const& payload_path, + std::filesystem::path const& metadata_path) + { + Reproducibility_metadata derived; + reconcile_payload_metadata(derived, triangulation); + auto const state_matches = + metadata.dimension == derived.dimension && + metadata.actual_vertices == derived.actual_vertices && + metadata.actual_edges == derived.actual_edges && + metadata.actual_faces == derived.actual_faces && + metadata.actual_simplices == derived.actual_simplices && + metadata.minimum_timeslice == derived.minimum_timeslice && + metadata.maximum_timeslice == derived.maximum_timeslice && + derived.placement_fingerprint && derived.topology_fingerprint && + metadata.placement_fingerprint == *derived.placement_fingerprint && + metadata.topology_fingerprint == *derived.topology_fingerprint; + if (!state_matches) + { + throw std::filesystem::filesystem_error( + "Triangulation state does not match its persistence metadata", + payload_path, metadata_path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + + template + void validate_serialized_payload(std::filesystem::path const& filename, + TriangulationType const& original) + { + if constexpr (requires(std::istream& input, TriangulationType& value) { + input >> value; + } && std::default_initializable) + { + auto const parsed = parse_payload(filename); + if constexpr (requires(TriangulationType const& value) { + value.dimension(); + value.number_of_vertices(); + value.number_of_finite_edges(); + value.number_of_finite_facets(); + value.number_of_finite_cells(); + }) + { + if (parsed.dimension() != original.dimension() || + parsed.number_of_vertices() != original.number_of_vertices() || + parsed.number_of_finite_edges() != + original.number_of_finite_edges() || + parsed.number_of_finite_facets() != + original.number_of_finite_facets() || + parsed.number_of_finite_cells() != + original.number_of_finite_cells()) + { + throw std::filesystem::filesystem_error( + "Serialized triangulation changed its incidence counts", + filename, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + if constexpr (HAS_CAUSAL_INFO) + { + if (canonical_topology_fingerprint(parsed) != + canonical_topology_fingerprint(original)) + { + throw std::filesystem::filesystem_error( + "Serialized triangulation changed its causal topology", + filename, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + } + else if constexpr (requires(TriangulationType const& left, + TriangulationType const& right) { + { left == right } -> std::convertible_to; + }) + { + if (!(parsed == original)) + { + throw std::filesystem::filesystem_error( + "Serialized triangulation did not round-trip exactly", filename, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + } + } + } + + inline void write_text(std::filesystem::path const& filename, + std::string_view const contents) + { + std::ofstream file(filename, std::ios::out | std::ios::trunc); + if (!file.is_open()) + { + throw std::filesystem::filesystem_error( + "Could not open temporary metadata file for writing", filename, + std::make_error_code(std::errc::bad_file_descriptor)); + } + file << contents; + if (!file) + { + throw std::filesystem::filesystem_error( + "Could not serialize persistence metadata", filename, + std::make_error_code(std::errc::io_error)); + } + file.flush(); + if (!file) + { + throw std::filesystem::filesystem_error( + "Could not flush persistence metadata", filename, + std::make_error_code(std::errc::io_error)); + } + file.close(); + if (!file) + { + throw std::filesystem::filesystem_error( + "Could not close persistence metadata", filename, + std::make_error_code(std::errc::io_error)); + } + } + + template + void write_payload(std::filesystem::path const& filename, + TriangulationType const& triangulation, + std::optional const& metadata) + { + WriteFileOperation const operation; + fmt::print("Writing to file {}\n", filename.string()); + std::scoped_lock const lock(write_file_mutex()); + auto temporary = filename; + temporary += ".tmp"; + auto const metadata_destination = metadata_filename(filename); + auto metadata_temporary = metadata_destination; + metadata_temporary += ".tmp"; + auto resolved_metadata = metadata; + if (resolved_metadata) + { + reconcile_payload_metadata(*resolved_metadata, triangulation); + } + + std::error_code cleanup_error; + std::filesystem::remove(temporary, cleanup_error); + std::filesystem::remove(metadata_temporary, cleanup_error); + try + { + std::ofstream file(temporary, std::ios::out | std::ios::trunc); + if (!file.is_open()) + { + throw std::filesystem::filesystem_error( + "Could not open temporary file for writing", filename, + std::make_error_code(std::errc::bad_file_descriptor)); + } + file << std::setprecision(std::numeric_limits::max_digits10) + << triangulation; + write_causal_info(file, triangulation); + if (!file) + { + throw std::filesystem::filesystem_error( + "Could not serialize triangulation", filename, + std::make_error_code(std::errc::io_error)); + } + file.flush(); + if (!file) + { + throw std::filesystem::filesystem_error( + "Could not flush serialized triangulation", filename, + std::make_error_code(std::errc::io_error)); + } + file.close(); + if (!file) + { + throw std::filesystem::filesystem_error( + "Could not close serialized triangulation", filename, + std::make_error_code(std::errc::io_error)); + } + + validate_serialized_payload(temporary, triangulation); + if (resolved_metadata) + { + auto const integrity = payload_integrity(temporary); + write_text(metadata_temporary, + metadata_text(*resolved_metadata, integrity)); + auto const recorded = read_persistence_metadata(metadata_temporary); + if (recorded.payload.size != integrity.size || + recorded.payload.digest != integrity.digest) + { + throw std::filesystem::filesystem_error( + "Persistence metadata did not round-trip exactly", + metadata_temporary, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + validate_persistence_metadata(recorded, triangulation, temporary, + metadata_temporary); + + // Publish the manifest first. A process interrupted between these + // replacements observes a detectable checksum mismatch, never a + // payload silently paired with stale provenance. + replace_file(metadata_temporary, metadata_destination); + } + replace_file(temporary, filename); + if (!resolved_metadata) + { + std::filesystem::remove(metadata_destination, cleanup_error); + } + } + catch (...) + { + std::filesystem::remove(temporary, cleanup_error); + std::filesystem::remove(metadata_temporary, cleanup_error); + throw; + } + } } // namespace detail /// @brief Return current date and time @@ -253,53 +1470,80 @@ namespace utilities void write_file(std::filesystem::path const& filename, TriangulationType const& triangulation) { - detail::WriteFileOperation const operation; - fmt::print("Writing to file {}\n", filename.string()); - std::scoped_lock const lock(detail::write_file_mutex()); - auto temporary = filename; - temporary += ".tmp"; - - std::error_code cleanup_error; - std::filesystem::remove(temporary, cleanup_error); - try - { - std::ofstream file(temporary, std::ios::out | std::ios::trunc); - if (!file.is_open()) - { - throw std::filesystem::filesystem_error( - "Could not open temporary file for writing", filename, - std::make_error_code(std::errc::bad_file_descriptor)); - } - file << triangulation; - if (!file) - { - throw std::filesystem::filesystem_error( - "Could not serialize triangulation", filename, - std::make_error_code(std::errc::io_error)); - } - file.flush(); - if (!file) - { - throw std::filesystem::filesystem_error( - "Could not flush serialized triangulation", filename, - std::make_error_code(std::errc::io_error)); - } - file.close(); - if (!file) - { - throw std::filesystem::filesystem_error( - "Could not close serialized triangulation", filename, - std::make_error_code(std::errc::io_error)); - } - detail::replace_file(temporary, filename); - } - catch (...) - { - std::filesystem::remove(temporary, cleanup_error); - throw; - } + detail::write_payload(filename, triangulation, std::nullopt); } // write_file + /// @brief Atomically replace a triangulation and its verifiable provenance. + /// @details Derives payload-dependent metadata from the triangulation and + /// validates the complete typed manifest before either file is published. + template + void write_file(std::filesystem::path const& filename, + TriangulationType const& triangulation, + Reproducibility_metadata const& metadata) + { detail::write_payload(filename, triangulation, metadata); } + + /// @brief Fingerprint vertices, causal metadata, and abstract finite cells. + template + [[nodiscard]] auto canonical_topology_fingerprint( + ManifoldType const& manifold) -> std::uint64_t + { + auto const triangulation = manifold.delaunay_snapshot(); + return detail::canonical_topology_fingerprint(triangulation); + } + + /// @brief Fingerprint finite vertex coordinates and timeslice metadata. + template + [[nodiscard]] auto canonical_placement_fingerprint( + ManifoldType const& manifold) -> std::uint64_t + { + auto const triangulation = manifold.delaunay_snapshot(); + return detail::canonical_placement_fingerprint(triangulation); + } + + /// @brief Build provenance from a canonical manifold state. + template + [[nodiscard]] auto make_reproducibility_metadata(ManifoldType const& manifold, + cdt::Random_seed const seed, + Artifact_kind const artifact) + -> Reproducibility_metadata + { + return {.artifact = artifact, + .seed = seed, + .topology = ManifoldType::topology, + .dimension = ManifoldType::dimension, + .desired_simplices = manifold.N3(), + .desired_timeslices = manifold.max_time(), + .actual_vertices = manifold.N0(), + .actual_edges = manifold.N1(), + .actual_faces = manifold.N2(), + .actual_simplices = manifold.N3(), + .minimum_timeslice = manifold.min_time(), + .maximum_timeslice = manifold.max_time(), + .initial_radius = manifold.initial_radius(), + .foliation_spacing = manifold.foliation_spacing(), + .placement_fingerprint = canonical_placement_fingerprint(manifold), + .topology_fingerprint = canonical_topology_fingerprint(manifold)}; + } + + /// @brief Refresh state-dependent provenance after a transition sequence. + template + void update_reproducibility_state(Reproducibility_metadata& metadata, + ManifoldType const& manifold) + { + metadata.topology = ManifoldType::topology; + metadata.dimension = ManifoldType::dimension; + metadata.actual_vertices = manifold.N0(); + metadata.actual_edges = manifold.N1(); + metadata.actual_faces = manifold.N2(); + metadata.actual_simplices = manifold.N3(); + metadata.minimum_timeslice = manifold.min_time(); + metadata.maximum_timeslice = manifold.max_time(); + metadata.initial_radius = manifold.initial_radius(); + metadata.foliation_spacing = manifold.foliation_spacing(); + metadata.placement_fingerprint = canonical_placement_fingerprint(manifold); + metadata.topology_fingerprint = canonical_topology_fingerprint(manifold); + } + /// @brief Write the runtime results to a file /// @details The filename is generated by the **make_filename()** and /// writen using another **write_file()** function, which is currently @@ -318,7 +1562,10 @@ namespace utilities template void write_file(ManifoldType const& t_universe, cdt::Random_seed const seed) { - write_file(make_filename(t_universe, seed), t_universe.delaunay_snapshot()); + auto const metadata = make_reproducibility_metadata( + t_universe, seed, Artifact_kind::FINAL_TRIANGULATION); + write_file(make_filename(t_universe, seed), t_universe.delaunay_snapshot(), + metadata); } /// @brief Write a checkpoint with its effective seed and pass in its name. @@ -326,8 +1573,30 @@ namespace utilities void write_file(ManifoldType const& t_universe, cdt::Random_seed const seed, Int_precision const completed_passes) { + auto metadata = make_reproducibility_metadata(t_universe, seed, + Artifact_kind::CHECKPOINT); + metadata.completed_passes = completed_passes; write_file(make_filename(t_universe, seed, completed_passes), - t_universe.delaunay_snapshot()); + t_universe.delaunay_snapshot(), metadata); + } + + /// @brief Write a named stochastic artifact and its complete provenance. + template + void write_file(ManifoldType const& universe, + Reproducibility_metadata const& metadata) + { + auto filename = make_filename(universe, metadata.seed); + if (metadata.artifact == Artifact_kind::CHECKPOINT) + { + if (!metadata.completed_passes) + { + throw std::invalid_argument( + "Checkpoint metadata must record completed passes."); + } + filename = + make_filename(universe, metadata.seed, *metadata.completed_passes); + } + write_file(filename, universe.delaunay_snapshot(), metadata); } /// @brief Read triangulation from file @@ -340,27 +1609,12 @@ namespace utilities static std::mutex mutex; fmt::print("Reading from file {}\n", filename.string()); std::scoped_lock const lock(mutex); - std::ifstream file(filename, std::ios::in); - if (!file.is_open()) - { - throw std::filesystem::filesystem_error( - "Could not open file for reading", filename, - std::make_error_code(std::errc::bad_file_descriptor)); - } - TriangulationType triangulation; - file >> triangulation; - if (!file) - { - throw std::filesystem::filesystem_error( - "Could not parse triangulation", filename, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - file >> std::ws; - if (!file.eof()) + auto const metadata = detail::validate_payload_integrity(filename); + auto triangulation = detail::parse_payload(filename); + if (metadata) { - throw std::filesystem::filesystem_error( - "Unexpected trailing data after triangulation", filename, - std::make_error_code(std::errc::illegal_byte_sequence)); + detail::validate_persistence_metadata(*metadata, triangulation, filename, + metadata_filename(filename)); } return triangulation; } // read_file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a9b5ae45b..cab611b46 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -38,16 +38,23 @@ function(add_cli_failure_test test_name target expected_regex) set_tests_properties(${test_name} PROPERTIES LABELS "integration;cli-boundary") endfunction() -add_test(NAME cdt COMMAND $ -s -n127 -t4 -a0.6 -k1.1 -l0.1 -p1 --seed 92) -set_tests_properties( - cdt - PROPERTIES LABELS "integration" - PASS_REGULAR_EXPRESSION "Writing to file S3-[0-9]+-") +add_test( + NAME cdt + COMMAND + ${CMAKE_COMMAND} "-DTEST_EXECUTABLE=$" + "-DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}" + "-DTEST_DIRECTORY=${CMAKE_CURRENT_BINARY_DIR}/cdt-output" + "-DEXPECTED_ARTIFACT=final-triangulation" + "-DEXPECTED_SEED=92" + "-DTEST_ARGUMENTS=-s;-n127;-t4;-a0.6;-k1.1;-l0.1;-p1;--seed;92" + -P ${PROJECT_SOURCE_DIR}/cmake/RunPersistenceOutputTest.cmake) +set_tests_properties(cdt PROPERTIES LABELS "integration;persistence") add_test( NAME cdt-no-output COMMAND ${CMAKE_COMMAND} -DCDT_EXECUTABLE=$ + -DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR} -DTEST_DIRECTORY=${CMAKE_CURRENT_BINARY_DIR}/cdt-no-output -P ${PROJECT_SOURCE_DIR}/cmake/RunCdtNoOutputTest.cmake) set_tests_properties(cdt-no-output PROPERTIES LABELS "integration") @@ -72,11 +79,27 @@ add_cli_failure_test(cdt-empty-population cdt "would create an empty triangulati add_cli_failure_test(cdt-integer-narrowing cdt "exceeds the supported integer range" -s -n2147483648 -t3 -a0.6 -k1.1 -l0.1 --seed 92) -add_test(NAME initialize COMMAND $ -s -n640 -t4 -o --seed 92) -set_tests_properties( - initialize - PROPERTIES LABELS "integration" - PASS_REGULAR_EXPRESSION "Writing to file S3-4") +add_test( + NAME initialize + COMMAND + ${CMAKE_COMMAND} "-DTEST_EXECUTABLE=$" + "-DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}" + "-DTEST_DIRECTORY=${CMAKE_CURRENT_BINARY_DIR}/initialize-output" + "-DEXPECTED_ARTIFACT=initial-triangulation" + "-DEXPECTED_SEED=92" + "-DTEST_ARGUMENTS=-s;-n640;-t4;-o;--seed;92" + -P ${PROJECT_SOURCE_DIR}/cmake/RunPersistenceOutputTest.cmake) +set_tests_properties(initialize PROPERTIES LABELS "integration;persistence") + +add_test( + NAME initialize-generated-seed + COMMAND + ${CMAKE_COMMAND} "-DTEST_EXECUTABLE=$" + "-DTEST_ROOT=${CMAKE_CURRENT_BINARY_DIR}" + "-DTEST_DIRECTORY=${CMAKE_CURRENT_BINARY_DIR}/initialize-generated-seed-output" + "-DEXPECTED_ARTIFACT=initial-triangulation" + "-DTEST_ARGUMENTS=-s;-n64;-t3;-o" -P ${PROJECT_SOURCE_DIR}/cmake/RunPersistenceOutputTest.cmake) +set_tests_properties(initialize-generated-seed PROPERTIES LABELS "integration;persistence") add_cli_failure_test(initialize-minimum-simplices initialize "Simplices and timeslices must each be at least 2." -s -n1 -t1 -o --seed 92) diff --git a/src/cdt.cpp b/src/cdt.cpp index bb53db1e4..aacf7e48c 100644 --- a/src/cdt.cpp +++ b/src/cdt.cpp @@ -130,14 +130,15 @@ try throw invalid_argument("Number of timeslices not specified."); } + 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); + timeslices, dimensions, initial_radius, foliation_spacing, + root_random.seed()); auto const config = runtime_config::make_simulation( triangulation_config, alpha, k, lambda, passes, checkpoint, !args.count("no-output")); - auto root_random = - args.count("seed") != 0 ? cdt::Random{seed} : cdt::Random{}; auto initialization_random = root_random.split(cdt::random_streams::initialization); auto transition_random = root_random.split(cdt::random_streams::transitions); @@ -156,7 +157,7 @@ try config.triangulation().timeslices()); fmt::print("Number of passes: {}\n", config.passes()); fmt::print("Checkpoint every {} passes.\n", config.checkpoint()); - fmt::print("Effective random seed: {}\n", root_random.seed()); + fmt::print("Effective random seed: {}\n", config.triangulation().seed()); fmt::print("=== Parameters ===\n"); fmt::print("Alpha: {}\n", config.alpha()); fmt::print("K: {}\n", config.k()); @@ -167,11 +168,6 @@ try timer.start(); fmt::print("cdt started at {}\n", utilities::current_date_time()); - // Initialize the Metropolis algorithm - Metropolis_3 run(config.alpha(), config.k(), config.lambda(), config.passes(), - config.checkpoint(), config.write_files(), - std::move(transition_random)); - // Make a triangulation manifolds::Manifold_3 universe; @@ -181,6 +177,22 @@ try config.triangulation().foliation_spacing()); swap(populated_universe, universe); + auto reproducibility = utilities::make_reproducibility_metadata( + universe, config.triangulation().seed(), + utilities::Artifact_kind::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(); + reproducibility.checkpoint_interval = config.checkpoint(); + + // 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); + // Look at triangulation universe.print(); universe.print_details(); @@ -210,7 +222,10 @@ try // Write results to file if (config.write_files()) { - utilities::write_file(result, root_random.seed(), config.passes()); + utilities::write_file( + result, run.reproducibility_metadata( + result, utilities::Artifact_kind::FINAL_TRIANGULATION, + config.passes())); } return EXIT_SUCCESS; diff --git a/src/initialize.cpp b/src/initialize.cpp index 03c5a5cff..048146185 100644 --- a/src/initialize.cpp +++ b/src/initialize.cpp @@ -99,13 +99,14 @@ try throw invalid_argument("Number of timeslices not specified."); } + auto root_random = + args.count("seed") != 0 ? cdt::Random{seed} : cdt::Random{}; auto const config = runtime_config::make_triangulation( args.count("spherical") != 0, args.count("toroidal") != 0, simplices, - timeslices, dimensions, initial_radius, foliation_spacing); + timeslices, dimensions, initial_radius, foliation_spacing, + root_random.seed()); auto const save_file = args.count("output") != 0; - auto root_random = - args.count("seed") != 0 ? cdt::Random{seed} : cdt::Random{}; - auto initialization_random = + auto initialization_random = root_random.split(cdt::random_streams::initialization); // Display job parameters @@ -125,7 +126,15 @@ try universe.print(); universe.print_volume_per_timeslice(); fmt::print("Final number of simplices: {}\n", universe.N3()); - if (save_file) { utilities::write_file(universe, root_random.seed()); } + if (save_file) + { + auto metadata = utilities::make_reproducibility_metadata( + universe, config.seed(), + utilities::Artifact_kind::INITIAL_TRIANGULATION); + metadata.desired_simplices = config.simplices(); + metadata.desired_timeslices = config.timeslices(); + utilities::write_file(universe, metadata); + } return EXIT_SUCCESS; } catch (invalid_argument const& InvalidArgument) diff --git a/tests/Ergodic_moves_3_test.cpp b/tests/Ergodic_moves_3_test.cpp index 81660e9b4..5c20786ba 100644 --- a/tests/Ergodic_moves_3_test.cpp +++ b/tests/Ergodic_moves_3_test.cpp @@ -74,6 +74,33 @@ namespace } } // namespace +SCENARIO("Canonical proposal selection preserves sorted-rank semantics" * + doctest::test_suite("ergodic")) +{ + GIVEN("An unordered proposal domain and two identical generators") + { + vector candidates{9, 1, 7, 3, 5, 2, 8, 4, 6, 0}; + auto sorted = candidates; + ranges::sort(sorted); + mt19937_64 selection_generator{92}; + mt19937_64 rank_generator{92}; + uniform_int_distribution rank_distribution{0, sorted.size() - 1}; + auto const expected = sorted[rank_distribution(rank_generator)]; + + WHEN("The canonical rank is selected with partial ordering") + { + auto const selected = ergodic_moves::detail::canonical_random_element( + candidates, selection_generator, ranges::less{}); + + THEN("It matches full canonical sorting for the same random draw.") + { + REQUIRE(selected); + CHECK_EQ(*selected, expected); + } + } + } +} + SCENARIO("Use check_move to validate successful move" * doctest::test_suite("ergodic")) { diff --git a/tests/Metropolis_test.cpp b/tests/Metropolis_test.cpp index cb125570d..b70515e82 100644 --- a/tests/Metropolis_test.cpp +++ b/tests/Metropolis_test.cpp @@ -425,8 +425,8 @@ SCENARIO("The (6,2) proposal uses the caller-owned generator throughout" * REQUIRE(expanded.has_value()); auto const state = std::move(expanded).value(); auto const triangulation = state.delaunay_snapshot(); - auto const vertices = - foliated_triangulations::collect_vertices<3>(triangulation); + auto vertices = foliated_triangulations::collect_vertices<3>(triangulation); + ergodic_moves::detail::canonicalize(vertices); WHEN("Several seeds select that vertex and construct its inverse move.") { @@ -573,7 +573,7 @@ SCENARIO("Using the Metropolis algorithm" * doctest::test_suite("metropolis")) } } -SCENARIO("Metropolis runs replay every transition from one seed" * +SCENARIO("Metropolis runs replay every transition from an identical start" * doctest::test_suite("metropolis")) { auto const initial = minimal_23_manifold(); @@ -612,4 +612,59 @@ SCENARIO("Metropolis runs replay every transition from one seed" * CHECK(same_counts(first.get_attempted(), replay.get_attempted())); CHECK(same_counts(first.get_succeeded(), replay.get_succeeded())); CHECK(same_counts(first.get_failed(), replay.get_failed())); + CHECK_EQ(first.transition_count(), first.get_proposed().total()); + CHECK_EQ(replay.transition_count(), replay.get_proposed().total()); + CHECK_EQ(first.transition_trace(), replay.transition_trace()); +} + +SCENARIO("Metropolis provenance is derived from the actual run" * + doctest::test_suite("metropolis")) +{ + GIVEN("A default-constructed strategy") + { + Metropolis_3 strategy; + + THEN("Its run-owned generator uses the named transition stream.") + { CHECK_EQ(strategy.stream(), cdt::random_streams::transitions); } + } + + GIVEN("An injected generator and stale caller-supplied run metadata") + { + auto const manifold = minimal_23_manifold(); + utilities::Reproducibility_metadata supplied{.seed = 7, + .transition_stream = 99, + .alpha = 0.7L, + .k = 2.0L, + .lambda = 3.0L, + .configured_passes = 100, + .checkpoint_interval = 50}; + auto constexpr seed = cdt::Random_seed{92}; + auto constexpr transition_stream = cdt::Random_stream{17}; + Metropolis_3 strategy{ + 0.6L, 1.1L, 0.1L, 2, 1, false, cdt::Random{seed, transition_stream}, + supplied + }; + + WHEN("Output provenance is materialized") + { + auto const metadata = strategy.reproducibility_metadata( + manifold, utilities::Artifact_kind::FINAL_TRIANGULATION, 0); + + THEN("Run-owned seed, stream, action, and cadence replace stale claims.") + { + CHECK_EQ(metadata.seed, seed); + CHECK_EQ(metadata.transition_stream, transition_stream); + REQUIRE(metadata.alpha); + REQUIRE(metadata.k); + REQUIRE(metadata.lambda); + REQUIRE(metadata.configured_passes); + REQUIRE(metadata.checkpoint_interval); + CHECK_EQ(*metadata.alpha, 0.6L); + CHECK_EQ(*metadata.k, 1.1L); + CHECK_EQ(*metadata.lambda, 0.1L); + CHECK_EQ(*metadata.configured_passes, 2); + CHECK_EQ(*metadata.checkpoint_interval, 1); + } + } + } } diff --git a/tests/Random_test.cpp b/tests/Random_test.cpp index 0fba45118..f7228912b 100644 --- a/tests/Random_test.cpp +++ b/tests/Random_test.cpp @@ -65,6 +65,28 @@ SCENARIO("PCG runs are reproducible and independently split" * } } +SCENARIO("Distinct seeds have pinned PCG transition prefixes" * + doctest::test_suite("random")) +{ + auto constexpr seeds = std::array{cdt::Random_seed{92}, cdt::Random_seed{93}}; + auto constexpr expected = std::array{ + std::array{6582339598257575626ULL, + 18421894668611219029ULL, 5474286625189102014ULL}, + std::array{8015168658319708503ULL, + 18242522798591373819ULL, 6016913651608525167ULL} + }; + + for (std::size_t seed_index = 0; seed_index < seeds.size(); ++seed_index) + { + cdt::Random generator{seeds[seed_index], cdt::random_streams::transitions}; + CAPTURE(seeds[seed_index]); + for (auto const expected_sample : expected[seed_index]) + { + CHECK_EQ(generator(), expected_sample); + } + } +} + SCENARIO("Random distributions respect their boundaries" * doctest::test_suite("random")) { diff --git a/tests/Runtime_config_test.cpp b/tests/Runtime_config_test.cpp index 634f657ff..e40aecb5d 100644 --- a/tests/Runtime_config_test.cpp +++ b/tests/Runtime_config_test.cpp @@ -70,6 +70,7 @@ SCENARIO("Runtime triangulation options parse into a validated value" * CHECK_EQ(config.dimensions(), 3); CHECK_EQ(config.initial_radius(), 1.0); CHECK_EQ(config.foliation_spacing(), 1.0); + CHECK_EQ(config.seed(), 0); } } } @@ -190,6 +191,14 @@ SCENARIO("Simulation options parse into a validated value" * CHECK_FALSE(config.write_files()); } } + WHEN("An explicit root seed is parsed with the triangulation options.") + { + auto const seeded_triangulation = runtime_config::make_triangulation( + true, false, 64, 3, 3, 1.0, 1.0, cdt::Random_seed{92}); + + THEN("The effective seed is retained by validated configuration.") + { CHECK_EQ(seeded_triangulation.seed(), 92); } + } WHEN("Alpha is outside its physical domain.") { THEN("The parameter is rejected with a domain error.") diff --git a/tests/Utilities_test.cpp b/tests/Utilities_test.cpp index 05fe339a3..84b474561 100644 --- a/tests/Utilities_test.cpp +++ b/tests/Utilities_test.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include using namespace std; @@ -105,6 +106,65 @@ namespace auto operator>>(std::istream& input, SingleInteger& parsed) -> std::istream& { return input >> parsed.value; } + + struct AbstractTriangulationProbe + { + struct DataStructure + { + [[nodiscard]] auto is_valid() const noexcept -> bool { return true; } + }; + + int value{}; + + [[nodiscard]] auto tds() const noexcept -> DataStructure { return {}; } + [[nodiscard]] auto is_valid() const noexcept -> bool { return false; } + }; + + auto operator>>(std::istream& input, AbstractTriangulationProbe& parsed) + -> std::istream& + { return input >> parsed.value; } + + void replace_metadata_field(std::filesystem::path const& filename, + std::string_view const field, + std::string_view const replacement) + { + 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 value_start = start + prefix.size(); + auto const value_end = contents.find('\n', value_start); + contents.replace(value_start, value_end - value_start, replacement); + std::ofstream output{filename, std::ios::trunc}; + output << contents; + } + + void corrupt_metadata_hex_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 value_start = start + prefix.size(); + if (value_start == contents.size() || contents[value_start] == '\n') + { + throw std::runtime_error{"Metadata field is empty"}; + } + contents[value_start] = contents[value_start] == '0' ? '1' : '0'; + std::ofstream output{filename, std::ios::trunc}; + output << contents; + } } // namespace SCENARIO("Various string/stream/time utilities" * @@ -136,6 +196,7 @@ SCENARIO("Various string/stream/time utilities" * } GIVEN("A running environment.") { + CHECK_FALSE(cdt::BUILD_CONFIGURATION.empty()); WHEN("The current time is requested.") { THEN("The output is correct.") @@ -239,6 +300,89 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * CHECK_EQ(triangulation_from_file, triangulation); } } + WHEN("Causal vertex and cell metadata are round-tripped") + { + TemporaryDirectory const directory; + auto const filename = directory.file("causal-info.off"); + auto annotated = manifold.delaunay_snapshot(); + Int_precision vertex_info{10}; + for (auto vertex = annotated.finite_vertices_begin(); + vertex != annotated.finite_vertices_end(); ++vertex) + { + vertex->info() = vertex_info++; + } + for (auto cell = annotated.finite_cells_begin(); + cell != annotated.finite_cells_end(); ++cell) + { + cell->info() = 31; + } + + write_file(filename, annotated); + auto const restored = read_file>(filename); + + THEN("The versioned payload trailer restores the complete causal state") + { + CHECK_EQ(detail::canonical_topology_fingerprint(restored), + detail::canonical_topology_fingerprint(annotated)); + } + } + WHEN("A stochastic artifact is written with reproducibility metadata") + { + TemporaryDirectory const directory; + auto const filename = directory.file("checkpoint.off"); + auto metadata = make_reproducibility_metadata( + manifold, cdt::Random_seed{92}, Artifact_kind::CHECKPOINT); + metadata.desired_simplices = 64; + metadata.desired_timeslices = 3; + metadata.alpha = 0.6L; + metadata.k = 1.1L; + metadata.lambda = 0.1L; + metadata.configured_passes = 10; + metadata.checkpoint_interval = 2; + metadata.completed_passes = 4; + metadata.transition_trace = 0x1234; + metadata.transition_count = 17; + + write_file(filename, manifold.delaunay_snapshot(), metadata); + auto const sidecar = metadata_filename(filename); + + THEN("The payload and portable provenance sidecar round-trip together") + { + REQUIRE(std::filesystem::exists(filename)); + REQUIRE(std::filesystem::exists(sidecar)); + std::ifstream metadata_input{sidecar}; + std::string const contents{ + std::istreambuf_iterator{metadata_input}, + std::istreambuf_iterator{}}; + CHECK_NE(contents.find("cdt-plusplus-metadata-v1"), std::string::npos); + CHECK_NE(contents.find("artifact=checkpoint"), std::string::npos); + CHECK_NE(contents.find("resume_supported=false"), std::string::npos); + CHECK_NE(contents.find("fresh_topology_replay_supported=false"), + std::string::npos); + CHECK_NE( + contents.find("transition_replay_requires_identical_start=true"), + std::string::npos); + CHECK_NE(contents.find("random.seed=92"), std::string::npos); + CHECK_NE(contents.find("random.initialization_stream=0"), + std::string::npos); + CHECK_NE(contents.find("random.transition_stream=1"), + std::string::npos); + CHECK_NE(contents.find("desired.simplices=64"), std::string::npos); + CHECK_NE(contents.find("alpha=0.6"), std::string::npos); + CHECK_NE(contents.find("completed_passes=4"), std::string::npos); + CHECK_NE(contents.find("transition_trace.fnv1a64=0000000000001234"), + std::string::npos); + CHECK_NE(contents.find("placement.fnv1a64="), std::string::npos); + CHECK_NE(contents.find("topology.fnv1a64="), std::string::npos); + CHECK_NOTHROW(read_file>(filename)); + auto payload_temporary = filename; + payload_temporary += ".tmp"; + auto metadata_temporary = sidecar; + metadata_temporary += ".tmp"; + CHECK_FALSE(std::filesystem::exists(payload_temporary)); + CHECK_FALSE(std::filesystem::exists(metadata_temporary)); + } + } } } @@ -332,6 +476,144 @@ SCENARIO("File serialization reports complete failures" * } } } + + GIVEN("A valid triangulation payload with a reproducibility sidecar.") + { + Delaunay_t<3> triangulation; + triangulation.insert(Point_t<3>(0, 0, 0)); + triangulation.insert(Point_t<3>(1, 0, 0)); + triangulation.insert(Point_t<3>(0, 1, 0)); + triangulation.insert(Point_t<3>(0, 0, 1)); + manifolds::Manifold_3 const manifold( + foliated_triangulations::FoliatedTriangulation_3(triangulation, 0, 1)); + + WHEN("The payload is truncated after it was committed.") + { + auto const filename = directory.file("truncated.off"); + auto const metadata = make_reproducibility_metadata( + manifold, cdt::Random_seed{92}, Artifact_kind::CHECKPOINT); + auto checkpoint_metadata = metadata; + checkpoint_metadata.completed_passes = 2; + write_file(filename, triangulation, checkpoint_metadata); + auto const original_size = std::filesystem::file_size(filename); + REQUIRE_GT(original_size, 2); + std::filesystem::resize_file(filename, original_size / 2); + + THEN("The checksum mismatch is diagnosed before topology is accepted.") + { + CHECK_THROWS_AS(read_file>(filename), + std::filesystem::filesystem_error); + } + } + + WHEN("The provenance sidecar is malformed.") + { + auto const filename = directory.file("malformed-metadata.off"); + auto const metadata = make_reproducibility_metadata( + manifold, cdt::Random_seed{92}, Artifact_kind::CHECKPOINT); + auto checkpoint_metadata = metadata; + checkpoint_metadata.completed_passes = 2; + write_file(filename, triangulation, checkpoint_metadata); + { + std::ofstream output{metadata_filename(filename), std::ios::trunc}; + output << "not-valid-metadata\n"; + } + + THEN("The artifact is rejected rather than treated as legacy data.") + { + CHECK_THROWS_AS(read_file>(filename), + std::filesystem::filesystem_error); + } + } + + WHEN("A payload-derived topology fingerprint is changed in the sidecar.") + { + auto const filename = directory.file("changed-topology-fingerprint.off"); + auto metadata = make_reproducibility_metadata( + manifold, cdt::Random_seed{92}, Artifact_kind::CHECKPOINT); + metadata.completed_passes = 2; + write_file(filename, triangulation, metadata); + corrupt_metadata_hex_field(metadata_filename(filename), + "topology.fnv1a64"); + + THEN("The semantic manifest/payload mismatch is rejected.") + { + CHECK_THROWS_AS(read_file>(filename), + std::filesystem::filesystem_error); + } + } + + WHEN("A payload-derived incidence count is changed in the sidecar.") + { + auto const filename = directory.file("changed-incidence-count.off"); + auto metadata = make_reproducibility_metadata( + manifold, cdt::Random_seed{92}, Artifact_kind::CHECKPOINT); + metadata.completed_passes = 2; + write_file(filename, triangulation, metadata); + replace_metadata_field(metadata_filename(filename), "actual.simplices", + "999"); + + THEN("The semantic manifest/payload mismatch is rejected.") + { + CHECK_THROWS_AS(read_file>(filename), + std::filesystem::filesystem_error); + } + } + + WHEN("A typed checkpoint field contains non-numeric data.") + { + auto const filename = directory.file("invalid-completed-passes.off"); + auto metadata = make_reproducibility_metadata( + manifold, cdt::Random_seed{92}, Artifact_kind::CHECKPOINT); + metadata.completed_passes = 2; + write_file(filename, triangulation, metadata); + replace_metadata_field(metadata_filename(filename), "completed_passes", + "not-a-number"); + + THEN("The malformed typed field is rejected.") + { + CHECK_THROWS_AS(read_file>(filename), + std::filesystem::filesystem_error); + } + } + + WHEN("Caller-supplied payload-derived provenance is stale.") + { + auto const filename = directory.file("reconciled-provenance.off"); + auto metadata = make_reproducibility_metadata( + manifold, cdt::Random_seed{92}, Artifact_kind::CHECKPOINT); + metadata.completed_passes = 2; + metadata.actual_simplices = 999; + metadata.topology_fingerprint = 0; + metadata.placement_fingerprint = 0; + + THEN("The writer derives those fields from the serialized state.") + { + CHECK_NOTHROW(write_file(filename, triangulation, metadata)); + CHECK_NOTHROW(read_file>(filename)); + std::ifstream input{metadata_filename(filename)}; + std::string const contents{std::istreambuf_iterator{input}, + std::istreambuf_iterator{}}; + CHECK_EQ(contents.find("actual.simplices=999"), std::string::npos); + } + } + } + + GIVEN( + "A TDS-valid abstract triangulation that is not geometrically Delaunay.") + { + auto const filename = directory.file("abstract-triangulation.off"); + { + std::ofstream output{filename}; + output << 42; + } + + WHEN("The abstract triangulation is parsed.") + { + THEN("TDS integrity does not impose the Delaunay empty-sphere property.") + { CHECK_NOTHROW(read_file(filename)); } + } + } } SCENARIO("Randomizing functions" * doctest::test_suite("utilities")) From cef03b5fb1aebcb72a85096267cdbcae7f43e9af Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Tue, 21 Jul 2026 17:04:08 -0700 Subject: [PATCH 2/2] fix(build): make runtime configuration header self-contained --- include/Runtime_config.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/include/Runtime_config.hpp b/include/Runtime_config.hpp index 47ca8c542..6b97e6896 100644 --- a/include/Runtime_config.hpp +++ b/include/Runtime_config.hpp @@ -16,6 +16,7 @@ #include #include +#include "Random.hpp" #include "Utilities.hpp" namespace runtime_config