From abfa888b33ace3c63efa85e475391be3691ba011 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Wed, 22 Jul 2026 21:42:26 -0700 Subject: [PATCH 1/4] feat(moves): add value-oriented move-run orchestration - Parse pass and checkpoint cadence into a proof-bearing domain value. - Return per-invocation accounting and checkpoint state from shared run logic. - Preserve RNG stream continuity when reusing move strategies. - Enforce parsed cadence, value-oriented accounting, and CRTP-free design. --- include/Metropolis.hpp | 411 +++++++++++++----------- include/Move_always.hpp | 218 ++++++------- include/Move_run.hpp | 236 ++++++++++++++ semgrep.yaml | 114 +++++++ tests/CMakeLists.txt | 2 + tests/Metropolis_test.cpp | 34 ++ tests/Move_always_test.cpp | 53 +++ tests/Move_run_test.cpp | 183 +++++++++++ tests/Public_api_consumer.cpp | 7 + tests/semgrep/functional_boundaries.cpp | 112 +++++++ 10 files changed, 1068 insertions(+), 302 deletions(-) create mode 100644 include/Move_run.hpp create mode 100644 tests/Move_run_test.cpp create mode 100644 tests/semgrep/functional_boundaries.cpp diff --git a/include/Metropolis.hpp b/include/Metropolis.hpp index 5274439fe..c348d90cb 100644 --- a/include/Metropolis.hpp +++ b/include/Metropolis.hpp @@ -26,6 +26,7 @@ // CDT headers #include "Ergodic_moves_3.hpp" +#include "Move_run.hpp" #include "Move_strategy.hpp" #include "Random.hpp" #include "S3Action.hpp" @@ -50,7 +51,31 @@ namespace cdt requires(ManifoldType::dimension == 3) class MoveStrategy { - using Counter = move_tracker::MoveTracker; + using Counter = move_tracker::MoveTracker; + using CommandResults = detail::MoveCommandResults; + + struct RunStatistics + { + /// @brief The geometry used by the latest acceptance decision + Geometry geometry; + + /// @brief Compact fingerprint of ordered transition outcomes + std::uint64_t transition_trace{14695981039346656037ULL}; + + /// @brief Number of transition records in the fingerprint + std::uint64_t transition_count{}; + + /// @brief Move types and raw sites proposed + Counter proposed; + + /// @brief Proposals committed as state transitions + Counter accepted; + + /// @brief Explicit self-transitions + Counter rejected; + }; + + using PassResult = detail::MovePassResult; /// @brief The length of the timelike edges long double m_Alpha{}; @@ -62,22 +87,12 @@ namespace cdt /// the cosmological constant long double m_Lambda{}; - /// @brief The number of move passes executed by the algorithm - /// @details Each move pass makes a number of attempts equal to the number - /// of simplices in the triangulation. - Int_precision m_passes{1}; - - /// @brief The number of passes before a checkpoint - /// @details Each checkpoint writes a file containing the current - /// triangulation. - Int_precision m_checkpoint{1}; + /// @brief Positive pass and checkpoint cadence + MoveRunCadence m_cadence; /// @brief Whether checkpoint and final triangulation files may be written bool m_write_files{true}; - /// @brief The current geometry of the manifold - Geometry m_geometry; - /// @brief Run-owned random engine used for move, site, and acceptance draws cdt::Random m_generator{ cdt::Random{}.split(cdt::random_streams::transitions)}; @@ -85,34 +100,14 @@ namespace cdt /// @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. - Counter m_proposed_moves; + /// @brief Command counters from the latest completed invocation + CommandResults m_command_results; - /// @brief The number of proposals committed as state transitions - Counter m_accepted_moves; + /// @brief Metropolis statistics from the latest completed invocation + RunStatistics m_run_statistics; - /// @brief The number of explicit self-transitions - /// @details Includes inapplicable sites, failed candidate construction, and - /// candidates rejected by the Metropolis-Hastings draw. - Counter m_rejected_moves; - - /// @brief The number of proposal sites whose construction was attempted - /// @details This equals proposed moves. - Counter m_attempted_moves; - - /// @brief The number of attempts that produced a valid candidate manifold - /// @details A successful candidate may still be rejected by MH. - Counter m_succeeded_moves; - - /// @brief The number of inapplicable or invalid candidate constructions - Counter m_failed_moves; + /// @brief Checkpoint events from the latest completed invocation + Int_precision m_checkpoint_events{}; enum class Transition_outcome : std::uint8_t { @@ -121,16 +116,17 @@ namespace cdt REJECTED }; - void record_transition(move_tracker::move_type const move, - Transition_outcome const outcome) noexcept + static void record_transition(RunStatistics& statistics, + 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; + auto const append = [&statistics](std::uint8_t const value) { + statistics.transition_trace ^= value; + statistics.transition_trace *= 1099511628211ULL; }; append(static_cast(move)); append(static_cast(outcome)); - ++m_transition_count; + ++statistics.transition_count; } public: @@ -174,8 +170,8 @@ namespace cdt bool const write_files, cdt::Random random, std::optional reproducibility = std::nullopt) - : m_passes(passes) - , m_checkpoint{checkpoint} + : m_cadence{ + detail::parse_move_run_cadence(passes, checkpoint, "Metropolis")} , m_write_files{write_files} , m_generator{std::move(random)} , m_reproducibility{ @@ -197,17 +193,8 @@ namespace cdt 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"}; - } - if (m_checkpoint <= 0) - { - throw std::invalid_argument{ - "Metropolis checkpoint interval must be positive"}; - } + m_reproducibility.configured_passes = m_cadence.passes(); + m_reproducibility.checkpoint_interval = m_cadence.checkpoint(); #ifndef NDEBUG spdlog::debug("{} called.\n", CDT_PRETTY_FUNCTION); #endif @@ -239,10 +226,15 @@ namespace cdt [[nodiscard]] auto Lambda() const noexcept { return m_Lambda; } /// @returns The number of passes to make - [[nodiscard]] auto passes() const noexcept { return m_passes; } + [[nodiscard]] auto passes() const noexcept { return m_cadence.passes(); } /// @returns The number of passes before writing a checkpoint file - [[nodiscard]] auto checkpoint() const noexcept { return m_checkpoint; } + [[nodiscard]] auto checkpoint() const noexcept + { return m_cadence.checkpoint(); } + + /// @returns Checkpoint events completed by the latest invocation. + [[nodiscard]] auto checkpoint_events() const noexcept + { return m_checkpoint_events; } /// @returns Whether the strategy writes checkpoint triangulation files [[nodiscard]] auto writes_files() const noexcept { return m_write_files; } @@ -256,11 +248,11 @@ namespace cdt /// @returns FNV-1a fingerprint of the ordered move/outcome transition /// trace. [[nodiscard]] auto transition_trace() const noexcept - { return m_transition_trace; } + { return m_run_statistics.transition_trace; } /// @returns Number of transitions represented by transition_trace(). [[nodiscard]] auto transition_count() const noexcept - { return m_transition_count; } + { return m_run_statistics.transition_count; } /// @brief Materialize output provenance for the supplied canonical state. [[nodiscard]] auto reproducibility_metadata( @@ -268,45 +260,32 @@ namespace cdt 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; + return make_reproducibility_metadata(manifold, artifact, completed_passes, + m_run_statistics); } /// @returns The container of trial moves - auto get_proposed() const { return m_proposed_moves; } + auto get_proposed() const { return m_run_statistics.proposed; } /// @returns The container of accepted moves - auto get_accepted() const { return m_accepted_moves; } + auto get_accepted() const { return m_run_statistics.accepted; } /// @returns The container of rejected moves - auto get_rejected() const { return m_rejected_moves; } + auto get_rejected() const { return m_run_statistics.rejected; } /// @returns The container of attempted moves - auto get_attempted() const { return m_attempted_moves; } + auto get_attempted() const { return m_command_results.attempted; } /// @returns The container of successful moves - auto get_succeeded() const { return m_succeeded_moves; } + auto get_succeeded() const { return m_command_results.succeeded; } /// @returns The container of failed moves - auto get_failed() const { return m_failed_moves; } + auto get_failed() const { return m_command_results.failed; } /// @returns The geometry used by the most recent acceptance decision [[nodiscard]] auto get_geometry() const noexcept -> Geometry const& - { return m_geometry; } + { return m_run_statistics.geometry; } /// @returns The inverse Pachner move [[nodiscard]] static auto constexpr reverse_move( @@ -428,14 +407,32 @@ namespace cdt return std::unexpected("Unknown 3D Pachner move.\n"); } - public: - /// @brief Attempt and immediately resolve one Markov transition - /// @param current Canonical state, updated only after a successful MH - /// accept - /// @param move Uniformly selected move type - /// @param trial_value Uniform draw in [0,1], injectable for focused tests - /// @returns True only when a valid candidate is accepted and committed + [[nodiscard]] auto make_reproducibility_metadata( + ManifoldType const& manifold, utilities::Artifact_kind const artifact, + Int_precision const completed_passes, + RunStatistics const& statistics) const + -> utilities::Reproducibility_metadata + { + auto metadata = m_reproducibility; + metadata.artifact = artifact; + metadata.completed_passes = completed_passes; + metadata.transition_trace = statistics.transition_trace; + metadata.transition_count = statistics.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; + } + auto attempt_transition(ManifoldType& current, + CommandResults& command_results, + RunStatistics& statistics, move_tracker::move_type const move, long double const trial_value) -> bool { @@ -445,157 +442,185 @@ namespace cdt throw std::invalid_argument("MH trial value must lie in [0, 1]."); } - m_geometry = current.get_geometry(); - ++m_proposed_moves[move]; - ++m_attempted_moves[move]; + statistics.geometry = current.get_geometry(); + ++statistics.proposed[move]; + ++command_results.attempted[move]; auto candidate = propose_candidate(current, move); if (!candidate || !ergodic_moves::detail::check_move(current, *candidate, move)) { - ++m_failed_moves[move]; - ++m_rejected_moves[move]; - record_transition(move, Transition_outcome::CANDIDATE_FAILED); + ++command_results.failed[move]; + ++statistics.rejected[move]; + record_transition(statistics, move, + Transition_outcome::CANDIDATE_FAILED); return false; } - ++m_succeeded_moves[move]; - auto const probability = - acceptance_probability(m_geometry, candidate->get_geometry(), move); + ++command_results.succeeded[move]; + auto const probability = acceptance_probability( + statistics.geometry, candidate->get_geometry(), move); if (mpfr_cmp_ld(probability.fr(), trial_value) >= 0) { swap(*candidate, current); - m_geometry = current.get_geometry(); - ++m_accepted_moves[move]; - record_transition(move, Transition_outcome::ACCEPTED); + statistics.geometry = current.get_geometry(); + ++statistics.accepted[move]; + record_transition(statistics, move, Transition_outcome::ACCEPTED); return true; } - ++m_rejected_moves[move]; - record_transition(move, Transition_outcome::REJECTED); + ++statistics.rejected[move]; + record_transition(statistics, move, Transition_outcome::REJECTED); return false; } - /// @brief Initialize the cached action geometry from the canonical manifold - void initialize(ManifoldType const& manifold) - { m_geometry = manifold.get_geometry(); } - - /// @brief Run sequential Metropolis-Hastings passes on a manifold - auto operator()(ManifoldType const& t_manifold) -> ManifoldType + [[nodiscard]] auto execute_pass(ManifoldType current, + RunStatistics statistics, + Int_precision const attempts) -> PassResult { -#ifndef NDEBUG - spdlog::debug("{} called.\n", CDT_PRETTY_FUNCTION); -#endif - - fmt::print( - "Starting Metropolis-Hastings algorithm in {}+1 dimensions ...\n", - ManifoldType::dimension - 1); - fmt::print("Effective random seed: {} (stream {}).\n", m_generator.seed(), - m_generator.stream()); - - m_proposed_moves.reset(); - m_accepted_moves.reset(); - m_rejected_moves.reset(); - m_attempted_moves.reset(); - m_succeeded_moves.reset(); - m_failed_moves.reset(); - m_transition_trace = 14695981039346656037ULL; - m_transition_count = 0; - - auto current = t_manifold; - initialize(current); + auto command_results = CommandResults{}; std::uniform_real_distribution acceptance_draw{0.0L, 1.0L}; - - fmt::print("Making random moves ...\n"); - for (auto pass_number = 1; pass_number <= m_passes; ++pass_number) + for (auto move_attempt = Int_precision{0}; move_attempt < attempts; + ++move_attempt) { - fmt::print("=== Pass {} ===\n", pass_number); - auto const attempts_this_pass = current.N3(); - for (auto move_attempt = 0; move_attempt < attempts_this_pass; - ++move_attempt) - { - auto const move = move_tracker::generate_random_move_3(m_generator); - static_cast( - attempt_transition(current, move, acceptance_draw(m_generator))); - } - - if (pass_number % m_checkpoint == 0) - { - print_results(); - if (m_write_files) - { - fmt::print("Writing to file.\n"); - utilities::write_file( - current, reproducibility_metadata( - current, utilities::Artifact_kind::CHECKPOINT, - pass_number)); - } - } + auto const move = move_tracker::generate_random_move_3(m_generator); + static_cast(attempt_transition(current, command_results, + statistics, move, + acceptance_draw(m_generator))); } + return {.manifold = std::move(current), + .command_results = std::move(command_results), + .strategy_state = std::move(statistics)}; + } - fmt::print("=== Run results ===\n"); - print_results(); - return current; - } // operator() - - /// @brief Display results of run - void print_results() + static void print_results(CommandResults const& command_results, + RunStatistics const& statistics) { fmt::print("=== Move Results ===\n"); fmt::print( "There were {} proposed moves with {} accepted moves and {} rejected " "moves.\n", - m_proposed_moves.total(), m_accepted_moves.total(), - m_rejected_moves.total()); + statistics.proposed.total(), statistics.accepted.total(), + statistics.rejected.total()); fmt::print( "There were {} candidate construction attempts with {} successful " "candidates and {} failed candidates.\n", - m_attempted_moves.total(), m_succeeded_moves.total(), - m_failed_moves.total()); + command_results.attempted.total(), command_results.succeeded.total(), + command_results.failed.total()); fmt::print( "(2,3) moves: {} proposed ({} accepted and {} rejected); candidate " "construction: {} attempted ({} succeeded and {} failed).\n", - m_proposed_moves.two_three_moves(), - m_accepted_moves.two_three_moves(), - m_rejected_moves.two_three_moves(), - m_attempted_moves.two_three_moves(), - m_succeeded_moves.two_three_moves(), - m_failed_moves.two_three_moves()); + statistics.proposed.two_three_moves(), + statistics.accepted.two_three_moves(), + statistics.rejected.two_three_moves(), + command_results.attempted.two_three_moves(), + command_results.succeeded.two_three_moves(), + command_results.failed.two_three_moves()); fmt::print( "(3,2) moves: {} proposed ({} accepted and {} rejected); candidate " "construction: {} attempted ({} succeeded and {} failed).\n", - m_proposed_moves.three_two_moves(), - m_accepted_moves.three_two_moves(), - m_rejected_moves.three_two_moves(), - m_attempted_moves.three_two_moves(), - m_succeeded_moves.three_two_moves(), - m_failed_moves.three_two_moves()); + statistics.proposed.three_two_moves(), + statistics.accepted.three_two_moves(), + statistics.rejected.three_two_moves(), + command_results.attempted.three_two_moves(), + command_results.succeeded.three_two_moves(), + command_results.failed.three_two_moves()); fmt::print( "(2,6) moves: {} proposed ({} accepted and {} rejected); candidate " "construction: {} attempted ({} succeeded and {} failed).\n", - m_proposed_moves.two_six_moves(), m_accepted_moves.two_six_moves(), - m_rejected_moves.two_six_moves(), m_attempted_moves.two_six_moves(), - m_succeeded_moves.two_six_moves(), m_failed_moves.two_six_moves()); + statistics.proposed.two_six_moves(), + statistics.accepted.two_six_moves(), + statistics.rejected.two_six_moves(), + command_results.attempted.two_six_moves(), + command_results.succeeded.two_six_moves(), + command_results.failed.two_six_moves()); fmt::print( "(6,2) moves: {} proposed ({} accepted and {} rejected); candidate " "construction: {} attempted ({} succeeded and {} failed).\n", - m_proposed_moves.six_two_moves(), m_accepted_moves.six_two_moves(), - m_rejected_moves.six_two_moves(), m_attempted_moves.six_two_moves(), - m_succeeded_moves.six_two_moves(), m_failed_moves.six_two_moves()); + statistics.proposed.six_two_moves(), + statistics.accepted.six_two_moves(), + statistics.rejected.six_two_moves(), + command_results.attempted.six_two_moves(), + command_results.succeeded.six_two_moves(), + command_results.failed.six_two_moves()); fmt::print( "(4,4) moves: {} proposed ({} accepted and {} rejected); candidate " "construction: {} attempted ({} succeeded and {} failed).\n", - m_proposed_moves.four_four_moves(), - m_accepted_moves.four_four_moves(), - m_rejected_moves.four_four_moves(), - m_attempted_moves.four_four_moves(), - m_succeeded_moves.four_four_moves(), - m_failed_moves.four_four_moves()); - } // print_results + statistics.proposed.four_four_moves(), + statistics.accepted.four_four_moves(), + statistics.rejected.four_four_moves(), + command_results.attempted.four_four_moves(), + command_results.succeeded.four_four_moves(), + command_results.failed.four_four_moves()); + } + + public: + /// @brief Attempt and immediately resolve one Markov transition + /// @param current Canonical state, updated only after a successful MH + /// accept + /// @param move Uniformly selected move type + /// @param trial_value Uniform draw in [0,1], injectable for focused tests + /// @returns True only when a valid candidate is accepted and committed + auto attempt_transition(ManifoldType& current, + move_tracker::move_type const move, + long double const trial_value) -> bool + { + return attempt_transition(current, m_command_results, m_run_statistics, + move, trial_value); + } + + /// @brief Initialize the cached action geometry from the canonical manifold + void initialize(ManifoldType const& manifold) + { m_run_statistics.geometry = manifold.get_geometry(); } + + /// @brief Execute a fresh run while continuing the owned random stream. + /// @details Counters, transition statistics, and checkpoint events are + /// replaced only after the invocation completes. + auto operator()(ManifoldType const& t_manifold) -> ManifoldType + { +#ifndef NDEBUG + spdlog::debug("{} called.\n", CDT_PRETTY_FUNCTION); +#endif + + auto initial_statistics = RunStatistics{}; + initial_statistics.geometry = t_manifold.get_geometry(); + auto result = detail::execute_move_run( + t_manifold, std::move(initial_statistics), m_cadence, + detail::MoveRunIdentity{.algorithm = "Metropolis-Hastings", + .seed = seed(), + .stream = stream()}, + m_write_files, + [this](ManifoldType current, RunStatistics statistics, + Int_precision const attempts) { + return execute_pass(std::move(current), std::move(statistics), + attempts); + }, + [](ManifoldType const&, CommandResults const& command_results, + RunStatistics const& statistics) { + print_results(command_results, statistics); + }, + [this](ManifoldType const& current, CommandResults const&, + RunStatistics const& statistics, + Int_precision const pass_number) { + utilities::write_file( + current, make_reproducibility_metadata( + current, utilities::Artifact_kind::CHECKPOINT, + pass_number, statistics)); + }); + + m_command_results = std::move(result.command_results); + m_run_statistics = std::move(result.strategy_state); + m_checkpoint_events = result.checkpoint_events; + return std::move(result.manifold); + } + + /// @brief Display results of the latest completed invocation. + void print_results() const + { print_results(m_command_results, m_run_statistics); } }; // Metropolis using Metropolis_3 = diff --git a/include/Move_always.hpp b/include/Move_always.hpp index 60493f87b..30f9a6abf 100644 --- a/include/Move_always.hpp +++ b/include/Move_always.hpp @@ -13,9 +13,11 @@ #ifndef INCLUDE_MOVE_ALWAYS_HPP_ #define INCLUDE_MOVE_ALWAYS_HPP_ -#include +#include +#include #include "Move_command.hpp" +#include "Move_run.hpp" #include "Move_strategy.hpp" #include "Random.hpp" @@ -26,30 +28,66 @@ namespace cdt requires(ManifoldType::dimension == 3) class MoveStrategy // NOLINT { - using Counter = move_tracker::MoveTracker; + using CommandResults = detail::MoveCommandResults; + using PassResult = detail::MovePassResult; - /// @brief The number of move passes executed by the algorithm - /// @details Each move pass makes a number of attempts equal to the number - /// of simplices in the triangulation. - Int_precision m_passes{1}; - - /// @brief The number of passes before a checkpoint - /// @details Each checkpoint writes a file containing the current - /// triangulation. - Int_precision m_checkpoint{1}; + /// @brief Positive pass and checkpoint cadence + MoveRunCadence m_cadence; /// @brief Run-owned random stream used for move selection and site ordering cdt::Random m_random; - /// @brief The number of moves that were attempted by a MoveCommand - Counter m_attempted_moves; + /// @brief Whether checkpoint triangulation files may be written + bool m_write_files{true}; + + /// @brief Command counters from the latest completed invocation + CommandResults m_command_results; + + /// @brief Checkpoint events from the latest completed invocation + Int_precision m_checkpoint_events{}; - /// @brief The number of moves that succeeded in the MoveCommand - Counter m_successful_moves; + [[nodiscard]] auto execute_pass(ManifoldType current, + std::monostate strategy_state, + Int_precision const attempts) -> PassResult + { + MoveCommand command{std::move(current)}; + for (auto move_attempt = Int_precision{0}; move_attempt < attempts; + ++move_attempt) + { + command.enqueue(move_tracker::generate_random_move_3(m_random)); + } + command.execute(m_random); + auto command_results = + detail::consume_command_results(command); + return {.manifold = std::move(command.get_results()), + .command_results = std::move(command_results), + .strategy_state = strategy_state}; + } - /// @brief The number of moves that a MoveCommand failed to make due to an - /// error. - Counter m_failed_moves; + static void print_results(CommandResults const& results) + { + fmt::print("=== Move Results ===\n"); + fmt::print("(2,3) moves: {} attempted = {} successful and {} failed.\n", + results.attempted.two_three_moves(), + results.succeeded.two_three_moves(), + results.failed.two_three_moves()); + fmt::print("(3,2) moves: {} attempted = {} successful and {} failed.\n", + results.attempted.three_two_moves(), + results.succeeded.three_two_moves(), + results.failed.three_two_moves()); + fmt::print("(2,6) moves: {} attempted = {} successful and {} failed.\n", + results.attempted.two_six_moves(), + results.succeeded.two_six_moves(), + results.failed.two_six_moves()); + fmt::print("(6,2) moves: {} attempted = {} successful and {} failed.\n", + results.attempted.six_two_moves(), + results.succeeded.six_two_moves(), + results.failed.six_two_moves()); + fmt::print("(4,4) moves: {} attempted = {} successful and {} failed.\n", + results.attempted.four_four_moves(), + results.succeeded.four_four_moves(), + results.failed.four_four_moves()); + } public: /// @brief Default ctor @@ -60,129 +98,91 @@ namespace cdt /// @param t_checkpoint Number of passes per checkpoint [[maybe_unused]] MoveStrategy(Int_precision const t_number_of_passes, Int_precision const t_checkpoint) - : MoveStrategy{t_number_of_passes, t_checkpoint, cdt::Random{}} + : MoveStrategy{t_number_of_passes, t_checkpoint, cdt::Random{}, true} {} /// @brief Construct a replayable MoveAlways run from an explicit seed. [[maybe_unused]] MoveStrategy(Int_precision const t_number_of_passes, Int_precision const t_checkpoint, - cdt::Random_seed const seed) - : MoveStrategy{t_number_of_passes, t_checkpoint, cdt::Random{seed}} + cdt::Random_seed const seed, + bool const write_files = true) + : MoveStrategy{t_number_of_passes, t_checkpoint, cdt::Random{seed}, + write_files} {} /// @brief Construct a MoveAlways run from an owned PCG stream. [[maybe_unused]] MoveStrategy(Int_precision const t_number_of_passes, Int_precision const t_checkpoint, - cdt::Random random) - : m_passes{t_number_of_passes} - , m_checkpoint{t_checkpoint} + cdt::Random random, + bool const write_files = true) + : m_cadence{detail::parse_move_run_cadence(t_number_of_passes, + t_checkpoint, "MoveAlways")} , m_random{std::move(random)} - { - if (m_passes < 0) - { - throw std::invalid_argument{"MoveAlways passes cannot be negative"}; - } - if (m_checkpoint <= 0) - { - throw std::invalid_argument{ - "MoveAlways checkpoint interval must be positive"}; - } - } + , m_write_files{write_files} + {} /// @returns The number of passes made on a triangulation - [[nodiscard]] auto passes() const { return m_passes; } + [[nodiscard]] auto passes() const noexcept { return m_cadence.passes(); } /// @returns The number of passes per checkpoint - [[nodiscard]] auto checkpoint() const { return m_checkpoint; } + [[nodiscard]] auto checkpoint() const noexcept + { return m_cadence.checkpoint(); } + + /// @returns Checkpoint events completed by the latest invocation. + [[nodiscard]] auto checkpoint_events() const noexcept + { return m_checkpoint_events; } /// @returns The effective root seed used for this run. [[nodiscard]] auto seed() const noexcept { return m_random.seed(); } + /// @returns The PCG stream selector used for this run. + [[nodiscard]] auto stream() const noexcept { return m_random.stream(); } + + /// @returns Whether the strategy writes checkpoint triangulation files. + [[nodiscard]] auto writes_files() const noexcept { return m_write_files; } + /// @returns The MoveTracker of attempted moves - auto get_attempted() const { return m_attempted_moves; } + [[nodiscard]] auto get_attempted() const + { return m_command_results.attempted; } /// @returns The MoveTracker of successful moves - auto get_succeeded() const { return m_successful_moves; } + [[nodiscard]] auto get_succeeded() const + { return m_command_results.succeeded; } /// @returns The array of failed moves - auto get_failed() const { return m_failed_moves; } + [[nodiscard]] auto get_failed() const { return m_command_results.failed; } - /// @brief Call operator + /// @brief Execute a fresh run while continuing the owned random stream. + /// @details Counters and checkpoint events are replaced only after the + /// invocation completes. auto operator()(ManifoldType const& t_manifold) -> ManifoldType { #ifndef NDEBUG spdlog::debug("{} called.\n", CDT_PRETTY_FUNCTION); #endif - fmt::print("Starting Move Always algorithm in {}+1 dimensions ...\n", - ManifoldType::dimension - 1); - fmt::print("Effective random seed: {} (stream {}).\n", m_random.seed(), - m_random.stream()); - - m_attempted_moves.reset(); - m_successful_moves.reset(); - m_failed_moves.reset(); - - // Start the move command - MoveCommand command(t_manifold); - - fmt::print("Making random moves ...\n"); - - // Loop through passes - for (auto pass_number = 1; pass_number <= m_passes; ++pass_number) - { - fmt::print("=== Pass {} ===\n", pass_number); - auto total_simplices_this_pass = command.get_const_results().N3(); - // Make a random move per simplex - for (auto move_attempt = 0; move_attempt < total_simplices_this_pass; - ++move_attempt) - { - // Pick a move to attempt - command.enqueue(move_tracker::generate_random_move_3(m_random)); - } - command.execute(m_random); - // Update attempted, successful, and failed moves - m_attempted_moves += command.get_attempted(); - m_successful_moves += command.get_succeeded(); - m_failed_moves += command.get_failed(); - command.reset_counters(); - - if (pass_number % m_checkpoint == 0) - { - fmt::print("Writing checkpoint for pass {}.\n", pass_number); - print_results(); - utilities::write_file(command.get_results(), m_random.seed(), - pass_number); - } - } - print_results(); - return command.get_results(); + auto result = detail::execute_move_run( + t_manifold, std::monostate{}, m_cadence, + detail::MoveRunIdentity{ + .algorithm = "Move Always", .seed = seed(), .stream = stream()}, + m_write_files, + [this](ManifoldType current, std::monostate state, + Int_precision const attempts) { + return execute_pass(std::move(current), state, attempts); + }, + [](ManifoldType const&, CommandResults const& results, + std::monostate const&) { print_results(results); }, + [this](ManifoldType const& current, CommandResults const&, + std::monostate const&, Int_precision const pass_number) { + utilities::write_file(current, seed(), pass_number); + }); + + m_command_results = std::move(result.command_results); + m_checkpoint_events = result.checkpoint_events; + return std::move(result.manifold); } - /// @brief Display results of run - void print_results() - { - fmt::print("=== Move Results ===\n"); - fmt::print("(2,3) moves: {} attempted = {} successful and {} failed.\n", - m_attempted_moves.two_three_moves(), - m_successful_moves.two_three_moves(), - m_failed_moves.two_three_moves()); - fmt::print("(3,2) moves: {} attempted = {} successful and {} failed.\n", - m_attempted_moves.three_two_moves(), - m_successful_moves.three_two_moves(), - m_failed_moves.three_two_moves()); - fmt::print("(2,6) moves: {} attempted = {} successful and {} failed.\n", - m_attempted_moves.two_six_moves(), - m_successful_moves.two_six_moves(), - m_failed_moves.two_six_moves()); - fmt::print("(6,2) moves: {} attempted = {} successful and {} failed.\n", - m_attempted_moves.six_two_moves(), - m_successful_moves.six_two_moves(), - m_failed_moves.six_two_moves()); - fmt::print("(4,4) moves: {} attempted = {} successful and {} failed.\n", - m_attempted_moves.four_four_moves(), - m_successful_moves.four_four_moves(), - m_failed_moves.four_four_moves()); - } + /// @brief Display results of the latest completed invocation. + void print_results() const { print_results(m_command_results); } }; using MoveAlways_3 = diff --git a/include/Move_run.hpp b/include/Move_run.hpp new file mode 100644 index 000000000..655d61f45 --- /dev/null +++ b/include/Move_run.hpp @@ -0,0 +1,236 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL + + Copyright © 2026 Adam Getchell + ******************************************************************************/ + +/// @file Move_run.hpp +/// @brief Shared value-oriented orchestration for ergodic-move strategies + +#ifndef CDT_PLUSPLUS_MOVE_RUN_HPP +#define CDT_PLUSPLUS_MOVE_RUN_HPP + +#include + +#include +#include +#include +#include +#include + +#include "Move_tracker.hpp" +#include "Random.hpp" + +namespace cdt +{ + /// @brief Reasons raw move-run cadence cannot become a domain value. + enum class MoveRunCadenceError + { + NONPOSITIVE_PASSES, + NONPOSITIVE_CHECKPOINT + }; + + /// @brief Positive pass count and checkpoint interval for a move run. + /// @details Once parsed, the orchestration core can use both values without + /// repeating positivity checks. + class MoveRunCadence + { + struct Parsed + {}; + + Int_precision m_passes{1}; + Int_precision m_checkpoint{1}; + + MoveRunCadence(Int_precision const passes, Int_precision const checkpoint, + Parsed /*proof*/) noexcept + : m_passes{passes}, m_checkpoint{checkpoint} + {} + + public: + /// @brief Construct the valid single-pass default cadence. + MoveRunCadence() = default; + + /// @brief Parse raw counts into a cadence that proves positivity. + /// @param passes Number of passes; must be greater than zero. + /// @param checkpoint Number of passes between checkpoint events; must be + /// greater than zero. + /// @returns A cadence on success, or + /// MoveRunCadenceError::NONPOSITIVE_PASSES when passes is nonpositive, + /// otherwise MoveRunCadenceError::NONPOSITIVE_CHECKPOINT when checkpoint is + /// nonpositive. The passes error takes precedence when both are invalid. + [[nodiscard]] static auto parse(Int_precision const passes, + Int_precision const checkpoint) noexcept + -> std::expected + { + if (passes <= 0) + { + return std::unexpected{MoveRunCadenceError::NONPOSITIVE_PASSES}; + } + if (checkpoint <= 0) + { + return std::unexpected{MoveRunCadenceError::NONPOSITIVE_CHECKPOINT}; + } + return MoveRunCadence{passes, checkpoint, Parsed{}}; + } + + /// @returns The positive number of passes in a run. + [[nodiscard]] auto constexpr passes() const noexcept { return m_passes; } + + /// @returns The positive number of passes between checkpoint events. + [[nodiscard]] auto constexpr checkpoint() const noexcept + { return m_checkpoint; } + }; + + namespace detail + { + /// @brief Convert a raw constructor boundary or throw its established + /// error. + [[nodiscard]] inline auto parse_move_run_cadence( + Int_precision const passes, Int_precision const checkpoint, + std::string_view const strategy_name) -> MoveRunCadence + { + auto cadence = MoveRunCadence::parse(passes, checkpoint); + if (cadence) { return *cadence; } + + switch (cadence.error()) + { + case MoveRunCadenceError::NONPOSITIVE_PASSES: + throw std::invalid_argument{ + fmt::format("{} passes must be positive", strategy_name)}; + case MoveRunCadenceError::NONPOSITIVE_CHECKPOINT: + throw std::invalid_argument{fmt::format( + "{} checkpoint interval must be positive", strategy_name)}; + } + throw std::logic_error{"Unknown move-run cadence error"}; + } + + /// @brief Attempted, succeeded, and failed command results. + template + requires(ManifoldType::dimension == 3) + struct MoveCommandResults + { + using Counter = move_tracker::MoveTracker; + + Counter attempted; + Counter succeeded; + Counter failed; + }; + + /// @brief Add one pass delta to accumulated command results. + template + [[nodiscard]] auto accumulate_command_results( + MoveCommandResults totals, + MoveCommandResults const& delta) + -> MoveCommandResults + { + totals.attempted += delta.attempted; + totals.succeeded += delta.succeeded; + totals.failed += delta.failed; + return totals; + } + + /// @brief Consume cumulative MoveCommand counters exactly once. + template + [[nodiscard]] auto consume_command_results(Command& command) + -> MoveCommandResults + { + MoveCommandResults result{ + .attempted = command.get_attempted(), + .succeeded = command.get_succeeded(), + .failed = command.get_failed()}; + command.reset_counters(); + return result; + } + + /// @brief Values produced by one strategy-specific pass. + template + struct MovePassResult + { + ManifoldType manifold; + MoveCommandResults command_results; + StrategyState strategy_state; + }; + + /// @brief Complete values produced by one move-run invocation. + template + struct MoveRunResult + { + ManifoldType manifold; + MoveCommandResults command_results; + StrategyState strategy_state; + Int_precision checkpoint_events{}; + }; + + /// @brief Stable identity displayed by the effectful run shell. + struct MoveRunIdentity + { + std::string_view algorithm; + cdt::Random_seed seed; + cdt::Random_stream stream; + }; + + /// @brief Execute shared pass, accounting, checkpoint, and report cadence. + /// @details The pass callable owns strategy-specific selection and + /// transition effects. The reporting and checkpoint callables make output + /// effects explicit. All run values are returned for one commit by the + /// caller, so a reusable strategy never exposes partially reset counters. + template + [[nodiscard]] auto execute_move_run( + ManifoldType initial, StrategyState initial_strategy_state, + MoveRunCadence const cadence, MoveRunIdentity const identity, + bool const writes_files, ExecutePass&& execute_pass, Report&& report, + Checkpoint&& checkpoint) -> MoveRunResult + { + auto current = std::move(initial); + auto command_totals = MoveCommandResults{}; + auto strategy_state = std::move(initial_strategy_state); + auto checkpoint_events = Int_precision{}; + + fmt::print("Starting {} algorithm in {}+1 dimensions ...\n", + identity.algorithm, ManifoldType::dimension - 1); + fmt::print("Effective random seed: {} (stream {}).\n", identity.seed, + identity.stream); + fmt::print("Making random moves ...\n"); + + for (auto pass_index = Int_precision{}; pass_index < cadence.passes(); + ++pass_index) + { + auto const pass_number = pass_index + 1; + fmt::print("=== Pass {} ===\n", pass_number); + auto const attempts = current.N3(); + auto pass = std::invoke(execute_pass, std::move(current), + std::move(strategy_state), attempts); + current = std::move(pass.manifold); + command_totals = accumulate_command_results(std::move(command_totals), + pass.command_results); + strategy_state = std::move(pass.strategy_state); + + if (pass_number % cadence.checkpoint() == 0) + { + ++checkpoint_events; + std::invoke(report, std::as_const(current), + std::as_const(command_totals), + std::as_const(strategy_state)); + if (writes_files) + { + fmt::print("Writing checkpoint for pass {}.\n", pass_number); + std::invoke(checkpoint, std::as_const(current), + std::as_const(command_totals), + std::as_const(strategy_state), pass_number); + } + } + } + + fmt::print("=== Run results ===\n"); + std::invoke(report, std::as_const(current), std::as_const(command_totals), + std::as_const(strategy_state)); + return {.manifold = std::move(current), + .command_results = std::move(command_totals), + .strategy_state = std::move(strategy_state), + .checkpoint_events = checkpoint_events}; + } + } // namespace detail +} // namespace cdt + +#endif // CDT_PLUSPLUS_MOVE_RUN_HPP diff --git a/semgrep.yaml b/semgrep.yaml index f2c74ba39..f544c3d44 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -1,6 +1,120 @@ --- # Repository-owned Semgrep rules for narrow CDT++ maintenance invariants. rules: + - id: cdt.cpp.no-crtp-inheritance + languages: + - cpp + severity: ERROR + message: >- + Do not encode static polymorphism with CRTP. Prefer a free function, + concept-constrained callable, explicit value, or ordinary composition so + data flow and dispatch remain visible. + metadata: + category: architecture + tracking_issue: "https://github.com/acgetchell/CDT-plusplus/issues/103" + rationale: >- + Self-typed inheritance hides coupling in a base-class protocol and + works against the repository's value-oriented functional design. + paths: + include: + - "/include/**/*.hpp" + - "/src/**/*.cpp" + - "/tests/*_test.cpp" + - "/tests/**/*_test.cpp" + - "/tests/semgrep/**/*.cpp" + pattern-regex: |- + (?x) + \b(?:class|struct)[\t\r\n ]+ + (?[A-Za-z_][A-Za-z0-9_]*) + (?:[\t\r\n ]+final)? + [\t\r\n ]*:[\t\r\n ]* + (?: + (?:(?:public|protected|private|virtual)[\t\r\n ]+)* + (?:[A-Za-z_][A-Za-z0-9_]*::)* + [A-Za-z_][A-Za-z0-9_]* + (?:[\t\r\n ]*<[^<>{};,\r\n]*>)? + [\t\r\n ]*,[\t\r\n ]* + )* + (?:(?:public|protected|private|virtual)[\t\r\n ]+)* + (?:[A-Za-z_][A-Za-z0-9_]*::)* + [A-Za-z_][A-Za-z0-9_]*[\t\r\n ]* + <[\t\r\n ]* + \k + (?:[\t\r\n ]*<[^<>{};\r\n]+>)? + (?:[\t\r\n ]*,[^<>{};\r\n]*)? + [\t\r\n ]*> + - id: cdt.cpp.move-run-accounting-is-value-oriented + languages: + - cpp + severity: ERROR + message: >- + Keep per-run move accounting in a fresh value returned by the shared + runner, then replace strategy state once. Do not reset or increment + persistent counters while orchestrating a run. + metadata: + category: architecture + tracking_issue: "https://github.com/acgetchell/CDT-plusplus/issues/103" + rationale: >- + A returned run result makes state transitions explicit, prevents stale + counter reuse, and keeps pass execution independently testable. + paths: + include: + - "/include/Metropolis.hpp" + - "/include/Move_always.hpp" + - "/tests/semgrep/**/*.cpp" + pattern-either: + - patterns: + - pattern: $COUNTER.reset(); + - metavariable-regex: + metavariable: $COUNTER + regex: ^m_(?:attempted|successful|succeeded|failed|proposed|accepted|rejected)_moves$ + - patterns: + - pattern: $TOTAL += $COMMAND.$GETTER(); + - metavariable-regex: + metavariable: $TOTAL + regex: ^m_(?:attempted|successful|succeeded|failed|proposed|accepted|rejected)_moves$ + - metavariable-regex: + metavariable: $GETTER + regex: ^get_(?:attempted|successful|succeeded|failed|proposed|accepted|rejected)$ + - id: cdt.cpp.move-run-cadence-is-parsed-once + languages: + - cpp + severity: ERROR + message: >- + Store MoveRunCadence after parsing raw pass and checkpoint values at the + boundary. Do not preserve or revalidate raw cadence fields in a strategy. + metadata: + category: correctness + tracking_issue: "https://github.com/acgetchell/CDT-plusplus/issues/103" + rationale: >- + A proof-bearing cadence value makes zero, negative, and inconsistent + scheduling states unrepresentable in the move-run core. + paths: + include: + - "/include/Metropolis.hpp" + - "/include/Move_always.hpp" + - "/tests/semgrep/**/*.cpp" + pattern-either: + - pattern-regex: |- + (?xm) + ^[\t ]* + (?: + (?:cdt::)?Int_precision + | std::size_t + | std::(?:u?int(?:8|16|32|64)_t) + | (?:unsigned[\t ]+)?(?:short|int|long(?:[\t ]+long)?) + ) + (?:[\t ]+const)?[\t ]+ + m_(?:passes|checkpoint)\b[^;\r\n]*; + - patterns: + - pattern-either: + - pattern: if ($FIELD <= 0) { ... } + - pattern: if ($FIELD < 1) { ... } + - pattern: if (0 >= $FIELD) { ... } + - pattern: if (1 > $FIELD) { ... } + - metavariable-regex: + metavariable: $FIELD + regex: ^m_(?:passes|checkpoint)$ - id: cdt.cpp.no-routine-output-in-move-hot-paths languages: - cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8c3add204..1024da018 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable( Metropolis_test.cpp Move_always_test.cpp Move_command_test.cpp + Move_run_test.cpp Move_tracker_test.cpp Random_test.cpp Runtime_config_test.cpp @@ -81,6 +82,7 @@ set( Metropolis.hpp Move_always.hpp Move_command.hpp + Move_run.hpp Move_strategy.hpp Move_tracker.hpp Mpfr_value.hpp diff --git a/tests/Metropolis_test.cpp b/tests/Metropolis_test.cpp index e7d95c23a..6270fbe6d 100644 --- a/tests/Metropolis_test.cpp +++ b/tests/Metropolis_test.cpp @@ -618,6 +618,40 @@ SCENARIO("Metropolis runs replay every transition from an identical start" * CHECK_EQ(first.transition_trace(), replay.transition_trace()); } +SCENARIO("Metropolis multi-pass accounting is per invocation" * + doctest::test_suite("metropolis")) +{ + auto const initial = minimal_23_manifold(); + auto constexpr passes = Int_precision{4}; + auto constexpr checkpoint = Int_precision{2}; + auto constexpr seed = cdt::Random_seed{103}; + Metropolis_3 strategy(0.6L, 0.0L, 0.0L, passes, checkpoint, false, seed); + + static_cast(strategy(initial)); + auto const first_attempted = strategy.get_attempted().total(); + auto const first_succeeded = strategy.get_succeeded().total(); + auto const first_failed = strategy.get_failed().total(); + auto const first_trace = strategy.transition_trace(); + + CHECK_EQ(strategy.checkpoint_events(), 2); + CHECK_EQ(first_attempted, first_succeeded + first_failed); + CHECK_EQ(strategy.transition_count(), first_attempted); + + static_cast(strategy(initial)); + auto const second_attempted = strategy.get_attempted().total(); + auto const second_succeeded = strategy.get_succeeded().total(); + auto const second_failed = strategy.get_failed().total(); + + CHECK_EQ(strategy.checkpoint_events(), 2); + CHECK_EQ(second_attempted, second_succeeded + second_failed); + CHECK_EQ(strategy.transition_count(), second_attempted); + CHECK_NE(strategy.transition_trace(), first_trace); + + Metropolis_3 replay(0.6L, 0.0L, 0.0L, passes, checkpoint, false, seed); + static_cast(replay(initial)); + CHECK_EQ(replay.transition_trace(), first_trace); +} + SCENARIO("Metropolis provenance is derived from the actual run" * doctest::test_suite("metropolis")) { diff --git a/tests/Move_always_test.cpp b/tests/Move_always_test.cpp index 94e9bdfc3..b449ea248 100644 --- a/tests/Move_always_test.cpp +++ b/tests/Move_always_test.cpp @@ -12,12 +12,32 @@ #include +#include #include +#include using namespace cdt; using namespace std; using namespace manifolds; +namespace +{ + [[nodiscard]] auto minimal_23_manifold() -> Manifold_3 + { + auto constexpr radius = 2.0 * std::numbers::inv_sqrt3_v; + auto constexpr root_2 = std::numbers::sqrt2_v; + vector vertices{ + Point_t<3>{ 1, 0, 0}, + Point_t<3>{ 0, 1, 0}, + Point_t<3>{ 0, 0, 1}, + Point_t<3>{radius, radius, radius}, + Point_t<3>{root_2, root_2, 0} + }; + vector timevalues{1, 1, 1, 2, 2}; + return Manifold_3{make_causal_vertices<3>(vertices, timevalues)}; + } +} // namespace + static_assert(std::is_nothrow_swappable_v); SCENARIO("MoveStrategy special member and swap properties" * @@ -94,6 +114,8 @@ SCENARIO("MoveAlways member functions" * doctest::test_suite("move_always")) } CHECK_THROWS_AS(MoveAlways_3(-1, checkpoint, cdt::Random_seed{92}), std::invalid_argument); + CHECK_THROWS_AS(MoveAlways_3(0, checkpoint, cdt::Random_seed{92}), + std::invalid_argument); CHECK_THROWS_AS(MoveAlways_3(passes, 0, cdt::Random_seed{92}), std::invalid_argument); THEN("Attempted, successful, and failed moves are zero-initialized.") @@ -123,6 +145,37 @@ SCENARIO("MoveAlways member functions" * doctest::test_suite("move_always")) } } +SCENARIO("MoveAlways multi-pass accounting is per invocation" * + doctest::test_suite("move_always")) +{ + auto const initial = minimal_23_manifold(); + auto constexpr passes = Int_precision{4}; + auto constexpr checkpoint = Int_precision{2}; + auto constexpr seed = cdt::Random_seed{103}; + MoveAlways_3 strategy(passes, checkpoint, seed, false); + + auto const first_result = strategy(initial); + auto const first_attempted = strategy.get_attempted().total(); + auto const first_succeeded = strategy.get_succeeded().total(); + auto const first_failed = strategy.get_failed().total(); + + CHECK_EQ(strategy.checkpoint_events(), 2); + CHECK_EQ(first_attempted, first_succeeded + first_failed); + + auto const second_result = strategy(initial); + auto const second_attempted = strategy.get_attempted().total(); + auto const second_succeeded = strategy.get_succeeded().total(); + auto const second_failed = strategy.get_failed().total(); + + CHECK_EQ(strategy.checkpoint_events(), 2); + CHECK_EQ(second_attempted, second_succeeded + second_failed); + + MoveAlways_3 replay(passes, checkpoint, seed, false); + auto const replay_result = replay(initial); + CHECK_EQ(first_result.delaunay_snapshot(), replay_result.delaunay_snapshot()); + CHECK_NE(first_result.delaunay_snapshot(), second_result.delaunay_snapshot()); +} + SCENARIO("Using the MoveAlways algorithm" * doctest::test_suite("move_always")) { spdlog::debug("Using the MoveAlways algorithm.\n"); diff --git a/tests/Move_run_test.cpp b/tests/Move_run_test.cpp new file mode 100644 index 000000000..1a8e3ef37 --- /dev/null +++ b/tests/Move_run_test.cpp @@ -0,0 +1,183 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL + + Copyright © 2026 Adam Getchell + ******************************************************************************/ + +/// @file Move_run_test.cpp +/// @brief Tests for shared move-run domain values and accounting + +#include "Move_run.hpp" + +#include + +#include +#include +#include + +using namespace cdt; + +namespace +{ + struct ScriptedManifold + { + static auto constexpr dimension = 3; + + Int_precision simplices{2}; + + [[nodiscard]] auto N3() const noexcept { return simplices; } + }; + + class ScriptedCommand + { + detail::MoveCommandResults m_results; + int m_reset_count{}; + + public: + [[nodiscard]] auto results() noexcept -> auto& { return m_results; } + + [[nodiscard]] auto get_attempted() const -> auto const& + { return m_results.attempted; } + + [[nodiscard]] auto get_succeeded() const -> auto const& + { return m_results.succeeded; } + + [[nodiscard]] auto get_failed() const -> auto const& + { return m_results.failed; } + + void reset_counters() + { + ++m_reset_count; + m_results.attempted.reset(); + m_results.succeeded.reset(); + m_results.failed.reset(); + } + + [[nodiscard]] auto reset_count() const noexcept { return m_reset_count; } + }; +} // namespace + +static_assert(std::is_default_constructible_v); +static_assert( + !std::is_constructible_v); + +SCENARIO("Raw move-run cadence is parsed into a positive domain value" * + doctest::test_suite("move_run")) +{ + GIVEN("The positive boundary and largest representable counts") + { + auto const minimum = MoveRunCadence::parse(1, 1); + auto const maximum = + MoveRunCadence::parse(std::numeric_limits::max(), + std::numeric_limits::max()); + + THEN("Both become infallible cadence values") + { + REQUIRE(minimum); + CHECK_EQ(minimum->passes(), 1); + CHECK_EQ(minimum->checkpoint(), 1); + REQUIRE(maximum); + CHECK_EQ(maximum->passes(), std::numeric_limits::max()); + CHECK_EQ(maximum->checkpoint(), + std::numeric_limits::max()); + } + } + + GIVEN("A nonpositive pass count") + { + THEN("Parsing preserves its rejection reason") + { + auto const zero = MoveRunCadence::parse(0, 1); + auto const negative = MoveRunCadence::parse(-1, 1); + auto const both_invalid = MoveRunCadence::parse(-1, 0); + REQUIRE_FALSE(zero); + REQUIRE_FALSE(negative); + REQUIRE_FALSE(both_invalid); + CHECK_EQ(zero.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); + CHECK_EQ(negative.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); + CHECK_EQ(both_invalid.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); + } + } + + GIVEN("A nonpositive checkpoint interval") + { + THEN("Parsing preserves its rejection reason") + { + auto const zero = MoveRunCadence::parse(1, 0); + auto const negative = MoveRunCadence::parse(1, -1); + REQUIRE_FALSE(zero); + REQUIRE_FALSE(negative); + CHECK_EQ(zero.error(), MoveRunCadenceError::NONPOSITIVE_CHECKPOINT); + CHECK_EQ(negative.error(), MoveRunCadenceError::NONPOSITIVE_CHECKPOINT); + } + } +} + +SCENARIO("MoveCommand results are consumed and reset once" * + doctest::test_suite("move_run")) +{ + ScriptedCommand command; + auto constexpr move = move_tracker::move_type::TWO_THREE; + command.results().attempted[move] = 3; + command.results().succeeded[move] = 1; + command.results().failed[move] = 2; + + auto const consumed = + detail::consume_command_results(command); + + CHECK_EQ(consumed.attempted.total(), 3); + CHECK_EQ(consumed.succeeded.total(), 1); + CHECK_EQ(consumed.failed.total(), 2); + CHECK_EQ(command.get_attempted().total(), 0); + CHECK_EQ(command.get_succeeded().total(), 0); + CHECK_EQ(command.get_failed().total(), 0); + CHECK_EQ(command.reset_count(), 1); +} + +SCENARIO("Shared move-run orchestration accumulates pass deltas once" * + doctest::test_suite("move_run")) +{ + auto const cadence = MoveRunCadence::parse(3, 2); + REQUIRE(cadence); + std::vector reports; + std::vector checkpoints; + + auto result = detail::execute_move_run( + ScriptedManifold{}, 0, *cadence, + detail::MoveRunIdentity{.algorithm = "Scripted", + .seed = Random_seed{103}, + .stream = Random_stream{7}}, + true, + [](ScriptedManifold current, int pass_index, + Int_precision const attempts) { + ++pass_index; + detail::MoveCommandResults delta; + auto constexpr move = move_tracker::move_type::TWO_THREE; + delta.attempted[move] = attempts; + delta.succeeded[move] = pass_index; + delta.failed[move] = attempts - pass_index; + current.simplices = attempts + 1; + return detail::MovePassResult{ + .manifold = current, + .command_results = delta, + .strategy_state = pass_index}; + }, + [&reports](ScriptedManifold const&, + detail::MoveCommandResults const& totals, + int const&) { reports.push_back(totals.attempted.total()); }, + [&checkpoints](ScriptedManifold const&, + detail::MoveCommandResults const& totals, + int const&, Int_precision const pass_number) { + CHECK_EQ(totals.attempted.total(), 5); + checkpoints.push_back(pass_number); + }); + + CHECK_EQ(result.manifold.N3(), 5); + CHECK_EQ(result.strategy_state, 3); + CHECK_EQ(result.command_results.attempted.total(), 9); + CHECK_EQ(result.command_results.succeeded.total(), 6); + CHECK_EQ(result.command_results.failed.total(), 3); + CHECK_EQ(result.checkpoint_events, 1); + CHECK_EQ(reports, std::vector{5, 9}); + CHECK_EQ(checkpoints, std::vector{2}); +} diff --git a/tests/Public_api_consumer.cpp b/tests/Public_api_consumer.cpp index f91b55115..e1026b3ed 100644 --- a/tests/Public_api_consumer.cpp +++ b/tests/Public_api_consumer.cpp @@ -10,6 +10,7 @@ #include "Metropolis.hpp" #include "Move_always.hpp" #include "Move_command.hpp" +#include "Move_run.hpp" #include "Runtime_config.hpp" #include "S3Action.hpp" @@ -34,6 +35,12 @@ static_assert( std::same_as>); static_assert(std::is_constructible_v, Manifold>); +static_assert(requires { + { + cdt::MoveRunCadence::parse(1, 1) + } + -> std::same_as>; +}); static_assert(requires(Manifold const& manifold, cdt::Random& random) { { cdt::ergodic_moves::null_move(manifold) } -> std::same_as; diff --git a/tests/semgrep/functional_boundaries.cpp b/tests/semgrep/functional_boundaries.cpp new file mode 100644 index 000000000..9dd09609d --- /dev/null +++ b/tests/semgrep/functional_boundaries.cpp @@ -0,0 +1,112 @@ +template +class Static_polymorphism +{}; + +// ruleid: cdt.cpp.no-crtp-inheritance +class Concrete_move : public Static_polymorphism +{}; + +// ruleid: cdt.cpp.no-crtp-inheritance +struct Final_move final : private cdt::detail::Static_polymorphism +{}; + +template +// ruleid: cdt.cpp.no-crtp-inheritance +class Generic_move : protected Static_polymorphism> +{}; + +// ruleid: cdt.cpp.no-crtp-inheritance +class Layered_move + : public Move_interface + , public Static_polymorphism +{}; + +// ok: cdt.cpp.no-crtp-inheritance +class Runtime_move : public Move_interface +{}; + +// ok: cdt.cpp.no-crtp-inheritance +class Policy_container + : public Move_policy> +{}; + +class Composed_move +{ + // ok: cdt.cpp.no-crtp-inheritance + Static_polymorphism implementation; +}; + +class Imperative_move_run +{ + public: + void reset() + { + // ruleid: cdt.cpp.move-run-accounting-is-value-oriented + m_attempted_moves.reset(); + // ruleid: cdt.cpp.move-run-accounting-is-value-oriented + m_succeeded_moves.reset(); + } + + void consume(Command& command) + { + // ruleid: cdt.cpp.move-run-accounting-is-value-oriented + m_attempted_moves += command.get_attempted(); + // ruleid: cdt.cpp.move-run-accounting-is-value-oriented + m_failed_moves += command.get_failed(); + } + + private: + Move_counter m_attempted_moves{}; + Move_counter m_succeeded_moves{}; + Move_counter m_failed_moves{}; +}; + +class Value_oriented_move_run +{ + public: + void commit(MoveRunResult result) + { + // ok: cdt.cpp.move-run-accounting-is-value-oriented + m_result = std::move(result); + } + + private: + MoveRunResult m_result{}; +}; + +auto collect_command_result(Command& command) +{ + Move_counter attempted_moves{}; + // ok: cdt.cpp.move-run-accounting-is-value-oriented + attempted_moves += command.get_attempted(); + return MoveCommandResults{std::move(attempted_moves), {}, {}}; +} + +class Raw_cadence_strategy +{ + public: + void execute() + { + // ruleid: cdt.cpp.move-run-cadence-is-parsed-once + if (m_passes <= 0) { return; } + } + + private: + // ruleid: cdt.cpp.move-run-cadence-is-parsed-once + Int_precision m_passes{1}; + // ruleid: cdt.cpp.move-run-cadence-is-parsed-once + std::size_t m_checkpoint = 1; +}; + +class Parsed_cadence_strategy +{ + private: + // ok: cdt.cpp.move-run-cadence-is-parsed-once + MoveRunCadence m_cadence{}; +}; + +auto parse_cadence(Int_precision raw_passes, Int_precision raw_checkpoint) +{ + // ok: cdt.cpp.move-run-cadence-is-parsed-once + return MoveRunCadence::parse(raw_passes, raw_checkpoint); +} From d7fa637abf03d9afb892d48f7119feb8c3580fb5 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Wed, 22 Jul 2026 22:32:10 -0700 Subject: [PATCH 2/4] test(moves): make MoveAlways replay checks deterministic Compare both seeded invocations and their move counters instead of assuming that distinct RNG states must produce different triangulations. --- tests/Move_always_test.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/Move_always_test.cpp b/tests/Move_always_test.cpp index b449ea248..c453ef87d 100644 --- a/tests/Move_always_test.cpp +++ b/tests/Move_always_test.cpp @@ -171,9 +171,19 @@ SCENARIO("MoveAlways multi-pass accounting is per invocation" * CHECK_EQ(second_attempted, second_succeeded + second_failed); MoveAlways_3 replay(passes, checkpoint, seed, false); - auto const replay_result = replay(initial); - CHECK_EQ(first_result.delaunay_snapshot(), replay_result.delaunay_snapshot()); - CHECK_NE(first_result.delaunay_snapshot(), second_result.delaunay_snapshot()); + auto const replay_first_result = replay(initial); + CHECK_EQ(first_result.delaunay_snapshot(), + replay_first_result.delaunay_snapshot()); + CHECK_EQ(first_attempted, replay.get_attempted().total()); + CHECK_EQ(first_succeeded, replay.get_succeeded().total()); + CHECK_EQ(first_failed, replay.get_failed().total()); + + auto const replay_second_result = replay(initial); + CHECK_EQ(second_result.delaunay_snapshot(), + replay_second_result.delaunay_snapshot()); + CHECK_EQ(second_attempted, replay.get_attempted().total()); + CHECK_EQ(second_succeeded, replay.get_succeeded().total()); + CHECK_EQ(second_failed, replay.get_failed().total()); } SCENARIO("Using the MoveAlways algorithm" * doctest::test_suite("move_always")) From f59c06f159cd1b688108e2c3f77afb0cd1d186db Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Thu, 23 Jul 2026 00:01:44 -0700 Subject: [PATCH 3/4] refactor(moves): own move-run callbacks by value - Give pass, report, and checkpoint callables explicit value ownership. - Document MoveAlways cadence, RNG, and file-output contracts. --- include/Move_always.hpp | 17 ++- include/Move_run.hpp | 13 +- tests/Metropolis_test.cpp | 83 +++++++----- tests/Move_always_test.cpp | 102 +++++++++------ tests/Move_run_test.cpp | 250 ++++++++++++++++++++++++------------- 5 files changed, 306 insertions(+), 159 deletions(-) diff --git a/include/Move_always.hpp b/include/Move_always.hpp index 30f9a6abf..c3d0b221a 100644 --- a/include/Move_always.hpp +++ b/include/Move_always.hpp @@ -93,15 +93,21 @@ namespace cdt /// @brief Default ctor MoveStrategy() = default; - /// @brief Constructor for MoveAlways - /// @param t_number_of_passes Number of passes to run - /// @param t_checkpoint Number of passes per checkpoint + /// @brief Construct a MoveAlways run using a fresh entropy-backed stream. + /// @param t_number_of_passes Positive number of passes to run. + /// @param t_checkpoint Positive number of passes per checkpoint. + /// @throws std::invalid_argument When either cadence value is nonpositive. [[maybe_unused]] MoveStrategy(Int_precision const t_number_of_passes, Int_precision const t_checkpoint) : MoveStrategy{t_number_of_passes, t_checkpoint, cdt::Random{}, true} {} /// @brief Construct a replayable MoveAlways run from an explicit seed. + /// @param t_number_of_passes Positive number of passes to run. + /// @param t_checkpoint Positive number of passes per checkpoint. + /// @param seed Root seed for the owned random stream. + /// @param write_files Whether checkpoints may write triangulation files. + /// @throws std::invalid_argument When either cadence value is nonpositive. [[maybe_unused]] MoveStrategy(Int_precision const t_number_of_passes, Int_precision const t_checkpoint, cdt::Random_seed const seed, @@ -111,6 +117,11 @@ namespace cdt {} /// @brief Construct a MoveAlways run from an owned PCG stream. + /// @param t_number_of_passes Positive number of passes to run. + /// @param t_checkpoint Positive number of passes per checkpoint. + /// @param random Stream whose current state becomes owned by this strategy. + /// @param write_files Whether checkpoints may write triangulation files. + /// @throws std::invalid_argument When either cadence value is nonpositive. [[maybe_unused]] MoveStrategy(Int_precision const t_number_of_passes, Int_precision const t_checkpoint, cdt::Random random, diff --git a/include/Move_run.hpp b/include/Move_run.hpp index 655d61f45..579837a45 100644 --- a/include/Move_run.hpp +++ b/include/Move_run.hpp @@ -176,11 +176,14 @@ namespace cdt /// caller, so a reusable strategy never exposes partially reset counters. template - [[nodiscard]] auto execute_move_run( - ManifoldType initial, StrategyState initial_strategy_state, - MoveRunCadence const cadence, MoveRunIdentity const identity, - bool const writes_files, ExecutePass&& execute_pass, Report&& report, - Checkpoint&& checkpoint) -> MoveRunResult + [[nodiscard]] auto execute_move_run(ManifoldType initial, + StrategyState initial_strategy_state, + MoveRunCadence const cadence, + MoveRunIdentity const identity, + bool const writes_files, + ExecutePass execute_pass, Report report, + Checkpoint checkpoint) + -> MoveRunResult { auto current = std::move(initial); auto command_totals = MoveCommandResults{}; diff --git a/tests/Metropolis_test.cpp b/tests/Metropolis_test.cpp index 6270fbe6d..873b18e22 100644 --- a/tests/Metropolis_test.cpp +++ b/tests/Metropolis_test.cpp @@ -621,35 +621,60 @@ SCENARIO("Metropolis runs replay every transition from an identical start" * SCENARIO("Metropolis multi-pass accounting is per invocation" * doctest::test_suite("metropolis")) { - auto const initial = minimal_23_manifold(); - auto constexpr passes = Int_precision{4}; - auto constexpr checkpoint = Int_precision{2}; - auto constexpr seed = cdt::Random_seed{103}; - Metropolis_3 strategy(0.6L, 0.0L, 0.0L, passes, checkpoint, false, seed); - - static_cast(strategy(initial)); - auto const first_attempted = strategy.get_attempted().total(); - auto const first_succeeded = strategy.get_succeeded().total(); - auto const first_failed = strategy.get_failed().total(); - auto const first_trace = strategy.transition_trace(); - - CHECK_EQ(strategy.checkpoint_events(), 2); - CHECK_EQ(first_attempted, first_succeeded + first_failed); - CHECK_EQ(strategy.transition_count(), first_attempted); - - static_cast(strategy(initial)); - auto const second_attempted = strategy.get_attempted().total(); - auto const second_succeeded = strategy.get_succeeded().total(); - auto const second_failed = strategy.get_failed().total(); - - CHECK_EQ(strategy.checkpoint_events(), 2); - CHECK_EQ(second_attempted, second_succeeded + second_failed); - CHECK_EQ(strategy.transition_count(), second_attempted); - CHECK_NE(strategy.transition_trace(), first_trace); - - Metropolis_3 replay(0.6L, 0.0L, 0.0L, passes, checkpoint, false, seed); - static_cast(replay(initial)); - CHECK_EQ(replay.transition_trace(), first_trace); + GIVEN("A fixed seed and a four-pass Metropolis strategy.") + { + auto const initial = minimal_23_manifold(); + auto constexpr passes = Int_precision{4}; + auto constexpr checkpoint = Int_precision{2}; + auto constexpr seed = cdt::Random_seed{103}; + Metropolis_3 strategy(0.6L, 0.0L, 0.0L, passes, checkpoint, false, seed); + CAPTURE(seed); + + WHEN("The strategy and a fresh replay each run twice.") + { + static_cast(strategy(initial)); + auto const first_attempted = strategy.get_attempted().total(); + auto const first_succeeded = strategy.get_succeeded().total(); + auto const first_failed = strategy.get_failed().total(); + auto const first_trace = strategy.transition_trace(); + auto const first_transitions = strategy.transition_count(); + auto const first_checkpoints = strategy.checkpoint_events(); + + static_cast(strategy(initial)); + auto const second_attempted = strategy.get_attempted().total(); + auto const second_succeeded = strategy.get_succeeded().total(); + auto const second_failed = strategy.get_failed().total(); + auto const second_trace = strategy.transition_trace(); + auto const second_transitions = strategy.transition_count(); + auto const second_checkpoints = strategy.checkpoint_events(); + + Metropolis_3 replay(0.6L, 0.0L, 0.0L, passes, checkpoint, false, seed); + static_cast(replay(initial)); + auto const replay_first_trace = replay.transition_trace(); + static_cast(replay(initial)); + auto const replay_second_trace = replay.transition_trace(); + + THEN("Each invocation has exact accounting and is replayable.") + { + CHECK_EQ(first_checkpoints, passes / checkpoint); + CHECK_EQ(first_attempted, 8); + CHECK_EQ(first_succeeded, 1); + CHECK_EQ(first_failed, 7); + CHECK_EQ(first_attempted, first_succeeded + first_failed); + CHECK_EQ(first_transitions, first_attempted); + + CHECK_EQ(second_checkpoints, passes / checkpoint); + CHECK_EQ(second_attempted, 8); + CHECK_EQ(second_succeeded, 2); + CHECK_EQ(second_failed, 6); + CHECK_EQ(second_attempted, second_succeeded + second_failed); + CHECK_EQ(second_transitions, second_attempted); + + CHECK_EQ(replay_first_trace, first_trace); + CHECK_EQ(replay_second_trace, second_trace); + } + } + } } SCENARIO("Metropolis provenance is derived from the actual run" * diff --git a/tests/Move_always_test.cpp b/tests/Move_always_test.cpp index c453ef87d..6639f4ba8 100644 --- a/tests/Move_always_test.cpp +++ b/tests/Move_always_test.cpp @@ -148,42 +148,72 @@ SCENARIO("MoveAlways member functions" * doctest::test_suite("move_always")) SCENARIO("MoveAlways multi-pass accounting is per invocation" * doctest::test_suite("move_always")) { - auto const initial = minimal_23_manifold(); - auto constexpr passes = Int_precision{4}; - auto constexpr checkpoint = Int_precision{2}; - auto constexpr seed = cdt::Random_seed{103}; - MoveAlways_3 strategy(passes, checkpoint, seed, false); - - auto const first_result = strategy(initial); - auto const first_attempted = strategy.get_attempted().total(); - auto const first_succeeded = strategy.get_succeeded().total(); - auto const first_failed = strategy.get_failed().total(); - - CHECK_EQ(strategy.checkpoint_events(), 2); - CHECK_EQ(first_attempted, first_succeeded + first_failed); - - auto const second_result = strategy(initial); - auto const second_attempted = strategy.get_attempted().total(); - auto const second_succeeded = strategy.get_succeeded().total(); - auto const second_failed = strategy.get_failed().total(); - - CHECK_EQ(strategy.checkpoint_events(), 2); - CHECK_EQ(second_attempted, second_succeeded + second_failed); - - MoveAlways_3 replay(passes, checkpoint, seed, false); - auto const replay_first_result = replay(initial); - CHECK_EQ(first_result.delaunay_snapshot(), - replay_first_result.delaunay_snapshot()); - CHECK_EQ(first_attempted, replay.get_attempted().total()); - CHECK_EQ(first_succeeded, replay.get_succeeded().total()); - CHECK_EQ(first_failed, replay.get_failed().total()); - - auto const replay_second_result = replay(initial); - CHECK_EQ(second_result.delaunay_snapshot(), - replay_second_result.delaunay_snapshot()); - CHECK_EQ(second_attempted, replay.get_attempted().total()); - CHECK_EQ(second_succeeded, replay.get_succeeded().total()); - CHECK_EQ(second_failed, replay.get_failed().total()); + GIVEN("A fixed seed and a four-pass MoveAlways strategy.") + { + auto const initial = minimal_23_manifold(); + auto constexpr passes = Int_precision{4}; + auto constexpr checkpoint = Int_precision{2}; + auto constexpr seed = cdt::Random_seed{103}; + MoveAlways_3 strategy(passes, checkpoint, seed, false); + CAPTURE(seed); + + WHEN("The strategy and a fresh replay each run twice.") + { + auto const first_result = strategy(initial); + auto const first_attempted = strategy.get_attempted().total(); + auto const first_succeeded = strategy.get_succeeded().total(); + auto const first_failed = strategy.get_failed().total(); + auto const first_checkpoints = strategy.checkpoint_events(); + + auto const second_result = strategy(initial); + auto const second_attempted = strategy.get_attempted().total(); + auto const second_succeeded = strategy.get_succeeded().total(); + auto const second_failed = strategy.get_failed().total(); + auto const second_checkpoints = strategy.checkpoint_events(); + + MoveAlways_3 replay(passes, checkpoint, seed, false); + auto const replay_first_result = replay(initial); + auto const replay_first_attempted = replay.get_attempted().total(); + auto const replay_first_succeeded = replay.get_succeeded().total(); + auto const replay_first_failed = replay.get_failed().total(); + auto const replay_first_checkpoints = replay.checkpoint_events(); + + auto const replay_second_result = replay(initial); + auto const replay_second_attempted = replay.get_attempted().total(); + auto const replay_second_succeeded = replay.get_succeeded().total(); + auto const replay_second_failed = replay.get_failed().total(); + auto const replay_second_checkpoints = replay.checkpoint_events(); + + THEN("Each invocation has exact accounting and is replayable.") + { + CHECK_EQ(first_checkpoints, passes / checkpoint); + CHECK_EQ(first_attempted, 10); + CHECK_EQ(first_succeeded, 1); + CHECK_EQ(first_failed, 9); + CHECK_EQ(first_attempted, first_succeeded + first_failed); + + CHECK_EQ(second_checkpoints, passes / checkpoint); + CHECK_EQ(second_attempted, 9); + CHECK_EQ(second_succeeded, 2); + CHECK_EQ(second_failed, 7); + CHECK_EQ(second_attempted, second_succeeded + second_failed); + + CHECK_EQ(replay_first_checkpoints, passes / checkpoint); + CHECK_EQ(first_result.delaunay_snapshot(), + replay_first_result.delaunay_snapshot()); + CHECK_EQ(first_attempted, replay_first_attempted); + CHECK_EQ(first_succeeded, replay_first_succeeded); + CHECK_EQ(first_failed, replay_first_failed); + + CHECK_EQ(replay_second_checkpoints, passes / checkpoint); + CHECK_EQ(second_result.delaunay_snapshot(), + replay_second_result.delaunay_snapshot()); + CHECK_EQ(second_attempted, replay_second_attempted); + CHECK_EQ(second_succeeded, replay_second_succeeded); + CHECK_EQ(second_failed, replay_second_failed); + } + } + } } SCENARIO("Using the MoveAlways algorithm" * doctest::test_suite("move_always")) diff --git a/tests/Move_run_test.cpp b/tests/Move_run_test.cpp index 1a8e3ef37..a8f8ed3a6 100644 --- a/tests/Move_run_test.cpp +++ b/tests/Move_run_test.cpp @@ -64,51 +64,62 @@ static_assert( SCENARIO("Raw move-run cadence is parsed into a positive domain value" * doctest::test_suite("move_run")) { - GIVEN("The positive boundary and largest representable counts") + GIVEN("The positive boundary and largest representable counts.") { - auto const minimum = MoveRunCadence::parse(1, 1); - auto const maximum = - MoveRunCadence::parse(std::numeric_limits::max(), - std::numeric_limits::max()); - - THEN("Both become infallible cadence values") + WHEN("Both cadence pairs are parsed.") { - REQUIRE(minimum); - CHECK_EQ(minimum->passes(), 1); - CHECK_EQ(minimum->checkpoint(), 1); - REQUIRE(maximum); - CHECK_EQ(maximum->passes(), std::numeric_limits::max()); - CHECK_EQ(maximum->checkpoint(), - std::numeric_limits::max()); + auto const minimum = MoveRunCadence::parse(1, 1); + auto const maximum = + MoveRunCadence::parse(std::numeric_limits::max(), + std::numeric_limits::max()); + + THEN("Both become infallible cadence values.") + { + REQUIRE(minimum); + CHECK_EQ(minimum->passes(), 1); + CHECK_EQ(minimum->checkpoint(), 1); + REQUIRE(maximum); + CHECK_EQ(maximum->passes(), std::numeric_limits::max()); + CHECK_EQ(maximum->checkpoint(), + std::numeric_limits::max()); + } } } - GIVEN("A nonpositive pass count") + GIVEN("A nonpositive pass count.") { - THEN("Parsing preserves its rejection reason") + WHEN("Cadences containing it are parsed.") { auto const zero = MoveRunCadence::parse(0, 1); auto const negative = MoveRunCadence::parse(-1, 1); auto const both_invalid = MoveRunCadence::parse(-1, 0); - REQUIRE_FALSE(zero); - REQUIRE_FALSE(negative); - REQUIRE_FALSE(both_invalid); - CHECK_EQ(zero.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); - CHECK_EQ(negative.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); - CHECK_EQ(both_invalid.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); + + THEN("Parsing preserves the pass-count rejection reason.") + { + REQUIRE_FALSE(zero); + REQUIRE_FALSE(negative); + REQUIRE_FALSE(both_invalid); + CHECK_EQ(zero.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); + CHECK_EQ(negative.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); + CHECK_EQ(both_invalid.error(), MoveRunCadenceError::NONPOSITIVE_PASSES); + } } } - GIVEN("A nonpositive checkpoint interval") + GIVEN("A nonpositive checkpoint interval.") { - THEN("Parsing preserves its rejection reason") + WHEN("Cadences containing it are parsed.") { auto const zero = MoveRunCadence::parse(1, 0); auto const negative = MoveRunCadence::parse(1, -1); - REQUIRE_FALSE(zero); - REQUIRE_FALSE(negative); - CHECK_EQ(zero.error(), MoveRunCadenceError::NONPOSITIVE_CHECKPOINT); - CHECK_EQ(negative.error(), MoveRunCadenceError::NONPOSITIVE_CHECKPOINT); + + THEN("Parsing preserves the checkpoint rejection reason.") + { + REQUIRE_FALSE(zero); + REQUIRE_FALSE(negative); + CHECK_EQ(zero.error(), MoveRunCadenceError::NONPOSITIVE_CHECKPOINT); + CHECK_EQ(negative.error(), MoveRunCadenceError::NONPOSITIVE_CHECKPOINT); + } } } } @@ -116,68 +127,135 @@ SCENARIO("Raw move-run cadence is parsed into a positive domain value" * SCENARIO("MoveCommand results are consumed and reset once" * doctest::test_suite("move_run")) { - ScriptedCommand command; - auto constexpr move = move_tracker::move_type::TWO_THREE; - command.results().attempted[move] = 3; - command.results().succeeded[move] = 1; - command.results().failed[move] = 2; - - auto const consumed = - detail::consume_command_results(command); - - CHECK_EQ(consumed.attempted.total(), 3); - CHECK_EQ(consumed.succeeded.total(), 1); - CHECK_EQ(consumed.failed.total(), 2); - CHECK_EQ(command.get_attempted().total(), 0); - CHECK_EQ(command.get_succeeded().total(), 0); - CHECK_EQ(command.get_failed().total(), 0); - CHECK_EQ(command.reset_count(), 1); + GIVEN("A command with cumulative attempted, succeeded, and failed counts.") + { + ScriptedCommand command; + auto constexpr move = move_tracker::move_type::TWO_THREE; + command.results().attempted[move] = 3; + command.results().succeeded[move] = 1; + command.results().failed[move] = 2; + + WHEN("The command results are consumed.") + { + auto const consumed = + detail::consume_command_results(command); + + THEN("The snapshot is exact and the source resets once.") + { + CHECK_EQ(consumed.attempted.total(), 3); + CHECK_EQ(consumed.succeeded.total(), 1); + CHECK_EQ(consumed.failed.total(), 2); + CHECK_EQ(command.get_attempted().total(), 0); + CHECK_EQ(command.get_succeeded().total(), 0); + CHECK_EQ(command.get_failed().total(), 0); + CHECK_EQ(command.reset_count(), 1); + } + } + } } SCENARIO("Shared move-run orchestration accumulates pass deltas once" * doctest::test_suite("move_run")) { - auto const cadence = MoveRunCadence::parse(3, 2); - REQUIRE(cadence); - std::vector reports; - std::vector checkpoints; - - auto result = detail::execute_move_run( - ScriptedManifold{}, 0, *cadence, - detail::MoveRunIdentity{.algorithm = "Scripted", - .seed = Random_seed{103}, - .stream = Random_stream{7}}, - true, - [](ScriptedManifold current, int pass_index, - Int_precision const attempts) { - ++pass_index; - detail::MoveCommandResults delta; - auto constexpr move = move_tracker::move_type::TWO_THREE; - delta.attempted[move] = attempts; - delta.succeeded[move] = pass_index; - delta.failed[move] = attempts - pass_index; - current.simplices = attempts + 1; - return detail::MovePassResult{ - .manifold = current, - .command_results = delta, - .strategy_state = pass_index}; - }, - [&reports](ScriptedManifold const&, - detail::MoveCommandResults const& totals, - int const&) { reports.push_back(totals.attempted.total()); }, - [&checkpoints](ScriptedManifold const&, + GIVEN("A three-pass cadence with reporting and checkpoint collectors.") + { + auto const cadence = MoveRunCadence::parse(3, 2); + REQUIRE(cadence); + std::vector reports; + std::vector checkpoints; + std::vector checkpoint_attempts; + + WHEN("The shared runner executes scripted pass deltas.") + { + auto result = detail::execute_move_run( + ScriptedManifold{}, 0, *cadence, + detail::MoveRunIdentity{.algorithm = "Scripted", + .seed = Random_seed{103}, + .stream = Random_stream{7}}, + true, + [](ScriptedManifold current, int pass_index, + Int_precision const attempts) { + ++pass_index; + detail::MoveCommandResults delta; + auto constexpr move = move_tracker::move_type::TWO_THREE; + delta.attempted[move] = attempts; + delta.succeeded[move] = pass_index; + delta.failed[move] = attempts - pass_index; + current.simplices = attempts + 1; + return detail::MovePassResult{ + .manifold = current, + .command_results = delta, + .strategy_state = pass_index}; + }, + [&reports](ScriptedManifold const&, detail::MoveCommandResults const& totals, - int const&, Int_precision const pass_number) { - CHECK_EQ(totals.attempted.total(), 5); - checkpoints.push_back(pass_number); - }); - - CHECK_EQ(result.manifold.N3(), 5); - CHECK_EQ(result.strategy_state, 3); - CHECK_EQ(result.command_results.attempted.total(), 9); - CHECK_EQ(result.command_results.succeeded.total(), 6); - CHECK_EQ(result.command_results.failed.total(), 3); - CHECK_EQ(result.checkpoint_events, 1); - CHECK_EQ(reports, std::vector{5, 9}); - CHECK_EQ(checkpoints, std::vector{2}); + int const&) { + reports.push_back(totals.attempted.total()); + }, + [&checkpoints, &checkpoint_attempts]( + ScriptedManifold const&, + detail::MoveCommandResults const& totals, + int const&, Int_precision const pass_number) { + checkpoints.push_back(pass_number); + checkpoint_attempts.push_back(totals.attempted.total()); + }); + + THEN("Each delta is accumulated once at the configured cadence.") + { + CHECK_EQ(result.manifold.N3(), 5); + CHECK_EQ(result.strategy_state, 3); + CHECK_EQ(result.command_results.attempted.total(), 9); + CHECK_EQ(result.command_results.succeeded.total(), 6); + CHECK_EQ(result.command_results.failed.total(), 3); + CHECK_EQ(result.checkpoint_events, 1); + CHECK_EQ(reports, std::vector{5, 9}); + CHECK_EQ(checkpoints, std::vector{2}); + CHECK_EQ(checkpoint_attempts, std::vector{5}); + } + } + } +} + +SCENARIO( + "Shared move-run orchestration suppresses disabled checkpoint effects" * + doctest::test_suite("move_run")) +{ + GIVEN("A checkpoint-every-pass cadence with file writes disabled.") + { + auto const cadence = MoveRunCadence::parse(2, 1); + REQUIRE(cadence); + auto report_calls = 0; + auto checkpoint_calls = 0; + + WHEN("The shared runner executes two passes.") + { + auto result = detail::execute_move_run( + ScriptedManifold{}, 0, *cadence, + detail::MoveRunIdentity{.algorithm = "Scripted", + .seed = Random_seed{103}, + .stream = Random_stream{7}}, + false, + [](ScriptedManifold current, int pass_index, Int_precision) { + return detail::MovePassResult{ + .manifold = current, + .command_results = {}, + .strategy_state = pass_index + 1}; + }, + [&report_calls](ScriptedManifold const&, + detail::MoveCommandResults const&, + int const&) { ++report_calls; }, + [&checkpoint_calls]( + ScriptedManifold const&, + detail::MoveCommandResults const&, int const&, + Int_precision) { ++checkpoint_calls; }); + + THEN("Accounting and reports continue without checkpoint effects.") + { + CHECK_EQ(result.strategy_state, 2); + CHECK_EQ(result.checkpoint_events, 2); + CHECK_EQ(report_calls, 3); + CHECK_EQ(checkpoint_calls, 0); + } + } + } } From 3c322ee242a1d3b2e4d475196950d2dd6ad35c4c Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Thu, 23 Jul 2026 00:52:02 -0700 Subject: [PATCH 4/4] test(moves): make seeded replay fixtures cross-platform - Preserve exact accounting expectations across libc++, libstdc++, and MSVC STL. - Verify MoveAlways stream continuation with an exact per-move oracle. --- tests/Metropolis_test.cpp | 55 +++++++++++++++++++--- tests/Move_always_test.cpp | 95 +++++++++++++++++++++++++++++++------- 2 files changed, 127 insertions(+), 23 deletions(-) diff --git a/tests/Metropolis_test.cpp b/tests/Metropolis_test.cpp index 873b18e22..c19d72390 100644 --- a/tests/Metropolis_test.cpp +++ b/tests/Metropolis_test.cpp @@ -127,6 +127,47 @@ namespace [[nodiscard]] auto calls() const noexcept -> std::size_t { return m_calls; } }; + + struct Expected_run_accounting + { + Int_precision attempted; + Int_precision succeeded; + Int_precision failed; + }; + + struct Expected_metropolis_fixture + { + Expected_run_accounting first; + Expected_run_accounting second; + char const* standard_library; + }; + + /// `std::uniform_int_distribution` mappings vary by standard library. + [[nodiscard]] consteval auto expected_metropolis_fixture() + -> Expected_metropolis_fixture + { +#if defined(_LIBCPP_VERSION) + return { + .first = {8, 1, 7}, + .second = {8, 2, 6}, + .standard_library = "libc++" + }; +#elif defined(__GLIBCXX__) + return { + .first = { 8, 3, 5}, + .second = {10, 2, 8}, + .standard_library = "libstdc++" + }; +#elif defined(_MSVC_STL_VERSION) + return { + .first = { 8, 3, 5}, + .second = {10, 2, 8}, + .standard_library = "msvc-stl" + }; +#else +#error Unsupported standard library for deterministic Metropolis fixture +#endif + } } // namespace static_assert(std::is_nothrow_swappable_v); @@ -627,8 +668,10 @@ SCENARIO("Metropolis multi-pass accounting is per invocation" * auto constexpr passes = Int_precision{4}; auto constexpr checkpoint = Int_precision{2}; auto constexpr seed = cdt::Random_seed{103}; + auto constexpr expected = expected_metropolis_fixture(); Metropolis_3 strategy(0.6L, 0.0L, 0.0L, passes, checkpoint, false, seed); CAPTURE(seed); + CAPTURE(expected.standard_library); WHEN("The strategy and a fresh replay each run twice.") { @@ -657,16 +700,16 @@ SCENARIO("Metropolis multi-pass accounting is per invocation" * THEN("Each invocation has exact accounting and is replayable.") { CHECK_EQ(first_checkpoints, passes / checkpoint); - CHECK_EQ(first_attempted, 8); - CHECK_EQ(first_succeeded, 1); - CHECK_EQ(first_failed, 7); + CHECK_EQ(first_attempted, expected.first.attempted); + CHECK_EQ(first_succeeded, expected.first.succeeded); + CHECK_EQ(first_failed, expected.first.failed); CHECK_EQ(first_attempted, first_succeeded + first_failed); CHECK_EQ(first_transitions, first_attempted); CHECK_EQ(second_checkpoints, passes / checkpoint); - CHECK_EQ(second_attempted, 8); - CHECK_EQ(second_succeeded, 2); - CHECK_EQ(second_failed, 6); + CHECK_EQ(second_attempted, expected.second.attempted); + CHECK_EQ(second_succeeded, expected.second.succeeded); + CHECK_EQ(second_failed, expected.second.failed); CHECK_EQ(second_attempted, second_succeeded + second_failed); CHECK_EQ(second_transitions, second_attempted); diff --git a/tests/Move_always_test.cpp b/tests/Move_always_test.cpp index 6639f4ba8..396b2221c 100644 --- a/tests/Move_always_test.cpp +++ b/tests/Move_always_test.cpp @@ -36,6 +36,59 @@ namespace vector timevalues{1, 1, 1, 2, 2}; return Manifold_3{make_causal_vertices<3>(vertices, timevalues)}; } + + struct Expected_run_accounting + { + Int_precision attempted; + Int_precision succeeded; + Int_precision failed; + }; + + struct Expected_move_always_fixture + { + Expected_run_accounting first; + Expected_run_accounting second; + move_tracker::move_type continuation_move; + Int_precision first_continuation_attempts; + Int_precision second_continuation_attempts; + char const* standard_library; + }; + + /// `std::uniform_int_distribution` mappings vary by standard library. + [[nodiscard]] consteval auto expected_move_always_fixture() + -> Expected_move_always_fixture + { +#if defined(_LIBCPP_VERSION) + return { + .first = {10, 1, 9}, + .second = { 9, 2, 7}, + .continuation_move = move_tracker::move_type::TWO_THREE, + .first_continuation_attempts = 3, + .second_continuation_attempts = 1, + .standard_library = "libc++" + }; +#elif defined(__GLIBCXX__) + return { + .first = {9, 3, 6}, + .second = {9, 3, 6}, + .continuation_move = move_tracker::move_type::TWO_SIX, + .first_continuation_attempts = 3, + .second_continuation_attempts = 1, + .standard_library = "libstdc++" + }; +#elif defined(_MSVC_STL_VERSION) + return { + .first = {8, 0, 8}, + .second = {9, 2, 7}, + .continuation_move = move_tracker::move_type::TWO_THREE, + .first_continuation_attempts = 0, + .second_continuation_attempts = 1, + .standard_library = "msvc-stl" + }; +#else +#error Unsupported standard library for deterministic MoveAlways fixture +#endif + } } // namespace static_assert(std::is_nothrow_swappable_v); @@ -154,22 +207,26 @@ SCENARIO("MoveAlways multi-pass accounting is per invocation" * auto constexpr passes = Int_precision{4}; auto constexpr checkpoint = Int_precision{2}; auto constexpr seed = cdt::Random_seed{103}; + auto constexpr expected = expected_move_always_fixture(); MoveAlways_3 strategy(passes, checkpoint, seed, false); CAPTURE(seed); + CAPTURE(expected.standard_library); WHEN("The strategy and a fresh replay each run twice.") { - auto const first_result = strategy(initial); - auto const first_attempted = strategy.get_attempted().total(); - auto const first_succeeded = strategy.get_succeeded().total(); - auto const first_failed = strategy.get_failed().total(); - auto const first_checkpoints = strategy.checkpoint_events(); - - auto const second_result = strategy(initial); - auto const second_attempted = strategy.get_attempted().total(); - auto const second_succeeded = strategy.get_succeeded().total(); - auto const second_failed = strategy.get_failed().total(); - auto const second_checkpoints = strategy.checkpoint_events(); + auto const first_result = strategy(initial); + auto const first_attempted_moves = strategy.get_attempted(); + auto const first_attempted = first_attempted_moves.total(); + auto const first_succeeded = strategy.get_succeeded().total(); + auto const first_failed = strategy.get_failed().total(); + auto const first_checkpoints = strategy.checkpoint_events(); + + auto const second_result = strategy(initial); + auto const second_attempted_moves = strategy.get_attempted(); + auto const second_attempted = second_attempted_moves.total(); + auto const second_succeeded = strategy.get_succeeded().total(); + auto const second_failed = strategy.get_failed().total(); + auto const second_checkpoints = strategy.checkpoint_events(); MoveAlways_3 replay(passes, checkpoint, seed, false); auto const replay_first_result = replay(initial); @@ -187,16 +244,20 @@ SCENARIO("MoveAlways multi-pass accounting is per invocation" * THEN("Each invocation has exact accounting and is replayable.") { CHECK_EQ(first_checkpoints, passes / checkpoint); - CHECK_EQ(first_attempted, 10); - CHECK_EQ(first_succeeded, 1); - CHECK_EQ(first_failed, 9); + CHECK_EQ(first_attempted, expected.first.attempted); + CHECK_EQ(first_succeeded, expected.first.succeeded); + CHECK_EQ(first_failed, expected.first.failed); CHECK_EQ(first_attempted, first_succeeded + first_failed); + CHECK_EQ(first_attempted_moves[expected.continuation_move], + expected.first_continuation_attempts); CHECK_EQ(second_checkpoints, passes / checkpoint); - CHECK_EQ(second_attempted, 9); - CHECK_EQ(second_succeeded, 2); - CHECK_EQ(second_failed, 7); + CHECK_EQ(second_attempted, expected.second.attempted); + CHECK_EQ(second_succeeded, expected.second.succeeded); + CHECK_EQ(second_failed, expected.second.failed); CHECK_EQ(second_attempted, second_succeeded + second_failed); + CHECK_EQ(second_attempted_moves[expected.continuation_move], + expected.second_continuation_attempts); CHECK_EQ(replay_first_checkpoints, passes / checkpoint); CHECK_EQ(first_result.delaunay_snapshot(),