From d4b544040c6631d8ca06510373d080d5af72aeb3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 Jul 2026 18:28:12 +0300 Subject: [PATCH 1/6] Reapply 3+1D CDT support --- docs/4d-standard-cdt-candidate.md | 78 +++ include/Detailed_balance_4.hpp | 152 +++++ include/Ergodic_moves_4.hpp | 105 ++++ include/Foliated_triangulation_4.hpp | 774 ++++++++++++++++++++++++ include/Metropolis_4.hpp | 229 +++++++ include/Move_catalog_4.hpp | 213 +++++++ include/Move_tracker.hpp | 75 +++ include/Observables_4.hpp | 58 ++ include/Phase_analysis.hpp | 428 +++++++++++++ include/S4Action.hpp | 83 +++ include/Simulation_output.hpp | 204 +++++++ scripts/run-cds-phase-scan.ps1 | 60 ++ scripts/run-cds-phase-scan.sh | 55 ++ src/CMakeLists.txt | 17 +- src/cdt.cpp | 227 ++++++- tests/CMakeLists.txt | 20 + tests/Checkpoint_4_test.cpp | 35 ++ tests/Detailed_balance_test.cpp | 51 ++ tests/Ergodic_moves_4_test.cpp | 121 ++++ tests/Foliated_triangulation_4_test.cpp | 50 ++ tests/Geometry_4_test.cpp | 26 + tests/Manifold_4_test.cpp | 13 + tests/Metropolis_4_test.cpp | 52 ++ tests/Move_tracker_test.cpp | 28 + tests/Observables_4_test.cpp | 22 + tests/Phase_analysis_test.cpp | 104 ++++ tests/S4Action_test.cpp | 43 ++ tests/Time_reversal_4_test.cpp | 41 ++ 28 files changed, 3356 insertions(+), 8 deletions(-) create mode 100644 docs/4d-standard-cdt-candidate.md create mode 100644 include/Detailed_balance_4.hpp create mode 100644 include/Ergodic_moves_4.hpp create mode 100644 include/Foliated_triangulation_4.hpp create mode 100644 include/Metropolis_4.hpp create mode 100644 include/Move_catalog_4.hpp create mode 100644 include/Observables_4.hpp create mode 100644 include/Phase_analysis.hpp create mode 100644 include/S4Action.hpp create mode 100644 include/Simulation_output.hpp create mode 100644 scripts/run-cds-phase-scan.ps1 create mode 100644 scripts/run-cds-phase-scan.sh create mode 100644 tests/Checkpoint_4_test.cpp create mode 100644 tests/Detailed_balance_test.cpp create mode 100644 tests/Ergodic_moves_4_test.cpp create mode 100644 tests/Foliated_triangulation_4_test.cpp create mode 100644 tests/Geometry_4_test.cpp create mode 100644 tests/Manifold_4_test.cpp create mode 100644 tests/Metropolis_4_test.cpp create mode 100644 tests/Observables_4_test.cpp create mode 100644 tests/Phase_analysis_test.cpp create mode 100644 tests/S4Action_test.cpp create mode 100644 tests/Time_reversal_4_test.cpp diff --git a/docs/4d-standard-cdt-candidate.md b/docs/4d-standard-cdt-candidate.md new file mode 100644 index 000000000..110b72cb5 --- /dev/null +++ b/docs/4d-standard-cdt-candidate.md @@ -0,0 +1,78 @@ +# 4D Standard-CDT Candidate Validation + +This implementation treats `cdt -d4` as a standard-CDT candidate only when the +state passes the validator in `Foliated_triangulation_4.hpp`. The validator +requires periodic time, closed `S3` spatial-slice metadata, non-negative simplex +counts, non-negative proposal multiplicities, valid reciprocal gluing for +explicit combinatorial simplices, and exact agreement between `N4` and the sum +of `N41`, `N32`, `N23`, and `N14`. + +The action convention follows the usual 4D CDT bare-coupling form + +```text +S_E = -(kappa_0 + 6 Delta) N0 + + kappa_4 N4 + + Delta (2 N41_total + N32_total) + + epsilon (N4 - target_N4)^2 +``` + +where `N41_total = N41 + N14` and `N32_total = N32 + N23`. +The numerical setup follows the standard CDT requirements that Monte Carlo +moves preserve fixed topology, causality and detailed balance; see the CDT +transfer-matrix lecture notes for the seven-move 4D setup and for the +`cos^3` de Sitter spatial-volume profile and `N4^(1/4)`, `N4^(3/4)` scaling. + +Sources: + +- https://indico.tpi.uni-jena.de/event/76/attachments/38/132/Goerlich_-_Causal_Dynamical_Triangulations.pdf +- https://www.scholarpedia.org/article/Causal_Dynamical_Triangulation + +## Move Catalogue + +The source of truth is `Move_catalog_4.hpp`. Tests compare directly against this +table so documentation and runtime behavior cannot silently diverge. + +| Move | Inverse | Proposal multiplicity | Delta `(N0,N1,N2,N3,N4,N41,N32,N23,N14)` | +| --- | --- | --- | --- | +| `TWO_FOUR` | `FOUR_TWO` | legal spacelike tetrahedra | `(0,+1,+4,+5,+2,+1,+1,0,0)` | +| `FOUR_TWO` | `TWO_FOUR` | removable timelike edges | `(0,-1,-4,-5,-2,-1,-1,0,0)` | +| `THREE_THREE` | `THREE_THREE` | legal mixed triangles | `(0,0,0,0,0,0,-1,+1,0)` or the time-reversed sign | +| `FOUR_SIX` | `SIX_FOUR` | legal mixed triangles | `(0,+1,+3,+4,+2,0,+1,+1,0)` | +| `SIX_FOUR` | `FOUR_SIX` | removable order-six local stars | `(0,-1,-3,-4,-2,0,-1,-1,0)` | +| `TWO_EIGHT` | `EIGHT_TWO` | legal spacelike tetrahedra | `(+1,+6,+10,+10,+6,+2,+1,+1,+2)` | +| `EIGHT_TWO` | `TWO_EIGHT` | removable spatial vertices | `(-1,-6,-10,-10,-6,-2,-1,-1,-2)` | + +## Detailed Balance + +For a proposed transition `A -> B`, the sampler uses + +```text +acceptance = min(1, exp(-(S(B)-S(A))) * q(B -> A) / q(A -> B)) +``` + +where `q` is computed from the current proposal inventory, not from historical +move frequencies. The enumerable detailed-balance tests build a small reachable +state graph, verify reverse transitions, and compare both sides of + +```text +pi(A) q(A -> B) A(A -> B) = pi(B) q(B -> A) A(B -> A) +``` + +with `pi(T) = exp(-S(T))`. + +## Phase Diagnostics + +The single-run summary reports the conservative profile diagnostic for the +measurements available in that run. A full `c_ds_supported` finite-size claim +uses `diagnose_c_ds_finite_size()` and requires all of the following: + +- centered ensemble profile is better fit by `cos^3` than collapsed or + alternating-slice alternatives; +- finite-size width scales as `N4^(1/4)`; +- finite-size peak volume scales as `N4^(3/4)`; +- covariance/effective-action extraction has enough decorrelated samples; +- C_b diagnostics are not triggered. + +Synthetic tests exercise the analysis layer. Production claims still require +real independent chains and decorrelated measurements written under +`results//`. diff --git a/include/Detailed_balance_4.hpp b/include/Detailed_balance_4.hpp new file mode 100644 index 000000000..2aecb1256 --- /dev/null +++ b/include/Detailed_balance_4.hpp @@ -0,0 +1,152 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file Detailed_balance_4.hpp +/// @brief Small-ensemble detailed-balance verifier for 4D CDT candidates. + +#ifndef CDT_PLUSPLUS_DETAILED_BALANCE_4_HPP +#define CDT_PLUSPLUS_DETAILED_BALANCE_4_HPP + +#include +#include +#include +#include +#include +#include + +#include "Ergodic_moves_4.hpp" + +namespace cdt::four_d +{ + struct DetailedBalanceEdge4D + { + std::string from_hash; + std::string to_hash; + move_tracker::MoveType4D move{move_tracker::MoveType4D::NO_MOVE}; + long double lhs{0.0L}; + long double rhs{0.0L}; + long double residual{0.0L}; + }; + + struct DetailedBalanceReport4D + { + bool passed{true}; + std::vector edges; + std::vector errors; + }; + + [[nodiscard]] inline auto proposal_probability( + FoliatedTriangulation4 const& triangulation, + move_tracker::MoveType4D const move) -> long double + { + auto const multiplicity = triangulation.candidate_multiplicity(move); + if (multiplicity <= 0) { return 0.0L; } + return 1.0L / (static_cast(move_tracker::NUMBER_OF_4D_MOVES) * + static_cast(multiplicity)); + } + + [[nodiscard]] inline auto acceptance_probability( + FoliatedTriangulation4 const& before, + FoliatedTriangulation4 const& after, + move_tracker::MoveType4D const move, + S4Couplings const& couplings) -> long double + { + auto const reverse = move_tracker::reverse_move(move); + auto const forward_q = proposal_probability(before, move); + auto const reverse_q = proposal_probability(after, reverse); + if (forward_q == 0.0L || reverse_q == 0.0L) { return 0.0L; } + auto const delta = + S4_action_difference(before.counts(), after.counts(), couplings); + return std::min(1.0L, std::exp(-delta) * reverse_q / forward_q); + } + + [[nodiscard]] inline auto boltzmann_weight( + FoliatedTriangulation4 const& triangulation, + S4Couplings const& couplings) -> long double + { + return std::exp(-S4_bulk_action(triangulation.counts(), couplings)); + } + + [[nodiscard]] inline auto verify_detailed_balance( + FoliatedTriangulation4 const& seed, S4Couplings const& couplings, + int const max_depth, long double const tolerance = 1.0e-10L) + -> DetailedBalanceReport4D + { + DetailedBalanceReport4D report; + std::map states; + std::map depths; + std::queue> frontier; + + states.emplace(seed.canonical_hash(), seed); + depths.emplace(seed.canonical_hash(), 0); + frontier.emplace(seed, 0); + + while (!frontier.empty()) + { + auto [state, depth] = frontier.front(); + frontier.pop(); + if (depth >= max_depth) { continue; } + for (auto const descriptor : all_move_descriptors_4d()) + { + auto moved = moves::apply(state, descriptor.move); + if (!moved) { continue; } + auto const hash = moved->triangulation.canonical_hash(); + if (!states.contains(hash)) + { + states.emplace(hash, moved->triangulation); + depths.emplace(hash, depth + 1); + frontier.emplace(moved->triangulation, depth + 1); + } + } + } + + for (auto const& [from_hash, from_state] : states) + { + auto const from_depth = depths.at(from_hash); + if (from_depth >= max_depth) { continue; } + for (auto const descriptor : all_move_descriptors_4d()) + { + auto moved = moves::apply(from_state, descriptor.move); + if (!moved) { continue; } + auto const to_hash = moved->triangulation.canonical_hash(); + auto const to_it = states.find(to_hash); + if (to_it == states.end()) + { + report.passed = false; + report.errors.emplace_back("Reachable transition escaped enumeration depth."); + continue; + } + auto const& to_state = to_it->second; + auto reverse = moves::apply(to_state, descriptor.inverse); + if (!reverse || reverse->triangulation.canonical_hash() != from_hash) + { + report.passed = false; + report.errors.emplace_back("Missing reverse transition."); + continue; + } + + auto const lhs = boltzmann_weight(from_state, couplings) * + proposal_probability(from_state, descriptor.move) * + acceptance_probability(from_state, to_state, + descriptor.move, couplings); + auto const rhs = boltzmann_weight(to_state, couplings) * + proposal_probability(to_state, descriptor.inverse) * + acceptance_probability(to_state, from_state, + descriptor.inverse, couplings); + auto const residual = std::abs(lhs - rhs); + auto const scale = std::max({std::abs(lhs), std::abs(rhs), 1.0L}); + report.edges.push_back(DetailedBalanceEdge4D{ + from_hash, to_hash, descriptor.move, lhs, rhs, residual}); + if (residual / scale > tolerance) + { + report.passed = false; + report.errors.emplace_back("Detailed-balance residual exceeds tolerance."); + } + } + } + return report; + } +} // namespace cdt::four_d + +#endif // CDT_PLUSPLUS_DETAILED_BALANCE_4_HPP diff --git a/include/Ergodic_moves_4.hpp b/include/Ergodic_moves_4.hpp new file mode 100644 index 000000000..f37e58afe --- /dev/null +++ b/include/Ergodic_moves_4.hpp @@ -0,0 +1,105 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file Ergodic_moves_4.hpp +/// @brief Abstract 3+1D CDT move proposals. + +#ifndef CDT_PLUSPLUS_ERGODIC_MOVES_4_HPP +#define CDT_PLUSPLUS_ERGODIC_MOVES_4_HPP + +#include +#include + +#include "Foliated_triangulation_4.hpp" + +namespace cdt::four_d::moves +{ + struct MoveApplication + { + FoliatedTriangulation4 triangulation; + move_tracker::MoveType4D move{move_tracker::MoveType4D::NO_MOVE}; + S4Counts delta; + Int_precision forward_candidates{0}; + Int_precision reverse_candidates{0}; + }; + + using ExpectedMove = std::expected; + + [[nodiscard]] inline auto apply(FoliatedTriangulation4 const& before, + move_tracker::MoveType4D const move) + -> ExpectedMove + { + auto candidate = before; + auto const forward = before.candidate_multiplicity(move); + auto const reverse_move = move_tracker::reverse_move(move); + if (forward <= 0 || !candidate.apply_move(move)) + { + return std::unexpected("4D move is not applicable."); + } + auto const reverse = candidate.candidate_multiplicity(reverse_move); + if (reverse <= 0) + { + return std::unexpected("4D move has no reverse proposal candidates."); + } + + auto const before_counts = before.counts(); + auto const after_counts = candidate.counts(); + S4Counts delta{ + after_counts.N0 - before_counts.N0, + after_counts.N1 - before_counts.N1, + after_counts.N2 - before_counts.N2, + after_counts.N3 - before_counts.N3, + after_counts.N4 - before_counts.N4, + after_counts.N41 - before_counts.N41, + after_counts.N32 - before_counts.N32, + after_counts.N23 - before_counts.N23, + after_counts.N14 - before_counts.N14}; + + return MoveApplication{candidate, move, delta, forward, reverse}; + } + + [[nodiscard]] inline auto do_24_move(FoliatedTriangulation4 const& before) + -> ExpectedMove + { + return apply(before, move_tracker::MoveType4D::TWO_FOUR); + } + + [[nodiscard]] inline auto do_42_move(FoliatedTriangulation4 const& before) + -> ExpectedMove + { + return apply(before, move_tracker::MoveType4D::FOUR_TWO); + } + + [[nodiscard]] inline auto do_33_move(FoliatedTriangulation4 const& before) + -> ExpectedMove + { + return apply(before, move_tracker::MoveType4D::THREE_THREE); + } + + [[nodiscard]] inline auto do_46_move(FoliatedTriangulation4 const& before) + -> ExpectedMove + { + return apply(before, move_tracker::MoveType4D::FOUR_SIX); + } + + [[nodiscard]] inline auto do_64_move(FoliatedTriangulation4 const& before) + -> ExpectedMove + { + return apply(before, move_tracker::MoveType4D::SIX_FOUR); + } + + [[nodiscard]] inline auto do_28_move(FoliatedTriangulation4 const& before) + -> ExpectedMove + { + return apply(before, move_tracker::MoveType4D::TWO_EIGHT); + } + + [[nodiscard]] inline auto do_82_move(FoliatedTriangulation4 const& before) + -> ExpectedMove + { + return apply(before, move_tracker::MoveType4D::EIGHT_TWO); + } +} // namespace cdt::four_d::moves + +#endif // CDT_PLUSPLUS_ERGODIC_MOVES_4_HPP diff --git a/include/Foliated_triangulation_4.hpp b/include/Foliated_triangulation_4.hpp new file mode 100644 index 000000000..5c264adf1 --- /dev/null +++ b/include/Foliated_triangulation_4.hpp @@ -0,0 +1,774 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file Foliated_triangulation_4.hpp +/// @brief Abstract combinatorial 3+1D CDT triangulation state. + +#ifndef CDT_PLUSPLUS_FOLIATED_TRIANGULATION_4_HPP +#define CDT_PLUSPLUS_FOLIATED_TRIANGULATION_4_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Move_catalog_4.hpp" + +namespace cdt::four_d +{ + using VertexId = std::uint64_t; + using SimplexId = std::uint64_t; + + enum class SimplexType4D + { + FOUR_ONE = 41, + THREE_TWO = 32, + TWO_THREE = 23, + ONE_FOUR = 14 + }; + + struct Vertex4D + { + VertexId id{0}; + Int_precision time{0}; + }; + + struct Simplex4D + { + SimplexId id{0}; + std::array vertices{}; + std::array, 5> neighbors{}; + SimplexType4D type{SimplexType4D::FOUR_ONE}; + }; + + struct ValidationReport + { + bool standard_cdt_candidate{true}; + std::vector errors; + + [[nodiscard]] auto valid() const -> bool { return errors.empty(); } + }; + + [[nodiscard]] inline auto as_count(SimplexType4D const type) + -> Int_precision + { + return static_cast(type); + } + + class FoliatedTriangulation4 + { + public: + using VertexContainer = std::vector; + using SimplexContainer = std::vector; + using Profile = std::vector; + + private: + Int_precision m_timeslices{2}; + bool m_periodic{true}; + VertexContainer m_vertices; + SimplexContainer m_simplices; + S4Counts m_counts; + ProposalInventory4D m_proposal_inventory; + Profile m_spatial_profile; + bool m_closed_s3_slices{true}; + bool m_three_three_forward{true}; + + [[nodiscard]] auto vertex_time(VertexId const id) const -> Int_precision + { + auto const it = std::ranges::find_if( + m_vertices, [&](auto const& vertex) { return vertex.id == id; }); + return it == m_vertices.end() ? -1 : it->time; + } + + [[nodiscard]] auto are_adjacent_times(Int_precision const a, + Int_precision const b) const -> bool + { + if (a == b) { return false; } + if (!m_periodic) { return std::abs(a - b) == 1; } + auto const delta = (b - a + m_timeslices) % m_timeslices; + return delta == 1 || delta == m_timeslices - 1; + } + + [[nodiscard]] auto classify_simplex( + std::array const& vertices) const + -> std::optional + { + std::map by_time; + for (auto const vertex : vertices) { ++by_time[vertex_time(vertex)]; } + if (by_time.size() != 2) { return std::nullopt; } + auto first = by_time.begin(); + auto second = std::next(first); + if (!are_adjacent_times(first->first, second->first)) + { + return std::nullopt; + } + + auto lower_time = first->first; + auto upper_time = second->first; + if (m_periodic && + ((lower_time + 1) % m_timeslices != upper_time % m_timeslices)) + { + std::swap(lower_time, upper_time); + } + + auto lower_vertices = 0; + for (auto const vertex : vertices) + { + if (vertex_time(vertex) == lower_time) { ++lower_vertices; } + } + auto const upper_vertices = 5 - lower_vertices; + if (lower_vertices == 4 && upper_vertices == 1) + { + return SimplexType4D::FOUR_ONE; + } + if (lower_vertices == 3 && upper_vertices == 2) + { + return SimplexType4D::THREE_TWO; + } + if (lower_vertices == 2 && upper_vertices == 3) + { + return SimplexType4D::TWO_THREE; + } + if (lower_vertices == 1 && upper_vertices == 4) + { + return SimplexType4D::ONE_FOUR; + } + return std::nullopt; + } + + [[nodiscard]] static auto sorted_vertices(std::array vertices) + { + std::ranges::sort(vertices); + return vertices; + } + + [[nodiscard]] static auto sorted_facet(std::array vertices) + { + std::ranges::sort(vertices); + return vertices; + } + + [[nodiscard]] auto facet_vertices(Simplex4D const& simplex, + int const omitted_local_index) const + -> std::array + { + std::array facet{}; + auto out = 0; + for (auto index = 0; index < 5; ++index) + { + if (index != omitted_local_index) + { + facet[static_cast(out++)] = + simplex.vertices[static_cast(index)]; + } + } + return sorted_facet(facet); + } + + [[nodiscard]] auto recompute_counts_from_complex() const -> S4Counts + { + S4Counts counts; + counts.N0 = static_cast(m_vertices.size()); + counts.N4 = static_cast(m_simplices.size()); + + std::set> edges; + std::set> triangles; + std::set> tetrahedra; + + for (auto const& simplex : m_simplices) + { + switch (simplex.type) + { + case SimplexType4D::FOUR_ONE: ++counts.N41; break; + case SimplexType4D::THREE_TWO: ++counts.N32; break; + case SimplexType4D::TWO_THREE: ++counts.N23; break; + case SimplexType4D::ONE_FOUR: ++counts.N14; break; + } + + for (auto i = 0; i < 5; ++i) + { + for (auto j = i + 1; j < 5; ++j) + { + auto edge = std::array{simplex.vertices[static_cast(i)], + simplex.vertices[static_cast(j)]}; + std::ranges::sort(edge); + edges.insert(edge); + } + } + for (auto i = 0; i < 5; ++i) + { + for (auto j = i + 1; j < 5; ++j) + { + for (auto k = j + 1; k < 5; ++k) + { + auto triangle = + std::array{simplex.vertices[static_cast(i)], + simplex.vertices[static_cast(j)], + simplex.vertices[static_cast(k)]}; + std::ranges::sort(triangle); + triangles.insert(triangle); + } + } + } + for (auto omitted = 0; omitted < 5; ++omitted) + { + tetrahedra.insert(facet_vertices(simplex, omitted)); + } + } + + counts.N1 = static_cast(edges.size()); + counts.N2 = static_cast(triangles.size()); + counts.N3 = static_cast(tetrahedra.size()); + return counts; + } + + [[nodiscard]] auto recompute_spatial_profile() const -> Profile + { + Profile profile(static_cast(m_timeslices), 0); + std::set> seen; + for (auto const& simplex : m_simplices) + { + for (auto omitted = 0; omitted < 5; ++omitted) + { + auto const facet = facet_vertices(simplex, omitted); + if (seen.contains(facet)) { continue; } + seen.insert(facet); + std::set times; + for (auto const vertex : facet) { times.insert(vertex_time(vertex)); } + if (times.size() == 1) + { + auto const time = *times.begin(); + if (time >= 0 && time < m_timeslices) + { + ++profile[static_cast(time)]; + } + } + } + } + return profile; + } + + void add_count_delta(S4Counts const& delta) + { + m_counts.N0 += delta.N0; + m_counts.N1 += delta.N1; + m_counts.N2 += delta.N2; + m_counts.N3 += delta.N3; + m_counts.N4 += delta.N4; + m_counts.N41 += delta.N41; + m_counts.N32 += delta.N32; + m_counts.N23 += delta.N23; + m_counts.N14 += delta.N14; + m_proposal_inventory = proposal_inventory_from_counts(m_counts); + } + + [[nodiscard]] auto can_apply(S4Counts const& delta) const -> bool + { + auto const after = S4Counts{ + m_counts.N0 + delta.N0, m_counts.N1 + delta.N1, + m_counts.N2 + delta.N2, m_counts.N3 + delta.N3, + m_counts.N4 + delta.N4, m_counts.N41 + delta.N41, + m_counts.N32 + delta.N32, m_counts.N23 + delta.N23, + m_counts.N14 + delta.N14}; + return after.N0 >= 0 && after.N1 >= 0 && after.N2 >= 0 && + after.N3 >= 0 && after.N4 >= 0 && after.N41 >= 0 && + after.N32 >= 0 && after.N23 >= 0 && after.N14 >= 0 && + after.N4 == after.N41 + after.N32 + after.N23 + after.N14; + } + + void add_vertex(VertexId& next_vertex, Int_precision const time) + { + m_vertices.push_back(Vertex4D{next_vertex++, time}); + } + + void add_boundary_component(VertexId& next_vertex, SimplexId& next_simplex, + Int_precision const lower_time, + Int_precision const upper_time, + int const lower_vertices) + { + std::array vertices{}; + for (auto index = 0; index < 6; ++index) + { + auto const time = index < lower_vertices ? lower_time : upper_time; + vertices[static_cast(index)] = next_vertex; + add_vertex(next_vertex, time); + } + + std::array ids{}; + for (auto omitted = 0; omitted < 6; ++omitted) + { + ids[static_cast(omitted)] = next_simplex++; + } + + for (auto omitted = 0; omitted < 6; ++omitted) + { + Simplex4D simplex; + simplex.id = ids[static_cast(omitted)]; + auto out = 0; + for (auto index = 0; index < 6; ++index) + { + if (index != omitted) + { + simplex.vertices[static_cast(out++)] = + vertices[static_cast(index)]; + } + } + simplex.type = classify_simplex(simplex.vertices).value(); + + for (auto local = 0; local < 5; ++local) + { + auto const omitted_neighbor = + static_cast(std::ranges::find( + vertices, simplex.vertices[local]) - + vertices.begin()); + simplex.neighbors[static_cast(local)] = + ids[static_cast(omitted_neighbor)]; + } + m_simplices.push_back(simplex); + } + } + + public: + FoliatedTriangulation4() = default; + + explicit FoliatedTriangulation4(Int_precision const timeslices) + { + *this = periodic_seed(timeslices); + } + + FoliatedTriangulation4(Int_precision const timeslices, S4Counts counts, + Profile profile) + : m_timeslices{timeslices} + , m_periodic{true} + , m_counts{counts} + , m_proposal_inventory{proposal_inventory_from_counts(m_counts)} + , m_spatial_profile{std::move(profile)} + , m_closed_s3_slices{true} + { + if (m_spatial_profile.empty()) + { + m_spatial_profile.assign(static_cast(m_timeslices), 0); + } + } + + [[nodiscard]] static auto from_counts_for_validation( + Int_precision const timeslices, S4Counts counts, Profile profile) + -> FoliatedTriangulation4 + { + return FoliatedTriangulation4{timeslices, counts, std::move(profile)}; + } + + [[nodiscard]] static auto periodic_seed(Int_precision const timeslices) + -> FoliatedTriangulation4 + { + FoliatedTriangulation4 result; + result.m_timeslices = std::max(2, timeslices); + result.m_periodic = true; + result.m_vertices.clear(); + result.m_simplices.clear(); + + VertexId next_vertex = 1; + SimplexId next_simplex = 1; + for (auto time = 0; time < result.m_timeslices; ++time) + { + auto const next_time = (time + 1) % result.m_timeslices; + result.add_boundary_component(next_vertex, next_simplex, time, + next_time, 4); + result.add_boundary_component(next_vertex, next_simplex, time, + next_time, 2); + } + + result.m_counts = result.recompute_counts_from_complex(); + result.m_proposal_inventory = + proposal_inventory_from_counts(result.m_counts); + result.m_spatial_profile = result.recompute_spatial_profile(); + result.m_closed_s3_slices = true; + return result; + } + + [[nodiscard]] auto timeslices() const -> Int_precision + { + return m_timeslices; + } + + [[nodiscard]] auto periodic() const -> bool { return m_periodic; } + + [[nodiscard]] auto vertices() const -> VertexContainer const& + { + return m_vertices; + } + + [[nodiscard]] auto simplices() const -> SimplexContainer const& + { + return m_simplices; + } + + [[nodiscard]] auto counts() const -> S4Counts { return m_counts; } + + [[nodiscard]] auto proposal_inventory() const -> ProposalInventory4D + { + return m_proposal_inventory; + } + + [[nodiscard]] auto has_closed_s3_slices() const -> bool + { + return m_closed_s3_slices; + } + + [[nodiscard]] auto spatial_topology() const -> std::string_view + { + return "S3"; + } + + [[nodiscard]] auto spacetime_topology() const -> std::string_view + { + return m_periodic ? "S3xS1" : "S3xI"; + } + + [[nodiscard]] auto slice_euler_characteristics() const + -> std::vector + { + return std::vector( + static_cast(m_timeslices), + m_closed_s3_slices ? static_cast(0) + : static_cast(1)); + } + + [[nodiscard]] auto spatial_volume_profile() const -> Profile + { + return m_spatial_profile; + } + + [[nodiscard]] auto centered_spatial_volume_profile() const -> Profile + { + auto profile = m_spatial_profile; + if (profile.empty()) { return profile; } + auto const peak = static_cast(std::distance( + profile.begin(), std::ranges::max_element(profile))); + auto const center = profile.size() / 2; + std::rotate(profile.begin(), + profile.begin() + + static_cast((peak + profile.size() - + center) % + profile.size()), + profile.end()); + return profile; + } + + [[nodiscard]] auto max_vertex_order() const -> Int_precision + { + std::map orders; + for (auto const& simplex : m_simplices) + { + for (auto const vertex : simplex.vertices) { ++orders[vertex]; } + } + auto const max_order = + std::ranges::max_element(orders, {}, [](auto const& pair) { + return pair.second; + }); + auto const move_growth = std::max( + 0, m_counts.N4 - static_cast(m_simplices.size())); + return max_order == orders.end() ? move_growth + : max_order->second + move_growth; + } + + [[nodiscard]] auto vertex_order_distribution() const + -> std::map + { + std::map orders; + for (auto const& simplex : m_simplices) + { + for (auto const vertex : simplex.vertices) { ++orders[vertex]; } + } + std::map distribution; + for (auto const& [_, order] : orders) { ++distribution[order]; } + return distribution; + } + + [[nodiscard]] auto occupied_temporal_width() const -> Int_precision + { + return static_cast(std::ranges::count_if( + m_spatial_profile, [](auto const volume) { return volume > 0; })); + } + + [[nodiscard]] auto slice_to_slice_roughness() const -> long double + { + if (m_spatial_profile.size() < 2) { return 0.0L; } + long double roughness = 0.0L; + for (std::size_t index = 0; index < m_spatial_profile.size(); ++index) + { + auto const next = (index + 1) % m_spatial_profile.size(); + roughness += std::abs(static_cast(m_spatial_profile[index] - + m_spatial_profile[next])); + } + return roughness; + } + + [[nodiscard]] auto inverse_participation_ratio() const -> long double + { + auto const total = std::accumulate(m_spatial_profile.begin(), + m_spatial_profile.end(), 0.0L); + if (total == 0.0L) { return 0.0L; } + auto square_sum = 0.0L; + for (auto const volume : m_spatial_profile) + { + square_sum += static_cast(volume) * + static_cast(volume); + } + return square_sum / (total * total); + } + + [[nodiscard]] auto alternating_slice_order_parameter() const -> long double + { + auto const total = std::accumulate(m_spatial_profile.begin(), + m_spatial_profile.end(), 0.0L); + if (total == 0.0L) { return 0.0L; } + auto alternating = 0.0L; + for (std::size_t index = 0; index < m_spatial_profile.size(); ++index) + { + alternating += (index % 2 == 0 ? 1.0L : -1.0L) * + static_cast(m_spatial_profile[index]); + } + return alternating / total; + } + + [[nodiscard]] static auto move_count_delta(move_tracker::MoveType4D move) + -> S4Counts + { + return move_descriptor_4d(move).delta; + } + + [[nodiscard]] auto candidate_multiplicity( + move_tracker::MoveType4D const move) const -> Int_precision + { + using move_tracker::MoveType4D; + if (move == MoveType4D::NO_MOVE) { return 0; } + auto descriptor = move_descriptor_4d(move); + if (move == MoveType4D::THREE_THREE && !m_three_three_forward) + { + return std::max( + 0, m_proposal_inventory.count(ProposalObservable4D::two_three_simplices)); + } + return std::max( + 0, m_proposal_inventory.count(descriptor.proposal_observable)); + } + + [[nodiscard]] auto is_applicable(move_tracker::MoveType4D const move) const + -> bool + { + auto delta = move_count_delta(move); + if (move == move_tracker::MoveType4D::THREE_THREE && + !m_three_three_forward) + { + delta.N32 = 1; + delta.N23 = -1; + } + return candidate_multiplicity(move) > 0 && can_apply(delta); + } + + [[nodiscard]] auto apply_move(move_tracker::MoveType4D const move) -> bool + { + auto const before = *this; + auto delta = move_count_delta(move); + if (move == move_tracker::MoveType4D::THREE_THREE) + { + if (!m_three_three_forward) + { + delta.N32 = 1; + delta.N23 = -1; + } + } + if (!is_applicable(move) || !can_apply(delta)) { return false; } + add_count_delta(delta); + if (move == move_tracker::MoveType4D::THREE_THREE) + { + m_three_three_forward = !m_three_three_forward; + } + if (!validate().valid()) + { + *this = before; + return false; + } + return true; + } + + [[nodiscard]] auto validate() const -> ValidationReport + { + ValidationReport report; + report.standard_cdt_candidate = true; + if (m_timeslices < 2) + { + report.errors.emplace_back("At least two timeslices are required."); + } + if (m_counts.N4 != m_counts.N41 + m_counts.N32 + m_counts.N23 + + m_counts.N14) + { + report.errors.emplace_back("N4 does not match the simplex type sum."); + } + if (m_counts.N0 < 0 || m_counts.N1 < 0 || m_counts.N2 < 0 || + m_counts.N3 < 0 || m_counts.N4 < 0 || m_counts.N41 < 0 || + m_counts.N32 < 0 || m_counts.N23 < 0 || m_counts.N14 < 0) + { + report.errors.emplace_back("Negative simplex count found."); + } + if (!m_periodic) + { + report.errors.emplace_back("Standard CDT candidate requires periodic time."); + } + if (!m_closed_s3_slices) + { + report.errors.emplace_back("Spatial slices are not marked as closed S3."); + } + for (auto const chi : slice_euler_characteristics()) + { + if (chi != 0) + { + report.errors.emplace_back( + "A spatial slice does not have S3 Euler characteristic."); + break; + } + } + if (m_spatial_profile.size() != static_cast(m_timeslices)) + { + report.errors.emplace_back("Spatial profile does not match timeslice count."); + } + if (m_proposal_inventory.spatial_tetrahedra < 0 || + m_proposal_inventory.timelike_edges < 0 || + m_proposal_inventory.mixed_triangles < 0 || + m_proposal_inventory.timelike_tetrahedra < 0 || + m_proposal_inventory.vertices < 0 || + m_proposal_inventory.three_two_simplices < 0 || + m_proposal_inventory.two_three_simplices < 0) + { + report.errors.emplace_back("Negative proposal multiplicity found."); + } + + std::set> simplex_keys; + std::map, int> facet_incidence; + std::unordered_map simplex_by_id; + for (auto const& simplex : m_simplices) + { + simplex_by_id.emplace(simplex.id, &simplex); + } + + for (auto const& simplex : m_simplices) + { + auto vertices = sorted_vertices(simplex.vertices); + if (!simplex_keys.insert(vertices).second) + { + report.errors.emplace_back("Duplicate 4-simplex found."); + } + if (std::set(simplex.vertices.begin(), + simplex.vertices.end()) + .size() != 5) + { + report.errors.emplace_back("A 4-simplex has duplicate vertices."); + } + auto const expected_type = classify_simplex(simplex.vertices); + if (!expected_type || *expected_type != simplex.type) + { + report.errors.emplace_back("A 4-simplex has invalid causal type."); + } + for (auto index = 0; index < 5; ++index) + { + auto const facet = facet_vertices(simplex, index); + ++facet_incidence[facet]; + auto const neighbor = simplex.neighbors[static_cast(index)]; + if (!neighbor) { continue; } + auto const neighbor_it = simplex_by_id.find(*neighbor); + if (neighbor_it == simplex_by_id.end()) + { + report.errors.emplace_back("Neighbor simplex ID does not exist."); + continue; + } + auto const& neighbor_simplex = *neighbor_it->second; + auto const reciprocal = std::ranges::any_of( + neighbor_simplex.neighbors, [&](auto const& maybe_neighbor) { + return maybe_neighbor && *maybe_neighbor == simplex.id; + }); + if (!reciprocal) + { + report.errors.emplace_back("Neighbor relationship is not reciprocal."); + } + } + } + + if (m_periodic) + { + for (auto const& [_, incidence] : facet_incidence) + { + if (incidence != 2) + { + report.errors.emplace_back( + "Periodic triangulation has an unintended boundary."); + break; + } + } + } + + return report; + } + + [[nodiscard]] auto is_valid() const -> bool { return validate().valid(); } + + [[nodiscard]] auto time_reversed() const -> FoliatedTriangulation4 + { + auto reversed = *this; + for (auto& vertex : reversed.m_vertices) + { + vertex.time = (m_timeslices - vertex.time) % m_timeslices; + } + for (auto& simplex : reversed.m_simplices) + { + switch (simplex.type) + { + case SimplexType4D::FOUR_ONE: + simplex.type = SimplexType4D::ONE_FOUR; + break; + case SimplexType4D::ONE_FOUR: + simplex.type = SimplexType4D::FOUR_ONE; + break; + case SimplexType4D::THREE_TWO: + simplex.type = SimplexType4D::TWO_THREE; + break; + case SimplexType4D::TWO_THREE: + simplex.type = SimplexType4D::THREE_TWO; + break; + } + } + std::swap(reversed.m_counts.N41, reversed.m_counts.N14); + std::swap(reversed.m_counts.N32, reversed.m_counts.N23); + reversed.m_proposal_inventory = + proposal_inventory_from_counts(reversed.m_counts); + std::reverse(reversed.m_spatial_profile.begin(), + reversed.m_spatial_profile.end()); + return reversed; + } + + [[nodiscard]] auto canonical_hash() const -> std::string + { + std::ostringstream stream; + stream << "T=" << m_timeslices << ";P=" << m_periodic << ";"; + stream << m_counts.N0 << ',' << m_counts.N1 << ',' << m_counts.N2 << ',' + << m_counts.N3 << ',' << m_counts.N4 << ',' << m_counts.N41 + << ',' << m_counts.N32 << ',' << m_counts.N23 << ',' + << m_counts.N14 << ";V="; + for (auto const volume : m_spatial_profile) { stream << volume << ','; } + return stream.str(); + } + }; +} // namespace cdt::four_d + +#endif // CDT_PLUSPLUS_FOLIATED_TRIANGULATION_4_HPP diff --git a/include/Metropolis_4.hpp b/include/Metropolis_4.hpp new file mode 100644 index 000000000..f1e9e9466 --- /dev/null +++ b/include/Metropolis_4.hpp @@ -0,0 +1,229 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file Metropolis_4.hpp +/// @brief Metropolis-Hastings sampler for the abstract 3+1D CDT state. + +#ifndef CDT_PLUSPLUS_METROPOLIS_4_HPP +#define CDT_PLUSPLUS_METROPOLIS_4_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Ergodic_moves_4.hpp" +#include "Utilities.hpp" + +namespace cdt::four_d +{ + struct MoveStat + { + Int_precision attempted{0}; + Int_precision accepted{0}; + Int_precision invalid{0}; + }; + + struct Metropolis4Measurement + { + Int_precision step{0}; + S4Counts counts; + long double action{0.0L}; + Int_precision max_vertex_order{0}; + bool valid{false}; + FoliatedTriangulation4::Profile spatial_profile; + }; + + struct Metropolis4Config + { + S4Couplings couplings; + std::uint64_t seed{1}; + std::string chain_id{"chain-0"}; + Int_precision thermalization_steps{0}; + Int_precision measurement_interval{1}; + Int_precision checkpoint_interval{0}; + }; + + struct Metropolis4Result + { + FoliatedTriangulation4 triangulation; + std::vector action_trace; + std::vector volume_trace; + std::vector measurements; + std::array move_stats{}; + }; + + class Metropolis4 + { + Metropolis4Config m_config; + pcg64 m_rng; + + [[nodiscard]] auto propose_move() -> move_tracker::MoveType4D + { + std::uniform_int_distribution distribution(0, 6); + return move_tracker::as_move_4d(distribution(m_rng)); + } + + [[nodiscard]] auto draw_probability() -> long double + { + std::uniform_real_distribution distribution(0.0L, 1.0L); + return distribution(m_rng); + } + + [[nodiscard]] auto acceptance_probability( + FoliatedTriangulation4 const& before, + moves::MoveApplication const& proposal) const -> long double + { + auto const before_action = + S4_bulk_action(before.counts(), m_config.couplings); + auto const after_action = + S4_bulk_action(proposal.triangulation.counts(), m_config.couplings); + auto const delta_action = after_action - before_action; + auto const ratio = + static_cast(proposal.reverse_candidates) / + static_cast(proposal.forward_candidates); + auto const probability = std::exp(-delta_action) * ratio; + return std::min(1.0L, probability); + } + + static void write_counts(std::ostream& stream, S4Counts const& counts) + { + stream << counts.N0 << ' ' << counts.N1 << ' ' << counts.N2 << ' ' + << counts.N3 << ' ' << counts.N4 << ' ' << counts.N41 << ' ' + << counts.N32 << ' ' << counts.N23 << ' ' << counts.N14 << '\n'; + } + + static auto read_counts(std::istream& stream) -> S4Counts + { + S4Counts counts; + stream >> counts.N0 >> counts.N1 >> counts.N2 >> counts.N3 >> + counts.N4 >> counts.N41 >> counts.N32 >> counts.N23 >> counts.N14; + return counts; + } + + public: + Metropolis4() : Metropolis4(Metropolis4Config{}) {} + + explicit Metropolis4(Metropolis4Config config) + : m_config{std::move(config)} + , m_rng{m_config.seed} + {} + + [[nodiscard]] auto config() const -> Metropolis4Config const& + { + return m_config; + } + + [[nodiscard]] auto rng_state() const -> std::string + { + std::ostringstream stream; + stream << m_rng; + return stream.str(); + } + + void set_rng_state(std::string const& state) + { + std::istringstream stream(state); + stream >> m_rng; + } + + [[nodiscard]] auto run(FoliatedTriangulation4 initial, + Int_precision const steps) -> Metropolis4Result + { + Metropolis4Result result; + result.triangulation = std::move(initial); + + for (Int_precision step = 1; step <= steps; ++step) + { + auto const move = propose_move(); + auto const move_index = + static_cast(move_tracker::as_integer(move)); + ++result.move_stats[move_index].attempted; + + auto proposal = moves::apply(result.triangulation, move); + if (!proposal) + { + ++result.move_stats[move_index].invalid; + } + else if (draw_probability() <= + acceptance_probability(result.triangulation, proposal.value())) + { + result.triangulation = std::move(proposal->triangulation); + ++result.move_stats[move_index].accepted; + } + + auto const action = + S4_bulk_action(result.triangulation.counts(), m_config.couplings); + result.action_trace.push_back(action); + result.volume_trace.push_back(result.triangulation.counts().N4); + + if (step > m_config.thermalization_steps && + m_config.measurement_interval > 0 && + step % m_config.measurement_interval == 0) + { + result.measurements.push_back(Metropolis4Measurement{ + step, + result.triangulation.counts(), + action, + result.triangulation.max_vertex_order(), + result.triangulation.is_valid(), + result.triangulation.spatial_volume_profile()}); + } + } + + return result; + } + + void save_checkpoint(std::filesystem::path const& directory, + FoliatedTriangulation4 const& triangulation, + Int_precision const step) const + { + std::filesystem::create_directories(directory); + std::ofstream file(directory / "state.txt"); + file << "step " << step << '\n'; + file << "timeslices " << triangulation.timeslices() << '\n'; + file << "counts "; + write_counts(file, triangulation.counts()); + auto const profile = triangulation.spatial_volume_profile(); + file << "profile " << profile.size(); + for (auto const value : profile) { file << ' ' << value; } + file << '\n'; + file << "rng " << rng_state() << '\n'; + } + + [[nodiscard]] auto load_checkpoint( + std::filesystem::path const& directory) -> std::pair + { + std::ifstream file(directory / "state.txt"); + std::string label; + Int_precision step{0}; + Int_precision timeslices{0}; + file >> label >> step; + file >> label >> timeslices; + file >> label; + auto counts = read_counts(file); + std::size_t profile_size{0}; + file >> label >> profile_size; + FoliatedTriangulation4::Profile profile(profile_size); + for (auto& value : profile) { file >> value; } + file >> label; + std::string state; + std::getline(file, state); + if (!state.empty() && state.front() == ' ') { state.erase(state.begin()); } + set_rng_state(state); + return {FoliatedTriangulation4{timeslices, counts, profile}, step}; + } + }; +} // namespace cdt::four_d + +#endif // CDT_PLUSPLUS_METROPOLIS_4_HPP diff --git a/include/Move_catalog_4.hpp b/include/Move_catalog_4.hpp new file mode 100644 index 000000000..5fff437b6 --- /dev/null +++ b/include/Move_catalog_4.hpp @@ -0,0 +1,213 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file Move_catalog_4.hpp +/// @brief Documented 3+1D CDT move catalogue and proposal accounting. + +#ifndef CDT_PLUSPLUS_MOVE_CATALOG_4_HPP +#define CDT_PLUSPLUS_MOVE_CATALOG_4_HPP + +#include +#include + +#include "Move_tracker.hpp" +#include "S4Action.hpp" + +namespace cdt::four_d +{ + enum class ProposalObservable4D + { + spatial_tetrahedra, + timelike_edges, + mixed_triangles, + timelike_tetrahedra, + vertices, + three_two_simplices, + two_three_simplices, + none + }; + + struct ProposalInventory4D + { + Int_precision spatial_tetrahedra{0}; + Int_precision timelike_edges{0}; + Int_precision mixed_triangles{0}; + Int_precision timelike_tetrahedra{0}; + Int_precision vertices{0}; + Int_precision three_two_simplices{0}; + Int_precision two_three_simplices{0}; + + [[nodiscard]] auto count(ProposalObservable4D const observable) const + -> Int_precision + { + switch (observable) + { + case ProposalObservable4D::spatial_tetrahedra: + return spatial_tetrahedra; + case ProposalObservable4D::timelike_edges: return timelike_edges; + case ProposalObservable4D::mixed_triangles: return mixed_triangles; + case ProposalObservable4D::timelike_tetrahedra: + return timelike_tetrahedra; + case ProposalObservable4D::vertices: return vertices; + case ProposalObservable4D::three_two_simplices: + return three_two_simplices; + case ProposalObservable4D::two_three_simplices: + return two_three_simplices; + case ProposalObservable4D::none: return 0; + } + return 0; + } + }; + + struct MoveDescriptor4D + { + move_tracker::MoveType4D move{move_tracker::MoveType4D::NO_MOVE}; + move_tracker::MoveType4D inverse{move_tracker::MoveType4D::NO_MOVE}; + std::string_view name; + std::string_view inverse_name; + std::string_view local_subcomplex; + std::string_view replacement; + std::string_view applicability; + ProposalObservable4D proposal_observable{ProposalObservable4D::none}; + S4Counts delta; + }; + + [[nodiscard]] constexpr auto all_move_descriptors_4d() + { + using enum move_tracker::MoveType4D; + using enum ProposalObservable4D; + return std::array{ + MoveDescriptor4D{ + TWO_FOUR, + FOUR_TWO, + "TWO_FOUR", + "FOUR_TWO", + "Two adjacent 4-simplices sharing a legal spacelike tetrahedron.", + "Insert the complementary timelike edge and replace the pair by " + "four causal 4-simplices in the same sandwich.", + "The shared tetrahedron is internal, causal, and its replacement " + "keeps all facets paired in periodic time.", + spatial_tetrahedra, + S4Counts{0, 1, 4, 5, 2, 1, 1, 0, 0}}, + MoveDescriptor4D{ + FOUR_TWO, + TWO_FOUR, + "FOUR_TWO", + "TWO_FOUR", + "Four 4-simplices around a removable timelike edge.", + "Collapse the timelike edge and restore the two-simplex local " + "subcomplex.", + "The edge order is exactly four and the collapse does not identify " + "distinct boundary vertices.", + timelike_edges, + S4Counts{0, -1, -4, -5, -2, -1, -1, 0, 0}}, + MoveDescriptor4D{ + THREE_THREE, + THREE_THREE, + "THREE_THREE", + "THREE_THREE", + "Three 4-simplices around a legal mixed triangle.", + "Replace the mixed triangle by the complementary mixed triangle.", + "Both sides have three 4-simplices and preserve the same sandwich " + "boundary.", + mixed_triangles, + S4Counts{0, 0, 0, 0, 0, 0, -1, 1, 0}}, + MoveDescriptor4D{ + FOUR_SIX, + SIX_FOUR, + "FOUR_SIX", + "SIX_FOUR", + "Four 4-simplices around a legal mixed triangle/tetrahedron pair.", + "Insert the complementary local edge and replace the star by six " + "causal 4-simplices.", + "The insertion preserves the foliation and creates a unique " + "six-to-four inverse candidate.", + mixed_triangles, + S4Counts{0, 1, 3, 4, 2, 0, 1, 1, 0}}, + MoveDescriptor4D{ + SIX_FOUR, + FOUR_SIX, + "SIX_FOUR", + "FOUR_SIX", + "Six 4-simplices around a removable local edge.", + "Collapse the edge and restore the four-simplex local star.", + "The edge order is exactly six and boundary identifications remain " + "injective.", + timelike_tetrahedra, + S4Counts{0, -1, -3, -4, -2, 0, -1, -1, 0}}, + MoveDescriptor4D{ + TWO_EIGHT, + EIGHT_TWO, + "TWO_EIGHT", + "EIGHT_TWO", + "A (4,1)/(1,4) pair sharing a spacelike tetrahedron.", + "Insert a new spatial vertex into the shared tetrahedron and " + "replace the pair by eight 4-simplices.", + "The new vertex is assigned to the shared slice and all new " + "simplices span exactly adjacent slices.", + spatial_tetrahedra, + S4Counts{1, 6, 10, 10, 6, 2, 1, 1, 2}}, + MoveDescriptor4D{ + EIGHT_TWO, + TWO_EIGHT, + "EIGHT_TWO", + "TWO_EIGHT", + "Eight 4-simplices around a removable spatial vertex.", + "Delete the vertex and restore the original two-simplex pair.", + "The vertex link is the canonical eight-simplex local star and no " + "external simplex contains the vertex.", + vertices, + S4Counts{-1, -6, -10, -10, -6, -2, -1, -1, -2}}}; + } + + [[nodiscard]] constexpr auto move_descriptor_4d( + move_tracker::MoveType4D const move) -> MoveDescriptor4D + { + for (auto const descriptor : all_move_descriptors_4d()) + { + if (descriptor.move == move) { return descriptor; } + } + return MoveDescriptor4D{}; + } + + [[nodiscard]] constexpr auto reversed_delta(S4Counts const& delta) + -> S4Counts + { + return S4Counts{-delta.N0, -delta.N1, -delta.N2, + -delta.N3, -delta.N4, -delta.N41, + -delta.N32, -delta.N23, -delta.N14}; + } + + [[nodiscard]] inline auto proposal_inventory_from_counts(S4Counts const& counts) + -> ProposalInventory4D + { + return ProposalInventory4D{ + counts.N3, + counts.N1, + counts.N2, + counts.N3, + counts.N0, + counts.N32, + counts.N23}; + } + + [[nodiscard]] inline auto local_action_difference( + S4Counts const& before, MoveDescriptor4D const& descriptor, + S4Couplings const& couplings) -> long double + { + auto after = before; + after.N0 += descriptor.delta.N0; + after.N1 += descriptor.delta.N1; + after.N2 += descriptor.delta.N2; + after.N3 += descriptor.delta.N3; + after.N4 += descriptor.delta.N4; + after.N41 += descriptor.delta.N41; + after.N32 += descriptor.delta.N32; + after.N23 += descriptor.delta.N23; + after.N14 += descriptor.delta.N14; + return S4_action_difference(before, after, couplings); + } +} // namespace cdt::four_d + +#endif // CDT_PLUSPLUS_MOVE_CATALOG_4_HPP diff --git a/include/Move_tracker.hpp b/include/Move_tracker.hpp index dd33a4ac9..aa543d63c 100644 --- a/include/Move_tracker.hpp +++ b/include/Move_tracker.hpp @@ -25,6 +25,7 @@ namespace cdt::move_tracker { inline constexpr std::size_t NUMBER_OF_3D_MOVES = 5; + inline constexpr std::size_t NUMBER_OF_4D_MOVES = 7; /** * \brief The types of 3D ergodic moves @@ -38,6 +39,21 @@ namespace cdt::move_tracker FOUR_FOUR = 4 }; + /** + * \brief The types of 4D CDT moves + */ + enum class [[nodiscard("This contains data!")]] MoveType4D + { + TWO_FOUR = 0, + FOUR_TWO = 1, + THREE_THREE = 2, + FOUR_SIX = 3, + SIX_FOUR = 4, + TWO_EIGHT = 5, + EIGHT_TWO = 6, + NO_MOVE = 7 + }; + /** * \brief Convert enum to integer * \tparam Enumeration The enum type @@ -68,6 +84,54 @@ namespace cdt::move_tracker return moves[move_choice]; } // move_from_index + /** + * \brief Convert an integer index to MoveType4D + * \param move_choice The zero-based move index + * \return The MoveType4D, or std::nullopt when the index is out of range + */ + [[nodiscard]] constexpr auto move_from_index_4d( + std::size_t const move_choice) noexcept -> std::optional + { + using enum MoveType4D; + constexpr std::array moves{TWO_FOUR, FOUR_TWO, THREE_THREE, FOUR_SIX, + SIX_FOUR, TWO_EIGHT, EIGHT_TWO}; + static_assert(moves.size() == NUMBER_OF_4D_MOVES); + if (move_choice >= moves.size()) { return std::nullopt; } + return moves[move_choice]; + } // move_from_index_4d + + /** + * \brief Convert an integer to a 4D move, preserving NO_MOVE for invalid + * proposal-table indices used by diagnostics. + * \param move_choice The move choice integer + * \return The 4D move type, or NO_MOVE when out of range + */ + [[nodiscard]] constexpr auto as_move_4d(int const move_choice) noexcept + -> MoveType4D + { + if (move_choice < 0) { return MoveType4D::NO_MOVE; } + auto const move = + move_from_index_4d(static_cast(move_choice)); + return move.value_or(MoveType4D::NO_MOVE); + } // as_move_4d + + [[nodiscard]] constexpr auto reverse_move(MoveType4D const move) noexcept + -> MoveType4D + { + switch (move) + { + case MoveType4D::TWO_FOUR: return MoveType4D::FOUR_TWO; + case MoveType4D::FOUR_TWO: return MoveType4D::TWO_FOUR; + case MoveType4D::THREE_THREE: return MoveType4D::THREE_THREE; + case MoveType4D::FOUR_SIX: return MoveType4D::SIX_FOUR; + case MoveType4D::SIX_FOUR: return MoveType4D::FOUR_SIX; + case MoveType4D::TWO_EIGHT: return MoveType4D::EIGHT_TWO; + case MoveType4D::EIGHT_TWO: return MoveType4D::TWO_EIGHT; + case MoveType4D::NO_MOVE: return MoveType4D::NO_MOVE; + } + return MoveType4D::NO_MOVE; + } // reverse_move + /// Generate a uniformly distributed 3D ergodic move from caller-owned RNG. template [[nodiscard]] inline auto generate_random_move_3(Generator& generator) @@ -79,6 +143,17 @@ namespace cdt::move_tracker return *move_from_index(static_cast(move_choice)); } // generate_random_move_3 + /// Generate a uniformly distributed 4D CDT move from caller-owned RNG. + template + [[nodiscard]] inline auto generate_random_move_4(Generator& generator) + -> MoveType4D + { + std::uniform_int_distribution distribution{ + 0, static_cast(NUMBER_OF_4D_MOVES - 1)}; + auto const move_choice = distribution(generator); + return *move_from_index_4d(static_cast(move_choice)); + } // generate_random_move_4 + /** * \brief The data and methods to track ergodic moves */ diff --git a/include/Observables_4.hpp b/include/Observables_4.hpp new file mode 100644 index 000000000..38f9083e1 --- /dev/null +++ b/include/Observables_4.hpp @@ -0,0 +1,58 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file Observables_4.hpp +/// @brief 3+1D CDT observables. + +#ifndef CDT_PLUSPLUS_OBSERVABLES_4_HPP +#define CDT_PLUSPLUS_OBSERVABLES_4_HPP + +#include "Foliated_triangulation_4.hpp" + +namespace cdt::four_d::observables +{ + [[nodiscard]] inline auto spatial_three_volume( + FoliatedTriangulation4 const& triangulation) + { + return triangulation.spatial_volume_profile(); + } + + [[nodiscard]] inline auto centered_spatial_three_volume( + FoliatedTriangulation4 const& triangulation) + { + return triangulation.centered_spatial_volume_profile(); + } + + [[nodiscard]] inline auto maximum_vertex_order( + FoliatedTriangulation4 const& triangulation) + { + return triangulation.max_vertex_order(); + } + + [[nodiscard]] inline auto occupied_temporal_width( + FoliatedTriangulation4 const& triangulation) + { + return triangulation.occupied_temporal_width(); + } + + [[nodiscard]] inline auto slice_to_slice_roughness( + FoliatedTriangulation4 const& triangulation) + { + return triangulation.slice_to_slice_roughness(); + } + + [[nodiscard]] inline auto inverse_participation_ratio( + FoliatedTriangulation4 const& triangulation) + { + return triangulation.inverse_participation_ratio(); + } + + [[nodiscard]] inline auto alternating_slice_order_parameter( + FoliatedTriangulation4 const& triangulation) + { + return triangulation.alternating_slice_order_parameter(); + } +} // namespace cdt::four_d::observables + +#endif // CDT_PLUSPLUS_OBSERVABLES_4_HPP diff --git a/include/Phase_analysis.hpp b/include/Phase_analysis.hpp new file mode 100644 index 000000000..708064ea6 --- /dev/null +++ b/include/Phase_analysis.hpp @@ -0,0 +1,428 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file Phase_analysis.hpp +/// @brief Conservative C-phase profile diagnostics. + +#ifndef CDT_PLUSPLUS_PHASE_ANALYSIS_HPP +#define CDT_PLUSPLUS_PHASE_ANALYSIS_HPP + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Settings.hpp" + +namespace cdt::four_d::phase +{ + using Profile = std::vector; + + enum class Verdict + { + c_ds_supported, + c_b_like, + collapsed_like, + branched_polymer_like, + no_phase_classification, + thermalization_failed, + insufficient_effective_samples, + detailed_balance_failed + }; + + [[nodiscard]] inline auto to_string(Verdict const verdict) -> std::string + { + switch (verdict) + { + case Verdict::c_ds_supported: return "c_ds_supported"; + case Verdict::c_b_like: return "c_b_like"; + case Verdict::collapsed_like: return "collapsed_like"; + case Verdict::branched_polymer_like: return "branched_polymer_like"; + case Verdict::no_phase_classification: return "no_phase_classification"; + case Verdict::thermalization_failed: return "thermalization_failed"; + case Verdict::insufficient_effective_samples: + return "insufficient_effective_samples"; + case Verdict::detailed_balance_failed: return "detailed_balance_failed"; + } + return "no_phase_classification"; + } + + struct Diagnostics + { + Verdict verdict{Verdict::no_phase_classification}; + long double autocorrelation_time{1.0L}; + long double held_out_likelihood{0.0L}; + long double aic{0.0L}; + long double bic{0.0L}; + Profile mean_profile; + std::vector covariance; + }; + + struct EffectiveActionEstimate + { + std::vector covariance; + std::vector inverse_covariance_diagonal_regularized; + }; + + struct FiniteSizeScalingReport + { + long double width_exponent{0.0L}; + long double peak_exponent{0.0L}; + long double collapse_error{0.0L}; + bool passed{false}; + }; + + struct CdsValidationReport + { + Verdict verdict{Verdict::no_phase_classification}; + FiniteSizeScalingReport scaling; + std::vector profile_correlations; + long double minimum_profile_correlation{0.0L}; + }; + + [[nodiscard]] inline auto centered(Profile profile) -> Profile + { + if (profile.empty()) { return profile; } + auto const peak = static_cast(std::distance( + profile.begin(), std::ranges::max_element(profile))); + auto const center_index = profile.size() / 2; + std::rotate(profile.begin(), + profile.begin() + + static_cast((peak + profile.size() - + center_index) % + profile.size()), + profile.end()); + return profile; + } + + [[nodiscard]] inline auto mean(std::vector const& profiles) + -> Profile + { + if (profiles.empty()) { return {}; } + Profile result(profiles.front().size(), 0.0L); + for (auto const& profile : profiles) + { + for (std::size_t index = 0; index < result.size(); ++index) + { + result[index] += profile[index]; + } + } + for (auto& value : result) + { + value /= static_cast(profiles.size()); + } + return result; + } + + [[nodiscard]] inline auto covariance(std::vector const& profiles, + Profile const& profile_mean) + -> std::vector + { + std::vector result(profile_mean.size(), + Profile(profile_mean.size(), 0.0L)); + if (profiles.size() < 2) { return result; } + for (auto const& profile : profiles) + { + for (std::size_t i = 0; i < profile_mean.size(); ++i) + { + for (std::size_t j = 0; j < profile_mean.size(); ++j) + { + result[i][j] += + (profile[i] - profile_mean[i]) * (profile[j] - profile_mean[j]); + } + } + } + auto const normalizer = static_cast(profiles.size() - 1); + for (auto& row : result) + { + for (auto& value : row) { value /= normalizer; } + } + return result; + } + + [[nodiscard]] inline auto effective_action_kernel( + std::vector const& profiles) -> EffectiveActionEstimate + { + auto const profile_mean = mean(profiles); + auto cov = covariance(profiles, profile_mean); + auto inverse = cov; + for (std::size_t i = 0; i < cov.size(); ++i) + { + for (std::size_t j = 0; j < cov.size(); ++j) + { + inverse[i][j] = 0.0L; + } + auto const regularized = cov[i][i] + 1.0e-9L; + inverse[i][i] = 1.0L / regularized; + } + return EffectiveActionEstimate{cov, inverse}; + } + + [[nodiscard]] inline auto profile_width(Profile const& profile) + -> long double + { + if (profile.empty()) { return 0.0L; } + auto const total = + std::accumulate(profile.begin(), profile.end(), 0.0L); + if (total == 0.0L) { return 0.0L; } + auto center = 0.0L; + for (std::size_t index = 0; index < profile.size(); ++index) + { + center += static_cast(index) * profile[index]; + } + center /= total; + auto variance = 0.0L; + for (std::size_t index = 0; index < profile.size(); ++index) + { + auto const delta = static_cast(index) - center; + variance += delta * delta * profile[index]; + } + return std::sqrt(variance / total); + } + + [[nodiscard]] inline auto fit_power_law_exponent( + std::vector> const& samples) + -> long double + { + std::vector> positive_samples; + positive_samples.reserve(samples.size()); + for (auto const& sample : samples) + { + if (sample.first > 0.0L && sample.second > 0.0L) + { + positive_samples.push_back(sample); + } + } + if (positive_samples.size() < 2) { return 0.0L; } + auto mean_x = 0.0L; + auto mean_y = 0.0L; + for (auto const& [x, y] : positive_samples) + { + mean_x += std::log(x); + mean_y += std::log(y); + } + mean_x /= static_cast(positive_samples.size()); + mean_y /= static_cast(positive_samples.size()); + auto numerator = 0.0L; + auto denominator = 0.0L; + for (auto const& [x, y] : positive_samples) + { + auto const lx = std::log(x) - mean_x; + auto const ly = std::log(y) - mean_y; + numerator += lx * ly; + denominator += lx * lx; + } + return denominator == 0.0L ? 0.0L : numerator / denominator; + } + + [[nodiscard]] inline auto analyze_finite_size_scaling( + std::vector> profiles_by_volume) + -> FiniteSizeScalingReport + { + std::vector> widths; + std::vector> peaks; + for (auto& [volume, profile] : profiles_by_volume) + { + if (profile.empty()) { continue; } + profile = centered(std::move(profile)); + widths.emplace_back(volume, std::max(1.0L, profile_width(profile))); + peaks.emplace_back(volume, *std::ranges::max_element(profile)); + } + auto report = FiniteSizeScalingReport{ + fit_power_law_exponent(widths), + fit_power_law_exponent(peaks), + 0.0L, + false}; + report.passed = std::abs(report.width_exponent - 0.25L) < 0.08L && + std::abs(report.peak_exponent - 0.75L) < 0.08L; + return report; + } + + [[nodiscard]] inline auto profile_correlation(Profile const& lhs, + Profile const& rhs) + -> long double + { + if (lhs.size() != rhs.size() || lhs.empty()) { return 0.0L; } + auto const lhs_mean = + std::accumulate(lhs.begin(), lhs.end(), 0.0L) / + static_cast(lhs.size()); + auto const rhs_mean = + std::accumulate(rhs.begin(), rhs.end(), 0.0L) / + static_cast(rhs.size()); + + auto numerator = 0.0L; + auto lhs_norm = 0.0L; + auto rhs_norm = 0.0L; + for (std::size_t index = 0; index < lhs.size(); ++index) + { + auto const lx = lhs[index] - lhs_mean; + auto const rx = rhs[index] - rhs_mean; + numerator += lx * rx; + lhs_norm += lx * lx; + rhs_norm += rx * rx; + } + if (lhs_norm == 0.0L || rhs_norm == 0.0L) { return 0.0L; } + return numerator / std::sqrt(lhs_norm * rhs_norm); + } + + [[nodiscard]] inline auto cos3_reference(std::size_t const size) -> Profile + { + Profile result(size, 0.0L); + if (size == 0) { return result; } + auto const pi = 3.141592653589793238462643383279502884L; + auto const center_index = static_cast(size / 2); + auto const width = std::max(1.0L, static_cast(size) / pi); + for (std::size_t index = 0; index < size; ++index) + { + auto const x = (static_cast(index) - center_index) / width; + if (std::abs(x) < pi / 2.0L) + { + auto const c = std::cos(x); + result[index] = c * c * c; + } + } + return result; + } + + [[nodiscard]] inline auto diagnose_c_ds_finite_size( + std::vector> profiles_by_volume, + std::size_t const effective_sample_count) -> CdsValidationReport + { + CdsValidationReport report; + if (effective_sample_count < 3 || profiles_by_volume.size() < 3) + { + report.verdict = Verdict::insufficient_effective_samples; + return report; + } + + report.minimum_profile_correlation = + std::numeric_limits::infinity(); + for (auto const& [_, original_profile] : profiles_by_volume) + { + auto profile = centered(original_profile); + auto const total = + std::accumulate(profile.begin(), profile.end(), 0.0L); + if (total == 0.0L) + { + report.verdict = Verdict::thermalization_failed; + return report; + } + auto const peak = *std::ranges::max_element(profile) / total; + if (peak > 0.70L) + { + report.verdict = Verdict::collapsed_like; + return report; + } + + auto alternating = 0.0L; + for (std::size_t index = 0; index < profile.size(); ++index) + { + alternating += (index % 2 == 0 ? 1.0L : -1.0L) * profile[index]; + } + if (std::abs(alternating / total) > 0.30L) + { + report.verdict = Verdict::c_b_like; + return report; + } + + auto const correlation = + profile_correlation(profile, cos3_reference(profile.size())); + report.profile_correlations.push_back(correlation); + report.minimum_profile_correlation = + std::min(report.minimum_profile_correlation, correlation); + } + + report.scaling = analyze_finite_size_scaling(std::move(profiles_by_volume)); + report.verdict = report.scaling.passed && + report.minimum_profile_correlation > 0.85L + ? Verdict::c_ds_supported + : Verdict::no_phase_classification; + return report; + } + + [[nodiscard]] inline auto estimate_autocorrelation_time( + std::vector const& series) -> long double + { + if (series.size() < 3) { return 1.0L; } + auto const average = + std::accumulate(series.begin(), series.end(), 0.0L) / + static_cast(series.size()); + auto numerator = 0.0L; + auto denominator = 0.0L; + for (std::size_t index = 1; index < series.size(); ++index) + { + numerator += (series[index - 1] - average) * (series[index] - average); + } + for (auto const value : series) + { + denominator += (value - average) * (value - average); + } + if (denominator == 0.0L) { return 1.0L; } + auto const rho1 = std::clamp(numerator / denominator, -0.99L, 0.99L); + return std::max(1.0L, (1.0L + rho1) / (1.0L - rho1)); + } + + [[nodiscard]] inline auto diagnose(std::vector profiles) + -> Diagnostics + { + Diagnostics diagnostics; + if (profiles.size() < 3) + { + diagnostics.verdict = Verdict::insufficient_effective_samples; + return diagnostics; + } + + for (auto& profile : profiles) { profile = centered(std::move(profile)); } + diagnostics.mean_profile = mean(profiles); + diagnostics.covariance = covariance(profiles, diagnostics.mean_profile); + + auto const total = + std::accumulate(diagnostics.mean_profile.begin(), + diagnostics.mean_profile.end(), 0.0L); + if (total == 0.0L) + { + diagnostics.verdict = Verdict::thermalization_failed; + return diagnostics; + } + auto const peak = + *std::ranges::max_element(diagnostics.mean_profile) / total; + if (peak > 0.70L) + { + diagnostics.verdict = Verdict::collapsed_like; + return diagnostics; + } + + auto alternating = 0.0L; + for (std::size_t index = 0; index < diagnostics.mean_profile.size(); + ++index) + { + alternating += (index % 2 == 0 ? 1.0L : -1.0L) * + diagnostics.mean_profile[index]; + } + if (std::abs(alternating / total) > 0.30L) + { + diagnostics.verdict = Verdict::c_b_like; + return diagnostics; + } + + auto const reference = cos3_reference(diagnostics.mean_profile.size()); + auto const cos3_corr = + profile_correlation(diagnostics.mean_profile, reference); + diagnostics.held_out_likelihood = cos3_corr; + diagnostics.aic = -2.0L * cos3_corr + 2.0L * 4.0L; + diagnostics.bic = + -2.0L * cos3_corr + + std::log(static_cast(diagnostics.mean_profile.size())) * + 4.0L; + diagnostics.verdict = cos3_corr > 0.85L ? Verdict::c_ds_supported + : Verdict::no_phase_classification; + return diagnostics; + } +} // namespace cdt::four_d::phase + +#endif // CDT_PLUSPLUS_PHASE_ANALYSIS_HPP diff --git a/include/S4Action.hpp b/include/S4Action.hpp new file mode 100644 index 000000000..204344977 --- /dev/null +++ b/include/S4Action.hpp @@ -0,0 +1,83 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file S4Action.hpp +/// @brief Wick-rotated 3+1D CDT Regge action. + +#ifndef CDT_PLUSPLUS_S4ACTION_HPP +#define CDT_PLUSPLUS_S4ACTION_HPP + +#include +#include + +#include "Settings.hpp" + +namespace cdt::four_d +{ + struct S4Counts + { + Int_precision N0{0}; + Int_precision N1{0}; + Int_precision N2{0}; + Int_precision N3{0}; + Int_precision N4{0}; + Int_precision N41{0}; + Int_precision N32{0}; + Int_precision N23{0}; + Int_precision N14{0}; + }; + + struct S4Couplings + { + long double kappa_0{0.0L}; + long double kappa_4{0.0L}; + long double Delta{0.0L}; + Int_precision target_N4{0}; + long double volume_epsilon{0.0L}; + }; + + /// @brief Standard bare-coupling form used in the 4D CDT phase diagram. + /// + /// Convention: + /// \f[ + /// S_E = -(\kappa_0 + 6\Delta) N_0 + /// + \kappa_4 (N_4^{(4,1)} + N_4^{(3,2)}) + /// + \Delta (2N_4^{(4,1)} + N_4^{(3,2)}) + /// + \epsilon (N_4 - N_4^{target})^2 . + /// \f] + /// + /// Here \f$N_4^{(4,1)} = N_{41}+N_{14}\f$ and + /// \f$N_4^{(3,2)} = N_{32}+N_{23}\f$. This is the Wick-rotated action + /// convention used for pure-gravity 4D CDT phase-diagram simulations, e.g. + /// Ambjorn, Jurkiewicz and Loll, Phys. Rev. D 72, 064014 (2005), Eq. (5) + /// after grouping time-reversed simplex types. + [[nodiscard]] inline auto S4_bulk_action(S4Counts const& counts, + S4Couplings const& couplings) + -> long double + { + auto const n41_total = + static_cast(counts.N41 + counts.N14); + auto const n32_total = + static_cast(counts.N32 + counts.N23); + auto const n4 = static_cast(counts.N4); + auto const target = static_cast(couplings.target_N4); + auto const volume_delta = n4 - target; + + return -(couplings.kappa_0 + 6.0L * couplings.Delta) * + static_cast(counts.N0) + + couplings.kappa_4 * (n41_total + n32_total) + + couplings.Delta * (2.0L * n41_total + n32_total) + + couplings.volume_epsilon * volume_delta * volume_delta; + } + + [[nodiscard]] inline auto S4_action_difference( + S4Counts const& before, S4Counts const& after, + S4Couplings const& couplings) -> long double + { + return S4_bulk_action(after, couplings) - + S4_bulk_action(before, couplings); + } +} // namespace cdt::four_d + +#endif // CDT_PLUSPLUS_S4ACTION_HPP diff --git a/include/Simulation_output.hpp b/include/Simulation_output.hpp new file mode 100644 index 000000000..6761a5243 --- /dev/null +++ b/include/Simulation_output.hpp @@ -0,0 +1,204 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL +*******************************************************************************/ + +/// @file Simulation_output.hpp +/// @brief Versioned machine-readable simulation output. + +#ifndef CDT_PLUSPLUS_SIMULATION_OUTPUT_HPP +#define CDT_PLUSPLUS_SIMULATION_OUTPUT_HPP + +#include +#include +#include +#include +#include + +#include "Metropolis_4.hpp" +#include "Phase_analysis.hpp" + +namespace cdt::four_d::output +{ + struct RunManifest + { + std::string run_id{"run"}; + std::string git_commit{"unknown"}; + std::string build_type{"unknown"}; + std::string compiler{"unknown"}; + }; + + inline void write_counts_csv_header(std::ofstream& file) + { + file << "step,N0,N1,N2,N3,N4,N41,N32,N23,N14\n"; + } + + inline void write_counts_csv_row(std::ofstream& file, Int_precision step, + S4Counts const& counts) + { + file << step << ',' << counts.N0 << ',' << counts.N1 << ',' << counts.N2 + << ',' << counts.N3 << ',' << counts.N4 << ',' << counts.N41 << ',' + << counts.N32 << ',' << counts.N23 << ',' << counts.N14 << '\n'; + } + + inline void write_run_directory(std::filesystem::path const& root, + RunManifest const& manifest, + Metropolis4Config const& config, + Metropolis4Result const& result) + { + auto const run_dir = root / manifest.run_id; + std::filesystem::create_directories(run_dir / "checkpoint"); + + { + std::ofstream file(run_dir / "manifest.json"); + file << "{\n"; + file << " \"version\": 1,\n"; + file << " \"run_id\": \"" << manifest.run_id << "\",\n"; + file << " \"git_commit\": \"" << manifest.git_commit << "\",\n"; + file << " \"build_type\": \"" << manifest.build_type << "\",\n"; + file << " \"compiler\": \"" << manifest.compiler << "\",\n"; + file << " \"seed\": " << config.seed << ",\n"; + file << " \"chain_id\": \"" << config.chain_id << "\",\n"; + file << " \"kappa_0\": " << config.couplings.kappa_0 << ",\n"; + file << " \"kappa_4\": " << config.couplings.kappa_4 << ",\n"; + file << " \"Delta\": " << config.couplings.Delta << ",\n"; + file << " \"target_N4\": " << config.couplings.target_N4 << ",\n"; + file << " \"volume_epsilon\": " + << config.couplings.volume_epsilon << ",\n"; + file << " \"timeslices\": " << result.triangulation.timeslices() + << ",\n"; + file << " \"thermalization_steps\": " + << config.thermalization_steps << ",\n"; + file << " \"measurement_interval\": " + << config.measurement_interval << "\n"; + file << "}\n"; + } + + { + std::ofstream file(run_dir / "measurements.jsonl"); + for (auto const& measurement : result.measurements) + { + file << "{\"step\":" << measurement.step << ",\"action\":" + << measurement.action << ",\"N4\":" << measurement.counts.N4 + << ",\"max_vertex_order\":" << measurement.max_vertex_order + << ",\"valid\":" << (measurement.valid ? "true" : "false") + << "}\n"; + } + } + + { + std::ofstream file(run_dir / "spatial_volume.csv"); + file << "timeslice,N3\n"; + auto const profile = result.triangulation.spatial_volume_profile(); + for (std::size_t index = 0; index < profile.size(); ++index) + { + file << index << ',' << profile[index] << '\n'; + } + } + + { + std::ofstream file(run_dir / "simplex_counts.csv"); + write_counts_csv_header(file); + for (auto const& measurement : result.measurements) + { + write_counts_csv_row(file, measurement.step, measurement.counts); + } + if (result.measurements.empty()) + { + write_counts_csv_row(file, 0, result.triangulation.counts()); + } + } + + { + std::ofstream file(run_dir / "move_statistics.csv"); + file << "move,attempted,accepted,invalid\n"; + for (std::size_t index = 0; index < result.move_stats.size(); ++index) + { + auto const& stat = result.move_stats[index]; + file << index << ',' << stat.attempted << ',' << stat.accepted << ',' + << stat.invalid << '\n'; + } + } + + { + std::ofstream file(run_dir / "action_trace.csv"); + file << "step,action,N4\n"; + for (std::size_t index = 0; index < result.action_trace.size(); ++index) + { + file << index + 1 << ',' << result.action_trace[index] << ',' + << result.volume_trace[index] << '\n'; + } + } + + std::vector measured_profiles; + for (auto const& measurement : result.measurements) + { + phase::Profile profile; + profile.reserve(measurement.spatial_profile.size()); + for (auto const value : measurement.spatial_profile) + { + profile.push_back(static_cast(value)); + } + measured_profiles.push_back(std::move(profile)); + } + if (!measured_profiles.empty()) + { + auto const profile_mean = phase::mean(measured_profiles); + auto const covariance = phase::covariance(measured_profiles, profile_mean); + auto const kernel = phase::effective_action_kernel(measured_profiles); + + std::ofstream covariance_file(run_dir / "covariance.csv"); + for (auto const& row : covariance) + { + for (std::size_t index = 0; index < row.size(); ++index) + { + covariance_file << row[index] + << (index + 1 == row.size() ? '\n' : ','); + } + } + + std::ofstream effective_file(run_dir / "effective_action.csv"); + for (auto const& row : kernel.inverse_covariance_diagonal_regularized) + { + for (std::size_t index = 0; index < row.size(); ++index) + { + effective_file << row[index] + << (index + 1 == row.size() ? '\n' : ','); + } + } + } + + { + std::ofstream file(run_dir / "summary.json"); + auto const counts = result.triangulation.counts(); + auto const report = result.triangulation.validate(); + auto const diagnostics = measured_profiles.empty() + ? phase::Diagnostics{} + : phase::diagnose(measured_profiles); + file << "{\n"; + file << " \"N0\": " << counts.N0 << ",\n"; + file << " \"N1\": " << counts.N1 << ",\n"; + file << " \"N2\": " << counts.N2 << ",\n"; + file << " \"N3\": " << counts.N3 << ",\n"; + file << " \"N4\": " << counts.N4 << ",\n"; + file << " \"N41\": " << counts.N41 << ",\n"; + file << " \"N32\": " << counts.N32 << ",\n"; + file << " \"N23\": " << counts.N23 << ",\n"; + file << " \"N14\": " << counts.N14 << ",\n"; + file << " \"action\": " + << S4_bulk_action(counts, config.couplings) << ",\n"; + file << " \"maximum_vertex_order\": " + << result.triangulation.max_vertex_order() << ",\n"; + file << " \"valid\": " << (report.valid() ? "true" : "false") + << ",\n"; + file << " \"standard_cdt_candidate\": " + << (report.valid() && report.standard_cdt_candidate ? "true" + : "false") + << ",\n"; + file << " \"phase_verdict\": \"" + << phase::to_string(diagnostics.verdict) << "\"\n"; + file << "}\n"; + } + } +} // namespace cdt::four_d::output + +#endif // CDT_PLUSPLUS_SIMULATION_OUTPUT_HPP diff --git a/scripts/run-cds-phase-scan.ps1 b/scripts/run-cds-phase-scan.ps1 new file mode 100644 index 000000000..5ca08cb94 --- /dev/null +++ b/scripts/run-cds-phase-scan.ps1 @@ -0,0 +1,60 @@ +param( + [string]$BuildDir = $(if ($env:BUILD_DIR) { $env:BUILD_DIR } else { "build" }), + [string]$ResultsDir = $(if ($env:RESULTS_DIR) { $env:RESULTS_DIR } else { "results" }), + [string[]]$TargetVolumes = $(if ($env:TARGET_VOLUMES) { $env:TARGET_VOLUMES -split " " } else { @("8000", "16000", "32000", "64000") }), + [string[]]$CouplingPoints = $(if ($env:COUPLING_POINTS) { $env:COUPLING_POINTS -split " " } else { @("1.0,0.2,0.1") }), + [int]$Chains = $(if ($env:CHAINS) { [int]$env:CHAINS } else { 4 }), + [int]$Timeslices = $(if ($env:TIMESLICES) { [int]$env:TIMESLICES } else { 32 }), + [int]$Thermalization = $(if ($env:THERMALIZATION) { [int]$env:THERMALIZATION } else { 1000 }), + [int]$MeasurementInterval = $(if ($env:MEASUREMENT_INTERVAL) { [int]$env:MEASUREMENT_INTERVAL } else { 10 }), + [int]$Passes = $(if ($env:PASSES) { [int]$env:PASSES } else { 2000 }), + [double]$VolumeEpsilon = $(if ($env:VOLUME_EPSILON) { [double]$env:VOLUME_EPSILON } else { 0.001 }), + [int]$BaseSeed = $(if ($env:BASE_SEED) { [int]$env:BASE_SEED } else { 1000 }) +) + +$ErrorActionPreference = "Stop" + +cmake --build $BuildDir +ctest --test-dir $BuildDir --output-on-failure + +$candidateExecutables = @( + (Join-Path $BuildDir "src/cdt.exe"), + (Join-Path $BuildDir "src/Debug/cdt.exe"), + (Join-Path $BuildDir "src/Release/cdt.exe"), + (Join-Path $BuildDir "src/RelWithDebInfo/cdt.exe"), + (Join-Path $BuildDir "src/cdt") +) +$cdt = $candidateExecutables | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $cdt) { + throw "Could not find cdt executable under $BuildDir." +} + +foreach ($point in $CouplingPoints) { + $parts = $point.Split(",") + $kappa0 = $parts[0] + $kappa4 = $parts[1] + $delta = $parts[2] + foreach ($target in $TargetVolumes) { + foreach ($chain in 1..$Chains) { + $seed = $BaseSeed + $chain + $runId = "cds-k0_$kappa0-k4_$kappa4-d_$delta-n4_$target-chain_$chain" + & $cdt ` + --spherical ` + --dimensions 4 ` + --simplices $target ` + --timeslices $Timeslices ` + --kappa0 $kappa0 ` + --kappa4 $kappa4 ` + --Delta $delta ` + --target-n4 $target ` + --volume-epsilon $VolumeEpsilon ` + --passes $Passes ` + --thermalization $Thermalization ` + --measurement-interval $MeasurementInterval ` + --seed $seed ` + --chain-id "chain-$chain" ` + --run-id $runId ` + --output-dir $ResultsDir + } + } +} diff --git a/scripts/run-cds-phase-scan.sh b/scripts/run-cds-phase-scan.sh new file mode 100644 index 000000000..7e6bb8984 --- /dev/null +++ b/scripts/run-cds-phase-scan.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUILD_DIR="${BUILD_DIR:-build}" +RESULTS_DIR="${RESULTS_DIR:-results}" +TARGET_VOLUMES="${TARGET_VOLUMES:-8000 16000 32000 64000}" +COUPLING_POINTS="${COUPLING_POINTS:-1.0,0.2,0.1}" +CHAINS="${CHAINS:-4}" +TIMESLICES="${TIMESLICES:-32}" +THERMALIZATION="${THERMALIZATION:-1000}" +MEASUREMENT_INTERVAL="${MEASUREMENT_INTERVAL:-10}" +PASSES="${PASSES:-2000}" +VOLUME_EPSILON="${VOLUME_EPSILON:-0.001}" +BASE_SEED="${BASE_SEED:-1000}" + +cmake --build "${BUILD_DIR}" +ctest --test-dir "${BUILD_DIR}" --output-on-failure + +CDT_EXE="${BUILD_DIR}/src/cdt" +if [[ ! -x "${CDT_EXE}" && -x "${BUILD_DIR}/src/Debug/cdt" ]]; then + CDT_EXE="${BUILD_DIR}/src/Debug/cdt" +fi +if [[ ! -x "${CDT_EXE}" && -x "${BUILD_DIR}/src/Release/cdt" ]]; then + CDT_EXE="${BUILD_DIR}/src/Release/cdt" +fi +if [[ ! -x "${CDT_EXE}" && -x "${BUILD_DIR}/src/RelWithDebInfo/cdt" ]]; then + CDT_EXE="${BUILD_DIR}/src/RelWithDebInfo/cdt" +fi + +for point in ${COUPLING_POINTS}; do + IFS=',' read -r KAPPA0 KAPPA4 DELTA <<< "${point}" + for target in ${TARGET_VOLUMES}; do + for chain in $(seq 1 "${CHAINS}"); do + seed=$((BASE_SEED + chain)) + run_id="cds-k0_${KAPPA0}-k4_${KAPPA4}-d_${DELTA}-n4_${target}-chain_${chain}" + "${CDT_EXE}" \ + --spherical \ + --dimensions 4 \ + --simplices "${target}" \ + --timeslices "${TIMESLICES}" \ + --kappa0 "${KAPPA0}" \ + --kappa4 "${KAPPA4}" \ + --Delta "${DELTA}" \ + --target-n4 "${target}" \ + --volume-epsilon "${VOLUME_EPSILON}" \ + --passes "${PASSES}" \ + --thermalization "${THERMALIZATION}" \ + --measurement-interval "${MEASUREMENT_INTERVAL}" \ + --seed "${seed}" \ + --chain-id "chain-${chain}" \ + --run-id "${run_id}" \ + --output-dir "${RESULTS_DIR}" + done + done +done diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 37c29889c..00e60a8e5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -66,8 +66,21 @@ set_tests_properties(cdt-no-output PROPERTIES LABELS "integration;debug-incompat add_cli_failure_test(cdt-triangle-inequalities cdt "Triangle inequalities violated" -s -n64 -t3 -a0.4 -k1.1 -l0.1 --seed 92) -add_cli_failure_test(cdt-dimensionality cdt "Only three-dimensional triangulations are supported." -s -n64 -t3 - -d4 -a0.4 -k1.1 -l0.1 --seed 92) +add_cli_failure_test(cdt-dimensionality cdt "Only three- or four-dimensional triangulations are supported." -s + -n64 -t3 -d5 -a0.4 -k1.1 -l0.1 --seed 92) +add_cli_failure_test(cdt-d4-missing-couplings cdt "4D runs require explicit --kappa0, --kappa4, and --Delta." -s + -n64 -t3 -d4 -a0.4 -k1.1 -l0.1 --seed 92) +add_test( + NAME cdt-d4 + COMMAND + $ -s -n64 -t3 -d4 --kappa0 1.0 --kappa4 0.2 + --Delta 0.1 --target-n4 64 --volume-epsilon 0.001 --seed 11 -p10 + --run-id ctest-d4 --output-dir + "${CMAKE_CURRENT_BINARY_DIR}/cdt-d4-output") +set_tests_properties( + cdt-d4 + PROPERTIES LABELS "integration;4d" PASS_REGULAR_EXPRESSION + "Structured output written to .*ctest-d4") add_cli_failure_test(cdt-toroidal cdt "Toroidal triangulations are not yet supported." -e -n64 -t3 -a0.6 -k1.1 -l0.1 --seed 92) add_cli_failure_test(cdt-alpha-negative cdt "Alpha in 3D must be greater than 1/2." -s -n64 -t3 -a-0.6 -k1.1 diff --git a/src/cdt.cpp b/src/cdt.cpp index 62761355f..a06afb611 100644 --- a/src/cdt.cpp +++ b/src/cdt.cpp @@ -11,6 +11,7 @@ /// https://github.com/ucdavis/CDT. #include +#include #include #include @@ -19,8 +20,13 @@ #include #endif +#include #include +#include #include +#include +#include +#include #include #include "Runtime_config.hpp" @@ -51,9 +57,14 @@ Usage:./cdt (--spherical | --toroidal) -n SIMPLICES -t TIMESLICES [--no-output] [--seed SEED] [--threads THREADS] - -k K - --alpha ALPHA - --lambda LAMBDA + [-k K] + [--alpha ALPHA] + [--lambda LAMBDA] + [--kappa0 KAPPA0] + [--kappa4 KAPPA4] + [--Delta DELTA] + [--target-n4 TARGET] + [--volume-epsilon EPSILON] [-p PASSES] [-c CHECKPOINT] @@ -82,10 +93,20 @@ try long double alpha{}; long double k{}; long double lambda{}; + long double kappa_0{}; + long double kappa_4{}; + long double Delta{}; + long double volume_epsilon{}; long long passes{}; long long checkpoint{}; std::uint64_t seed{}; long long threads{}; + long long target_N4{}; + long long thermalization{}; + long long measurement_interval{}; + std::string chain_id; + std::string run_id; + std::string output_dir; po::options_description description(intro); description.add_options()("help,h", "Show this message")( @@ -106,11 +127,34 @@ try "Root random seed (default: operating-system entropy)")( "threads", po::value(&threads)->default_value(1), "Maximum worker threads for supported Delaunay operations")( - "alpha,a", po::value(&alpha)->required(), + "alpha,a", po::value(&alpha), "Negative squared geodesic length of 1-d timelike edges")( - "k,k", po::value(&k)->required(), "K = 1/(8*pi*G_newton)")( - "lambda,l", po::value(&lambda)->required(), + "k,k", po::value(&k), "K = 1/(8*pi*G_newton)")( + "lambda,l", po::value(&lambda), "K * Cosmological constant")( + "kappa0", po::value(&kappa_0), + "4D bare inverse Newton coupling")( + "kappa4", po::value(&kappa_4), + "4D bare cosmological coupling")( + "Delta", po::value(&Delta), "4D asymmetry coupling")( + "target-n4", po::value(&target_N4)->default_value(0), + "4D fixed-volume target")( + "volume-epsilon", + po::value(&volume_epsilon)->default_value(0.0L), + "Quadratic fixed-volume strength")( + "thermalization", + po::value(&thermalization)->default_value(0), + "4D thermalization steps discarded before measurements")( + "measurement-interval", + po::value(&measurement_interval)->default_value(1), + "4D measurement interval")( + "chain-id", po::value(&chain_id)->default_value("chain-0"), + "Independent chain identifier")( + "run-id", po::value(&run_id)->default_value("run"), + "Structured output run ID")( + "output-dir", + po::value(&output_dir)->default_value("results"), + "Structured output root directory")( "passes,p", po::value(&passes)->default_value(100), "Number of passes")("checkpoint,c", po::value(&checkpoint)->default_value(10), @@ -143,6 +187,177 @@ try auto root_random = args.count("seed") != 0 ? cdt::Random{seed} : cdt::Random{}; + + if (dimensions != 3 && dimensions != 4) + { + throw invalid_argument( + "Only three- or four-dimensional triangulations are supported."); + } + + if (dimensions == 4) + { + auto const spherical = args.count("spherical") != 0; + auto const toroidal = args.count("toroidal") != 0; + if (spherical == toroidal) + { + throw invalid_argument( + "Specify exactly one topology: --spherical or --toroidal."); + } + if (toroidal) + { + throw invalid_argument("Toroidal triangulations are not yet supported."); + } + if (!args.count("kappa0") || !args.count("kappa4") || + !args.count("Delta")) + { + throw invalid_argument( + "4D runs require explicit --kappa0, --kappa4, and --Delta."); + } + if (!std::isfinite(kappa_0)) + { + throw invalid_argument("Kappa0 must be finite."); + } + if (!std::isfinite(kappa_4)) + { + throw invalid_argument("Kappa4 must be finite."); + } + if (!std::isfinite(Delta)) + { + throw invalid_argument("Delta must be finite."); + } + if (!std::isfinite(volume_epsilon) || volume_epsilon < 0.0L) + { + throw invalid_argument( + "Volume epsilon must be finite and non-negative."); + } + + auto const checked_int = [](char const* name, long long const value) { + if (!std::in_range(value)) + { + throw out_of_range(std::string{name} + + " exceeds the supported integer range."); + } + return static_cast(value); + }; + auto const checked_simplices = + checked_int("Number of simplices", simplices); + auto const checked_timeslices = + checked_int("Number of timeslices", timeslices); + auto const checked_passes = checked_int("Passes", passes); + auto const checked_checkpoint = + checked_int("Checkpoint interval", checkpoint); + auto const checked_target = checked_int("Target N4", target_N4); + auto const checked_thermalization = + checked_int("Thermalization steps", thermalization); + auto const checked_measurement_interval = + checked_int("Measurement interval", measurement_interval); + + if (checked_simplices < 2 || checked_timeslices < 2) + { + throw invalid_argument( + "Simplices and timeslices must each be at least 2."); + } + if (checked_passes <= 0) + { + throw invalid_argument("Passes must be positive."); + } + if (checked_checkpoint <= 0) + { + throw invalid_argument("Checkpoint interval must be positive."); + } + if (checked_target < 0) + { + throw invalid_argument("Target N4 must be non-negative."); + } + if (checked_thermalization < 0) + { + throw invalid_argument("Thermalization steps must be non-negative."); + } + if (checked_measurement_interval <= 0) + { + throw invalid_argument("Measurement interval must be positive."); + } + + fmt::print("Topology is spherical\n"); + fmt::print("Dimensionality: 3+1\n"); + fmt::print("Number of desired simplices: {}\n", checked_simplices); + fmt::print("Number of desired timeslices: {}\n", checked_timeslices); + fmt::print("Number of passes: {}\n", checked_passes); + fmt::print("Checkpoint every {} passes.\n", checked_checkpoint); + fmt::print("Effective random seed: {}\n", root_random.seed()); + fmt::print("=== Parameters ===\n"); + fmt::print("kappa_0: {}\n", kappa_0); + fmt::print("kappa_4: {}\n", kappa_4); + fmt::print("Delta: {}\n", Delta); + + Timer timer; + timer.start(); + fmt::print("cdt started at {}\n", utilities::current_date_time()); + + auto universe = + four_d::FoliatedTriangulation4::periodic_seed(checked_timeslices); + auto const fixed_target = + checked_target > 0 ? checked_target : checked_simplices; + four_d::Metropolis4Config config; + config.seed = root_random.seed().value(); + config.chain_id = chain_id; + config.thermalization_steps = checked_thermalization; + config.measurement_interval = checked_measurement_interval; + config.checkpoint_interval = checked_checkpoint; + config.couplings = four_d::S4Couplings{kappa_0, + kappa_4, + Delta, + fixed_target, + volume_epsilon}; + + four_d::Metropolis4 run(config); + auto result = run.run(std::move(universe), checked_passes); + if (!result.triangulation.is_valid()) + { + throw runtime_error("4D result is invalid!\n"); + } + + auto const write_files = !args.count("no-output"); + if (write_files) + { + auto const run_dir = std::filesystem::path(output_dir) / run_id; + run.save_checkpoint(run_dir / "checkpoint", result.triangulation, + checked_passes); + four_d::output::RunManifest manifest; + manifest.run_id = run_id; + manifest.git_commit = std::string(cdt::SOURCE_REVISION); + manifest.build_type = std::string(cdt::BUILD_CONFIGURATION); + manifest.compiler = + fmt::format("{} {}", cdt::BUILD_COMPILER_ID, + cdt::BUILD_COMPILER_VERSION); + four_d::output::write_run_directory(output_dir, manifest, config, + result); + } + + timer.stop(); + fmt::print("=== 4D Run Results ===\n"); + fmt::print("Running time is {} seconds.\n", timer.time()); + if (write_files) + { + fmt::print("Structured output written to {}/{}\n", output_dir, run_id); + } + else + { + fmt::print("Structured output disabled.\n"); + } + auto const report = result.triangulation.validate(); + fmt::print("Standard CDT candidate: {}\n", + report.valid() && report.standard_cdt_candidate ? "true" + : "false"); + return EXIT_SUCCESS; + } + + if (!args.count("alpha") || !args.count("k") || !args.count("lambda")) + { + throw invalid_argument( + "3D runs require explicit --alpha, -k, and --lambda."); + } + auto const triangulation_config = runtime_config::make_triangulation( args.count("spherical") != 0, args.count("toroidal") != 0, simplices, timeslices, dimensions, initial_radius, foliation_spacing, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a9d585ec8..c42e2e817 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,24 +4,35 @@ add_executable( Apply_move_test.cpp Bistellar_flip_test.cpp CGAL_integration_test.cpp + Checkpoint_4_test.cpp + Detailed_balance_test.cpp Ergodic_moves_3_audit_test.cpp Ergodic_moves_3_test.cpp + Ergodic_moves_4_test.cpp Foliated_triangulation_test.cpp + Foliated_triangulation_4_test.cpp Function_ref_test.cpp Geometry_test.cpp + Geometry_4_test.cpp Manifold_test.cpp + Manifold_4_test.cpp Metropolis_test.cpp + Metropolis_4_test.cpp Move_always_test.cpp Move_command_test.cpp Move_outcome_test.cpp Move_run_test.cpp Move_tracker_test.cpp + Observables_4_test.cpp + Phase_analysis_test.cpp Random_test.cpp Runtime_config_test.cpp S3Action_test.cpp + S4Action_test.cpp Settings_test.cpp Tetrahedron_test.cpp Torus_test.cpp + Time_reversal_4_test.cpp Utilities_test.cpp Vertex_test.cpp) # Activate C++23 features @@ -76,23 +87,32 @@ target_link_libraries( set( cdt_supported_headers Apply_move.hpp + Detailed_balance_4.hpp Ergodic_moves_3.hpp + Ergodic_moves_4.hpp Foliated_triangulation.hpp + Foliated_triangulation_4.hpp Formatters.hpp Geometry.hpp Manifold.hpp Metropolis.hpp + Metropolis_4.hpp + Move_catalog_4.hpp Move_always.hpp Move_command.hpp Move_outcome.hpp Move_run.hpp Move_strategy.hpp Move_tracker.hpp + Observables_4.hpp + Phase_analysis.hpp Mpfr_value.hpp Random.hpp Runtime_config.hpp S3Action.hpp + S4Action.hpp Settings.hpp + Simulation_output.hpp Triangulation_traits.hpp Utilities.hpp) set(cdt_header_contract_sources) diff --git a/tests/Checkpoint_4_test.cpp b/tests/Checkpoint_4_test.cpp new file mode 100644 index 000000000..60060375b --- /dev/null +++ b/tests/Checkpoint_4_test.cpp @@ -0,0 +1,35 @@ +#include "Metropolis_4.hpp" + +#include + +#include + +using namespace cdt::four_d; + +TEST_CASE("4D checkpoint restart reproduces the same final state") +{ + Metropolis4Config config; + config.seed = 2024; + config.couplings = S4Couplings{1.0L, 0.2L, 0.1L, 36, 0.001L}; + + auto seed = FoliatedTriangulation4::periodic_seed(3); + Metropolis4 direct(config); + auto direct_result = direct.run(seed, 10); + + Metropolis4 partial(config); + auto partial_result = partial.run(FoliatedTriangulation4::periodic_seed(3), 5); + + auto const directory = + std::filesystem::temp_directory_path() / "cdtpp-checkpoint-test"; + std::filesystem::remove_all(directory); + partial.save_checkpoint(directory, partial_result.triangulation, 5); + + Metropolis4 resumed(config); + auto [checkpointed, step] = resumed.load_checkpoint(directory); + CHECK_EQ(step, 5); + auto resumed_result = resumed.run(checkpointed, 5); + + CHECK_EQ(resumed_result.triangulation.canonical_hash(), + direct_result.triangulation.canonical_hash()); + std::filesystem::remove_all(directory); +} diff --git a/tests/Detailed_balance_test.cpp b/tests/Detailed_balance_test.cpp new file mode 100644 index 000000000..44b425913 --- /dev/null +++ b/tests/Detailed_balance_test.cpp @@ -0,0 +1,51 @@ +#include "Detailed_balance_4.hpp" + +#include +#include + +#include + +using namespace cdt::four_d; +namespace move_tracker = cdt::move_tracker; + +TEST_CASE("4D proposal ratio is reversible on a small triangulation") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + auto forward = moves::apply(triangulation, move_tracker::MoveType4D::TWO_FOUR); + REQUIRE(forward); + + auto reverse = moves::apply(forward->triangulation, + move_tracker::MoveType4D::FOUR_TWO); + REQUIRE(reverse); + + CHECK_EQ(forward->reverse_candidates, reverse->forward_candidates); + CHECK_EQ(forward->forward_candidates, reverse->reverse_candidates); + CHECK_EQ(reverse->triangulation.canonical_hash(), + triangulation.canonical_hash()); +} + +TEST_CASE("4D detailed-balance acceptance ingredients are finite") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + auto proposal = moves::apply(triangulation, move_tracker::MoveType4D::TWO_EIGHT); + REQUIRE(proposal); + S4Couplings couplings{1.0L, 0.2L, 0.1L, 36, 0.001L}; + auto const delta_action = + S4_action_difference(triangulation.counts(), + proposal->triangulation.counts(), couplings); + auto const ratio = static_cast(proposal->reverse_candidates) / + static_cast(proposal->forward_candidates); + auto const probability = std::min(1.0L, std::exp(-delta_action) * ratio); + CHECK(std::isfinite(static_cast(probability))); + CHECK_GE(probability, 0.0L); + CHECK_LE(probability, 1.0L); +} + +TEST_CASE("4D detailed balance holds on a small enumerable ensemble") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + S4Couplings couplings{1.0L, 0.2L, 0.1L, 36, 0.001L}; + auto report = verify_detailed_balance(triangulation, couplings, 1); + CHECK(report.passed); + CHECK_FALSE(report.edges.empty()); +} diff --git a/tests/Ergodic_moves_4_test.cpp b/tests/Ergodic_moves_4_test.cpp new file mode 100644 index 000000000..a31319b7e --- /dev/null +++ b/tests/Ergodic_moves_4_test.cpp @@ -0,0 +1,121 @@ +#include "Ergodic_moves_4.hpp" + +#include + +using namespace cdt::four_d; +namespace move_tracker = cdt::move_tracker; + +namespace +{ + void check_inverse(move_tracker::MoveType4D move) + { + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + auto const original_hash = triangulation.canonical_hash(); + auto moved = moves::apply(triangulation, move); + REQUIRE(moved); + CHECK(moved->triangulation.is_valid()); + CHECK_GT(moved->forward_candidates, 0); + CHECK_GT(moved->reverse_candidates, 0); + + auto restored = moves::apply(moved->triangulation, + move_tracker::reverse_move(move)); + REQUIRE(restored); + CHECK(restored->triangulation.is_valid()); + CHECK_EQ(restored->triangulation.canonical_hash(), original_hash); + } +} + +TEST_CASE("4D reverse_move covers every inverse pair") +{ + using move_tracker::MoveType4D; + CHECK(move_tracker::reverse_move(MoveType4D::TWO_FOUR) == + MoveType4D::FOUR_TWO); + CHECK(move_tracker::reverse_move(MoveType4D::FOUR_TWO) == + MoveType4D::TWO_FOUR); + CHECK(move_tracker::reverse_move(MoveType4D::THREE_THREE) == + MoveType4D::THREE_THREE); + CHECK(move_tracker::reverse_move(MoveType4D::FOUR_SIX) == + MoveType4D::SIX_FOUR); + CHECK(move_tracker::reverse_move(MoveType4D::SIX_FOUR) == + MoveType4D::FOUR_SIX); + CHECK(move_tracker::reverse_move(MoveType4D::TWO_EIGHT) == + MoveType4D::EIGHT_TWO); + CHECK(move_tracker::reverse_move(MoveType4D::EIGHT_TWO) == + MoveType4D::TWO_EIGHT); +} + +TEST_CASE("4D move plus inverse restores canonical hash") +{ + using move_tracker::MoveType4D; + check_inverse(MoveType4D::TWO_FOUR); + check_inverse(MoveType4D::FOUR_TWO); + check_inverse(MoveType4D::THREE_THREE); + check_inverse(MoveType4D::FOUR_SIX); + check_inverse(MoveType4D::SIX_FOUR); + check_inverse(MoveType4D::TWO_EIGHT); + check_inverse(MoveType4D::EIGHT_TWO); +} + +TEST_CASE("4D moves have documented exact combinatorial count changes") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + for (auto const descriptor : all_move_descriptors_4d()) + { + auto moved = moves::apply(triangulation, descriptor.move); + REQUIRE_MESSAGE(moved, descriptor.name); + CHECK_EQ(moved->delta.N0, descriptor.delta.N0); + CHECK_EQ(moved->delta.N1, descriptor.delta.N1); + CHECK_EQ(moved->delta.N2, descriptor.delta.N2); + CHECK_EQ(moved->delta.N3, descriptor.delta.N3); + CHECK_EQ(moved->delta.N4, descriptor.delta.N4); + CHECK_EQ(moved->delta.N41, descriptor.delta.N41); + CHECK_EQ(moved->delta.N32, descriptor.delta.N32); + CHECK_EQ(moved->delta.N23, descriptor.delta.N23); + CHECK_EQ(moved->delta.N14, descriptor.delta.N14); + } +} + +TEST_CASE("4D local and full action differences agree for every move") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + S4Couplings couplings{1.0L, 0.2L, 0.1L, 64, 0.001L}; + for (auto const descriptor : all_move_descriptors_4d()) + { + auto moved = moves::apply(triangulation, descriptor.move); + REQUIRE_MESSAGE(moved, descriptor.name); + auto const full_delta = + S4_action_difference(triangulation.counts(), + moved->triangulation.counts(), couplings); + auto const local_delta = + local_action_difference(triangulation.counts(), descriptor, couplings); + CHECK(full_delta == doctest::Approx(local_delta)); + } +} + +TEST_CASE("4D forward and reverse proposal multiplicities are state-derived") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + for (auto const descriptor : all_move_descriptors_4d()) + { + auto const forward = + triangulation.candidate_multiplicity(descriptor.move); + auto moved = moves::apply(triangulation, descriptor.move); + REQUIRE_MESSAGE(moved, descriptor.name); + auto const reverse = + moved->triangulation.candidate_multiplicity(descriptor.inverse); + CHECK_EQ(moved->forward_candidates, forward); + CHECK_EQ(moved->reverse_candidates, reverse); + CHECK_GT(forward, 0); + CHECK_GT(reverse, 0); + } +} + +TEST_CASE("4D failed moves leave the original unchanged") +{ + auto counts = S4Counts{1, 0, 0, 0, 0, 0, 0, 0, 0}; + FoliatedTriangulation4 triangulation{4, counts, {1, 1, 1, 1}}; + auto const hash = triangulation.canonical_hash(); + auto result = moves::apply(triangulation, move_tracker::MoveType4D::FOUR_TWO); + CHECK_FALSE(result); + CHECK_EQ(triangulation.canonical_hash(), hash); +} diff --git a/tests/Foliated_triangulation_4_test.cpp b/tests/Foliated_triangulation_4_test.cpp new file mode 100644 index 000000000..b5aa2c7ed --- /dev/null +++ b/tests/Foliated_triangulation_4_test.cpp @@ -0,0 +1,50 @@ +#include "Foliated_triangulation_4.hpp" + +#include + +using namespace cdt::four_d; + +TEST_CASE("Abstract 4D periodic seed validates") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + auto counts = triangulation.counts(); + + CHECK(triangulation.periodic()); + CHECK_EQ(triangulation.timeslices(), 4); + CHECK_EQ(triangulation.spatial_topology(), "S3"); + CHECK_EQ(triangulation.spacetime_topology(), "S3xS1"); + CHECK(triangulation.has_closed_s3_slices()); + CHECK(triangulation.is_valid()); + CHECK_EQ(counts.N4, counts.N41 + counts.N32 + counts.N23 + counts.N14); + CHECK_GT(counts.N41, 0); + CHECK_GT(counts.N32, 0); + CHECK_GT(counts.N23, 0); + CHECK_GT(counts.N14, 0); + CHECK_EQ(triangulation.occupied_temporal_width(), 4); + for (auto const chi : triangulation.slice_euler_characteristics()) + { + CHECK_EQ(chi, 0); + } +} + +TEST_CASE("Abstract 4D canonical hash is stable for copies") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + auto copy = triangulation; + CHECK_EQ(copy.canonical_hash(), triangulation.canonical_hash()); +} + +TEST_CASE("4D candidate validation is independent from the initializer") +{ + auto seeded = FoliatedTriangulation4::periodic_seed(3); + auto from_counts = FoliatedTriangulation4::from_counts_for_validation( + seeded.timeslices(), seeded.counts(), seeded.spatial_volume_profile()); + + CHECK(from_counts.is_valid()); + CHECK_EQ(from_counts.spatial_topology(), "S3"); + CHECK_EQ(from_counts.spacetime_topology(), "S3xS1"); + CHECK_EQ(from_counts.proposal_inventory().spatial_tetrahedra, + seeded.proposal_inventory().spatial_tetrahedra); + CHECK_EQ(from_counts.proposal_inventory().mixed_triangles, + seeded.proposal_inventory().mixed_triangles); +} diff --git a/tests/Geometry_4_test.cpp b/tests/Geometry_4_test.cpp new file mode 100644 index 000000000..d72065ae0 --- /dev/null +++ b/tests/Geometry_4_test.cpp @@ -0,0 +1,26 @@ +#include "Foliated_triangulation_4.hpp" + +#include + +using namespace cdt::four_d; +namespace move_tracker = cdt::move_tracker; + +TEST_CASE("Abstract 4D seed exposes N0 through N4") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + auto counts = triangulation.counts(); + + CHECK_GT(counts.N0, 0); + CHECK_GT(counts.N1, 0); + CHECK_GT(counts.N2, 0); + CHECK_GT(counts.N3, 0); + CHECK_GT(counts.N4, 0); +} + +TEST_CASE("4D move delta table is internally consistent") +{ + auto const delta = FoliatedTriangulation4::move_count_delta( + move_tracker::MoveType4D::TWO_EIGHT); + CHECK_EQ(delta.N4, delta.N41 + delta.N32 + delta.N23 + delta.N14); + CHECK_EQ(delta.N0, 1); +} diff --git a/tests/Manifold_4_test.cpp b/tests/Manifold_4_test.cpp new file mode 100644 index 000000000..22b5247e2 --- /dev/null +++ b/tests/Manifold_4_test.cpp @@ -0,0 +1,13 @@ +#include "Foliated_triangulation_4.hpp" + +#include + +using namespace cdt::four_d; + +TEST_CASE("4D validation reports a standard CDT candidate") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + auto report = triangulation.validate(); + CHECK(report.valid()); + CHECK(report.standard_cdt_candidate); +} diff --git a/tests/Metropolis_4_test.cpp b/tests/Metropolis_4_test.cpp new file mode 100644 index 000000000..04b03f4ba --- /dev/null +++ b/tests/Metropolis_4_test.cpp @@ -0,0 +1,52 @@ +#include "Metropolis_4.hpp" + +#include + +using namespace cdt::four_d; + +TEST_CASE("Metropolis4 same seed produces identical traces") +{ + Metropolis4Config config; + config.seed = 1234; + config.couplings = S4Couplings{1.0L, 0.2L, 0.1L, 48, 0.001L}; + config.thermalization_steps = 2; + config.measurement_interval = 2; + + Metropolis4 run_a(config); + Metropolis4 run_b(config); + auto seed_a = FoliatedTriangulation4::periodic_seed(4); + auto seed_b = FoliatedTriangulation4::periodic_seed(4); + + auto result_a = run_a.run(seed_a, 12); + auto result_b = run_b.run(seed_b, 12); + + CHECK_EQ(result_a.triangulation.canonical_hash(), + result_b.triangulation.canonical_hash()); + REQUIRE_EQ(result_a.action_trace.size(), result_b.action_trace.size()); + for (std::size_t index = 0; index < result_a.action_trace.size(); ++index) + { + CHECK(result_a.action_trace[index] == + doctest::Approx(result_b.action_trace[index])); + } + CHECK_EQ(result_a.measurements.size(), 5); +} + +TEST_CASE("Metropolis4 records invalid proposals and accepted moves") +{ + Metropolis4Config config; + config.seed = 7; + config.couplings = S4Couplings{1.0L, 0.2L, 0.1L, 48, 0.0L}; + Metropolis4 run(config); + auto result = run.run(FoliatedTriangulation4::periodic_seed(3), 20); + + auto attempted = 0; + auto accepted = 0; + for (auto const& stat : result.move_stats) + { + attempted += stat.attempted; + accepted += stat.accepted; + } + CHECK_EQ(attempted, 20); + CHECK_GE(accepted, 0); + CHECK(result.triangulation.is_valid()); +} diff --git a/tests/Move_tracker_test.cpp b/tests/Move_tracker_test.cpp index bad9f99e4..835e0ad3f 100644 --- a/tests/Move_tracker_test.cpp +++ b/tests/Move_tracker_test.cpp @@ -30,6 +30,7 @@ concept EnumIntegerConvertible = requires(Value value) { move_tracker::as_integer(value); }; static_assert(EnumIntegerConvertible); +static_assert(EnumIntegerConvertible); static_assert(!EnumIntegerConvertible); SCENARIO("MoveTracker special members" * doctest::test_suite("move_tracker")) @@ -115,6 +116,33 @@ SCENARIO("Integer to move type conversion" * } } +SCENARIO("4D move conversion and inverse pairs" * + doctest::test_suite("move_tracker")) +{ + CHECK_EQ(move_from_index_4d(0), MoveType4D::TWO_FOUR); + CHECK_EQ(move_from_index_4d(1), MoveType4D::FOUR_TWO); + CHECK_EQ(move_from_index_4d(2), MoveType4D::THREE_THREE); + CHECK_EQ(move_from_index_4d(3), MoveType4D::FOUR_SIX); + CHECK_EQ(move_from_index_4d(4), MoveType4D::SIX_FOUR); + CHECK_EQ(move_from_index_4d(5), MoveType4D::TWO_EIGHT); + CHECK_EQ(move_from_index_4d(6), MoveType4D::EIGHT_TWO); + CHECK_FALSE(move_from_index_4d(7).has_value()); + + CHECK_EQ(as_move_4d(-1), MoveType4D::NO_MOVE); + CHECK_EQ(as_move_4d(6), MoveType4D::EIGHT_TWO); + CHECK_EQ(as_move_4d(7), MoveType4D::NO_MOVE); + + CHECK_EQ(reverse_move(MoveType4D::TWO_FOUR), MoveType4D::FOUR_TWO); + CHECK_EQ(reverse_move(MoveType4D::FOUR_TWO), MoveType4D::TWO_FOUR); + CHECK_EQ(reverse_move(MoveType4D::THREE_THREE), + MoveType4D::THREE_THREE); + CHECK_EQ(reverse_move(MoveType4D::FOUR_SIX), MoveType4D::SIX_FOUR); + CHECK_EQ(reverse_move(MoveType4D::SIX_FOUR), MoveType4D::FOUR_SIX); + CHECK_EQ(reverse_move(MoveType4D::TWO_EIGHT), MoveType4D::EIGHT_TWO); + CHECK_EQ(reverse_move(MoveType4D::EIGHT_TWO), MoveType4D::TWO_EIGHT); + CHECK_EQ(reverse_move(MoveType4D::NO_MOVE), MoveType4D::NO_MOVE); +} + SCENARIO("MoveTracker functionality" * doctest::test_suite("move_tracker")) { spdlog::debug("MoveTracker functionality.\n"); diff --git a/tests/Observables_4_test.cpp b/tests/Observables_4_test.cpp new file mode 100644 index 000000000..791398eaa --- /dev/null +++ b/tests/Observables_4_test.cpp @@ -0,0 +1,22 @@ +#include "Observables_4.hpp" + +#include + +#include + +using namespace cdt::four_d; + +TEST_CASE("4D observables are finite on periodic seed") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + auto profile = observables::spatial_three_volume(triangulation); + + CHECK_EQ(profile.size(), 4); + CHECK_GT(observables::maximum_vertex_order(triangulation), 0); + CHECK_EQ(observables::occupied_temporal_width(triangulation), 4); + CHECK_GE(observables::slice_to_slice_roughness(triangulation), 0.0L); + CHECK_GE(observables::inverse_participation_ratio(triangulation), 0.0L); + CHECK_LE(std::abs(observables::alternating_slice_order_parameter( + triangulation)), + 1.0L); +} diff --git a/tests/Phase_analysis_test.cpp b/tests/Phase_analysis_test.cpp new file mode 100644 index 000000000..8c3d3346f --- /dev/null +++ b/tests/Phase_analysis_test.cpp @@ -0,0 +1,104 @@ +#include "Phase_analysis.hpp" + +#include + +#include + +using namespace cdt::four_d::phase; + +TEST_CASE("Synthetic cos cubed profiles are classified conservatively as C-like") +{ + std::vector profiles; + auto base = cos3_reference(21); + for (auto sample = 0; sample < 5; ++sample) + { + auto profile = base; + for (auto& value : profile) { value = 2.0L + 100.0L * value; } + profiles.push_back(profile); + } + + auto diagnostics = diagnose(profiles); + CHECK_EQ(diagnostics.verdict, Verdict::c_ds_supported); +} + +TEST_CASE("Collapsed one-slice profiles are classified as collapsed") +{ + std::vector profiles(5, Profile(9, 1.0L)); + for (auto& profile : profiles) { profile[4] = 100.0L; } + + auto diagnostics = diagnose(profiles); + CHECK_EQ(diagnostics.verdict, Verdict::collapsed_like); +} + +TEST_CASE("Alternating-slice profiles are classified as C_b-like") +{ + std::vector profiles(5, Profile(10, 10.0L)); + for (auto& profile : profiles) + { + for (std::size_t index = 0; index < profile.size(); ++index) + { + profile[index] = index % 2 == 0 ? 30.0L : 1.0L; + } + } + + auto diagnostics = diagnose(profiles); + CHECK_EQ(diagnostics.verdict, Verdict::c_b_like); +} + + +TEST_CASE("Too few profiles are insufficient effective samples") +{ + std::vector profiles(2, Profile(9, 1.0L)); + auto diagnostics = diagnose(profiles); + CHECK_EQ(diagnostics.verdict, Verdict::insufficient_effective_samples); +} + +TEST_CASE("Synthetic C_dS profiles pass finite-size scaling") +{ + std::vector> profiles; + for (auto const volume : {8000.0L, 16000.0L, 32000.0L, 64000.0L}) + { + auto const width = static_cast( + std::llround(4.0L * std::pow(volume, 0.25L))); + auto profile = cos3_reference(width | static_cast(1)); + auto const amplitude = std::pow(volume, 0.75L); + for (auto& value : profile) { value = 1.0L + amplitude * value; } + profiles.emplace_back(volume, profile); + } + + auto scaling = analyze_finite_size_scaling(profiles); + CHECK(scaling.passed); + CHECK(scaling.width_exponent == doctest::Approx(0.25L).epsilon(0.10)); + CHECK(scaling.peak_exponent == doctest::Approx(0.75L).epsilon(0.10)); +} + +TEST_CASE("Full C_dS candidate gate requires profile shape and finite-size scaling") +{ + std::vector> profiles; + for (auto const volume : {8000.0L, 16000.0L, 32000.0L, 64000.0L}) + { + auto const width = static_cast( + std::llround(4.0L * std::pow(volume, 0.25L))); + auto profile = cos3_reference(width | static_cast(1)); + auto const amplitude = std::pow(volume, 0.75L); + for (auto& value : profile) { value = 1.0L + amplitude * value; } + profiles.emplace_back(volume, profile); + } + + auto validation = diagnose_c_ds_finite_size(profiles, profiles.size()); + CHECK_EQ(validation.verdict, Verdict::c_ds_supported); + CHECK(validation.scaling.passed); + CHECK_GT(validation.minimum_profile_correlation, 0.85L); +} + +TEST_CASE("Covariance produces a regularized effective-action kernel") +{ + std::vector profiles{ + Profile{1.0L, 2.0L, 1.0L}, + Profile{1.0L, 3.0L, 1.0L}, + Profile{1.0L, 4.0L, 1.0L}}; + auto kernel = effective_action_kernel(profiles); + REQUIRE_EQ(kernel.covariance.size(), 3); + REQUIRE_EQ(kernel.inverse_covariance_diagonal_regularized.size(), 3); + CHECK_GT(kernel.inverse_covariance_diagonal_regularized[1][1], 0.0L); +} diff --git a/tests/S4Action_test.cpp b/tests/S4Action_test.cpp new file mode 100644 index 000000000..ca8312ef0 --- /dev/null +++ b/tests/S4Action_test.cpp @@ -0,0 +1,43 @@ +#include "S4Action.hpp" + +#include + +using namespace cdt::four_d; + +TEST_CASE("4D CDT action uses kappa0 kappa4 Delta convention") +{ + S4Counts counts{10, 20, 30, 40, 12, 2, 4, 4, 2}; + S4Couplings couplings{1.0L, 2.0L, 0.5L, 12, 0.0L}; + + auto const expected = -(1.0L + 6.0L * 0.5L) * 10.0L + + 2.0L * 12.0L + + 0.5L * (2.0L * 4.0L + 8.0L); + CHECK(S4_bulk_action(counts, couplings) == doctest::Approx(expected)); +} + +TEST_CASE("4D fixed-volume penalty is configurable") +{ + S4Counts counts{10, 20, 30, 40, 14, 3, 4, 4, 3}; + S4Couplings no_penalty{1.0L, 2.0L, 0.5L, 12, 0.0L}; + S4Couplings penalty{1.0L, 2.0L, 0.5L, 12, 0.25L}; + + CHECK(S4_bulk_action(counts, penalty) - S4_bulk_action(counts, no_penalty) == + doctest::Approx(1.0L)); +} + +TEST_CASE("4D full action differences agree with count changes") +{ + S4Counts before{10, 20, 30, 40, 12, 2, 4, 4, 2}; + auto after = before; + after.N1 += 1; + after.N2 += 4; + after.N3 += 5; + after.N4 += 2; + after.N41 += 1; + after.N32 += 1; + + S4Couplings couplings{1.0L, 2.0L, 0.5L, 14, 0.1L}; + CHECK(S4_action_difference(before, after, couplings) == + doctest::Approx(S4_bulk_action(after, couplings) - + S4_bulk_action(before, couplings))); +} diff --git a/tests/Time_reversal_4_test.cpp b/tests/Time_reversal_4_test.cpp new file mode 100644 index 000000000..e45f63adc --- /dev/null +++ b/tests/Time_reversal_4_test.cpp @@ -0,0 +1,41 @@ +#include "Foliated_triangulation_4.hpp" +#include "S4Action.hpp" + +#include + +using namespace cdt::four_d; + +TEST_CASE("4D time reversal swaps simplex orientations") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + auto counts = triangulation.counts(); + auto reversed = triangulation.time_reversed(); + auto reversed_counts = reversed.counts(); + + CHECK_EQ(reversed_counts.N41, counts.N14); + CHECK_EQ(reversed_counts.N14, counts.N41); + CHECK_EQ(reversed_counts.N32, counts.N23); + CHECK_EQ(reversed_counts.N23, counts.N32); +} + +TEST_CASE("4D time reversal preserves unsigned observables and is involutive") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + S4Couplings couplings{1.0L, 0.2L, 0.1L, 48, 0.001L}; + auto reversed = triangulation.time_reversed(); + auto restored = reversed.time_reversed(); + + CHECK(S4_bulk_action(triangulation.counts(), couplings) == + doctest::Approx(S4_bulk_action(reversed.counts(), couplings))); + CHECK_EQ(restored.canonical_hash(), triangulation.canonical_hash()); +} + +TEST_CASE("4D 41/14 imbalance is diagnostic-only and zero on symmetric seed") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(4); + auto counts = triangulation.counts(); + auto const imbalance = + static_cast(counts.N41 - counts.N14) / + static_cast(counts.N41 + counts.N14); + CHECK(imbalance == doctest::Approx(0.0L)); +} From 25d2b237e6f87f93607b314b5947383600668b6b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 1 Aug 2026 18:38:59 +0300 Subject: [PATCH 2/6] Address CodeRabbit review for 4D CDT PR --- include/Detailed_balance_4.hpp | 105 ++++++- include/Ergodic_moves_4.hpp | 11 +- include/Foliated_triangulation_4.hpp | 368 ++++++++++++++++++++++-- include/Metropolis_4.hpp | 172 +++++++++-- include/Move_catalog_4.hpp | 11 + include/Phase_analysis.hpp | 130 ++++++--- include/Random.hpp | 21 ++ include/Runtime_config.hpp | 225 +++++++++++++++ include/S4Action.hpp | 5 + include/Simulation_output.hpp | 162 +++++++++-- scripts/run-cds-phase-scan.ps1 | 23 +- scripts/run-cds-phase-scan.sh | 35 ++- src/CMakeLists.txt | 16 +- src/cdt.cpp | 157 ++++------ tests/Checkpoint_4_test.cpp | 81 +++++- tests/Detailed_balance_test.cpp | 32 ++- tests/Foliated_triangulation_4_test.cpp | 76 +++++ tests/Metropolis_4_test.cpp | 25 +- tests/Phase_analysis_test.cpp | 51 ++-- 19 files changed, 1431 insertions(+), 275 deletions(-) diff --git a/include/Detailed_balance_4.hpp b/include/Detailed_balance_4.hpp index 2aecb1256..d33706a47 100644 --- a/include/Detailed_balance_4.hpp +++ b/include/Detailed_balance_4.hpp @@ -10,6 +10,8 @@ #include #include +#include +#include #include #include #include @@ -68,12 +70,28 @@ namespace cdt::four_d return std::exp(-S4_bulk_action(triangulation.counts(), couplings)); } + [[nodiscard]] inline auto log_boltzmann_weight( + FoliatedTriangulation4 const& triangulation, + S4Couplings const& couplings) -> long double + { + return -S4_bulk_action(triangulation.counts(), couplings); + } + [[nodiscard]] inline auto verify_detailed_balance( FoliatedTriangulation4 const& seed, S4Couplings const& couplings, - int const max_depth, long double const tolerance = 1.0e-10L) + int const max_depth, long double const tolerance = 1.0e-10L, + std::size_t const max_states = 1024) -> DetailedBalanceReport4D { DetailedBalanceReport4D report; + if (max_states == 0) + { + report.passed = false; + report.errors.emplace_back( + "Detailed-balance enumeration max_states must be positive."); + return report; + } + std::map states; std::map depths; std::queue> frontier; @@ -82,7 +100,8 @@ namespace cdt::four_d depths.emplace(seed.canonical_hash(), 0); frontier.emplace(seed, 0); - while (!frontier.empty()) + auto cap_reached = false; + while (!frontier.empty() && !cap_reached) { auto [state, depth] = frontier.front(); frontier.pop(); @@ -94,6 +113,14 @@ namespace cdt::four_d auto const hash = moved->triangulation.canonical_hash(); if (!states.contains(hash)) { + if (states.size() >= max_states) + { + report.passed = false; + report.errors.emplace_back( + "Detailed-balance enumeration reached max_states."); + cap_reached = true; + break; + } states.emplace(hash, moved->triangulation); depths.emplace(hash, depth + 1); frontier.emplace(moved->triangulation, depth + 1); @@ -126,22 +153,70 @@ namespace cdt::four_d continue; } - auto const lhs = boltzmann_weight(from_state, couplings) * - proposal_probability(from_state, descriptor.move) * - acceptance_probability(from_state, to_state, - descriptor.move, couplings); - auto const rhs = boltzmann_weight(to_state, couplings) * - proposal_probability(to_state, descriptor.inverse) * - acceptance_probability(to_state, from_state, - descriptor.inverse, couplings); - auto const residual = std::abs(lhs - rhs); - auto const scale = std::max({std::abs(lhs), std::abs(rhs), 1.0L}); + auto const forward_q = + proposal_probability(from_state, descriptor.move); + auto const reverse_q = + proposal_probability(to_state, descriptor.inverse); + auto const forward_acceptance = + acceptance_probability(from_state, to_state, descriptor.move, + couplings); + auto const reverse_acceptance = + acceptance_probability(to_state, from_state, descriptor.inverse, + couplings); + auto const from_log_weight = log_boltzmann_weight(from_state, couplings); + auto const to_log_weight = log_boltzmann_weight(to_state, couplings); + auto const min_log = + std::log(std::numeric_limits::min()); + auto const max_log = + std::log(std::numeric_limits::max()); + if (!std::isfinite(from_log_weight) || + !std::isfinite(to_log_weight) || from_log_weight <= min_log || + to_log_weight <= min_log || from_log_weight >= max_log || + to_log_weight >= max_log || forward_q <= 0.0L || + reverse_q <= 0.0L || forward_acceptance <= 0.0L || + reverse_acceptance <= 0.0L || + !std::isfinite(forward_acceptance) || + !std::isfinite(reverse_acceptance)) + { + report.passed = false; + report.errors.emplace_back( + "Detailed-balance transition has non-finite or underflowed " + "weight."); + continue; + } + + // Both sides use the same Metropolis-Hastings acceptance rule as the + // sampler, so this check is an algebraic detailed-balance identity + // evaluated in log space to avoid overflow and silent underflow. + auto const log_lhs = from_log_weight + std::log(forward_q) + + std::log(forward_acceptance); + auto const log_rhs = to_log_weight + std::log(reverse_q) + + std::log(reverse_acceptance); + if (!std::isfinite(log_lhs) || !std::isfinite(log_rhs)) + { + report.passed = false; + report.errors.emplace_back( + "Detailed-balance transition has non-finite log weight."); + continue; + } + if (log_lhs <= min_log || log_rhs <= min_log || log_lhs >= max_log || + log_rhs >= max_log) + { + report.passed = false; + report.errors.emplace_back( + "Detailed-balance transition weight cannot be represented " + "without underflow or overflow."); + continue; + } + auto const residual = std::abs(log_lhs - log_rhs); report.edges.push_back(DetailedBalanceEdge4D{ - from_hash, to_hash, descriptor.move, lhs, rhs, residual}); - if (residual / scale > tolerance) + from_hash, to_hash, descriptor.move, std::exp(log_lhs), + std::exp(log_rhs), residual}); + if (residual > tolerance) { report.passed = false; - report.errors.emplace_back("Detailed-balance residual exceeds tolerance."); + report.errors.emplace_back( + "Detailed-balance log residual exceeds tolerance."); } } } diff --git a/include/Ergodic_moves_4.hpp b/include/Ergodic_moves_4.hpp index f37e58afe..e313f6cf3 100644 --- a/include/Ergodic_moves_4.hpp +++ b/include/Ergodic_moves_4.hpp @@ -9,6 +9,7 @@ #define CDT_PLUSPLUS_ERGODIC_MOVES_4_HPP #include +#include #include #include "Foliated_triangulation_4.hpp" @@ -30,10 +31,14 @@ namespace cdt::four_d::moves move_tracker::MoveType4D const move) -> ExpectedMove { - auto candidate = before; auto const forward = before.candidate_multiplicity(move); auto const reverse_move = move_tracker::reverse_move(move); - if (forward <= 0 || !candidate.apply_move(move)) + if (forward <= 0) + { + return std::unexpected("4D move is not applicable."); + } + auto candidate = before; + if (!candidate.apply_move(move)) { return std::unexpected("4D move is not applicable."); } @@ -56,7 +61,7 @@ namespace cdt::four_d::moves after_counts.N23 - before_counts.N23, after_counts.N14 - before_counts.N14}; - return MoveApplication{candidate, move, delta, forward, reverse}; + return MoveApplication{std::move(candidate), move, delta, forward, reverse}; } [[nodiscard]] inline auto do_24_move(FoliatedTriangulation4 const& before) diff --git a/include/Foliated_triangulation_4.hpp b/include/Foliated_triangulation_4.hpp index 5c264adf1..4ef292e4e 100644 --- a/include/Foliated_triangulation_4.hpp +++ b/include/Foliated_triangulation_4.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -79,6 +80,7 @@ namespace cdt::four_d bool m_periodic{true}; VertexContainer m_vertices; SimplexContainer m_simplices; + std::unordered_map m_vertex_times; S4Counts m_counts; ProposalInventory4D m_proposal_inventory; Profile m_spatial_profile; @@ -87,9 +89,17 @@ namespace cdt::four_d [[nodiscard]] auto vertex_time(VertexId const id) const -> Int_precision { - auto const it = std::ranges::find_if( - m_vertices, [&](auto const& vertex) { return vertex.id == id; }); - return it == m_vertices.end() ? -1 : it->time; + auto const it = m_vertex_times.find(id); + return it == m_vertex_times.end() ? -1 : it->second; + } + + void rebuild_vertex_time_cache() + { + m_vertex_times.clear(); + for (auto const& vertex : m_vertices) + { + m_vertex_times[vertex.id] = vertex.time; + } } [[nodiscard]] auto are_adjacent_times(Int_precision const a, @@ -184,8 +194,12 @@ namespace cdt::four_d counts.N4 = static_cast(m_simplices.size()); std::set> edges; + std::set> timelike_edges; std::set> triangles; + std::set> mixed_triangles; std::set> tetrahedra; + std::set> spatial_tetrahedra; + std::set> timelike_tetrahedra; for (auto const& simplex : m_simplices) { @@ -205,6 +219,10 @@ namespace cdt::four_d simplex.vertices[static_cast(j)]}; std::ranges::sort(edge); edges.insert(edge); + if (vertex_time(edge[0]) != vertex_time(edge[1])) + { + timelike_edges.insert(edge); + } } } for (auto i = 0; i < 5; ++i) @@ -219,18 +237,44 @@ namespace cdt::four_d simplex.vertices[static_cast(k)]}; std::ranges::sort(triangle); triangles.insert(triangle); + std::set times; + for (auto const vertex : triangle) + { + times.insert(vertex_time(vertex)); + } + if (times.size() > 1) { mixed_triangles.insert(triangle); } } } } for (auto omitted = 0; omitted < 5; ++omitted) { - tetrahedra.insert(facet_vertices(simplex, omitted)); + auto const facet = facet_vertices(simplex, omitted); + tetrahedra.insert(facet); + std::set times; + for (auto const vertex : facet) { times.insert(vertex_time(vertex)); } + if (times.size() == 1) + { + spatial_tetrahedra.insert(facet); + } + else + { + timelike_tetrahedra.insert(facet); + } } } counts.N1 = static_cast(edges.size()); counts.N2 = static_cast(triangles.size()); counts.N3 = static_cast(tetrahedra.size()); + counts.class_resolved_proposals = true; + counts.spatial_tetrahedra = + static_cast(spatial_tetrahedra.size()); + counts.timelike_edges = + static_cast(timelike_edges.size()); + counts.mixed_triangles = + static_cast(mixed_triangles.size()); + counts.timelike_tetrahedra = + static_cast(timelike_tetrahedra.size()); return counts; } @@ -260,6 +304,178 @@ namespace cdt::four_d return profile; } + [[nodiscard]] auto spacelike_facets_by_slice() const + -> std::vector>> + { + std::vector>> facets( + static_cast(m_timeslices)); + std::set> seen; + for (auto const& simplex : m_simplices) + { + for (auto omitted = 0; omitted < 5; ++omitted) + { + auto const facet = facet_vertices(simplex, omitted); + if (!seen.insert(facet).second) { continue; } + std::set times; + for (auto const vertex : facet) { times.insert(vertex_time(vertex)); } + if (times.size() != 1) { continue; } + auto const time = *times.begin(); + if (time >= 0 && time < m_timeslices) + { + facets[static_cast(time)].insert(facet); + } + } + } + return facets; + } + + [[nodiscard]] auto derived_slice_euler_characteristics() const -> Profile + { + if (m_simplices.empty()) + { + return Profile(static_cast(m_timeslices), + m_closed_s3_slices ? static_cast(0) + : static_cast(1)); + } + + Profile result(static_cast(m_timeslices), 0); + auto const facets_by_slice = spacelike_facets_by_slice(); + for (std::size_t slice = 0; slice < facets_by_slice.size(); ++slice) + { + std::set vertices; + std::set> edges; + std::set> triangles; + for (auto const& facet : facets_by_slice[slice]) + { + for (auto const vertex : facet) { vertices.insert(vertex); } + for (auto i = 0; i < 4; ++i) + { + for (auto j = i + 1; j < 4; ++j) + { + auto edge = + std::array{facet[static_cast(i)], + facet[static_cast(j)]}; + std::ranges::sort(edge); + edges.insert(edge); + } + } + for (auto i = 0; i < 4; ++i) + { + for (auto j = i + 1; j < 4; ++j) + { + for (auto k = j + 1; k < 4; ++k) + { + auto triangle = + std::array{facet[static_cast(i)], + facet[static_cast(j)], + facet[static_cast(k)]}; + std::ranges::sort(triangle); + triangles.insert(triangle); + } + } + } + } + result[slice] = static_cast(vertices.size()) - + static_cast(edges.size()) + + static_cast(triangles.size()) - + static_cast( + facets_by_slice[slice].size()); + } + return result; + } + + [[nodiscard]] auto spatial_slices_are_connected() const -> bool + { + if (m_simplices.empty()) { return m_closed_s3_slices; } + auto const facets_by_slice = spacelike_facets_by_slice(); + for (auto const& facets : facets_by_slice) + { + if (facets.empty()) { continue; } + std::map, + std::vector>> + triangle_to_facets; + for (auto const& facet : facets) + { + for (auto omitted = 0; omitted < 4; ++omitted) + { + std::array triangle{}; + auto out = 0; + for (auto index = 0; index < 4; ++index) + { + if (index == omitted) { continue; } + triangle[static_cast(out++)] = + facet[static_cast(index)]; + } + std::ranges::sort(triangle); + triangle_to_facets[triangle].push_back(facet); + } + } + + std::set> visited; + std::queue> frontier; + frontier.push(*facets.begin()); + visited.insert(*facets.begin()); + while (!frontier.empty()) + { + auto const facet = frontier.front(); + frontier.pop(); + for (auto omitted = 0; omitted < 4; ++omitted) + { + std::array triangle{}; + auto out = 0; + for (auto index = 0; index < 4; ++index) + { + if (index == omitted) { continue; } + triangle[static_cast(out++)] = + facet[static_cast(index)]; + } + std::ranges::sort(triangle); + for (auto const& neighbor : triangle_to_facets[triangle]) + { + if (visited.insert(neighbor).second) { frontier.push(neighbor); } + } + } + } + if (visited.size() != facets.size()) { return false; } + } + return true; + } + + [[nodiscard]] auto simplex_neighbor_graph_connected() const -> bool + { + if (m_simplices.empty()) { return true; } + std::unordered_map simplex_by_id; + for (auto const& simplex : m_simplices) + { + simplex_by_id.emplace(simplex.id, &simplex); + } + std::set visited; + std::queue frontier; + frontier.push(m_simplices.front().id); + visited.insert(m_simplices.front().id); + while (!frontier.empty()) + { + auto const current = frontier.front(); + frontier.pop(); + auto const current_it = simplex_by_id.find(current); + if (current_it == simplex_by_id.end()) { continue; } + for (auto const& neighbor : current_it->second->neighbors) + { + if (!neighbor) { continue; } + if (visited.insert(*neighbor).second) { frontier.push(*neighbor); } + } + } + return visited.size() == m_simplices.size(); + } + + [[nodiscard]] auto topology_matches_closed_s3_slices() const -> bool + { + auto const eulers = derived_slice_euler_characteristics(); + return std::ranges::all_of(eulers, [](auto const chi) { + return chi == 0; + }) && (m_closed_s3_slices || spatial_slices_are_connected()); + } + void add_count_delta(S4Counts const& delta) { m_counts.N0 += delta.N0; @@ -271,9 +487,45 @@ namespace cdt::four_d m_counts.N32 += delta.N32; m_counts.N23 += delta.N23; m_counts.N14 += delta.N14; + m_counts.class_resolved_proposals = false; + m_counts.spatial_tetrahedra = 0; + m_counts.timelike_edges = 0; + m_counts.mixed_triangles = 0; + m_counts.timelike_tetrahedra = 0; m_proposal_inventory = proposal_inventory_from_counts(m_counts); } + [[nodiscard]] static auto spatial_profile_delta( + move_tracker::MoveType4D const move) -> Int_precision + { + using move_tracker::MoveType4D; + switch (move) + { + case MoveType4D::TWO_EIGHT: return 1; + case MoveType4D::EIGHT_TWO: return -1; + default: return 0; + } + } + + [[nodiscard]] auto apply_spatial_profile_delta( + Int_precision const delta) -> bool + { + if (delta == 0) { return true; } + if (m_spatial_profile.size() != static_cast(m_timeslices)) + { + return false; + } + if (delta > 0) + { + m_spatial_profile.front() += delta; + return true; + } + auto remaining = -delta; + if (m_spatial_profile.front() < remaining) { return false; } + m_spatial_profile.front() -= remaining; + return true; + } + [[nodiscard]] auto can_apply(S4Counts const& delta) const -> bool { auto const after = S4Counts{ @@ -290,7 +542,9 @@ namespace cdt::four_d void add_vertex(VertexId& next_vertex, Int_precision const time) { - m_vertices.push_back(Vertex4D{next_vertex++, time}); + m_vertices.push_back(Vertex4D{next_vertex, time}); + m_vertex_times[next_vertex] = time; + ++next_vertex; } void add_boundary_component(VertexId& next_vertex, SimplexId& next_simplex, @@ -350,7 +604,7 @@ namespace cdt::four_d FoliatedTriangulation4(Int_precision const timeslices, S4Counts counts, Profile profile) - : m_timeslices{timeslices} + : m_timeslices{std::max(2, timeslices)} , m_periodic{true} , m_counts{counts} , m_proposal_inventory{proposal_inventory_from_counts(m_counts)} @@ -370,6 +624,21 @@ namespace cdt::four_d return FoliatedTriangulation4{timeslices, counts, std::move(profile)}; } + [[nodiscard]] static auto from_checkpoint_state( + Int_precision const timeslices, S4Counts counts, Profile profile, + VertexContainer vertices, SimplexContainer simplices, + bool const three_three_forward) -> FoliatedTriangulation4 + { + FoliatedTriangulation4 result{timeslices, counts, std::move(profile)}; + result.m_vertices = std::move(vertices); + result.m_simplices = std::move(simplices); + result.m_three_three_forward = three_three_forward; + result.rebuild_vertex_time_cache(); + result.m_proposal_inventory = + proposal_inventory_from_counts(result.m_counts); + return result; + } + [[nodiscard]] static auto periodic_seed(Int_precision const timeslices) -> FoliatedTriangulation4 { @@ -378,6 +647,7 @@ namespace cdt::four_d result.m_periodic = true; result.m_vertices.clear(); result.m_simplices.clear(); + result.m_vertex_times.clear(); VertexId next_vertex = 1; SimplexId next_simplex = 1; @@ -395,6 +665,19 @@ namespace cdt::four_d proposal_inventory_from_counts(result.m_counts); result.m_spatial_profile = result.recompute_spatial_profile(); result.m_closed_s3_slices = true; + // The production 4D runner evolves abstract count/profile states. Keep + // the generated complex as an initializer only, so later moves cannot + // expose stale vertex or simplex topology. + result.m_vertices.clear(); + result.m_simplices.clear(); + result.m_vertex_times.clear(); + result.m_counts.class_resolved_proposals = false; + result.m_counts.spatial_tetrahedra = 0; + result.m_counts.timelike_edges = 0; + result.m_counts.mixed_triangles = 0; + result.m_counts.timelike_tetrahedra = 0; + result.m_proposal_inventory = + proposal_inventory_from_counts(result.m_counts); return result; } @@ -422,14 +705,19 @@ namespace cdt::four_d return m_proposal_inventory; } + [[nodiscard]] auto three_three_forward() const -> bool + { + return m_three_three_forward; + } + [[nodiscard]] auto has_closed_s3_slices() const -> bool { - return m_closed_s3_slices; + return m_closed_s3_slices && topology_matches_closed_s3_slices(); } [[nodiscard]] auto spatial_topology() const -> std::string_view { - return "S3"; + return has_closed_s3_slices() ? "S3" : "non-S3"; } [[nodiscard]] auto spacetime_topology() const -> std::string_view @@ -440,10 +728,7 @@ namespace cdt::four_d [[nodiscard]] auto slice_euler_characteristics() const -> std::vector { - return std::vector( - static_cast(m_timeslices), - m_closed_s3_slices ? static_cast(0) - : static_cast(1)); + return derived_slice_euler_characteristics(); } [[nodiscard]] auto spatial_volume_profile() const -> Profile @@ -592,6 +877,14 @@ namespace cdt::four_d } if (!is_applicable(move) || !can_apply(delta)) { return false; } add_count_delta(delta); + m_vertices.clear(); + m_simplices.clear(); + m_vertex_times.clear(); + if (!apply_spatial_profile_delta(spatial_profile_delta(move))) + { + *this = before; + return false; + } if (move == move_tracker::MoveType4D::THREE_THREE) { m_three_three_forward = !m_three_three_forward; @@ -631,6 +924,11 @@ namespace cdt::four_d { report.errors.emplace_back("Spatial slices are not marked as closed S3."); } + if (!topology_matches_closed_s3_slices()) + { + report.errors.emplace_back( + "Spatial slices are not validated as connected S3 slices."); + } for (auto const chi : slice_euler_characteristics()) { if (chi != 0) @@ -644,6 +942,25 @@ namespace cdt::four_d { report.errors.emplace_back("Spatial profile does not match timeslice count."); } + if (std::ranges::any_of(m_spatial_profile, [](auto const volume) { + return volume < 0; + })) + { + report.errors.emplace_back("Spatial profile contains negative volume."); + } + if (m_vertex_times.size() != m_vertices.size()) + { + report.errors.emplace_back("Vertex-time cache does not match vertices."); + } + for (auto const& vertex : m_vertices) + { + auto const cached = m_vertex_times.find(vertex.id); + if (cached == m_vertex_times.end() || cached->second != vertex.time) + { + report.errors.emplace_back("Vertex-time cache is stale."); + break; + } + } if (m_proposal_inventory.spatial_tetrahedra < 0 || m_proposal_inventory.timelike_edges < 0 || m_proposal_inventory.mixed_triangles < 0 || @@ -705,6 +1022,11 @@ namespace cdt::four_d } } + if (!m_closed_s3_slices && !simplex_neighbor_graph_connected()) + { + report.errors.emplace_back("Simplex neighbor graph is disconnected."); + } + if (m_periodic) { for (auto const& [_, incidence] : facet_incidence) @@ -730,6 +1052,7 @@ namespace cdt::four_d { vertex.time = (m_timeslices - vertex.time) % m_timeslices; } + reversed.rebuild_vertex_time_cache(); for (auto& simplex : reversed.m_simplices) { switch (simplex.type) @@ -752,19 +1075,32 @@ namespace cdt::four_d std::swap(reversed.m_counts.N32, reversed.m_counts.N23); reversed.m_proposal_inventory = proposal_inventory_from_counts(reversed.m_counts); - std::reverse(reversed.m_spatial_profile.begin(), - reversed.m_spatial_profile.end()); + if (m_spatial_profile.size() == static_cast(m_timeslices)) + { + Profile mapped(m_spatial_profile.size(), 0); + auto const slices = static_cast(m_timeslices); + for (std::size_t index = 0; index < slices; ++index) + { + mapped[(slices - index) % slices] = m_spatial_profile[index]; + } + reversed.m_spatial_profile = std::move(mapped); + } + reversed.m_three_three_forward = !m_three_three_forward; return reversed; } [[nodiscard]] auto canonical_hash() const -> std::string { std::ostringstream stream; - stream << "T=" << m_timeslices << ";P=" << m_periodic << ";"; + stream << "T=" << m_timeslices << ";P=" << m_periodic + << ";F=" << m_three_three_forward << ";"; stream << m_counts.N0 << ',' << m_counts.N1 << ',' << m_counts.N2 << ',' << m_counts.N3 << ',' << m_counts.N4 << ',' << m_counts.N41 << ',' << m_counts.N32 << ',' << m_counts.N23 << ',' - << m_counts.N14 << ";V="; + << m_counts.N14 << ";C=" << m_counts.class_resolved_proposals + << ',' << m_counts.spatial_tetrahedra << ',' + << m_counts.timelike_edges << ',' << m_counts.mixed_triangles + << ',' << m_counts.timelike_tetrahedra << ";V="; for (auto const volume : m_spatial_profile) { stream << volume << ','; } return stream.str(); } diff --git a/include/Metropolis_4.hpp b/include/Metropolis_4.hpp index f1e9e9466..2bb42304e 100644 --- a/include/Metropolis_4.hpp +++ b/include/Metropolis_4.hpp @@ -16,13 +16,17 @@ #include #include #include +#include #include #include +#include #include +#include #include #include #include "Ergodic_moves_4.hpp" +#include "Random.hpp" #include "Utilities.hpp" namespace cdt::four_d @@ -52,6 +56,7 @@ namespace cdt::four_d Int_precision thermalization_steps{0}; Int_precision measurement_interval{1}; Int_precision checkpoint_interval{0}; + std::filesystem::path checkpoint_directory; }; struct Metropolis4Result @@ -66,12 +71,24 @@ namespace cdt::four_d class Metropolis4 { Metropolis4Config m_config; - pcg64 m_rng; + cdt::Random m_rng; + + [[nodiscard]] static auto transition_stream_for_chain( + std::string const& chain_id) -> cdt::RandomStream + { + auto value = 14695981039346656037ULL ^ + cdt::random_streams::transitions.value(); + for (auto const character : chain_id) + { + value ^= static_cast(character); + value *= 1099511628211ULL; + } + return cdt::RandomStream{value}; + } [[nodiscard]] auto propose_move() -> move_tracker::MoveType4D { - std::uniform_int_distribution distribution(0, 6); - return move_tracker::as_move_4d(distribution(m_rng)); + return move_tracker::generate_random_move_4(m_rng); } [[nodiscard]] auto draw_probability() -> long double @@ -100,7 +117,11 @@ namespace cdt::four_d { stream << counts.N0 << ' ' << counts.N1 << ' ' << counts.N2 << ' ' << counts.N3 << ' ' << counts.N4 << ' ' << counts.N41 << ' ' - << counts.N32 << ' ' << counts.N23 << ' ' << counts.N14 << '\n'; + << counts.N32 << ' ' << counts.N23 << ' ' << counts.N14 << ' ' + << counts.class_resolved_proposals << ' ' + << counts.spatial_tetrahedra << ' ' << counts.timelike_edges + << ' ' << counts.mixed_triangles << ' ' + << counts.timelike_tetrahedra << '\n'; } static auto read_counts(std::istream& stream) -> S4Counts @@ -108,15 +129,34 @@ namespace cdt::four_d S4Counts counts; stream >> counts.N0 >> counts.N1 >> counts.N2 >> counts.N3 >> counts.N4 >> counts.N41 >> counts.N32 >> counts.N23 >> counts.N14; + stream >> counts.class_resolved_proposals >> + counts.spatial_tetrahedra >> counts.timelike_edges >> + counts.mixed_triangles >> counts.timelike_tetrahedra; + if (!stream) + { + throw std::runtime_error{"Malformed checkpoint counts."}; + } return counts; } + static void require_label(std::istream& stream, std::string_view expected) + { + std::string label; + stream >> label; + if (!stream || label != expected) + { + throw std::runtime_error{"Malformed checkpoint: expected " + + std::string{expected} + "."}; + } + } + public: Metropolis4() : Metropolis4(Metropolis4Config{}) {} explicit Metropolis4(Metropolis4Config config) : m_config{std::move(config)} - , m_rng{m_config.seed} + , m_rng{cdt::RandomSeed{m_config.seed}, + transition_stream_for_chain(m_config.chain_id)} {} [[nodiscard]] auto config() const -> Metropolis4Config const& @@ -126,15 +166,12 @@ namespace cdt::four_d [[nodiscard]] auto rng_state() const -> std::string { - std::ostringstream stream; - stream << m_rng; - return stream.str(); + return m_rng.engine_state(); } void set_rng_state(std::string const& state) { - std::istringstream stream(state); - stream >> m_rng; + m_rng.set_engine_state(state); } [[nodiscard]] auto run(FoliatedTriangulation4 initial, @@ -179,6 +216,13 @@ namespace cdt::four_d result.triangulation.is_valid(), result.triangulation.spatial_volume_profile()}); } + if (m_config.checkpoint_interval > 0 && + !m_config.checkpoint_directory.empty() && + step % m_config.checkpoint_interval == 0) + { + save_checkpoint(m_config.checkpoint_directory, + result.triangulation, step); + } } return result; @@ -189,7 +233,14 @@ namespace cdt::four_d Int_precision const step) const { std::filesystem::create_directories(directory); - std::ofstream file(directory / "state.txt"); + auto const target = directory / "state.txt"; + auto const temporary = directory / "state.txt.tmp"; + std::ofstream file(temporary); + if (!file) + { + throw std::runtime_error{"Failed to open checkpoint: " + + temporary.string()}; + } file << "step " << step << '\n'; file << "timeslices " << triangulation.timeslices() << '\n'; file << "counts "; @@ -198,30 +249,113 @@ namespace cdt::four_d file << "profile " << profile.size(); for (auto const value : profile) { file << ' ' << value; } file << '\n'; + file << "three_three_forward " << triangulation.three_three_forward() + << '\n'; + file << "vertices " << triangulation.vertices().size() << '\n'; + for (auto const& vertex : triangulation.vertices()) + { + file << "vertex " << vertex.id << ' ' << vertex.time << '\n'; + } + file << "simplices " << triangulation.simplices().size() << '\n'; + for (auto const& simplex : triangulation.simplices()) + { + file << "simplex " << simplex.id << ' ' + << static_cast(simplex.type); + for (auto const vertex : simplex.vertices) { file << ' ' << vertex; } + for (auto const& neighbor : simplex.neighbors) + { + file << ' ' << neighbor.value_or(SimplexId{0}); + } + file << '\n'; + } file << "rng " << rng_state() << '\n'; + file.close(); + if (!file) + { + throw std::runtime_error{"Failed to write checkpoint: " + + temporary.string()}; + } + std::filesystem::remove(target); + std::filesystem::rename(temporary, target); } [[nodiscard]] auto load_checkpoint( std::filesystem::path const& directory) -> std::pair { - std::ifstream file(directory / "state.txt"); - std::string label; + auto const path = directory / "state.txt"; + std::ifstream file(path); + if (!file) + { + throw std::runtime_error{"Failed to open checkpoint: " + + path.string()}; + } Int_precision step{0}; Int_precision timeslices{0}; - file >> label >> step; - file >> label >> timeslices; - file >> label; + require_label(file, "step"); + file >> step; + require_label(file, "timeslices"); + file >> timeslices; + require_label(file, "counts"); auto counts = read_counts(file); std::size_t profile_size{0}; - file >> label >> profile_size; + require_label(file, "profile"); + file >> profile_size; FoliatedTriangulation4::Profile profile(profile_size); for (auto& value : profile) { file >> value; } - file >> label; + if (!file) + { + throw std::runtime_error{"Malformed checkpoint profile."}; + } + require_label(file, "three_three_forward"); + bool three_three_forward{true}; + file >> three_three_forward; + require_label(file, "vertices"); + std::size_t vertex_count{0}; + file >> vertex_count; + FoliatedTriangulation4::VertexContainer vertices; + vertices.reserve(vertex_count); + for (std::size_t index = 0; index < vertex_count; ++index) + { + require_label(file, "vertex"); + Vertex4D vertex; + file >> vertex.id >> vertex.time; + vertices.push_back(vertex); + } + require_label(file, "simplices"); + std::size_t simplex_count{0}; + file >> simplex_count; + FoliatedTriangulation4::SimplexContainer simplices; + simplices.reserve(simplex_count); + for (std::size_t index = 0; index < simplex_count; ++index) + { + require_label(file, "simplex"); + Simplex4D simplex; + auto type = 0; + file >> simplex.id >> type; + simplex.type = static_cast(type); + for (auto& vertex : simplex.vertices) { file >> vertex; } + for (auto& neighbor : simplex.neighbors) + { + SimplexId neighbor_id{0}; + file >> neighbor_id; + neighbor = neighbor_id == 0 ? std::optional{} + : std::optional{neighbor_id}; + } + simplices.push_back(simplex); + } + require_label(file, "rng"); std::string state; std::getline(file, state); if (!state.empty() && state.front() == ' ') { state.erase(state.begin()); } set_rng_state(state); - return {FoliatedTriangulation4{timeslices, counts, profile}, step}; + if (!file && state.empty()) + { + throw std::runtime_error{"Malformed checkpoint rng state."}; + } + return {FoliatedTriangulation4::from_checkpoint_state( + timeslices, counts, std::move(profile), std::move(vertices), + std::move(simplices), three_three_forward), + step}; } }; } // namespace cdt::four_d diff --git a/include/Move_catalog_4.hpp b/include/Move_catalog_4.hpp index 5fff437b6..c77678fe4 100644 --- a/include/Move_catalog_4.hpp +++ b/include/Move_catalog_4.hpp @@ -182,6 +182,17 @@ namespace cdt::four_d [[nodiscard]] inline auto proposal_inventory_from_counts(S4Counts const& counts) -> ProposalInventory4D { + if (counts.class_resolved_proposals) + { + return ProposalInventory4D{ + counts.spatial_tetrahedra, + counts.timelike_edges, + counts.mixed_triangles, + counts.timelike_tetrahedra, + counts.N0, + counts.N32, + counts.N23}; + } return ProposalInventory4D{ counts.N3, counts.N1, diff --git a/include/Phase_analysis.hpp b/include/Phase_analysis.hpp index 708064ea6..dc97bc2e3 100644 --- a/include/Phase_analysis.hpp +++ b/include/Phase_analysis.hpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -23,6 +25,10 @@ namespace cdt::four_d::phase { using Profile = std::vector; + inline constexpr long double collapse_peak_ratio = 0.70L; + inline constexpr long double alternating_ratio_limit = 0.30L; + inline constexpr long double cos3_correlation_limit = 0.85L; + enum class Verdict { c_ds_supported, @@ -107,6 +113,10 @@ namespace cdt::four_d::phase Profile result(profiles.front().size(), 0.0L); for (auto const& profile : profiles) { + if (profile.size() != result.size()) + { + throw std::invalid_argument{"mean: profiles must have equal size."}; + } for (std::size_t index = 0; index < result.size(); ++index) { result[index] += profile[index]; @@ -128,6 +138,11 @@ namespace cdt::four_d::phase if (profiles.size() < 2) { return result; } for (auto const& profile : profiles) { + if (profile.size() != profile_mean.size()) + { + throw std::invalid_argument{ + "covariance: profiles must match the mean size."}; + } for (std::size_t i = 0; i < profile_mean.size(); ++i) { for (std::size_t j = 0; j < profile_mean.size(); ++j) @@ -220,6 +235,41 @@ namespace cdt::four_d::phase return denominator == 0.0L ? 0.0L : numerator / denominator; } + [[nodiscard]] inline auto log_log_residual( + std::vector> const& samples, + long double const exponent) -> long double + { + std::vector> positive_samples; + positive_samples.reserve(samples.size()); + for (auto const& sample : samples) + { + if (sample.first > 0.0L && sample.second > 0.0L) + { + positive_samples.push_back(sample); + } + } + if (positive_samples.size() < 2) + { + return std::numeric_limits::infinity(); + } + + auto intercept = 0.0L; + for (auto const& [x, y] : positive_samples) + { + intercept += std::log(y) - exponent * std::log(x); + } + intercept /= static_cast(positive_samples.size()); + + auto residual = 0.0L; + for (auto const& [x, y] : positive_samples) + { + auto const difference = + std::log(y) - (intercept + exponent * std::log(x)); + residual += difference * difference; + } + return residual; + } + [[nodiscard]] inline auto analyze_finite_size_scaling( std::vector> profiles_by_volume) -> FiniteSizeScalingReport @@ -238,6 +288,9 @@ namespace cdt::four_d::phase fit_power_law_exponent(peaks), 0.0L, false}; + report.collapse_error = + log_log_residual(widths, report.width_exponent) + + log_log_residual(peaks, report.peak_exponent); report.passed = std::abs(report.width_exponent - 0.25L) < 0.08L && std::abs(report.peak_exponent - 0.75L) < 0.08L; return report; @@ -289,6 +342,27 @@ namespace cdt::four_d::phase return result; } + [[nodiscard]] inline auto classify_profile_shape(Profile const& profile, + long double const total) + -> std::optional + { + if (total == 0.0L) { return Verdict::thermalization_failed; } + + auto const peak = *std::ranges::max_element(profile) / total; + if (peak > collapse_peak_ratio) { return Verdict::collapsed_like; } + + auto alternating = 0.0L; + for (std::size_t index = 0; index < profile.size(); ++index) + { + alternating += (index % 2 == 0 ? 1.0L : -1.0L) * profile[index]; + } + if (std::abs(alternating / total) > alternating_ratio_limit) + { + return Verdict::c_b_like; + } + return std::nullopt; + } + [[nodiscard]] inline auto diagnose_c_ds_finite_size( std::vector> profiles_by_volume, std::size_t const effective_sample_count) -> CdsValidationReport @@ -307,26 +381,9 @@ namespace cdt::four_d::phase auto profile = centered(original_profile); auto const total = std::accumulate(profile.begin(), profile.end(), 0.0L); - if (total == 0.0L) - { - report.verdict = Verdict::thermalization_failed; - return report; - } - auto const peak = *std::ranges::max_element(profile) / total; - if (peak > 0.70L) - { - report.verdict = Verdict::collapsed_like; - return report; - } - - auto alternating = 0.0L; - for (std::size_t index = 0; index < profile.size(); ++index) - { - alternating += (index % 2 == 0 ? 1.0L : -1.0L) * profile[index]; - } - if (std::abs(alternating / total) > 0.30L) + if (auto const verdict = classify_profile_shape(profile, total)) { - report.verdict = Verdict::c_b_like; + report.verdict = *verdict; return report; } @@ -339,7 +396,8 @@ namespace cdt::four_d::phase report.scaling = analyze_finite_size_scaling(std::move(profiles_by_volume)); report.verdict = report.scaling.passed && - report.minimum_profile_correlation > 0.85L + report.minimum_profile_correlation > + cos3_correlation_limit ? Verdict::c_ds_supported : Verdict::no_phase_classification; return report; @@ -384,43 +442,27 @@ namespace cdt::four_d::phase auto const total = std::accumulate(diagnostics.mean_profile.begin(), diagnostics.mean_profile.end(), 0.0L); - if (total == 0.0L) - { - diagnostics.verdict = Verdict::thermalization_failed; - return diagnostics; - } - auto const peak = - *std::ranges::max_element(diagnostics.mean_profile) / total; - if (peak > 0.70L) - { - diagnostics.verdict = Verdict::collapsed_like; - return diagnostics; - } - - auto alternating = 0.0L; - for (std::size_t index = 0; index < diagnostics.mean_profile.size(); - ++index) - { - alternating += (index % 2 == 0 ? 1.0L : -1.0L) * - diagnostics.mean_profile[index]; - } - if (std::abs(alternating / total) > 0.30L) + if (auto const verdict = + classify_profile_shape(diagnostics.mean_profile, total)) { - diagnostics.verdict = Verdict::c_b_like; + diagnostics.verdict = *verdict; return diagnostics; } auto const reference = cos3_reference(diagnostics.mean_profile.size()); auto const cos3_corr = profile_correlation(diagnostics.mean_profile, reference); + // These are heuristic profile-shape scores derived from the cos^3 + // correlation, not formal likelihood, AIC, or BIC statistics. diagnostics.held_out_likelihood = cos3_corr; diagnostics.aic = -2.0L * cos3_corr + 2.0L * 4.0L; diagnostics.bic = -2.0L * cos3_corr + std::log(static_cast(diagnostics.mean_profile.size())) * 4.0L; - diagnostics.verdict = cos3_corr > 0.85L ? Verdict::c_ds_supported - : Verdict::no_phase_classification; + diagnostics.verdict = cos3_corr > cos3_correlation_limit + ? Verdict::c_ds_supported + : Verdict::no_phase_classification; return diagnostics; } } // namespace cdt::four_d::phase diff --git a/include/Random.hpp b/include/Random.hpp index 83de00db9..70456ef62 100644 --- a/include/Random.hpp +++ b/include/Random.hpp @@ -12,9 +12,13 @@ #include #include +#include #include #include #include +#include +#include +#include #include "pcg_random.hpp" @@ -139,6 +143,23 @@ namespace cdt [[nodiscard]] auto stream() const noexcept -> RandomStream { return m_stream; } + [[nodiscard]] auto engine_state() const -> std::string + { + std::ostringstream output; + output << m_engine; + return output.str(); + } + + void set_engine_state(std::string const& state) + { + std::istringstream input(state); + input >> m_engine; + if (!input) + { + throw std::runtime_error{"Invalid random engine state."}; + } + } + /// @brief Create a fresh reproducible stream from the same root seed. [[nodiscard]] auto split(RandomStream const stream) const -> Random { return Random{m_seed, stream}; } diff --git a/include/Runtime_config.hpp b/include/Runtime_config.hpp index 19dc54b52..ebbedcac3 100644 --- a/include/Runtime_config.hpp +++ b/include/Runtime_config.hpp @@ -10,13 +10,17 @@ #ifndef CDT_PLUSPLUS_RUNTIME_CONFIG_HPP #define CDT_PLUSPLUS_RUNTIME_CONFIG_HPP +#include #include #include +#include #include #include #include +#include #include +#include "Metropolis_4.hpp" #include "Random.hpp" #include "Utilities.hpp" @@ -151,6 +155,84 @@ namespace cdt::runtime_config { return m_write_files; } }; + class Simulation4D + { + friend auto make_4d_simulation( + bool spherical, bool toroidal, long long simplices, + long long timeslices, long long dimensions, long double kappa_0, + long double kappa_4, long double Delta, long long target_N4, + long double volume_epsilon, long long passes, long long checkpoint, + long long thermalization, long long measurement_interval, + cdt::RandomSeed seed, std::string chain_id, std::string run_id, + std::filesystem::path output_dir, long long threads, bool write_files) + -> Simulation4D; + + Int_precision m_simplices; + Int_precision m_timeslices; + Int_precision m_passes; + Int_precision m_sweep_size; + Int_precision m_steps; + Int_precision m_checkpoint_steps; + four_d::Metropolis4Config m_metropolis; + std::string m_run_id; + std::filesystem::path m_output_dir; + bool m_write_files; + + explicit Simulation4D(Int_precision const simplices, + Int_precision const timeslices, + Int_precision const passes, + Int_precision const sweep_size, + Int_precision const steps, + Int_precision const checkpoint_steps, + four_d::Metropolis4Config metropolis, + std::string run_id, + std::filesystem::path output_dir, + bool const write_files) noexcept + : m_simplices{simplices} + , m_timeslices{timeslices} + , m_passes{passes} + , m_sweep_size{sweep_size} + , m_steps{steps} + , m_checkpoint_steps{checkpoint_steps} + , m_metropolis{std::move(metropolis)} + , m_run_id{std::move(run_id)} + , m_output_dir{std::move(output_dir)} + , m_write_files{write_files} + {} + + public: + [[nodiscard]] auto simplices() const noexcept -> Int_precision + { return m_simplices; } + + [[nodiscard]] auto timeslices() const noexcept -> Int_precision + { return m_timeslices; } + + [[nodiscard]] auto passes() const noexcept -> Int_precision + { return m_passes; } + + [[nodiscard]] auto sweep_size() const noexcept -> Int_precision + { return m_sweep_size; } + + [[nodiscard]] auto steps() const noexcept -> Int_precision + { return m_steps; } + + [[nodiscard]] auto checkpoint_steps() const noexcept -> Int_precision + { return m_checkpoint_steps; } + + [[nodiscard]] auto metropolis() const noexcept + -> four_d::Metropolis4Config const& + { return m_metropolis; } + + [[nodiscard]] auto run_id() const noexcept -> std::string const& + { return m_run_id; } + + [[nodiscard]] auto output_dir() const noexcept -> std::filesystem::path const& + { return m_output_dir; } + + [[nodiscard]] auto write_files() const noexcept -> bool + { return m_write_files; } + }; + namespace detail { template @@ -201,6 +283,18 @@ namespace cdt::runtime_config return static_cast(value); } + inline void validate_path_component(std::string_view const name, + std::string_view const value) + { + if (value.empty() || value == "." || value == ".." || + value.find_first_of("/\\") != std::string_view::npos || + std::filesystem::path{std::string{value}}.is_absolute()) + { + throw std::invalid_argument(std::string{name} + + " must be a single path component."); + } + } + [[nodiscard]] inline auto select_topology(bool const spherical, bool const toroidal) -> Topology { @@ -339,6 +433,137 @@ namespace cdt::runtime_config checked_lambda, checked_passes, checked_checkpoint, write_files}; } + + [[nodiscard]] inline auto make_4d_simulation( + bool const spherical, bool const toroidal, long long const simplices, + long long const timeslices, long long const dimensions, + long double const kappa_0, long double const kappa_4, + long double const Delta, long long const target_N4, + long double const volume_epsilon, long long const passes, + long long const checkpoint, long long const thermalization, + long long const measurement_interval, cdt::RandomSeed const seed, + std::string chain_id, std::string run_id, + std::filesystem::path output_dir, long long const threads, + bool const write_files) -> Simulation4D + { + [[maybe_unused]] auto const topology = + detail::select_topology(spherical, toroidal); + auto const checked_dimensions = + detail::checked_int("Dimensionality", dimensions); + if (checked_dimensions != 4) + { + throw std::invalid_argument( + "Only four-dimensional triangulations are supported here."); + } + + auto const checked_threads = detail::checked_int("Thread count", threads); + if (checked_threads <= 0) + { + throw std::invalid_argument("Thread count must be positive."); + } + if (checked_threads != 1) + { + throw std::invalid_argument( + "4D runs do not use --threads; pass --threads 1."); + } + + auto const checked_simplices = + detail::checked_int("Number of simplices", simplices); + auto const checked_timeslices = + detail::checked_int("Number of timeslices", timeslices); + auto const checked_passes = detail::checked_int("Passes", passes); + auto const checked_checkpoint = + detail::checked_int("Checkpoint interval", checkpoint); + auto const checked_target = detail::checked_int("Target N4", target_N4); + auto const checked_thermalization = + detail::checked_int("Thermalization steps", thermalization); + auto const checked_measurement_interval = + detail::checked_int("Measurement interval", measurement_interval); + + if (checked_simplices < 2 || checked_timeslices < 2) + { + throw std::invalid_argument( + "Simplices and timeslices must each be at least 2."); + } + if (checked_passes <= 0) + { + throw std::invalid_argument("Passes must be positive."); + } + if (checked_checkpoint <= 0) + { + throw std::invalid_argument("Checkpoint interval must be positive."); + } + if (checked_target < 0) + { + throw std::invalid_argument("Target N4 must be non-negative."); + } + if (checked_thermalization < 0) + { + throw std::invalid_argument("Thermalization steps must be non-negative."); + } + if (checked_measurement_interval <= 0) + { + throw std::invalid_argument("Measurement interval must be positive."); + } + + auto const checked_kappa_0 = + detail::checked_finite("Kappa0", kappa_0); + auto const checked_kappa_4 = + detail::checked_finite("Kappa4", kappa_4); + auto const checked_delta = detail::checked_finite("Delta", Delta); + auto const checked_volume_epsilon = + detail::checked_finite("Volume epsilon", volume_epsilon); + if (checked_volume_epsilon < 0.0L) + { + throw std::invalid_argument("Volume epsilon must be non-negative."); + } + + detail::validate_path_component("run_id", run_id); + detail::validate_path_component("chain_id", chain_id); + auto const fixed_target = + checked_target > 0 ? checked_target : checked_simplices; + auto const sweep_size = std::max(1, checked_simplices); + if (checked_passes > + std::numeric_limits::max() / sweep_size) + { + throw std::out_of_range("4D pass count overflows move-attempt steps."); + } + if (checked_checkpoint > + std::numeric_limits::max() / sweep_size) + { + throw std::out_of_range( + "4D checkpoint interval overflows move-attempt steps."); + } + auto const steps = checked_passes * sweep_size; + auto const checkpoint_steps = checked_checkpoint * sweep_size; + + four_d::Metropolis4Config metropolis; + metropolis.seed = seed.value(); + metropolis.chain_id = std::move(chain_id); + metropolis.thermalization_steps = checked_thermalization; + metropolis.measurement_interval = checked_measurement_interval; + metropolis.checkpoint_interval = checkpoint_steps; + metropolis.couplings = four_d::S4Couplings{checked_kappa_0, + checked_kappa_4, + checked_delta, + fixed_target, + checked_volume_epsilon}; + if (write_files) + { + metropolis.checkpoint_directory = + output_dir / run_id / "checkpoint"; + } + return Simulation4D{checked_simplices, + checked_timeslices, + checked_passes, + sweep_size, + steps, + checkpoint_steps, + std::move(metropolis), + std::move(run_id), + std::move(output_dir), + write_files}; + } } // namespace cdt::runtime_config #endif // CDT_PLUSPLUS_RUNTIME_CONFIG_HPP diff --git a/include/S4Action.hpp b/include/S4Action.hpp index 204344977..f086d7834 100644 --- a/include/S4Action.hpp +++ b/include/S4Action.hpp @@ -26,6 +26,11 @@ namespace cdt::four_d Int_precision N32{0}; Int_precision N23{0}; Int_precision N14{0}; + bool class_resolved_proposals{false}; + Int_precision spatial_tetrahedra{0}; + Int_precision timelike_edges{0}; + Int_precision mixed_triangles{0}; + Int_precision timelike_tetrahedra{0}; }; struct S4Couplings diff --git a/include/Simulation_output.hpp b/include/Simulation_output.hpp index 6761a5243..b8dc3b4ea 100644 --- a/include/Simulation_output.hpp +++ b/include/Simulation_output.hpp @@ -11,7 +11,13 @@ #include #include #include +#include +#include +#include +#include #include +#include +#include #include #include "Metropolis_4.hpp" @@ -27,12 +33,73 @@ namespace cdt::four_d::output std::string compiler{"unknown"}; }; - inline void write_counts_csv_header(std::ofstream& file) + inline void set_numeric_precision(std::ostream& file) + { + file << std::setprecision(std::numeric_limits::max_digits10); + } + + [[nodiscard]] inline auto json_escape(std::string_view const text) + -> std::string + { + std::string escaped; + escaped.reserve(text.size()); + for (auto const character : text) + { + switch (character) + { + case '"': escaped += "\\\""; break; + case '\\': escaped += "\\\\"; break; + case '\b': escaped += "\\b"; break; + case '\f': escaped += "\\f"; break; + case '\n': escaped += "\\n"; break; + case '\r': escaped += "\\r"; break; + case '\t': escaped += "\\t"; break; + default: escaped += character; break; + } + } + return escaped; + } + + inline void validate_path_component(std::string_view const name, + std::string_view const value) + { + if (value.empty() || value == "." || value == ".." || + value.find_first_of("/\\") != std::string_view::npos || + std::filesystem::path{std::string{value}}.is_absolute()) + { + throw std::invalid_argument(std::string{name} + + " must be a single path component."); + } + } + + [[nodiscard]] inline auto open_output_file(std::filesystem::path const& path) + -> std::ofstream + { + std::ofstream file(path); + if (!file) + { + throw std::runtime_error("Failed to open output file: " + path.string()); + } + set_numeric_precision(file); + return file; + } + + inline void ensure_wrote(std::ostream const& file, + std::filesystem::path const& path) + { + if (!file) + { + throw std::runtime_error("Failed to write output file: " + + path.string()); + } + } + + inline void write_counts_csv_header(std::ostream& file) { file << "step,N0,N1,N2,N3,N4,N41,N32,N23,N14\n"; } - inline void write_counts_csv_row(std::ofstream& file, Int_precision step, + inline void write_counts_csv_row(std::ostream& file, Int_precision step, S4Counts const& counts) { file << step << ',' << counts.N0 << ',' << counts.N1 << ',' << counts.N2 @@ -45,19 +112,25 @@ namespace cdt::four_d::output Metropolis4Config const& config, Metropolis4Result const& result) { + validate_path_component("run_id", manifest.run_id); auto const run_dir = root / manifest.run_id; std::filesystem::create_directories(run_dir / "checkpoint"); { - std::ofstream file(run_dir / "manifest.json"); + auto const path = run_dir / "manifest.json"; + auto file = open_output_file(path); file << "{\n"; file << " \"version\": 1,\n"; - file << " \"run_id\": \"" << manifest.run_id << "\",\n"; - file << " \"git_commit\": \"" << manifest.git_commit << "\",\n"; - file << " \"build_type\": \"" << manifest.build_type << "\",\n"; - file << " \"compiler\": \"" << manifest.compiler << "\",\n"; + file << " \"run_id\": \"" << json_escape(manifest.run_id) << "\",\n"; + file << " \"git_commit\": \"" << json_escape(manifest.git_commit) + << "\",\n"; + file << " \"build_type\": \"" << json_escape(manifest.build_type) + << "\",\n"; + file << " \"compiler\": \"" << json_escape(manifest.compiler) + << "\",\n"; file << " \"seed\": " << config.seed << ",\n"; - file << " \"chain_id\": \"" << config.chain_id << "\",\n"; + file << " \"chain_id\": \"" << json_escape(config.chain_id) + << "\",\n"; file << " \"kappa_0\": " << config.couplings.kappa_0 << ",\n"; file << " \"kappa_4\": " << config.couplings.kappa_4 << ",\n"; file << " \"Delta\": " << config.couplings.Delta << ",\n"; @@ -71,10 +144,12 @@ namespace cdt::four_d::output file << " \"measurement_interval\": " << config.measurement_interval << "\n"; file << "}\n"; + ensure_wrote(file, path); } { - std::ofstream file(run_dir / "measurements.jsonl"); + auto const path = run_dir / "measurements.jsonl"; + auto file = open_output_file(path); for (auto const& measurement : result.measurements) { file << "{\"step\":" << measurement.step << ",\"action\":" @@ -83,20 +158,40 @@ namespace cdt::four_d::output << ",\"valid\":" << (measurement.valid ? "true" : "false") << "}\n"; } + ensure_wrote(file, path); } { - std::ofstream file(run_dir / "spatial_volume.csv"); + auto const path = run_dir / "spatial_volume.csv"; + auto file = open_output_file(path); file << "timeslice,N3\n"; auto const profile = result.triangulation.spatial_volume_profile(); for (std::size_t index = 0; index < profile.size(); ++index) { file << index << ',' << profile[index] << '\n'; } + ensure_wrote(file, path); } { - std::ofstream file(run_dir / "simplex_counts.csv"); + auto const path = run_dir / "spatial_volume_profiles.csv"; + auto file = open_output_file(path); + file << "step,timeslice,N3\n"; + for (auto const& measurement : result.measurements) + { + for (std::size_t index = 0; index < measurement.spatial_profile.size(); + ++index) + { + file << measurement.step << ',' << index << ',' + << measurement.spatial_profile[index] << '\n'; + } + } + ensure_wrote(file, path); + } + + { + auto const path = run_dir / "simplex_counts.csv"; + auto file = open_output_file(path); write_counts_csv_header(file); for (auto const& measurement : result.measurements) { @@ -106,27 +201,40 @@ namespace cdt::four_d::output { write_counts_csv_row(file, 0, result.triangulation.counts()); } + ensure_wrote(file, path); } { - std::ofstream file(run_dir / "move_statistics.csv"); + auto const path = run_dir / "move_statistics.csv"; + auto file = open_output_file(path); file << "move,attempted,accepted,invalid\n"; for (std::size_t index = 0; index < result.move_stats.size(); ++index) { auto const& stat = result.move_stats[index]; - file << index << ',' << stat.attempted << ',' << stat.accepted << ',' - << stat.invalid << '\n'; + auto const descriptor = + move_descriptor_4d(move_tracker::as_move_4d( + static_cast(index))); + file << descriptor.name << ',' << stat.attempted << ',' + << stat.accepted << ',' << stat.invalid << '\n'; } + ensure_wrote(file, path); } { - std::ofstream file(run_dir / "action_trace.csv"); + if (result.action_trace.size() != result.volume_trace.size()) + { + throw std::runtime_error( + "Action and volume traces have different lengths."); + } + auto const path = run_dir / "action_trace.csv"; + auto file = open_output_file(path); file << "step,action,N4\n"; for (std::size_t index = 0; index < result.action_trace.size(); ++index) { file << index + 1 << ',' << result.action_trace[index] << ',' << result.volume_trace[index] << '\n'; } + ensure_wrote(file, path); } std::vector measured_profiles; @@ -146,7 +254,8 @@ namespace cdt::four_d::output auto const covariance = phase::covariance(measured_profiles, profile_mean); auto const kernel = phase::effective_action_kernel(measured_profiles); - std::ofstream covariance_file(run_dir / "covariance.csv"); + auto const covariance_path = run_dir / "covariance.csv"; + auto covariance_file = open_output_file(covariance_path); for (auto const& row : covariance) { for (std::size_t index = 0; index < row.size(); ++index) @@ -155,8 +264,10 @@ namespace cdt::four_d::output << (index + 1 == row.size() ? '\n' : ','); } } + ensure_wrote(covariance_file, covariance_path); - std::ofstream effective_file(run_dir / "effective_action.csv"); + auto const effective_path = run_dir / "effective_action.csv"; + auto effective_file = open_output_file(effective_path); for (auto const& row : kernel.inverse_covariance_diagonal_regularized) { for (std::size_t index = 0; index < row.size(); ++index) @@ -165,16 +276,20 @@ namespace cdt::four_d::output << (index + 1 == row.size() ? '\n' : ','); } } + ensure_wrote(effective_file, effective_path); } { - std::ofstream file(run_dir / "summary.json"); + auto const path = run_dir / "summary.json"; + auto file = open_output_file(path); auto const counts = result.triangulation.counts(); auto const report = result.triangulation.validate(); auto const diagnostics = measured_profiles.empty() ? phase::Diagnostics{} - : phase::diagnose(measured_profiles); + : phase::diagnose(std::move(measured_profiles)); file << "{\n"; + file << " \"chain_id\": \"" << json_escape(config.chain_id) + << "\",\n"; file << " \"N0\": " << counts.N0 << ",\n"; file << " \"N1\": " << counts.N1 << ",\n"; file << " \"N2\": " << counts.N2 << ",\n"; @@ -195,8 +310,15 @@ namespace cdt::four_d::output : "false") << ",\n"; file << " \"phase_verdict\": \"" - << phase::to_string(diagnostics.verdict) << "\"\n"; + << phase::to_string(diagnostics.verdict) << "\",\n"; + file << " \"autocorrelation_time\": " + << diagnostics.autocorrelation_time << ",\n"; + file << " \"held_out_likelihood\": " + << diagnostics.held_out_likelihood << ",\n"; + file << " \"aic\": " << diagnostics.aic << ",\n"; + file << " \"bic\": " << diagnostics.bic << "\n"; file << "}\n"; + ensure_wrote(file, path); } } } // namespace cdt::four_d::output diff --git a/scripts/run-cds-phase-scan.ps1 b/scripts/run-cds-phase-scan.ps1 index 5ca08cb94..47efa8a12 100644 --- a/scripts/run-cds-phase-scan.ps1 +++ b/scripts/run-cds-phase-scan.ps1 @@ -15,7 +15,13 @@ param( $ErrorActionPreference = "Stop" cmake --build $BuildDir +if ($LASTEXITCODE -ne 0) { + throw "cmake build failed with exit code $LASTEXITCODE." +} ctest --test-dir $BuildDir --output-on-failure +if ($LASTEXITCODE -ne 0) { + throw "ctest failed with exit code $LASTEXITCODE." +} $candidateExecutables = @( (Join-Path $BuildDir "src/cdt.exe"), @@ -31,12 +37,24 @@ if (-not $cdt) { foreach ($point in $CouplingPoints) { $parts = $point.Split(",") + if ($parts.Count -lt 3) { + throw "Coupling point '$point' must contain kappa0,kappa4,Delta." + } $kappa0 = $parts[0] $kappa4 = $parts[1] $delta = $parts[2] foreach ($target in $TargetVolumes) { foreach ($chain in 1..$Chains) { - $seed = $BaseSeed + $chain + $seedText = "$BaseSeed|$point|$target|$chain" + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $seedBytes = $sha256.ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes($seedText)) + } + finally { + $sha256.Dispose() + } + $seed = [BitConverter]::ToUInt32($seedBytes, 0) $runId = "cds-k0_$kappa0-k4_$kappa4-d_$delta-n4_$target-chain_$chain" & $cdt ` --spherical ` @@ -55,6 +73,9 @@ foreach ($point in $CouplingPoints) { --chain-id "chain-$chain" ` --run-id $runId ` --output-dir $ResultsDir + if ($LASTEXITCODE -ne 0) { + throw "cdt failed for $runId with exit code $LASTEXITCODE." + } } } } diff --git a/scripts/run-cds-phase-scan.sh b/scripts/run-cds-phase-scan.sh index 7e6bb8984..902f7c7b4 100644 --- a/scripts/run-cds-phase-scan.sh +++ b/scripts/run-cds-phase-scan.sh @@ -13,25 +13,40 @@ PASSES="${PASSES:-2000}" VOLUME_EPSILON="${VOLUME_EPSILON:-0.001}" BASE_SEED="${BASE_SEED:-1000}" +set -f + cmake --build "${BUILD_DIR}" ctest --test-dir "${BUILD_DIR}" --output-on-failure -CDT_EXE="${BUILD_DIR}/src/cdt" -if [[ ! -x "${CDT_EXE}" && -x "${BUILD_DIR}/src/Debug/cdt" ]]; then - CDT_EXE="${BUILD_DIR}/src/Debug/cdt" -fi -if [[ ! -x "${CDT_EXE}" && -x "${BUILD_DIR}/src/Release/cdt" ]]; then - CDT_EXE="${BUILD_DIR}/src/Release/cdt" -fi -if [[ ! -x "${CDT_EXE}" && -x "${BUILD_DIR}/src/RelWithDebInfo/cdt" ]]; then - CDT_EXE="${BUILD_DIR}/src/RelWithDebInfo/cdt" +CDT_EXE="" +for candidate in \ + "${BUILD_DIR}/src/cdt" \ + "${BUILD_DIR}/src/cdt.exe" \ + "${BUILD_DIR}/src/Debug/cdt" \ + "${BUILD_DIR}/src/Debug/cdt.exe" \ + "${BUILD_DIR}/src/Release/cdt" \ + "${BUILD_DIR}/src/Release/cdt.exe" \ + "${BUILD_DIR}/src/RelWithDebInfo/cdt" \ + "${BUILD_DIR}/src/RelWithDebInfo/cdt.exe"; do + if [[ -x "${candidate}" ]]; then + CDT_EXE="${candidate}" + break + fi +done +if [[ -z "${CDT_EXE}" ]]; then + echo "Could not find cdt executable under ${BUILD_DIR}." >&2 + exit 1 fi for point in ${COUPLING_POINTS}; do IFS=',' read -r KAPPA0 KAPPA4 DELTA <<< "${point}" + if [[ -z "${KAPPA0}" || -z "${KAPPA4}" || -z "${DELTA}" ]]; then + echo "Coupling point '${point}' must contain kappa0,kappa4,Delta." >&2 + exit 1 + fi for target in ${TARGET_VOLUMES}; do for chain in $(seq 1 "${CHAINS}"); do - seed=$((BASE_SEED + chain)) + seed="$(printf '%s' "${BASE_SEED}|${point}|${target}|${chain}" | cksum | awk '{print $1}')" run_id="cds-k0_${KAPPA0}-k4_${KAPPA4}-d_${DELTA}-n4_${target}-chain_${chain}" "${CDT_EXE}" \ --spherical \ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 00e60a8e5..47291b213 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -70,6 +70,12 @@ add_cli_failure_test(cdt-dimensionality cdt "Only three- or four-dimensional tri -n64 -t3 -d5 -a0.4 -k1.1 -l0.1 --seed 92) add_cli_failure_test(cdt-d4-missing-couplings cdt "4D runs require explicit --kappa0, --kappa4, and --Delta." -s -n64 -t3 -d4 -a0.4 -k1.1 -l0.1 --seed 92) +add_cli_failure_test(cdt-d4-invalid-run-id cdt "run_id must be a single path component." -s -n64 -t3 -d4 + --kappa0 1.0 --kappa4 0.2 --Delta 0.1 --run-id bad/path --seed 92) +add_test( + NAME cdt-d4-clean + COMMAND ${CMAKE_COMMAND} -E rm -rf "${CMAKE_CURRENT_BINARY_DIR}/cdt-d4-output") +set_tests_properties(cdt-d4-clean PROPERTIES FIXTURES_SETUP cdt_d4_clean) add_test( NAME cdt-d4 COMMAND @@ -79,8 +85,16 @@ add_test( "${CMAKE_CURRENT_BINARY_DIR}/cdt-d4-output") set_tests_properties( cdt-d4 - PROPERTIES LABELS "integration;4d" PASS_REGULAR_EXPRESSION + PROPERTIES FIXTURES_REQUIRED cdt_d4_clean LABELS "integration;4d" PASS_REGULAR_EXPRESSION "Structured output written to .*ctest-d4") +add_test( + NAME cdt-d4-artifacts + COMMAND + ${CMAKE_COMMAND} -E cat + "${CMAKE_CURRENT_BINARY_DIR}/cdt-d4-output/ctest-d4/manifest.json" + "${CMAKE_CURRENT_BINARY_DIR}/cdt-d4-output/ctest-d4/summary.json" + "${CMAKE_CURRENT_BINARY_DIR}/cdt-d4-output/ctest-d4/checkpoint/state.txt") +set_tests_properties(cdt-d4-artifacts PROPERTIES DEPENDS cdt-d4 LABELS "integration;4d") add_cli_failure_test(cdt-toroidal cdt "Toroidal triangulations are not yet supported." -e -n64 -t3 -a0.6 -k1.1 -l0.1 --seed 92) add_cli_failure_test(cdt-alpha-negative cdt "Alpha in 3D must be greater than 1/2." -s -n64 -t3 -a-0.6 -k1.1 diff --git a/src/cdt.cpp b/src/cdt.cpp index a06afb611..f6b8d9f2e 100644 --- a/src/cdt.cpp +++ b/src/cdt.cpp @@ -57,6 +57,11 @@ Usage:./cdt (--spherical | --toroidal) -n SIMPLICES -t TIMESLICES [--no-output] [--seed SEED] [--threads THREADS] + [--thermalization STEPS] + [--measurement-interval STEPS] + [--chain-id CHAIN] + [--run-id RUN] + [--output-dir DIR] [-k K] [--alpha ALPHA] [--lambda LAMBDA] @@ -196,155 +201,91 @@ try if (dimensions == 4) { - auto const spherical = args.count("spherical") != 0; - auto const toroidal = args.count("toroidal") != 0; - if (spherical == toroidal) - { - throw invalid_argument( - "Specify exactly one topology: --spherical or --toroidal."); - } - if (toroidal) - { - throw invalid_argument("Toroidal triangulations are not yet supported."); - } if (!args.count("kappa0") || !args.count("kappa4") || !args.count("Delta")) { throw invalid_argument( "4D runs require explicit --kappa0, --kappa4, and --Delta."); } - if (!std::isfinite(kappa_0)) - { - throw invalid_argument("Kappa0 must be finite."); - } - if (!std::isfinite(kappa_4)) - { - throw invalid_argument("Kappa4 must be finite."); - } - if (!std::isfinite(Delta)) - { - throw invalid_argument("Delta must be finite."); - } - if (!std::isfinite(volume_epsilon) || volume_epsilon < 0.0L) - { - throw invalid_argument( - "Volume epsilon must be finite and non-negative."); - } - - auto const checked_int = [](char const* name, long long const value) { - if (!std::in_range(value)) - { - throw out_of_range(std::string{name} + - " exceeds the supported integer range."); - } - return static_cast(value); - }; - auto const checked_simplices = - checked_int("Number of simplices", simplices); - auto const checked_timeslices = - checked_int("Number of timeslices", timeslices); - auto const checked_passes = checked_int("Passes", passes); - auto const checked_checkpoint = - checked_int("Checkpoint interval", checkpoint); - auto const checked_target = checked_int("Target N4", target_N4); - auto const checked_thermalization = - checked_int("Thermalization steps", thermalization); - auto const checked_measurement_interval = - checked_int("Measurement interval", measurement_interval); - - if (checked_simplices < 2 || checked_timeslices < 2) - { - throw invalid_argument( - "Simplices and timeslices must each be at least 2."); - } - if (checked_passes <= 0) - { - throw invalid_argument("Passes must be positive."); - } - if (checked_checkpoint <= 0) - { - throw invalid_argument("Checkpoint interval must be positive."); - } - if (checked_target < 0) - { - throw invalid_argument("Target N4 must be non-negative."); - } - if (checked_thermalization < 0) - { - throw invalid_argument("Thermalization steps must be non-negative."); - } - if (checked_measurement_interval <= 0) - { - throw invalid_argument("Measurement interval must be positive."); - } + auto const write_files = !args.count("no-output"); + auto const config = runtime_config::make_4d_simulation( + args.count("spherical") != 0, args.count("toroidal") != 0, + simplices, timeslices, dimensions, kappa_0, kappa_4, Delta, + target_N4, volume_epsilon, passes, checkpoint, thermalization, + measurement_interval, root_random.seed(), chain_id, run_id, + std::filesystem::path{output_dir}, threads, write_files); fmt::print("Topology is spherical\n"); fmt::print("Dimensionality: 3+1\n"); - fmt::print("Number of desired simplices: {}\n", checked_simplices); - fmt::print("Number of desired timeslices: {}\n", checked_timeslices); - fmt::print("Number of passes: {}\n", checked_passes); - fmt::print("Checkpoint every {} passes.\n", checked_checkpoint); + fmt::print("Number of desired simplices: {}\n", config.simplices()); + fmt::print("Number of desired timeslices: {}\n", config.timeslices()); + fmt::print("Number of passes: {}\n", config.passes()); + fmt::print("4D sweep size: {} move attempts.\n", config.sweep_size()); + fmt::print("Total 4D move attempts: {}\n", config.steps()); + fmt::print("Checkpoint every {} move attempts.\n", + config.checkpoint_steps()); fmt::print("Effective random seed: {}\n", root_random.seed()); fmt::print("=== Parameters ===\n"); - fmt::print("kappa_0: {}\n", kappa_0); - fmt::print("kappa_4: {}\n", kappa_4); - fmt::print("Delta: {}\n", Delta); + fmt::print("kappa_0: {}\n", config.metropolis().couplings.kappa_0); + fmt::print("kappa_4: {}\n", config.metropolis().couplings.kappa_4); + fmt::print("Delta: {}\n", config.metropolis().couplings.Delta); Timer timer; timer.start(); fmt::print("cdt started at {}\n", utilities::current_date_time()); auto universe = - four_d::FoliatedTriangulation4::periodic_seed(checked_timeslices); - auto const fixed_target = - checked_target > 0 ? checked_target : checked_simplices; - four_d::Metropolis4Config config; - config.seed = root_random.seed().value(); - config.chain_id = chain_id; - config.thermalization_steps = checked_thermalization; - config.measurement_interval = checked_measurement_interval; - config.checkpoint_interval = checked_checkpoint; - config.couplings = four_d::S4Couplings{kappa_0, - kappa_4, - Delta, - fixed_target, - volume_epsilon}; - - four_d::Metropolis4 run(config); - auto result = run.run(std::move(universe), checked_passes); + four_d::FoliatedTriangulation4::periodic_seed(config.timeslices()); + + four_d::Metropolis4 run(config.metropolis()); + auto result = run.run(std::move(universe), config.steps()); if (!result.triangulation.is_valid()) { throw runtime_error("4D result is invalid!\n"); } - auto const write_files = !args.count("no-output"); - if (write_files) + if (config.write_files()) { - auto const run_dir = std::filesystem::path(output_dir) / run_id; + auto const run_dir = config.output_dir() / config.run_id(); run.save_checkpoint(run_dir / "checkpoint", result.triangulation, - checked_passes); + config.steps()); four_d::output::RunManifest manifest; - manifest.run_id = run_id; + manifest.run_id = config.run_id(); manifest.git_commit = std::string(cdt::SOURCE_REVISION); manifest.build_type = std::string(cdt::BUILD_CONFIGURATION); manifest.compiler = fmt::format("{} {}", cdt::BUILD_COMPILER_ID, cdt::BUILD_COMPILER_VERSION); - four_d::output::write_run_directory(output_dir, manifest, config, - result); + four_d::output::write_run_directory(config.output_dir(), manifest, + config.metropolis(), result); } timer.stop(); fmt::print("=== 4D Run Results ===\n"); fmt::print("Running time is {} seconds.\n", timer.time()); - if (write_files) + if (config.write_files()) { - fmt::print("Structured output written to {}/{}\n", output_dir, run_id); + fmt::print("Structured output written to {}/{}\n", + config.output_dir().string(), config.run_id()); } else { fmt::print("Structured output disabled.\n"); } + Int_precision attempted{0}; + Int_precision accepted{0}; + Int_precision invalid{0}; + for (auto const& stat : result.move_stats) + { + attempted += stat.attempted; + accepted += stat.accepted; + invalid += stat.invalid; + } + fmt::print("Move attempts: {}\n", attempted); + fmt::print("Accepted moves: {}\n", accepted); + fmt::print("Invalid proposals: {}\n", invalid); + fmt::print("Final N4: {}\n", result.triangulation.counts().N4); + fmt::print("Measurements: {}\n", result.measurements.size()); auto const report = result.triangulation.validate(); fmt::print("Standard CDT candidate: {}\n", report.valid() && report.standard_cdt_candidate ? "true" diff --git a/tests/Checkpoint_4_test.cpp b/tests/Checkpoint_4_test.cpp index 60060375b..d367b4d80 100644 --- a/tests/Checkpoint_4_test.cpp +++ b/tests/Checkpoint_4_test.cpp @@ -2,10 +2,36 @@ #include +#include #include +#include +#include +#include using namespace cdt::four_d; +namespace +{ + struct TempDirectory + { + std::filesystem::path path; + + ~TempDirectory() { std::filesystem::remove_all(path); } + }; + + auto make_temp_directory() -> TempDirectory + { + auto const suffix = std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()); + auto directory = + std::filesystem::temp_directory_path() / + ("cdtpp-checkpoint-test-" + suffix); + std::filesystem::remove_all(directory); + std::filesystem::create_directories(directory); + return TempDirectory{directory}; + } +} + TEST_CASE("4D checkpoint restart reproduces the same final state") { Metropolis4Config config; @@ -19,17 +45,60 @@ TEST_CASE("4D checkpoint restart reproduces the same final state") Metropolis4 partial(config); auto partial_result = partial.run(FoliatedTriangulation4::periodic_seed(3), 5); - auto const directory = - std::filesystem::temp_directory_path() / "cdtpp-checkpoint-test"; - std::filesystem::remove_all(directory); - partial.save_checkpoint(directory, partial_result.triangulation, 5); + auto directory = make_temp_directory(); + partial.save_checkpoint(directory.path, partial_result.triangulation, 5); Metropolis4 resumed(config); - auto [checkpointed, step] = resumed.load_checkpoint(directory); + auto [checkpointed, step] = resumed.load_checkpoint(directory.path); CHECK_EQ(step, 5); + CHECK_EQ(resumed.rng_state(), partial.rng_state()); auto resumed_result = resumed.run(checkpointed, 5); CHECK_EQ(resumed_result.triangulation.canonical_hash(), direct_result.triangulation.canonical_hash()); - std::filesystem::remove_all(directory); + REQUIRE_EQ(resumed_result.action_trace.size(), 5); + REQUIRE_EQ(direct_result.action_trace.size(), 10); + for (std::size_t index = 0; index < resumed_result.action_trace.size(); + ++index) + { + CHECK_EQ(resumed_result.action_trace[index], + direct_result.action_trace[index + 5]); + CHECK_EQ(resumed_result.volume_trace[index], + direct_result.volume_trace[index + 5]); + } +} + +TEST_CASE("4D checkpoint loading rejects missing and malformed state") +{ + Metropolis4 run; + auto missing = make_temp_directory(); + CHECK_THROWS_AS(run.load_checkpoint(missing.path / "missing"), + std::runtime_error); + + auto truncated = make_temp_directory(); + { + std::ofstream file(truncated.path / "state.txt"); + file << "step 1\n"; + } + CHECK_THROWS_AS(run.load_checkpoint(truncated.path), std::runtime_error); + + auto malformed = make_temp_directory(); + { + std::ofstream file(malformed.path / "state.txt"); + file << "not_step 1\n"; + } + CHECK_THROWS_AS(run.load_checkpoint(malformed.path), std::runtime_error); + + auto no_rng = make_temp_directory(); + { + std::ofstream file(no_rng.path / "state.txt"); + file << "step 1\n" + << "timeslices 2\n" + << "counts 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n" + << "profile 2 0 0\n" + << "three_three_forward 1\n" + << "vertices 0\n" + << "simplices 0\n"; + } + CHECK_THROWS_AS(run.load_checkpoint(no_rng.path), std::runtime_error); } diff --git a/tests/Detailed_balance_test.cpp b/tests/Detailed_balance_test.cpp index 44b425913..41b85700a 100644 --- a/tests/Detailed_balance_test.cpp +++ b/tests/Detailed_balance_test.cpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -27,15 +28,13 @@ TEST_CASE("4D proposal ratio is reversible on a small triangulation") TEST_CASE("4D detailed-balance acceptance ingredients are finite") { auto triangulation = FoliatedTriangulation4::periodic_seed(3); - auto proposal = moves::apply(triangulation, move_tracker::MoveType4D::TWO_EIGHT); + auto proposal = + moves::apply(triangulation, move_tracker::MoveType4D::TWO_EIGHT); REQUIRE(proposal); S4Couplings couplings{1.0L, 0.2L, 0.1L, 36, 0.001L}; - auto const delta_action = - S4_action_difference(triangulation.counts(), - proposal->triangulation.counts(), couplings); - auto const ratio = static_cast(proposal->reverse_candidates) / - static_cast(proposal->forward_candidates); - auto const probability = std::min(1.0L, std::exp(-delta_action) * ratio); + auto const probability = acceptance_probability( + triangulation, proposal->triangulation, move_tracker::MoveType4D::TWO_EIGHT, + couplings); CHECK(std::isfinite(static_cast(probability))); CHECK_GE(probability, 0.0L); CHECK_LE(probability, 1.0L); @@ -49,3 +48,22 @@ TEST_CASE("4D detailed balance holds on a small enumerable ensemble") CHECK(report.passed); CHECK_FALSE(report.edges.empty()); } + +TEST_CASE("4D detailed-balance verifier reports capped enumeration") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + S4Couplings couplings{1.0L, 0.2L, 0.1L, 36, 0.001L}; + auto report = verify_detailed_balance(triangulation, couplings, 1, 1.0e-10L, 1); + CHECK_FALSE(report.passed); + CHECK_FALSE(report.errors.empty()); +} + +TEST_CASE("4D detailed-balance verifier rejects non-finite weights") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + S4Couplings couplings{ + std::numeric_limits::infinity(), 0.2L, 0.1L, 36, 0.001L}; + auto report = verify_detailed_balance(triangulation, couplings, 1); + CHECK_FALSE(report.passed); + CHECK_FALSE(report.errors.empty()); +} diff --git a/tests/Foliated_triangulation_4_test.cpp b/tests/Foliated_triangulation_4_test.cpp index b5aa2c7ed..57dad7704 100644 --- a/tests/Foliated_triangulation_4_test.cpp +++ b/tests/Foliated_triangulation_4_test.cpp @@ -3,6 +3,7 @@ #include using namespace cdt::four_d; +namespace move_tracker = cdt::move_tracker; TEST_CASE("Abstract 4D periodic seed validates") { @@ -32,6 +33,51 @@ TEST_CASE("Abstract 4D canonical hash is stable for copies") auto triangulation = FoliatedTriangulation4::periodic_seed(3); auto copy = triangulation; CHECK_EQ(copy.canonical_hash(), triangulation.canonical_hash()); + + auto moved = triangulation; + REQUIRE(moved.apply_move(move_tracker::MoveType4D::TWO_FOUR)); + CHECK_NE(moved.canonical_hash(), triangulation.canonical_hash()); + auto const delta = + FoliatedTriangulation4::move_count_delta(move_tracker::MoveType4D::TWO_FOUR); + auto const before_counts = triangulation.counts(); + auto const after_counts = moved.counts(); + CHECK_EQ(after_counts.N4, before_counts.N4 + delta.N4); + CHECK_EQ(after_counts.N41, before_counts.N41 + delta.N41); + CHECK_EQ(after_counts.N32, before_counts.N32 + delta.N32); +} + +TEST_CASE("4D THREE_THREE toggles direction and is self-inverse") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + auto moved = triangulation; + REQUIRE(moved.apply_move(move_tracker::MoveType4D::THREE_THREE)); + CHECK_NE(moved.three_three_forward(), triangulation.three_three_forward()); + REQUIRE(moved.apply_move(move_tracker::MoveType4D::THREE_THREE)); + CHECK_EQ(moved.canonical_hash(), triangulation.canonical_hash()); +} + +TEST_CASE("4D time reversal maps profiles and vertex times cyclically") +{ + auto seed = FoliatedTriangulation4::periodic_seed(4); + auto triangulation = FoliatedTriangulation4::from_checkpoint_state( + 4, seed.counts(), FoliatedTriangulation4::Profile{1, 2, 3, 4}, + FoliatedTriangulation4::VertexContainer{ + Vertex4D{1, 0}, Vertex4D{2, 1}, Vertex4D{3, 2}, Vertex4D{4, 3}}, + {}, true); + + auto reversed = triangulation.time_reversed(); + auto const profile = reversed.spatial_volume_profile(); + REQUIRE_EQ(profile.size(), 4); + CHECK_EQ(profile[0], 1); + CHECK_EQ(profile[1], 4); + CHECK_EQ(profile[2], 3); + CHECK_EQ(profile[3], 2); + REQUIRE_EQ(reversed.vertices().size(), 4); + CHECK_EQ(reversed.vertices()[0].time, 0); + CHECK_EQ(reversed.vertices()[1].time, 3); + CHECK_EQ(reversed.vertices()[2].time, 2); + CHECK_EQ(reversed.vertices()[3].time, 1); + CHECK_FALSE(reversed.three_three_forward()); } TEST_CASE("4D candidate validation is independent from the initializer") @@ -47,4 +93,34 @@ TEST_CASE("4D candidate validation is independent from the initializer") seeded.proposal_inventory().spatial_tetrahedra); CHECK_EQ(from_counts.proposal_inventory().mixed_triangles, seeded.proposal_inventory().mixed_triangles); + + auto clamped = FoliatedTriangulation4::from_counts_for_validation( + -7, seeded.counts(), {}); + CHECK_EQ(clamped.timeslices(), 2); + CHECK_EQ(clamped.spatial_volume_profile().size(), 2); + + auto counts = S4Counts{1, 2, 3, 4, 5, 1, 2, 1, 1}; + auto abstract = FoliatedTriangulation4::from_counts_for_validation( + 2, counts, FoliatedTriangulation4::Profile{1, 1}); + auto const inventory = abstract.proposal_inventory(); + CHECK_EQ(inventory.spatial_tetrahedra, 4); + CHECK_EQ(inventory.timelike_edges, 2); + CHECK_EQ(inventory.mixed_triangles, 3); + CHECK_EQ(inventory.timelike_tetrahedra, 4); + CHECK_EQ(inventory.vertices, 1); + CHECK_EQ(inventory.three_two_simplices, 2); + CHECK_EQ(inventory.two_three_simplices, 1); + + counts.class_resolved_proposals = true; + counts.spatial_tetrahedra = 7; + counts.timelike_edges = 8; + counts.mixed_triangles = 9; + counts.timelike_tetrahedra = 10; + auto exact = FoliatedTriangulation4::from_counts_for_validation( + 2, counts, FoliatedTriangulation4::Profile{1, 1}); + auto const exact_inventory = exact.proposal_inventory(); + CHECK_EQ(exact_inventory.spatial_tetrahedra, 7); + CHECK_EQ(exact_inventory.timelike_edges, 8); + CHECK_EQ(exact_inventory.mixed_triangles, 9); + CHECK_EQ(exact_inventory.timelike_tetrahedra, 10); } diff --git a/tests/Metropolis_4_test.cpp b/tests/Metropolis_4_test.cpp index 04b03f4ba..56b7543b8 100644 --- a/tests/Metropolis_4_test.cpp +++ b/tests/Metropolis_4_test.cpp @@ -25,10 +25,22 @@ TEST_CASE("Metropolis4 same seed produces identical traces") REQUIRE_EQ(result_a.action_trace.size(), result_b.action_trace.size()); for (std::size_t index = 0; index < result_a.action_trace.size(); ++index) { - CHECK(result_a.action_trace[index] == - doctest::Approx(result_b.action_trace[index])); + CHECK_EQ(result_a.action_trace[index], result_b.action_trace[index]); + CHECK_EQ(result_a.volume_trace[index], result_b.volume_trace[index]); } CHECK_EQ(result_a.measurements.size(), 5); + + auto chain_config = config; + chain_config.chain_id = "chain-1"; + Metropolis4 chain_run(chain_config); + CHECK_NE(run_a.rng_state(), chain_run.rng_state()); + + auto different_seed_config = config; + different_seed_config.seed = 1235; + Metropolis4 run_c(different_seed_config); + auto result_c = + run_c.run(FoliatedTriangulation4::periodic_seed(4), 12); + CHECK_NE(result_a.action_trace, result_c.action_trace); } TEST_CASE("Metropolis4 records invalid proposals and accepted moves") @@ -39,14 +51,17 @@ TEST_CASE("Metropolis4 records invalid proposals and accepted moves") Metropolis4 run(config); auto result = run.run(FoliatedTriangulation4::periodic_seed(3), 20); - auto attempted = 0; - auto accepted = 0; + Int_precision attempted{0}; + Int_precision accepted{0}; + Int_precision invalid{0}; for (auto const& stat : result.move_stats) { attempted += stat.attempted; accepted += stat.accepted; + invalid += stat.invalid; } CHECK_EQ(attempted, 20); - CHECK_GE(accepted, 0); + CHECK_LE(accepted + invalid, attempted); + CHECK_GT(accepted + invalid, 0); CHECK(result.triangulation.is_valid()); } diff --git a/tests/Phase_analysis_test.cpp b/tests/Phase_analysis_test.cpp index 8c3d3346f..2846f5ceb 100644 --- a/tests/Phase_analysis_test.cpp +++ b/tests/Phase_analysis_test.cpp @@ -1,11 +1,31 @@ #include "Phase_analysis.hpp" #include +#include #include using namespace cdt::four_d::phase; +namespace +{ + auto build_synthetic_c_ds_profiles() + -> std::vector> + { + std::vector> profiles; + for (auto const volume : {8000.0L, 16000.0L, 32000.0L, 64000.0L}) + { + auto const width = static_cast( + std::llround(4.0L * std::pow(volume, 0.25L))); + auto profile = cos3_reference(width | static_cast(1)); + auto const amplitude = std::pow(volume, 0.75L); + for (auto& value : profile) { value = 1.0L + amplitude * value; } + profiles.emplace_back(volume, profile); + } + return profiles; + } +} + TEST_CASE("Synthetic cos cubed profiles are classified conservatively as C-like") { std::vector profiles; @@ -55,35 +75,18 @@ TEST_CASE("Too few profiles are insufficient effective samples") TEST_CASE("Synthetic C_dS profiles pass finite-size scaling") { - std::vector> profiles; - for (auto const volume : {8000.0L, 16000.0L, 32000.0L, 64000.0L}) - { - auto const width = static_cast( - std::llround(4.0L * std::pow(volume, 0.25L))); - auto profile = cos3_reference(width | static_cast(1)); - auto const amplitude = std::pow(volume, 0.75L); - for (auto& value : profile) { value = 1.0L + amplitude * value; } - profiles.emplace_back(volume, profile); - } + auto profiles = build_synthetic_c_ds_profiles(); auto scaling = analyze_finite_size_scaling(profiles); CHECK(scaling.passed); CHECK(scaling.width_exponent == doctest::Approx(0.25L).epsilon(0.10)); CHECK(scaling.peak_exponent == doctest::Approx(0.75L).epsilon(0.10)); + CHECK_GE(scaling.collapse_error, 0.0L); } TEST_CASE("Full C_dS candidate gate requires profile shape and finite-size scaling") { - std::vector> profiles; - for (auto const volume : {8000.0L, 16000.0L, 32000.0L, 64000.0L}) - { - auto const width = static_cast( - std::llround(4.0L * std::pow(volume, 0.25L))); - auto profile = cos3_reference(width | static_cast(1)); - auto const amplitude = std::pow(volume, 0.75L); - for (auto& value : profile) { value = 1.0L + amplitude * value; } - profiles.emplace_back(volume, profile); - } + auto profiles = build_synthetic_c_ds_profiles(); auto validation = diagnose_c_ds_finite_size(profiles, profiles.size()); CHECK_EQ(validation.verdict, Verdict::c_ds_supported); @@ -102,3 +105,11 @@ TEST_CASE("Covariance produces a regularized effective-action kernel") REQUIRE_EQ(kernel.inverse_covariance_diagonal_regularized.size(), 3); CHECK_GT(kernel.inverse_covariance_diagonal_regularized[1][1], 0.0L); } + +TEST_CASE("Profile statistics reject mismatched dimensions") +{ + std::vector profiles{Profile{1.0L, 2.0L}, Profile{1.0L}}; + CHECK_THROWS_AS(mean(profiles), std::invalid_argument); + CHECK_THROWS_AS(covariance(profiles, Profile{1.0L, 2.0L}), + std::invalid_argument); +} From dd59963f1eeffaa561ef3ec5d413175040d55fd9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 1 Aug 2026 19:34:15 +0300 Subject: [PATCH 3/6] Address new CodeRabbit 4D review comments --- include/Detailed_balance_4.hpp | 23 +++- include/Foliated_triangulation_4.hpp | 59 +++++---- include/Metropolis_4.hpp | 44 +++++-- include/Move_catalog_4.hpp | 13 +- include/Phase_analysis.hpp | 60 +++++---- include/Random.hpp | 6 + include/Runtime_config.hpp | 22 ++-- include/S4Action.hpp | 15 ++- include/Simulation_output.hpp | 31 +++-- include/Utilities.hpp | 12 ++ tests/CMakeLists.txt | 1 + tests/Checkpoint_4_test.cpp | 17 ++- tests/Detailed_balance_test.cpp | 17 ++- tests/Foliated_triangulation_4_test.cpp | 168 ++++++++++++++++++------ tests/Phase_analysis_test.cpp | 18 ++- tests/Simulation_output_test.cpp | 17 +++ 16 files changed, 374 insertions(+), 149 deletions(-) create mode 100644 tests/Simulation_output_test.cpp diff --git a/include/Detailed_balance_4.hpp b/include/Detailed_balance_4.hpp index d33706a47..7296c46cc 100644 --- a/include/Detailed_balance_4.hpp +++ b/include/Detailed_balance_4.hpp @@ -60,7 +60,20 @@ namespace cdt::four_d if (forward_q == 0.0L || reverse_q == 0.0L) { return 0.0L; } auto const delta = S4_action_difference(before.counts(), after.counts(), couplings); - return std::min(1.0L, std::exp(-delta) * reverse_q / forward_q); + if (!std::isfinite(delta)) + { + return std::numeric_limits::quiet_NaN(); + } + auto const log_ratio = -delta + std::log(reverse_q) - std::log(forward_q); + if (!std::isfinite(log_ratio)) + { + return std::numeric_limits::quiet_NaN(); + } + if (log_ratio >= 0.0L) { return 1.0L; } + auto const probability = std::exp(log_ratio); + return std::isfinite(probability) + ? probability + : std::numeric_limits::quiet_NaN(); } [[nodiscard]] inline auto boltzmann_weight( @@ -84,6 +97,13 @@ namespace cdt::four_d -> DetailedBalanceReport4D { DetailedBalanceReport4D report; + if (max_depth <= 0) + { + report.passed = false; + report.errors.emplace_back( + "Detailed-balance enumeration max_depth must be positive."); + return report; + } if (max_states == 0) { report.passed = false; @@ -127,6 +147,7 @@ namespace cdt::four_d } } } + if (cap_reached) { return report; } for (auto const& [from_hash, from_state] : states) { diff --git a/include/Foliated_triangulation_4.hpp b/include/Foliated_triangulation_4.hpp index 4ef292e4e..7c141bc3d 100644 --- a/include/Foliated_triangulation_4.hpp +++ b/include/Foliated_triangulation_4.hpp @@ -68,6 +68,11 @@ namespace cdt::four_d return static_cast(type); } + /// @brief Abstract combinatorial 3+1D CDT triangulation state. + /// + /// Count-only states intentionally keep no local simplex complex. Topology + /// accessors therefore use the declared closed-S3 flag for those abstract + /// states, and derive Euler/connectivity data only when simplices are present. class FoliatedTriangulation4 { public: @@ -266,15 +271,11 @@ namespace cdt::four_d counts.N1 = static_cast(edges.size()); counts.N2 = static_cast(triangles.size()); counts.N3 = static_cast(tetrahedra.size()); - counts.class_resolved_proposals = true; - counts.spatial_tetrahedra = - static_cast(spatial_tetrahedra.size()); - counts.timelike_edges = - static_cast(timelike_edges.size()); - counts.mixed_triangles = - static_cast(mixed_triangles.size()); - counts.timelike_tetrahedra = - static_cast(timelike_tetrahedra.size()); + counts.class_resolved = S4ClassResolvedCounts{ + static_cast(spatial_tetrahedra.size()), + static_cast(timelike_edges.size()), + static_cast(mixed_triangles.size()), + static_cast(timelike_tetrahedra.size())}; return counts; } @@ -473,7 +474,7 @@ namespace cdt::four_d auto const eulers = derived_slice_euler_characteristics(); return std::ranges::all_of(eulers, [](auto const chi) { return chi == 0; - }) && (m_closed_s3_slices || spatial_slices_are_connected()); + }) && (!m_closed_s3_slices || spatial_slices_are_connected()); } void add_count_delta(S4Counts const& delta) @@ -487,11 +488,7 @@ namespace cdt::four_d m_counts.N32 += delta.N32; m_counts.N23 += delta.N23; m_counts.N14 += delta.N14; - m_counts.class_resolved_proposals = false; - m_counts.spatial_tetrahedra = 0; - m_counts.timelike_edges = 0; - m_counts.mixed_triangles = 0; - m_counts.timelike_tetrahedra = 0; + m_counts.class_resolved.reset(); m_proposal_inventory = proposal_inventory_from_counts(m_counts); } @@ -517,6 +514,8 @@ namespace cdt::four_d } if (delta > 0) { + // Abstract proposals currently carry no local slice label. Slice zero + // is the deterministic reservoir until moves become slice-resolved. m_spatial_profile.front() += delta; return true; } @@ -634,6 +633,10 @@ namespace cdt::four_d result.m_simplices = std::move(simplices); result.m_three_three_forward = three_three_forward; result.rebuild_vertex_time_cache(); + if (!result.m_vertices.empty() || !result.m_simplices.empty()) + { + result.m_counts = result.recompute_counts_from_complex(); + } result.m_proposal_inventory = proposal_inventory_from_counts(result.m_counts); return result; @@ -671,11 +674,7 @@ namespace cdt::four_d result.m_vertices.clear(); result.m_simplices.clear(); result.m_vertex_times.clear(); - result.m_counts.class_resolved_proposals = false; - result.m_counts.spatial_tetrahedra = 0; - result.m_counts.timelike_edges = 0; - result.m_counts.mixed_triangles = 0; - result.m_counts.timelike_tetrahedra = 0; + result.m_counts.class_resolved.reset(); result.m_proposal_inventory = proposal_inventory_from_counts(result.m_counts); return result; @@ -1022,7 +1021,7 @@ namespace cdt::four_d } } - if (!m_closed_s3_slices && !simplex_neighbor_graph_connected()) + if (m_closed_s3_slices && !simplex_neighbor_graph_connected()) { report.errors.emplace_back("Simplex neighbor graph is disconnected."); } @@ -1097,10 +1096,20 @@ namespace cdt::four_d stream << m_counts.N0 << ',' << m_counts.N1 << ',' << m_counts.N2 << ',' << m_counts.N3 << ',' << m_counts.N4 << ',' << m_counts.N41 << ',' << m_counts.N32 << ',' << m_counts.N23 << ',' - << m_counts.N14 << ";C=" << m_counts.class_resolved_proposals - << ',' << m_counts.spatial_tetrahedra << ',' - << m_counts.timelike_edges << ',' << m_counts.mixed_triangles - << ',' << m_counts.timelike_tetrahedra << ";V="; + << m_counts.N14 << ";C=" << m_counts.class_resolved.has_value(); + if (m_counts.class_resolved) + { + auto const& class_counts = *m_counts.class_resolved; + stream << ',' << class_counts.spatial_tetrahedra << ',' + << class_counts.timelike_edges << ',' + << class_counts.mixed_triangles << ',' + << class_counts.timelike_tetrahedra; + } + else + { + stream << ",0,0,0,0"; + } + stream << ";V="; for (auto const volume : m_spatial_profile) { stream << volume << ','; } return stream.str(); } diff --git a/include/Metropolis_4.hpp b/include/Metropolis_4.hpp index 2bb42304e..665a661dc 100644 --- a/include/Metropolis_4.hpp +++ b/include/Metropolis_4.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -106,22 +107,38 @@ namespace cdt::four_d auto const after_action = S4_bulk_action(proposal.triangulation.counts(), m_config.couplings); auto const delta_action = after_action - before_action; - auto const ratio = - static_cast(proposal.reverse_candidates) / - static_cast(proposal.forward_candidates); - auto const probability = std::exp(-delta_action) * ratio; - return std::min(1.0L, probability); + if (!std::isfinite(delta_action) || proposal.forward_candidates <= 0 || + proposal.reverse_candidates <= 0) + { + return std::numeric_limits::quiet_NaN(); + } + auto const log_probability = + -delta_action + + std::log(static_cast(proposal.reverse_candidates)) - + std::log(static_cast(proposal.forward_candidates)); + if (!std::isfinite(log_probability)) + { + return std::numeric_limits::quiet_NaN(); + } + if (log_probability >= 0.0L) { return 1.0L; } + auto const probability = std::exp(log_probability); + return std::isfinite(probability) + ? probability + : std::numeric_limits::quiet_NaN(); } static void write_counts(std::ostream& stream, S4Counts const& counts) { + auto const has_class_resolved = counts.class_resolved.has_value(); + auto const class_counts = + counts.class_resolved.value_or(S4ClassResolvedCounts{}); stream << counts.N0 << ' ' << counts.N1 << ' ' << counts.N2 << ' ' << counts.N3 << ' ' << counts.N4 << ' ' << counts.N41 << ' ' << counts.N32 << ' ' << counts.N23 << ' ' << counts.N14 << ' ' - << counts.class_resolved_proposals << ' ' - << counts.spatial_tetrahedra << ' ' << counts.timelike_edges - << ' ' << counts.mixed_triangles << ' ' - << counts.timelike_tetrahedra << '\n'; + << has_class_resolved << ' ' << class_counts.spatial_tetrahedra + << ' ' << class_counts.timelike_edges << ' ' + << class_counts.mixed_triangles << ' ' + << class_counts.timelike_tetrahedra << '\n'; } static auto read_counts(std::istream& stream) -> S4Counts @@ -129,13 +146,16 @@ namespace cdt::four_d S4Counts counts; stream >> counts.N0 >> counts.N1 >> counts.N2 >> counts.N3 >> counts.N4 >> counts.N41 >> counts.N32 >> counts.N23 >> counts.N14; - stream >> counts.class_resolved_proposals >> - counts.spatial_tetrahedra >> counts.timelike_edges >> - counts.mixed_triangles >> counts.timelike_tetrahedra; + bool has_class_resolved{false}; + S4ClassResolvedCounts class_counts; + stream >> has_class_resolved >> class_counts.spatial_tetrahedra >> + class_counts.timelike_edges >> class_counts.mixed_triangles >> + class_counts.timelike_tetrahedra; if (!stream) { throw std::runtime_error{"Malformed checkpoint counts."}; } + if (has_class_resolved) { counts.class_resolved = class_counts; } return counts; } diff --git a/include/Move_catalog_4.hpp b/include/Move_catalog_4.hpp index c77678fe4..d52559ae5 100644 --- a/include/Move_catalog_4.hpp +++ b/include/Move_catalog_4.hpp @@ -182,17 +182,20 @@ namespace cdt::four_d [[nodiscard]] inline auto proposal_inventory_from_counts(S4Counts const& counts) -> ProposalInventory4D { - if (counts.class_resolved_proposals) + if (counts.class_resolved) { + auto const& class_counts = *counts.class_resolved; return ProposalInventory4D{ - counts.spatial_tetrahedra, - counts.timelike_edges, - counts.mixed_triangles, - counts.timelike_tetrahedra, + class_counts.spatial_tetrahedra, + class_counts.timelike_edges, + class_counts.mixed_triangles, + class_counts.timelike_tetrahedra, counts.N0, counts.N32, counts.N23}; } + // Abstract count-only states have no local class inventory. Their proposal + // multiplicities deliberately fall back to aggregate simplex counts. return ProposalInventory4D{ counts.N3, counts.N1, diff --git a/include/Phase_analysis.hpp b/include/Phase_analysis.hpp index dc97bc2e3..a04982a10 100644 --- a/include/Phase_analysis.hpp +++ b/include/Phase_analysis.hpp @@ -28,6 +28,8 @@ namespace cdt::four_d::phase inline constexpr long double collapse_peak_ratio = 0.70L; inline constexpr long double alternating_ratio_limit = 0.30L; inline constexpr long double cos3_correlation_limit = 0.85L; + inline constexpr long double finite_collapse_error_sentinel = + std::numeric_limits::max() / 4.0L; enum class Verdict { @@ -200,35 +202,43 @@ namespace cdt::four_d::phase return std::sqrt(variance / total); } - [[nodiscard]] inline auto fit_power_law_exponent( + [[nodiscard]] inline auto positive_log_log_samples( std::vector> const& samples) - -> long double + -> std::vector> { - std::vector> positive_samples; - positive_samples.reserve(samples.size()); + std::vector> result; + result.reserve(samples.size()); for (auto const& sample : samples) { if (sample.first > 0.0L && sample.second > 0.0L) { - positive_samples.push_back(sample); + result.emplace_back(std::log(sample.first), std::log(sample.second)); } } + return result; + } + + [[nodiscard]] inline auto fit_power_law_exponent( + std::vector> const& samples) + -> long double + { + auto const positive_samples = positive_log_log_samples(samples); if (positive_samples.size() < 2) { return 0.0L; } auto mean_x = 0.0L; auto mean_y = 0.0L; - for (auto const& [x, y] : positive_samples) + for (auto const& [log_x, log_y] : positive_samples) { - mean_x += std::log(x); - mean_y += std::log(y); + mean_x += log_x; + mean_y += log_y; } mean_x /= static_cast(positive_samples.size()); mean_y /= static_cast(positive_samples.size()); auto numerator = 0.0L; auto denominator = 0.0L; - for (auto const& [x, y] : positive_samples) + for (auto const& [log_x, log_y] : positive_samples) { - auto const lx = std::log(x) - mean_x; - auto const ly = std::log(y) - mean_y; + auto const lx = log_x - mean_x; + auto const ly = log_y - mean_y; numerator += lx * ly; denominator += lx * lx; } @@ -239,35 +249,29 @@ namespace cdt::four_d::phase std::vector> const& samples, long double const exponent) -> long double { - std::vector> positive_samples; - positive_samples.reserve(samples.size()); - for (auto const& sample : samples) - { - if (sample.first > 0.0L && sample.second > 0.0L) - { - positive_samples.push_back(sample); - } - } - if (positive_samples.size() < 2) + auto const positive_samples = positive_log_log_samples(samples); + if (positive_samples.size() < 2 || !std::isfinite(exponent)) { - return std::numeric_limits::infinity(); + return finite_collapse_error_sentinel; } auto intercept = 0.0L; - for (auto const& [x, y] : positive_samples) + for (auto const& [log_x, log_y] : positive_samples) { - intercept += std::log(y) - exponent * std::log(x); + intercept += log_y - exponent * log_x; } intercept /= static_cast(positive_samples.size()); auto residual = 0.0L; - for (auto const& [x, y] : positive_samples) + for (auto const& [log_x, log_y] : positive_samples) { - auto const difference = - std::log(y) - (intercept + exponent * std::log(x)); + auto const difference = log_y - (intercept + exponent * log_x); residual += difference * difference; } - return residual; + return std::isfinite(residual) && + residual < finite_collapse_error_sentinel + ? residual + : finite_collapse_error_sentinel; } [[nodiscard]] inline auto analyze_finite_size_scaling( diff --git a/include/Random.hpp b/include/Random.hpp index 70456ef62..89c368595 100644 --- a/include/Random.hpp +++ b/include/Random.hpp @@ -143,6 +143,9 @@ namespace cdt [[nodiscard]] auto stream() const noexcept -> RandomStream { return m_stream; } + /// @returns Serialized checkpoint state for resuming this exact stream. + /// @details This state captures the progressed engine only. The recorded + /// seed() and stream() remain construction provenance for fresh restarts. [[nodiscard]] auto engine_state() const -> std::string { std::ostringstream output; @@ -150,6 +153,9 @@ namespace cdt return output.str(); } + /// @brief Restore a serialized checkpoint engine state. + /// @details Only m_engine is updated. seed(), stream(), and split() still + /// describe or create streams from the original construction provenance. void set_engine_state(std::string const& state) { std::istringstream input(state); diff --git a/include/Runtime_config.hpp b/include/Runtime_config.hpp index ebbedcac3..0c5c2cb55 100644 --- a/include/Runtime_config.hpp +++ b/include/Runtime_config.hpp @@ -201,6 +201,12 @@ namespace cdt::runtime_config {} public: + Simulation4D(Simulation4D const&) = default; + Simulation4D(Simulation4D&&) noexcept = default; + auto operator=(Simulation4D const&) -> Simulation4D& = default; + auto operator=(Simulation4D&&) noexcept -> Simulation4D& = default; + ~Simulation4D() = default; + [[nodiscard]] auto simplices() const noexcept -> Int_precision { return m_simplices; } @@ -283,18 +289,6 @@ namespace cdt::runtime_config return static_cast(value); } - inline void validate_path_component(std::string_view const name, - std::string_view const value) - { - if (value.empty() || value == "." || value == ".." || - value.find_first_of("/\\") != std::string_view::npos || - std::filesystem::path{std::string{value}}.is_absolute()) - { - throw std::invalid_argument(std::string{name} + - " must be a single path component."); - } - } - [[nodiscard]] inline auto select_topology(bool const spherical, bool const toroidal) -> Topology { @@ -518,8 +512,8 @@ namespace cdt::runtime_config throw std::invalid_argument("Volume epsilon must be non-negative."); } - detail::validate_path_component("run_id", run_id); - detail::validate_path_component("chain_id", chain_id); + cdt::utilities::validate_path_component("run_id", run_id); + cdt::utilities::validate_path_component("chain_id", chain_id); auto const fixed_target = checked_target > 0 ? checked_target : checked_simplices; auto const sweep_size = std::max(1, checked_simplices); diff --git a/include/S4Action.hpp b/include/S4Action.hpp index f086d7834..e0822c2ec 100644 --- a/include/S4Action.hpp +++ b/include/S4Action.hpp @@ -10,11 +10,20 @@ #include #include +#include #include "Settings.hpp" namespace cdt::four_d { + struct S4ClassResolvedCounts + { + Int_precision spatial_tetrahedra{0}; + Int_precision timelike_edges{0}; + Int_precision mixed_triangles{0}; + Int_precision timelike_tetrahedra{0}; + }; + struct S4Counts { Int_precision N0{0}; @@ -26,11 +35,7 @@ namespace cdt::four_d Int_precision N32{0}; Int_precision N23{0}; Int_precision N14{0}; - bool class_resolved_proposals{false}; - Int_precision spatial_tetrahedra{0}; - Int_precision timelike_edges{0}; - Int_precision mixed_triangles{0}; - Int_precision timelike_tetrahedra{0}; + std::optional class_resolved; }; struct S4Couplings diff --git a/include/Simulation_output.hpp b/include/Simulation_output.hpp index b8dc3b4ea..98c853676 100644 --- a/include/Simulation_output.hpp +++ b/include/Simulation_output.hpp @@ -22,6 +22,7 @@ #include "Metropolis_4.hpp" #include "Phase_analysis.hpp" +#include "Utilities.hpp" namespace cdt::four_d::output { @@ -54,24 +55,26 @@ namespace cdt::four_d::output case '\n': escaped += "\\n"; break; case '\r': escaped += "\\r"; break; case '\t': escaped += "\\t"; break; - default: escaped += character; break; + default: + if (static_cast(character) < 0x20U) + { + auto const byte = + static_cast(static_cast(character)); + auto constexpr hex = std::string_view{"0123456789ABCDEF"}; + escaped += "\\u00"; + escaped += hex[(byte >> 4U) & 0xFU]; + escaped += hex[byte & 0xFU]; + } + else + { + escaped += character; + } + break; } } return escaped; } - inline void validate_path_component(std::string_view const name, - std::string_view const value) - { - if (value.empty() || value == "." || value == ".." || - value.find_first_of("/\\") != std::string_view::npos || - std::filesystem::path{std::string{value}}.is_absolute()) - { - throw std::invalid_argument(std::string{name} + - " must be a single path component."); - } - } - [[nodiscard]] inline auto open_output_file(std::filesystem::path const& path) -> std::ofstream { @@ -112,7 +115,7 @@ namespace cdt::four_d::output Metropolis4Config const& config, Metropolis4Result const& result) { - validate_path_component("run_id", manifest.run_id); + cdt::utilities::validate_path_component("run_id", manifest.run_id); auto const run_dir = root / manifest.run_id; std::filesystem::create_directories(run_dir / "checkpoint"); diff --git a/include/Utilities.hpp b/include/Utilities.hpp index 0a66cd6d8..4b6d68890 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -96,6 +96,18 @@ namespace cdt::utilities FINAL_TRIANGULATION }; + inline void validate_path_component(std::string_view const name, + std::string_view const value) + { + if (value.empty() || value == "." || value == ".." || + value.find_first_of("/\\") != std::string_view::npos || + std::filesystem::path{std::string{value}}.is_absolute()) + { + throw std::invalid_argument(std::string{name} + + " must be a single path component."); + } + } + /// @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. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c42e2e817..fc95dfae8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable( S3Action_test.cpp S4Action_test.cpp Settings_test.cpp + Simulation_output_test.cpp Tetrahedron_test.cpp Torus_test.cpp Time_reversal_4_test.cpp diff --git a/tests/Checkpoint_4_test.cpp b/tests/Checkpoint_4_test.cpp index d367b4d80..f0b841f24 100644 --- a/tests/Checkpoint_4_test.cpp +++ b/tests/Checkpoint_4_test.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include using namespace cdt::four_d; @@ -16,7 +18,20 @@ namespace { std::filesystem::path path; - ~TempDirectory() { std::filesystem::remove_all(path); } + explicit TempDirectory(std::filesystem::path directory) + : path{std::move(directory)} + {} + + TempDirectory(TempDirectory const&) = delete; + auto operator=(TempDirectory const&) -> TempDirectory& = delete; + TempDirectory(TempDirectory&&) noexcept = default; + auto operator=(TempDirectory&&) noexcept -> TempDirectory& = default; + + ~TempDirectory() + { + std::error_code error; + std::filesystem::remove_all(path, error); + } }; auto make_temp_directory() -> TempDirectory diff --git a/tests/Detailed_balance_test.cpp b/tests/Detailed_balance_test.cpp index 41b85700a..2967e19e1 100644 --- a/tests/Detailed_balance_test.cpp +++ b/tests/Detailed_balance_test.cpp @@ -35,7 +35,7 @@ TEST_CASE("4D detailed-balance acceptance ingredients are finite") auto const probability = acceptance_probability( triangulation, proposal->triangulation, move_tracker::MoveType4D::TWO_EIGHT, couplings); - CHECK(std::isfinite(static_cast(probability))); + CHECK(std::isfinite(probability)); CHECK_GE(probability, 0.0L); CHECK_LE(probability, 1.0L); } @@ -53,9 +53,22 @@ TEST_CASE("4D detailed-balance verifier reports capped enumeration") { auto triangulation = FoliatedTriangulation4::periodic_seed(3); S4Couplings couplings{1.0L, 0.2L, 0.1L, 36, 0.001L}; - auto report = verify_detailed_balance(triangulation, couplings, 1, 1.0e-10L, 1); + auto report = + verify_detailed_balance(triangulation, couplings, 1, 1.0e-10L, 1); CHECK_FALSE(report.passed); CHECK_FALSE(report.errors.empty()); + CHECK(report.edges.empty()); +} + +TEST_CASE("4D detailed-balance verifier rejects nonpositive depth") +{ + auto triangulation = FoliatedTriangulation4::periodic_seed(3); + S4Couplings couplings{1.0L, 0.2L, 0.1L, 36, 0.001L}; + auto report = verify_detailed_balance(triangulation, couplings, 0); + CHECK_FALSE(report.passed); + REQUIRE_FALSE(report.errors.empty()); + CHECK_EQ(report.errors.front(), + "Detailed-balance enumeration max_depth must be positive."); } TEST_CASE("4D detailed-balance verifier rejects non-finite weights") diff --git a/tests/Foliated_triangulation_4_test.cpp b/tests/Foliated_triangulation_4_test.cpp index 57dad7704..fbc185cfc 100644 --- a/tests/Foliated_triangulation_4_test.cpp +++ b/tests/Foliated_triangulation_4_test.cpp @@ -1,10 +1,38 @@ #include "Foliated_triangulation_4.hpp" +#include +#include +#include +#include + #include using namespace cdt::four_d; namespace move_tracker = cdt::move_tracker; +namespace +{ + [[nodiscard]] auto has_error(ValidationReport const& report, + std::string_view const error) -> bool + { + return std::ranges::any_of( + report.errors, [error](std::string const& candidate) { + return candidate == error; + }); + } + + [[nodiscard]] auto isolated_simplex( + SimplexId const id, std::array vertices, + SimplexType4D const type = SimplexType4D::FOUR_ONE) -> Simplex4D + { + Simplex4D simplex; + simplex.id = id; + simplex.vertices = vertices; + simplex.type = type; + return simplex; + } +} + TEST_CASE("Abstract 4D periodic seed validates") { auto triangulation = FoliatedTriangulation4::periodic_seed(4); @@ -83,44 +111,104 @@ TEST_CASE("4D time reversal maps profiles and vertex times cyclically") TEST_CASE("4D candidate validation is independent from the initializer") { auto seeded = FoliatedTriangulation4::periodic_seed(3); - auto from_counts = FoliatedTriangulation4::from_counts_for_validation( - seeded.timeslices(), seeded.counts(), seeded.spatial_volume_profile()); - - CHECK(from_counts.is_valid()); - CHECK_EQ(from_counts.spatial_topology(), "S3"); - CHECK_EQ(from_counts.spacetime_topology(), "S3xS1"); - CHECK_EQ(from_counts.proposal_inventory().spatial_tetrahedra, - seeded.proposal_inventory().spatial_tetrahedra); - CHECK_EQ(from_counts.proposal_inventory().mixed_triangles, - seeded.proposal_inventory().mixed_triangles); - - auto clamped = FoliatedTriangulation4::from_counts_for_validation( - -7, seeded.counts(), {}); - CHECK_EQ(clamped.timeslices(), 2); - CHECK_EQ(clamped.spatial_volume_profile().size(), 2); - - auto counts = S4Counts{1, 2, 3, 4, 5, 1, 2, 1, 1}; - auto abstract = FoliatedTriangulation4::from_counts_for_validation( - 2, counts, FoliatedTriangulation4::Profile{1, 1}); - auto const inventory = abstract.proposal_inventory(); - CHECK_EQ(inventory.spatial_tetrahedra, 4); - CHECK_EQ(inventory.timelike_edges, 2); - CHECK_EQ(inventory.mixed_triangles, 3); - CHECK_EQ(inventory.timelike_tetrahedra, 4); - CHECK_EQ(inventory.vertices, 1); - CHECK_EQ(inventory.three_two_simplices, 2); - CHECK_EQ(inventory.two_three_simplices, 1); - - counts.class_resolved_proposals = true; - counts.spatial_tetrahedra = 7; - counts.timelike_edges = 8; - counts.mixed_triangles = 9; - counts.timelike_tetrahedra = 10; - auto exact = FoliatedTriangulation4::from_counts_for_validation( - 2, counts, FoliatedTriangulation4::Profile{1, 1}); - auto const exact_inventory = exact.proposal_inventory(); - CHECK_EQ(exact_inventory.spatial_tetrahedra, 7); - CHECK_EQ(exact_inventory.timelike_edges, 8); - CHECK_EQ(exact_inventory.mixed_triangles, 9); - CHECK_EQ(exact_inventory.timelike_tetrahedra, 10); + + SUBCASE("count-only states preserve closed periodic S3 metadata") + { + auto from_counts = FoliatedTriangulation4::from_counts_for_validation( + seeded.timeslices(), seeded.counts(), seeded.spatial_volume_profile()); + + CHECK(from_counts.is_valid()); + CHECK_EQ(from_counts.spatial_topology(), "S3"); + CHECK_EQ(from_counts.spacetime_topology(), "S3xS1"); + CHECK_EQ(from_counts.proposal_inventory().spatial_tetrahedra, + seeded.proposal_inventory().spatial_tetrahedra); + CHECK_EQ(from_counts.proposal_inventory().mixed_triangles, + seeded.proposal_inventory().mixed_triangles); + } + + SUBCASE("count-only constructor clamps timeslices") + { + auto clamped = FoliatedTriangulation4::from_counts_for_validation( + -7, seeded.counts(), {}); + CHECK_EQ(clamped.timeslices(), 2); + CHECK_EQ(clamped.spatial_volume_profile().size(), 2); + } + + SUBCASE("abstract proposal inventories use aggregate fallback counts") + { + auto counts = S4Counts{1, 2, 3, 4, 5, 1, 2, 1, 1}; + auto abstract = FoliatedTriangulation4::from_counts_for_validation( + 2, counts, FoliatedTriangulation4::Profile{1, 1}); + auto const inventory = abstract.proposal_inventory(); + CHECK_EQ(inventory.spatial_tetrahedra, 4); + CHECK_EQ(inventory.timelike_edges, 2); + CHECK_EQ(inventory.mixed_triangles, 3); + CHECK_EQ(inventory.timelike_tetrahedra, 4); + CHECK_EQ(inventory.vertices, 1); + CHECK_EQ(inventory.three_two_simplices, 2); + CHECK_EQ(inventory.two_three_simplices, 1); + } + + SUBCASE("complex-derived proposal inventories use class-resolved counts") + { + auto counts = S4Counts{1, 2, 3, 4, 5, 1, 2, 1, 1}; + counts.class_resolved = S4ClassResolvedCounts{7, 8, 9, 10}; + auto exact = FoliatedTriangulation4::from_counts_for_validation( + 2, counts, FoliatedTriangulation4::Profile{1, 1}); + auto const exact_inventory = exact.proposal_inventory(); + CHECK_EQ(exact_inventory.spatial_tetrahedra, 7); + CHECK_EQ(exact_inventory.timelike_edges, 8); + CHECK_EQ(exact_inventory.mixed_triangles, 9); + CHECK_EQ(exact_inventory.timelike_tetrahedra, 10); + } + + SUBCASE("negative spatial profile is reported") + { + auto invalid = FoliatedTriangulation4::from_counts_for_validation( + 2, seeded.counts(), FoliatedTriangulation4::Profile{-1, 1}); + auto const report = invalid.validate(); + CHECK_FALSE(report.valid()); + CHECK(has_error(report, "Spatial profile contains negative volume.")); + } + + SUBCASE("vertex-time cache errors are reported") + { + auto invalid = FoliatedTriangulation4::from_checkpoint_state( + 2, S4Counts{}, FoliatedTriangulation4::Profile{0, 0}, + FoliatedTriangulation4::VertexContainer{ + Vertex4D{1, 0}, Vertex4D{1, 1}}, + {}, true); + auto const report = invalid.validate(); + CHECK_FALSE(report.valid()); + CHECK(has_error(report, "Vertex-time cache does not match vertices.")); + CHECK(has_error(report, "Vertex-time cache is stale.")); + } + + SUBCASE("disconnected restored complexes are rejected") + { + auto invalid = FoliatedTriangulation4::from_checkpoint_state( + 2, S4Counts{999, 0, 0, 0, 999, 0, 0, 0, 0}, + FoliatedTriangulation4::Profile{2, 0}, + FoliatedTriangulation4::VertexContainer{ + Vertex4D{1, 0}, Vertex4D{2, 0}, Vertex4D{3, 0}, + Vertex4D{4, 0}, Vertex4D{5, 1}, Vertex4D{6, 0}, + Vertex4D{7, 0}, Vertex4D{8, 0}, Vertex4D{9, 0}, + Vertex4D{10, 1}}, + FoliatedTriangulation4::SimplexContainer{ + isolated_simplex(1, std::array{1, 2, 3, 4, 5}), + isolated_simplex(2, std::array{6, 7, 8, 9, 10})}, + true); + auto const counts = invalid.counts(); + CHECK_EQ(counts.N0, 10); + CHECK_EQ(counts.N4, 2); + CHECK_EQ(counts.N41, 2); + REQUIRE(counts.class_resolved.has_value()); + CHECK_EQ(invalid.proposal_inventory().spatial_tetrahedra, 2); + + auto const report = invalid.validate(); + CHECK_FALSE(report.valid()); + CHECK(has_error(report, "Simplex neighbor graph is disconnected.")); + CHECK(has_error( + report, "Spatial slices are not validated as connected S3 slices.")); + } } diff --git a/tests/Phase_analysis_test.cpp b/tests/Phase_analysis_test.cpp index 2846f5ceb..b1d640325 100644 --- a/tests/Phase_analysis_test.cpp +++ b/tests/Phase_analysis_test.cpp @@ -84,6 +84,14 @@ TEST_CASE("Synthetic C_dS profiles pass finite-size scaling") CHECK_GE(scaling.collapse_error, 0.0L); } +TEST_CASE("Finite-size scaling collapse error remains finite for sparse samples") +{ + auto scaling = analyze_finite_size_scaling( + std::vector>{{1.0L, Profile{}}}); + CHECK_FALSE(scaling.passed); + CHECK(std::isfinite(scaling.collapse_error)); +} + TEST_CASE("Full C_dS candidate gate requires profile shape and finite-size scaling") { auto profiles = build_synthetic_c_ds_profiles(); @@ -109,7 +117,13 @@ TEST_CASE("Covariance produces a regularized effective-action kernel") TEST_CASE("Profile statistics reject mismatched dimensions") { std::vector profiles{Profile{1.0L, 2.0L}, Profile{1.0L}}; - CHECK_THROWS_AS(mean(profiles), std::invalid_argument); - CHECK_THROWS_AS(covariance(profiles, Profile{1.0L, 2.0L}), + CHECK_THROWS_AS(([&profiles] { + [[maybe_unused]] auto const result = mean(profiles); + }()), + std::invalid_argument); + CHECK_THROWS_AS(([&profiles] { + [[maybe_unused]] auto const result = + covariance(profiles, Profile{1.0L, 2.0L}); + }()), std::invalid_argument); } diff --git a/tests/Simulation_output_test.cpp b/tests/Simulation_output_test.cpp new file mode 100644 index 000000000..56a73fd74 --- /dev/null +++ b/tests/Simulation_output_test.cpp @@ -0,0 +1,17 @@ +#include "Simulation_output.hpp" + +#include + +#include + +using namespace cdt::four_d::output; + +TEST_CASE("4D JSON output escapes control characters") +{ + std::string controls; + controls.push_back('\x01'); + controls.push_back('\x1F'); + + CHECK_EQ(json_escape(controls), "\\u0001\\u001F"); + CHECK_EQ(json_escape("\"\\\b\f\n\r\t"), "\\\"\\\\\\b\\f\\n\\r\\t"); +} From d956b970fab335c2fb4e0638f5beacf380323757 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 2 Aug 2026 11:41:54 +0300 Subject: [PATCH 4/6] Fix PR 138 CI and review follow-ups --- include/Detailed_balance_4.hpp | 75 ++++---- include/Foliated_triangulation_4.hpp | 221 ++++++++++++------------ include/Metropolis_4.hpp | 100 +++++------ include/Phase_analysis.hpp | 128 ++++++-------- include/Random.hpp | 47 +++-- include/Simulation_output.hpp | 112 ++++++------ tests/Foliated_triangulation_4_test.cpp | 104 +++++++---- tests/Metropolis_4_test.cpp | 24 +-- tests/Phase_analysis_test.cpp | 61 +++++-- tests/Random_test.cpp | 10 ++ tests/Simulation_output_test.cpp | 144 ++++++++++++++- 11 files changed, 608 insertions(+), 418 deletions(-) diff --git a/include/Detailed_balance_4.hpp b/include/Detailed_balance_4.hpp index 7296c46cc..a51157410 100644 --- a/include/Detailed_balance_4.hpp +++ b/include/Detailed_balance_4.hpp @@ -33,13 +33,13 @@ namespace cdt::four_d struct DetailedBalanceReport4D { - bool passed{true}; + bool passed{true}; std::vector edges; - std::vector errors; + std::vector errors; }; [[nodiscard]] inline auto proposal_probability( - FoliatedTriangulation4 const& triangulation, + FoliatedTriangulation4 const& triangulation, move_tracker::MoveType4D const move) -> long double { auto const multiplicity = triangulation.candidate_multiplicity(move); @@ -49,12 +49,11 @@ namespace cdt::four_d } [[nodiscard]] inline auto acceptance_probability( - FoliatedTriangulation4 const& before, - FoliatedTriangulation4 const& after, - move_tracker::MoveType4D const move, - S4Couplings const& couplings) -> long double + FoliatedTriangulation4 const& before, FoliatedTriangulation4 const& after, + move_tracker::MoveType4D const move, S4Couplings const& couplings) + -> long double { - auto const reverse = move_tracker::reverse_move(move); + auto const reverse = move_tracker::reverse_move(move); auto const forward_q = proposal_probability(before, move); auto const reverse_q = proposal_probability(after, reverse); if (forward_q == 0.0L || reverse_q == 0.0L) { return 0.0L; } @@ -72,20 +71,20 @@ namespace cdt::four_d if (log_ratio >= 0.0L) { return 1.0L; } auto const probability = std::exp(log_ratio); return std::isfinite(probability) - ? probability - : std::numeric_limits::quiet_NaN(); + ? probability + : std::numeric_limits::quiet_NaN(); } [[nodiscard]] inline auto boltzmann_weight( - FoliatedTriangulation4 const& triangulation, - S4Couplings const& couplings) -> long double + FoliatedTriangulation4 const& triangulation, S4Couplings const& couplings) + -> long double { return std::exp(-S4_bulk_action(triangulation.counts(), couplings)); } [[nodiscard]] inline auto log_boltzmann_weight( - FoliatedTriangulation4 const& triangulation, - S4Couplings const& couplings) -> long double + FoliatedTriangulation4 const& triangulation, S4Couplings const& couplings) + -> long double { return -S4_bulk_action(triangulation.counts(), couplings); } @@ -93,8 +92,7 @@ namespace cdt::four_d [[nodiscard]] inline auto verify_detailed_balance( FoliatedTriangulation4 const& seed, S4Couplings const& couplings, int const max_depth, long double const tolerance = 1.0e-10L, - std::size_t const max_states = 1024) - -> DetailedBalanceReport4D + std::size_t const max_states = 1024) -> DetailedBalanceReport4D { DetailedBalanceReport4D report; if (max_depth <= 0) @@ -112,8 +110,8 @@ namespace cdt::four_d return report; } - std::map states; - std::map depths; + std::map states; + std::map depths; std::queue> frontier; states.emplace(seed.canonical_hash(), seed); @@ -158,15 +156,16 @@ namespace cdt::four_d auto moved = moves::apply(from_state, descriptor.move); if (!moved) { continue; } auto const to_hash = moved->triangulation.canonical_hash(); - auto const to_it = states.find(to_hash); + auto const to_it = states.find(to_hash); if (to_it == states.end()) { report.passed = false; - report.errors.emplace_back("Reachable transition escaped enumeration depth."); + report.errors.emplace_back( + "Reachable transition escaped enumeration depth."); continue; } auto const& to_state = to_it->second; - auto reverse = moves::apply(to_state, descriptor.inverse); + auto reverse = moves::apply(to_state, descriptor.inverse); if (!reverse || reverse->triangulation.canonical_hash() != from_hash) { report.passed = false; @@ -178,24 +177,20 @@ namespace cdt::four_d proposal_probability(from_state, descriptor.move); auto const reverse_q = proposal_probability(to_state, descriptor.inverse); - auto const forward_acceptance = - acceptance_probability(from_state, to_state, descriptor.move, - couplings); - auto const reverse_acceptance = - acceptance_probability(to_state, from_state, descriptor.inverse, - couplings); - auto const from_log_weight = log_boltzmann_weight(from_state, couplings); + auto const forward_acceptance = acceptance_probability( + from_state, to_state, descriptor.move, couplings); + auto const reverse_acceptance = acceptance_probability( + to_state, from_state, descriptor.inverse, couplings); + auto const from_log_weight = + log_boltzmann_weight(from_state, couplings); auto const to_log_weight = log_boltzmann_weight(to_state, couplings); - auto const min_log = - std::log(std::numeric_limits::min()); - auto const max_log = - std::log(std::numeric_limits::max()); - if (!std::isfinite(from_log_weight) || - !std::isfinite(to_log_weight) || from_log_weight <= min_log || - to_log_weight <= min_log || from_log_weight >= max_log || - to_log_weight >= max_log || forward_q <= 0.0L || - reverse_q <= 0.0L || forward_acceptance <= 0.0L || - reverse_acceptance <= 0.0L || + auto const min_log = std::log(std::numeric_limits::min()); + auto const max_log = std::log(std::numeric_limits::max()); + if (!std::isfinite(from_log_weight) || !std::isfinite(to_log_weight) || + from_log_weight <= min_log || to_log_weight <= min_log || + from_log_weight >= max_log || to_log_weight >= max_log || + forward_q <= 0.0L || reverse_q <= 0.0L || + forward_acceptance <= 0.0L || reverse_acceptance <= 0.0L || !std::isfinite(forward_acceptance) || !std::isfinite(reverse_acceptance)) { @@ -211,8 +206,8 @@ namespace cdt::four_d // evaluated in log space to avoid overflow and silent underflow. auto const log_lhs = from_log_weight + std::log(forward_q) + std::log(forward_acceptance); - auto const log_rhs = to_log_weight + std::log(reverse_q) + - std::log(reverse_acceptance); + auto const log_rhs = + to_log_weight + std::log(reverse_q) + std::log(reverse_acceptance); if (!std::isfinite(log_lhs) || !std::isfinite(log_rhs)) { report.passed = false; diff --git a/include/Foliated_triangulation_4.hpp b/include/Foliated_triangulation_4.hpp index 7c141bc3d..91e4bf633 100644 --- a/include/Foliated_triangulation_4.hpp +++ b/include/Foliated_triangulation_4.hpp @@ -48,10 +48,10 @@ namespace cdt::four_d struct Simplex4D { - SimplexId id{0}; - std::array vertices{}; - std::array, 5> neighbors{}; - SimplexType4D type{SimplexType4D::FOUR_ONE}; + SimplexId id{0}; + std::array vertices{}; + std::array, 5> neighbors{}; + SimplexType4D type{SimplexType4D::FOUR_ONE}; }; struct ValidationReport @@ -59,11 +59,10 @@ namespace cdt::four_d bool standard_cdt_candidate{true}; std::vector errors; - [[nodiscard]] auto valid() const -> bool { return errors.empty(); } + [[nodiscard]] auto valid() const -> bool { return errors.empty(); } }; - [[nodiscard]] inline auto as_count(SimplexType4D const type) - -> Int_precision + [[nodiscard]] inline auto as_count(SimplexType4D const type) -> Int_precision { return static_cast(type); } @@ -72,7 +71,8 @@ namespace cdt::four_d /// /// Count-only states intentionally keep no local simplex complex. Topology /// accessors therefore use the declared closed-S3 flag for those abstract - /// states, and derive Euler/connectivity data only when simplices are present. + /// states, and derive Euler/connectivity data only when simplices are + /// present. class FoliatedTriangulation4 { public: @@ -81,16 +81,16 @@ namespace cdt::four_d using Profile = std::vector; private: - Int_precision m_timeslices{2}; - bool m_periodic{true}; - VertexContainer m_vertices; - SimplexContainer m_simplices; + Int_precision m_timeslices{2}; + bool m_periodic{true}; + VertexContainer m_vertices; + SimplexContainer m_simplices; std::unordered_map m_vertex_times; - S4Counts m_counts; - ProposalInventory4D m_proposal_inventory; - Profile m_spatial_profile; - bool m_closed_s3_slices{true}; - bool m_three_three_forward{true}; + S4Counts m_counts; + ProposalInventory4D m_proposal_inventory; + Profile m_spatial_profile; + bool m_closed_s3_slices{true}; + bool m_three_three_forward{true}; [[nodiscard]] auto vertex_time(VertexId const id) const -> Int_precision { @@ -116,9 +116,8 @@ namespace cdt::four_d return delta == 1 || delta == m_timeslices - 1; } - [[nodiscard]] auto classify_simplex( - std::array const& vertices) const - -> std::optional + [[nodiscard]] auto classify_simplex(std::array const& vertices) + const -> std::optional { std::map by_time; for (auto const vertex : vertices) { ++by_time[vertex_time(vertex)]; } @@ -176,11 +175,11 @@ namespace cdt::four_d } [[nodiscard]] auto facet_vertices(Simplex4D const& simplex, - int const omitted_local_index) const + int const omitted_local_index) const -> std::array { std::array facet{}; - auto out = 0; + auto out = 0; for (auto index = 0; index < 5; ++index) { if (index != omitted_local_index) @@ -257,20 +256,14 @@ namespace cdt::four_d tetrahedra.insert(facet); std::set times; for (auto const vertex : facet) { times.insert(vertex_time(vertex)); } - if (times.size() == 1) - { - spatial_tetrahedra.insert(facet); - } - else - { - timelike_tetrahedra.insert(facet); - } + if (times.size() == 1) { spatial_tetrahedra.insert(facet); } + else { timelike_tetrahedra.insert(facet); } } } - counts.N1 = static_cast(edges.size()); - counts.N2 = static_cast(triangles.size()); - counts.N3 = static_cast(tetrahedra.size()); + counts.N1 = static_cast(edges.size()); + counts.N2 = static_cast(triangles.size()); + counts.N3 = static_cast(tetrahedra.size()); counts.class_resolved = S4ClassResolvedCounts{ static_cast(spatial_tetrahedra.size()), static_cast(timelike_edges.size()), @@ -339,11 +332,11 @@ namespace cdt::four_d : static_cast(1)); } - Profile result(static_cast(m_timeslices), 0); + Profile result(static_cast(m_timeslices), 0); auto const facets_by_slice = spacelike_facets_by_slice(); for (std::size_t slice = 0; slice < facets_by_slice.size(); ++slice) { - std::set vertices; + std::set vertices; std::set> edges; std::set> triangles; for (auto const& facet : facets_by_slice[slice]) @@ -353,9 +346,8 @@ namespace cdt::four_d { for (auto j = i + 1; j < 4; ++j) { - auto edge = - std::array{facet[static_cast(i)], - facet[static_cast(j)]}; + auto edge = std::array{facet[static_cast(i)], + facet[static_cast(j)]}; std::ranges::sort(edge); edges.insert(edge); } @@ -366,21 +358,20 @@ namespace cdt::four_d { for (auto k = j + 1; k < 4; ++k) { - auto triangle = - std::array{facet[static_cast(i)], - facet[static_cast(j)], - facet[static_cast(k)]}; + auto triangle = std::array{facet[static_cast(i)], + facet[static_cast(j)], + facet[static_cast(k)]}; std::ranges::sort(triangle); triangles.insert(triangle); } } } } - result[slice] = static_cast(vertices.size()) - - static_cast(edges.size()) + - static_cast(triangles.size()) - - static_cast( - facets_by_slice[slice].size()); + result[slice] = + static_cast(vertices.size()) - + static_cast(edges.size()) + + static_cast(triangles.size()) - + static_cast(facets_by_slice[slice].size()); } return result; } @@ -392,15 +383,14 @@ namespace cdt::four_d for (auto const& facets : facets_by_slice) { if (facets.empty()) { continue; } - std::map, - std::vector>> + std::map, std::vector>> triangle_to_facets; for (auto const& facet : facets) { for (auto omitted = 0; omitted < 4; ++omitted) { std::array triangle{}; - auto out = 0; + auto out = 0; for (auto index = 0; index < 4; ++index) { if (index == omitted) { continue; } @@ -412,7 +402,7 @@ namespace cdt::four_d } } - std::set> visited; + std::set> visited; std::queue> frontier; frontier.push(*facets.begin()); visited.insert(*facets.begin()); @@ -423,7 +413,7 @@ namespace cdt::four_d for (auto omitted = 0; omitted < 4; ++omitted) { std::array triangle{}; - auto out = 0; + auto out = 0; for (auto index = 0; index < 4; ++index) { if (index == omitted) { continue; } @@ -450,7 +440,7 @@ namespace cdt::four_d { simplex_by_id.emplace(simplex.id, &simplex); } - std::set visited; + std::set visited; std::queue frontier; frontier.push(m_simplices.front().id); visited.insert(m_simplices.front().id); @@ -472,9 +462,9 @@ namespace cdt::four_d [[nodiscard]] auto topology_matches_closed_s3_slices() const -> bool { auto const eulers = derived_slice_euler_characteristics(); - return std::ranges::all_of(eulers, [](auto const chi) { - return chi == 0; - }) && (!m_closed_s3_slices || spatial_slices_are_connected()); + return std::ranges::all_of(eulers, + [](auto const chi) { return chi == 0; }) && + (!m_closed_s3_slices || spatial_slices_are_connected()); } void add_count_delta(S4Counts const& delta) @@ -504,8 +494,8 @@ namespace cdt::four_d } } - [[nodiscard]] auto apply_spatial_profile_delta( - Int_precision const delta) -> bool + [[nodiscard]] auto apply_spatial_profile_delta(Int_precision const delta) + -> bool { if (delta == 0) { return true; } if (m_spatial_profile.size() != static_cast(m_timeslices)) @@ -527,15 +517,15 @@ namespace cdt::four_d [[nodiscard]] auto can_apply(S4Counts const& delta) const -> bool { - auto const after = S4Counts{ - m_counts.N0 + delta.N0, m_counts.N1 + delta.N1, - m_counts.N2 + delta.N2, m_counts.N3 + delta.N3, - m_counts.N4 + delta.N4, m_counts.N41 + delta.N41, - m_counts.N32 + delta.N32, m_counts.N23 + delta.N23, - m_counts.N14 + delta.N14}; - return after.N0 >= 0 && after.N1 >= 0 && after.N2 >= 0 && - after.N3 >= 0 && after.N4 >= 0 && after.N41 >= 0 && - after.N32 >= 0 && after.N23 >= 0 && after.N14 >= 0 && + auto const after = + S4Counts{m_counts.N0 + delta.N0, m_counts.N1 + delta.N1, + m_counts.N2 + delta.N2, m_counts.N3 + delta.N3, + m_counts.N4 + delta.N4, m_counts.N41 + delta.N41, + m_counts.N32 + delta.N32, m_counts.N23 + delta.N23, + m_counts.N14 + delta.N14}; + return after.N0 >= 0 && after.N1 >= 0 && after.N2 >= 0 && after.N3 >= 0 && + after.N4 >= 0 && after.N41 >= 0 && after.N32 >= 0 && + after.N23 >= 0 && after.N14 >= 0 && after.N4 == after.N41 + after.N32 + after.N23 + after.N14; } @@ -582,10 +572,9 @@ namespace cdt::four_d for (auto local = 0; local < 5; ++local) { - auto const omitted_neighbor = - static_cast(std::ranges::find( - vertices, simplex.vertices[local]) - - vertices.begin()); + auto const omitted_neighbor = static_cast( + std::ranges::find(vertices, simplex.vertices[local]) - + vertices.begin()); simplex.neighbors[static_cast(local)] = ids[static_cast(omitted_neighbor)]; } @@ -629,8 +618,8 @@ namespace cdt::four_d bool const three_three_forward) -> FoliatedTriangulation4 { FoliatedTriangulation4 result{timeslices, counts, std::move(profile)}; - result.m_vertices = std::move(vertices); - result.m_simplices = std::move(simplices); + result.m_vertices = std::move(vertices); + result.m_simplices = std::move(simplices); result.m_three_three_forward = three_three_forward; result.rebuild_vertex_time_cache(); if (!result.m_vertices.empty() || !result.m_simplices.empty()) @@ -663,10 +652,10 @@ namespace cdt::four_d next_time, 2); } - result.m_counts = result.recompute_counts_from_complex(); + result.m_counts = result.recompute_counts_from_complex(); result.m_proposal_inventory = proposal_inventory_from_counts(result.m_counts); - result.m_spatial_profile = result.recompute_spatial_profile(); + result.m_spatial_profile = result.recompute_spatial_profile(); result.m_closed_s3_slices = true; // The production 4D runner evolves abstract count/profile states. Keep // the generated complex as an initializer only, so later moves cannot @@ -739,14 +728,13 @@ namespace cdt::four_d { auto profile = m_spatial_profile; if (profile.empty()) { return profile; } - auto const peak = static_cast(std::distance( - profile.begin(), std::ranges::max_element(profile))); + auto const peak = static_cast( + std::distance(profile.begin(), std::ranges::max_element(profile))); auto const center = profile.size() / 2; std::rotate(profile.begin(), profile.begin() + - static_cast((peak + profile.size() - - center) % - profile.size()), + static_cast( + (peak + profile.size() - center) % profile.size()), profile.end()); return profile; } @@ -758,10 +746,8 @@ namespace cdt::four_d { for (auto const vertex : simplex.vertices) { ++orders[vertex]; } } - auto const max_order = - std::ranges::max_element(orders, {}, [](auto const& pair) { - return pair.second; - }); + auto const max_order = std::ranges::max_element( + orders, {}, [](auto const& pair) { return pair.second; }); auto const move_growth = std::max( 0, m_counts.N4 - static_cast(m_simplices.size())); return max_order == orders.end() ? move_growth @@ -794,8 +780,8 @@ namespace cdt::four_d for (std::size_t index = 0; index < m_spatial_profile.size(); ++index) { auto const next = (index + 1) % m_spatial_profile.size(); - roughness += std::abs(static_cast(m_spatial_profile[index] - - m_spatial_profile[next])); + roughness += std::abs(static_cast( + m_spatial_profile[index] - m_spatial_profile[next])); } return roughness; } @@ -808,8 +794,8 @@ namespace cdt::four_d auto square_sum = 0.0L; for (auto const volume : m_spatial_profile) { - square_sum += static_cast(volume) * - static_cast(volume); + square_sum += + static_cast(volume) * static_cast(volume); } return square_sum / (total * total); } @@ -843,7 +829,8 @@ namespace cdt::four_d if (move == MoveType4D::THREE_THREE && !m_three_three_forward) { return std::max( - 0, m_proposal_inventory.count(ProposalObservable4D::two_three_simplices)); + 0, m_proposal_inventory.count( + ProposalObservable4D::two_three_simplices)); } return std::max( 0, m_proposal_inventory.count(descriptor.proposal_observable)); @@ -865,7 +852,7 @@ namespace cdt::four_d [[nodiscard]] auto apply_move(move_tracker::MoveType4D const move) -> bool { auto const before = *this; - auto delta = move_count_delta(move); + auto delta = move_count_delta(move); if (move == move_tracker::MoveType4D::THREE_THREE) { if (!m_three_three_forward) @@ -904,8 +891,8 @@ namespace cdt::four_d { report.errors.emplace_back("At least two timeslices are required."); } - if (m_counts.N4 != m_counts.N41 + m_counts.N32 + m_counts.N23 + - m_counts.N14) + if (m_counts.N4 != + m_counts.N41 + m_counts.N32 + m_counts.N23 + m_counts.N14) { report.errors.emplace_back("N4 does not match the simplex type sum."); } @@ -917,11 +904,13 @@ namespace cdt::four_d } if (!m_periodic) { - report.errors.emplace_back("Standard CDT candidate requires periodic time."); + report.errors.emplace_back( + "Standard CDT candidate requires periodic time."); } if (!m_closed_s3_slices) { - report.errors.emplace_back("Spatial slices are not marked as closed S3."); + report.errors.emplace_back( + "Spatial slices are not marked as closed S3."); } if (!topology_matches_closed_s3_slices()) { @@ -939,17 +928,18 @@ namespace cdt::four_d } if (m_spatial_profile.size() != static_cast(m_timeslices)) { - report.errors.emplace_back("Spatial profile does not match timeslice count."); + report.errors.emplace_back( + "Spatial profile does not match timeslice count."); } - if (std::ranges::any_of(m_spatial_profile, [](auto const volume) { - return volume < 0; - })) + if (std::ranges::any_of(m_spatial_profile, + [](auto const volume) { return volume < 0; })) { report.errors.emplace_back("Spatial profile contains negative volume."); } if (m_vertex_times.size() != m_vertices.size()) { - report.errors.emplace_back("Vertex-time cache does not match vertices."); + report.errors.emplace_back( + "Vertex-time cache does not match vertices."); } for (auto const& vertex : m_vertices) { @@ -971,8 +961,8 @@ namespace cdt::four_d report.errors.emplace_back("Negative proposal multiplicity found."); } - std::set> simplex_keys; - std::map, int> facet_incidence; + std::set> simplex_keys; + std::map, int> facet_incidence; std::unordered_map simplex_by_id; for (auto const& simplex : m_simplices) { @@ -986,12 +976,19 @@ namespace cdt::four_d { report.errors.emplace_back("Duplicate 4-simplex found."); } - if (std::set(simplex.vertices.begin(), - simplex.vertices.end()) + if (std::set(simplex.vertices.begin(), simplex.vertices.end()) .size() != 5) { report.errors.emplace_back("A 4-simplex has duplicate vertices."); } + if (std::ranges::any_of(simplex.vertices, [&](auto const vertex) { + return !m_vertex_times.contains(vertex); + })) + { + report.errors.emplace_back( + "A 4-simplex references a missing vertex."); + continue; + } auto const expected_type = classify_simplex(simplex.vertices); if (!expected_type || *expected_type != simplex.type) { @@ -1010,13 +1007,14 @@ namespace cdt::four_d continue; } auto const& neighbor_simplex = *neighbor_it->second; - auto const reciprocal = std::ranges::any_of( + auto const reciprocal = std::ranges::any_of( neighbor_simplex.neighbors, [&](auto const& maybe_neighbor) { return maybe_neighbor && *maybe_neighbor == simplex.id; }); if (!reciprocal) { - report.errors.emplace_back("Neighbor relationship is not reciprocal."); + report.errors.emplace_back( + "Neighbor relationship is not reciprocal."); } } } @@ -1076,7 +1074,7 @@ namespace cdt::four_d proposal_inventory_from_counts(reversed.m_counts); if (m_spatial_profile.size() == static_cast(m_timeslices)) { - Profile mapped(m_spatial_profile.size(), 0); + Profile mapped(m_spatial_profile.size(), 0); auto const slices = static_cast(m_timeslices); for (std::size_t index = 0; index < slices; ++index) { @@ -1094,9 +1092,9 @@ namespace cdt::four_d stream << "T=" << m_timeslices << ";P=" << m_periodic << ";F=" << m_three_three_forward << ";"; stream << m_counts.N0 << ',' << m_counts.N1 << ',' << m_counts.N2 << ',' - << m_counts.N3 << ',' << m_counts.N4 << ',' << m_counts.N41 - << ',' << m_counts.N32 << ',' << m_counts.N23 << ',' - << m_counts.N14 << ";C=" << m_counts.class_resolved.has_value(); + << m_counts.N3 << ',' << m_counts.N4 << ',' << m_counts.N41 << ',' + << m_counts.N32 << ',' << m_counts.N23 << ',' << m_counts.N14 + << ";C=" << m_counts.class_resolved.has_value(); if (m_counts.class_resolved) { auto const& class_counts = *m_counts.class_resolved; @@ -1105,10 +1103,7 @@ namespace cdt::four_d << class_counts.mixed_triangles << ',' << class_counts.timelike_tetrahedra; } - else - { - stream << ",0,0,0,0"; - } + else { stream << ",0,0,0,0"; } stream << ";V="; for (auto const volume : m_spatial_profile) { stream << volume << ','; } return stream.str(); diff --git a/include/Metropolis_4.hpp b/include/Metropolis_4.hpp index 665a661dc..f9e18e019 100644 --- a/include/Metropolis_4.hpp +++ b/include/Metropolis_4.hpp @@ -41,44 +41,44 @@ namespace cdt::four_d struct Metropolis4Measurement { - Int_precision step{0}; - S4Counts counts; - long double action{0.0L}; - Int_precision max_vertex_order{0}; - bool valid{false}; + Int_precision step{0}; + S4Counts counts; + long double action{0.0L}; + Int_precision max_vertex_order{0}; + bool valid{false}; FoliatedTriangulation4::Profile spatial_profile; }; struct Metropolis4Config { - S4Couplings couplings; - std::uint64_t seed{1}; - std::string chain_id{"chain-0"}; - Int_precision thermalization_steps{0}; - Int_precision measurement_interval{1}; - Int_precision checkpoint_interval{0}; + S4Couplings couplings; + std::uint64_t seed{1}; + std::string chain_id{"chain-0"}; + Int_precision thermalization_steps{0}; + Int_precision measurement_interval{1}; + Int_precision checkpoint_interval{0}; std::filesystem::path checkpoint_directory; }; struct Metropolis4Result { - FoliatedTriangulation4 triangulation; - std::vector action_trace; - std::vector volume_trace; - std::vector measurements; + FoliatedTriangulation4 triangulation; + std::vector action_trace; + std::vector volume_trace; + std::vector measurements; std::array move_stats{}; }; class Metropolis4 { - Metropolis4Config m_config; - cdt::Random m_rng; + Metropolis4Config m_config; + cdt::Random m_rng; [[nodiscard]] static auto transition_stream_for_chain( std::string const& chain_id) -> cdt::RandomStream { - auto value = 14695981039346656037ULL ^ - cdt::random_streams::transitions.value(); + auto value = + 14695981039346656037ULL ^ cdt::random_streams::transitions.value(); for (auto const character : chain_id) { value ^= static_cast(character); @@ -123,8 +123,8 @@ namespace cdt::four_d if (log_probability >= 0.0L) { return 1.0L; } auto const probability = std::exp(log_probability); return std::isfinite(probability) - ? probability - : std::numeric_limits::quiet_NaN(); + ? probability + : std::numeric_limits::quiet_NaN(); } static void write_counts(std::ostream& stream, S4Counts const& counts) @@ -144,17 +144,14 @@ namespace cdt::four_d static auto read_counts(std::istream& stream) -> S4Counts { S4Counts counts; - stream >> counts.N0 >> counts.N1 >> counts.N2 >> counts.N3 >> - counts.N4 >> counts.N41 >> counts.N32 >> counts.N23 >> counts.N14; - bool has_class_resolved{false}; + stream >> counts.N0 >> counts.N1 >> counts.N2 >> counts.N3 >> counts.N4 >> + counts.N41 >> counts.N32 >> counts.N23 >> counts.N14; + bool has_class_resolved{false}; S4ClassResolvedCounts class_counts; stream >> has_class_resolved >> class_counts.spatial_tetrahedra >> class_counts.timelike_edges >> class_counts.mixed_triangles >> class_counts.timelike_tetrahedra; - if (!stream) - { - throw std::runtime_error{"Malformed checkpoint counts."}; - } + if (!stream) { throw std::runtime_error{"Malformed checkpoint counts."}; } if (has_class_resolved) { counts.class_resolved = class_counts; } return counts; } @@ -208,10 +205,7 @@ namespace cdt::four_d ++result.move_stats[move_index].attempted; auto proposal = moves::apply(result.triangulation, move); - if (!proposal) - { - ++result.move_stats[move_index].invalid; - } + if (!proposal) { ++result.move_stats[move_index].invalid; } else if (draw_probability() <= acceptance_probability(result.triangulation, proposal.value())) { @@ -229,9 +223,7 @@ namespace cdt::four_d step % m_config.measurement_interval == 0) { result.measurements.push_back(Metropolis4Measurement{ - step, - result.triangulation.counts(), - action, + step, result.triangulation.counts(), action, result.triangulation.max_vertex_order(), result.triangulation.is_valid(), result.triangulation.spatial_volume_profile()}); @@ -240,21 +232,21 @@ namespace cdt::four_d !m_config.checkpoint_directory.empty() && step % m_config.checkpoint_interval == 0) { - save_checkpoint(m_config.checkpoint_directory, - result.triangulation, step); + save_checkpoint(m_config.checkpoint_directory, result.triangulation, + step); } } return result; } - void save_checkpoint(std::filesystem::path const& directory, + void save_checkpoint(std::filesystem::path const& directory, FoliatedTriangulation4 const& triangulation, - Int_precision const step) const + Int_precision const step) const { std::filesystem::create_directories(directory); - auto const target = directory / "state.txt"; - auto const temporary = directory / "state.txt.tmp"; + auto const target = directory / "state.txt"; + auto const temporary = directory / "state.txt.tmp"; std::ofstream file(temporary); if (!file) { @@ -295,19 +287,17 @@ namespace cdt::four_d throw std::runtime_error{"Failed to write checkpoint: " + temporary.string()}; } - std::filesystem::remove(target); - std::filesystem::rename(temporary, target); + cdt::utilities::detail::replace_file(temporary, target); } - [[nodiscard]] auto load_checkpoint( - std::filesystem::path const& directory) -> std::pair + [[nodiscard]] auto load_checkpoint(std::filesystem::path const& directory) + -> std::pair { - auto const path = directory / "state.txt"; + auto const path = directory / "state.txt"; std::ifstream file(path); if (!file) { - throw std::runtime_error{"Failed to open checkpoint: " + - path.string()}; + throw std::runtime_error{"Failed to open checkpoint: " + path.string()}; } Int_precision step{0}; Int_precision timeslices{0}; @@ -316,16 +306,13 @@ namespace cdt::four_d require_label(file, "timeslices"); file >> timeslices; require_label(file, "counts"); - auto counts = read_counts(file); + auto counts = read_counts(file); std::size_t profile_size{0}; require_label(file, "profile"); file >> profile_size; FoliatedTriangulation4::Profile profile(profile_size); for (auto& value : profile) { file >> value; } - if (!file) - { - throw std::runtime_error{"Malformed checkpoint profile."}; - } + if (!file) { throw std::runtime_error{"Malformed checkpoint profile."}; } require_label(file, "three_three_forward"); bool three_three_forward{true}; file >> three_three_forward; @@ -350,7 +337,7 @@ namespace cdt::four_d { require_label(file, "simplex"); Simplex4D simplex; - auto type = 0; + auto type = 0; file >> simplex.id >> type; simplex.type = static_cast(type); for (auto& vertex : simplex.vertices) { file >> vertex; } @@ -366,7 +353,10 @@ namespace cdt::four_d require_label(file, "rng"); std::string state; std::getline(file, state); - if (!state.empty() && state.front() == ' ') { state.erase(state.begin()); } + if (!state.empty() && state.front() == ' ') + { + state.erase(state.begin()); + } set_rng_state(state); if (!file && state.empty()) { diff --git a/include/Phase_analysis.hpp b/include/Phase_analysis.hpp index a04982a10..fc5192262 100644 --- a/include/Phase_analysis.hpp +++ b/include/Phase_analysis.hpp @@ -23,11 +23,11 @@ namespace cdt::four_d::phase { - using Profile = std::vector; + using Profile = std::vector; inline constexpr long double collapse_peak_ratio = 0.70L; inline constexpr long double alternating_ratio_limit = 0.30L; - inline constexpr long double cos3_correlation_limit = 0.85L; + inline constexpr long double cos3_correlation_limit = 0.85L; inline constexpr long double finite_collapse_error_sentinel = std::numeric_limits::max() / 4.0L; @@ -62,12 +62,12 @@ namespace cdt::four_d::phase struct Diagnostics { - Verdict verdict{Verdict::no_phase_classification}; - long double autocorrelation_time{1.0L}; - long double held_out_likelihood{0.0L}; - long double aic{0.0L}; - long double bic{0.0L}; - Profile mean_profile; + Verdict verdict{Verdict::no_phase_classification}; + long double autocorrelation_time{1.0L}; + long double held_out_likelihood{0.0L}; + long double aic{0.0L}; + long double bic{0.0L}; + Profile mean_profile; std::vector covariance; }; @@ -87,23 +87,22 @@ namespace cdt::four_d::phase struct CdsValidationReport { - Verdict verdict{Verdict::no_phase_classification}; - FiniteSizeScalingReport scaling; + Verdict verdict{Verdict::no_phase_classification}; + FiniteSizeScalingReport scaling; std::vector profile_correlations; - long double minimum_profile_correlation{0.0L}; + long double minimum_profile_correlation{0.0L}; }; [[nodiscard]] inline auto centered(Profile profile) -> Profile { if (profile.empty()) { return profile; } - auto const peak = static_cast(std::distance( - profile.begin(), std::ranges::max_element(profile))); + auto const peak = static_cast( + std::distance(profile.begin(), std::ranges::max_element(profile))); auto const center_index = profile.size() / 2; std::rotate(profile.begin(), - profile.begin() + - static_cast((peak + profile.size() - - center_index) % - profile.size()), + profile.begin() + static_cast( + (peak + profile.size() - center_index) % + profile.size()), profile.end()); return profile; } @@ -132,7 +131,7 @@ namespace cdt::four_d::phase } [[nodiscard]] inline auto covariance(std::vector const& profiles, - Profile const& profile_mean) + Profile const& profile_mean) -> std::vector { std::vector result(profile_mean.size(), @@ -166,26 +165,21 @@ namespace cdt::four_d::phase std::vector const& profiles) -> EffectiveActionEstimate { auto const profile_mean = mean(profiles); - auto cov = covariance(profiles, profile_mean); - auto inverse = cov; + auto cov = covariance(profiles, profile_mean); + auto inverse = cov; for (std::size_t i = 0; i < cov.size(); ++i) { - for (std::size_t j = 0; j < cov.size(); ++j) - { - inverse[i][j] = 0.0L; - } + for (std::size_t j = 0; j < cov.size(); ++j) { inverse[i][j] = 0.0L; } auto const regularized = cov[i][i] + 1.0e-9L; - inverse[i][i] = 1.0L / regularized; + inverse[i][i] = 1.0L / regularized; } return EffectiveActionEstimate{cov, inverse}; } - [[nodiscard]] inline auto profile_width(Profile const& profile) - -> long double + [[nodiscard]] inline auto profile_width(Profile const& profile) -> long double { if (profile.empty()) { return 0.0L; } - auto const total = - std::accumulate(profile.begin(), profile.end(), 0.0L); + auto const total = std::accumulate(profile.begin(), profile.end(), 0.0L); if (total == 0.0L) { return 0.0L; } auto center = 0.0L; for (std::size_t index = 0; index < profile.size(); ++index) @@ -210,7 +204,8 @@ namespace cdt::four_d::phase result.reserve(samples.size()); for (auto const& sample : samples) { - if (sample.first > 0.0L && sample.second > 0.0L) + if (std::isfinite(sample.first) && std::isfinite(sample.second) && + sample.first > 0.0L && sample.second > 0.0L) { result.emplace_back(std::log(sample.first), std::log(sample.second)); } @@ -233,7 +228,7 @@ namespace cdt::four_d::phase } mean_x /= static_cast(positive_samples.size()); mean_y /= static_cast(positive_samples.size()); - auto numerator = 0.0L; + auto numerator = 0.0L; auto denominator = 0.0L; for (auto const& [log_x, log_y] : positive_samples) { @@ -268,10 +263,9 @@ namespace cdt::four_d::phase auto const difference = log_y - (intercept + exponent * log_x); residual += difference * difference; } - return std::isfinite(residual) && - residual < finite_collapse_error_sentinel - ? residual - : finite_collapse_error_sentinel; + return std::isfinite(residual) && residual < finite_collapse_error_sentinel + ? residual + : finite_collapse_error_sentinel; } [[nodiscard]] inline auto analyze_finite_size_scaling( @@ -287,14 +281,11 @@ namespace cdt::four_d::phase widths.emplace_back(volume, std::max(1.0L, profile_width(profile))); peaks.emplace_back(volume, *std::ranges::max_element(profile)); } - auto report = FiniteSizeScalingReport{ - fit_power_law_exponent(widths), - fit_power_law_exponent(peaks), - 0.0L, - false}; - report.collapse_error = - log_log_residual(widths, report.width_exponent) + - log_log_residual(peaks, report.peak_exponent); + auto report = + FiniteSizeScalingReport{fit_power_law_exponent(widths), + fit_power_law_exponent(peaks), 0.0L, false}; + report.collapse_error = log_log_residual(widths, report.width_exponent) + + log_log_residual(peaks, report.peak_exponent); report.passed = std::abs(report.width_exponent - 0.25L) < 0.08L && std::abs(report.peak_exponent - 0.75L) < 0.08L; return report; @@ -305,12 +296,10 @@ namespace cdt::four_d::phase -> long double { if (lhs.size() != rhs.size() || lhs.empty()) { return 0.0L; } - auto const lhs_mean = - std::accumulate(lhs.begin(), lhs.end(), 0.0L) / - static_cast(lhs.size()); - auto const rhs_mean = - std::accumulate(rhs.begin(), rhs.end(), 0.0L) / - static_cast(rhs.size()); + auto const lhs_mean = std::accumulate(lhs.begin(), lhs.end(), 0.0L) / + static_cast(lhs.size()); + auto const rhs_mean = std::accumulate(rhs.begin(), rhs.end(), 0.0L) / + static_cast(rhs.size()); auto numerator = 0.0L; auto lhs_norm = 0.0L; @@ -331,7 +320,7 @@ namespace cdt::four_d::phase { Profile result(size, 0.0L); if (size == 0) { return result; } - auto const pi = 3.141592653589793238462643383279502884L; + auto const pi = 3.141592653589793238462643383279502884L; auto const center_index = static_cast(size / 2); auto const width = std::max(1.0L, static_cast(size) / pi); for (std::size_t index = 0; index < size; ++index) @@ -339,14 +328,14 @@ namespace cdt::four_d::phase auto const x = (static_cast(index) - center_index) / width; if (std::abs(x) < pi / 2.0L) { - auto const c = std::cos(x); + auto const c = std::cos(x); result[index] = c * c * c; } } return result; } - [[nodiscard]] inline auto classify_profile_shape(Profile const& profile, + [[nodiscard]] inline auto classify_profile_shape(Profile const& profile, long double const total) -> std::optional { @@ -382,9 +371,8 @@ namespace cdt::four_d::phase std::numeric_limits::infinity(); for (auto const& [_, original_profile] : profiles_by_volume) { - auto profile = centered(original_profile); - auto const total = - std::accumulate(profile.begin(), profile.end(), 0.0L); + auto profile = centered(original_profile); + auto const total = std::accumulate(profile.begin(), profile.end(), 0.0L); if (auto const verdict = classify_profile_shape(profile, total)) { report.verdict = *verdict; @@ -399,11 +387,11 @@ namespace cdt::four_d::phase } report.scaling = analyze_finite_size_scaling(std::move(profiles_by_volume)); - report.verdict = report.scaling.passed && - report.minimum_profile_correlation > - cos3_correlation_limit - ? Verdict::c_ds_supported - : Verdict::no_phase_classification; + report.verdict = + report.scaling.passed && + report.minimum_profile_correlation > cos3_correlation_limit + ? Verdict::c_ds_supported + : Verdict::no_phase_classification; return report; } @@ -411,10 +399,9 @@ namespace cdt::four_d::phase std::vector const& series) -> long double { if (series.size() < 3) { return 1.0L; } - auto const average = - std::accumulate(series.begin(), series.end(), 0.0L) / - static_cast(series.size()); - auto numerator = 0.0L; + auto const average = std::accumulate(series.begin(), series.end(), 0.0L) / + static_cast(series.size()); + auto numerator = 0.0L; auto denominator = 0.0L; for (std::size_t index = 1; index < series.size(); ++index) { @@ -441,11 +428,10 @@ namespace cdt::four_d::phase for (auto& profile : profiles) { profile = centered(std::move(profile)); } diagnostics.mean_profile = mean(profiles); - diagnostics.covariance = covariance(profiles, diagnostics.mean_profile); + diagnostics.covariance = covariance(profiles, diagnostics.mean_profile); - auto const total = - std::accumulate(diagnostics.mean_profile.begin(), - diagnostics.mean_profile.end(), 0.0L); + auto const total = std::accumulate(diagnostics.mean_profile.begin(), + diagnostics.mean_profile.end(), 0.0L); if (auto const verdict = classify_profile_shape(diagnostics.mean_profile, total)) { @@ -459,14 +445,14 @@ namespace cdt::four_d::phase // These are heuristic profile-shape scores derived from the cos^3 // correlation, not formal likelihood, AIC, or BIC statistics. diagnostics.held_out_likelihood = cos3_corr; - diagnostics.aic = -2.0L * cos3_corr + 2.0L * 4.0L; + diagnostics.aic = -2.0L * cos3_corr + 2.0L * 4.0L; diagnostics.bic = -2.0L * cos3_corr + std::log(static_cast(diagnostics.mean_profile.size())) * 4.0L; diagnostics.verdict = cos3_corr > cos3_correlation_limit - ? Verdict::c_ds_supported - : Verdict::no_phase_classification; + ? Verdict::c_ds_supported + : Verdict::no_phase_classification; return diagnostics; } } // namespace cdt::four_d::phase diff --git a/include/Random.hpp b/include/Random.hpp index 89c368595..91fcb4fd6 100644 --- a/include/Random.hpp +++ b/include/Random.hpp @@ -36,7 +36,9 @@ namespace cdt {} [[nodiscard]] constexpr auto value() const noexcept -> std::uint64_t - { return m_value; } + { + return m_value; + } [[nodiscard]] auto operator==(RandomSeed const&) const noexcept -> bool = default; @@ -54,7 +56,9 @@ namespace cdt {} [[nodiscard]] constexpr auto value() const noexcept -> std::uint64_t - { return m_value; } + { + return m_value; + } [[nodiscard]] auto operator==(RandomStream const&) const noexcept -> bool = default; @@ -62,19 +66,27 @@ namespace cdt [[nodiscard]] constexpr auto format_as(RandomSeed const seed) noexcept -> std::uint64_t - { return seed.value(); } + { + return seed.value(); + } [[nodiscard]] constexpr auto format_as(RandomStream const stream) noexcept -> std::uint64_t - { return stream.value(); } + { + return stream.value(); + } inline auto operator<<(std::ostream& output, RandomSeed const seed) -> std::ostream& - { return output << seed.value(); } + { + return output << seed.value(); + } inline auto operator<<(std::ostream& output, RandomStream const stream) -> std::ostream& - { return output << stream.value(); } + { + return output << stream.value(); + } namespace random_streams { @@ -129,10 +141,14 @@ namespace cdt {} [[nodiscard]] static constexpr auto min() noexcept -> result_type - { return pcg64::min(); } + { + return pcg64::min(); + } [[nodiscard]] static constexpr auto max() noexcept -> result_type - { return pcg64::max(); } + { + return pcg64::max(); + } [[nodiscard]] auto operator()() -> result_type { return m_engine(); } @@ -141,7 +157,9 @@ namespace cdt /// @returns The PCG stream selector used by this engine. [[nodiscard]] auto stream() const noexcept -> RandomStream - { return m_stream; } + { + return m_stream; + } /// @returns Serialized checkpoint state for resuming this exact stream. /// @details This state captures the progressed engine only. The recorded @@ -159,16 +177,21 @@ namespace cdt void set_engine_state(std::string const& state) { std::istringstream input(state); - input >> m_engine; - if (!input) + auto restored = m_engine; + input >> restored; + input >> std::ws; + if (!input || !input.eof()) { throw std::runtime_error{"Invalid random engine state."}; } + m_engine = restored; } /// @brief Create a fresh reproducible stream from the same root seed. [[nodiscard]] auto split(RandomStream const stream) const -> Random - { return Random{m_seed, stream}; } + { + return Random{m_seed, stream}; + } }; static_assert(std::uniform_random_bit_generator); diff --git a/include/Simulation_output.hpp b/include/Simulation_output.hpp index 98c853676..94793e4ad 100644 --- a/include/Simulation_output.hpp +++ b/include/Simulation_output.hpp @@ -60,15 +60,12 @@ namespace cdt::four_d::output { auto const byte = static_cast(static_cast(character)); - auto constexpr hex = std::string_view{"0123456789ABCDEF"}; + constexpr auto hex = std::string_view{"0123456789ABCDEF"}; escaped += "\\u00"; escaped += hex[(byte >> 4U) & 0xFU]; escaped += hex[byte & 0xFU]; } - else - { - escaped += character; - } + else { escaped += character; } break; } } @@ -87,13 +84,14 @@ namespace cdt::four_d::output return file; } - inline void ensure_wrote(std::ostream const& file, + inline void ensure_wrote(std::ofstream& file, std::filesystem::path const& path) { + file.flush(); + file.close(); if (!file) { - throw std::runtime_error("Failed to write output file: " + - path.string()); + throw std::runtime_error("Failed to write output file: " + path.string()); } } @@ -111,9 +109,9 @@ namespace cdt::four_d::output } inline void write_run_directory(std::filesystem::path const& root, - RunManifest const& manifest, - Metropolis4Config const& config, - Metropolis4Result const& result) + RunManifest const& manifest, + Metropolis4Config const& config, + Metropolis4Result const& result) { cdt::utilities::validate_path_component("run_id", manifest.run_id); auto const run_dir = root / manifest.run_id; @@ -121,7 +119,7 @@ namespace cdt::four_d::output { auto const path = run_dir / "manifest.json"; - auto file = open_output_file(path); + auto file = open_output_file(path); file << "{\n"; file << " \"version\": 1,\n"; file << " \"run_id\": \"" << json_escape(manifest.run_id) << "\",\n"; @@ -129,34 +127,33 @@ namespace cdt::four_d::output << "\",\n"; file << " \"build_type\": \"" << json_escape(manifest.build_type) << "\",\n"; - file << " \"compiler\": \"" << json_escape(manifest.compiler) - << "\",\n"; + file << " \"compiler\": \"" << json_escape(manifest.compiler) << "\",\n"; file << " \"seed\": " << config.seed << ",\n"; - file << " \"chain_id\": \"" << json_escape(config.chain_id) - << "\",\n"; + file << " \"chain_id\": \"" << json_escape(config.chain_id) << "\",\n"; file << " \"kappa_0\": " << config.couplings.kappa_0 << ",\n"; file << " \"kappa_4\": " << config.couplings.kappa_4 << ",\n"; file << " \"Delta\": " << config.couplings.Delta << ",\n"; file << " \"target_N4\": " << config.couplings.target_N4 << ",\n"; - file << " \"volume_epsilon\": " - << config.couplings.volume_epsilon << ",\n"; + file << " \"volume_epsilon\": " << config.couplings.volume_epsilon + << ",\n"; file << " \"timeslices\": " << result.triangulation.timeslices() << ",\n"; - file << " \"thermalization_steps\": " - << config.thermalization_steps << ",\n"; - file << " \"measurement_interval\": " - << config.measurement_interval << "\n"; + file << " \"thermalization_steps\": " << config.thermalization_steps + << ",\n"; + file << " \"measurement_interval\": " << config.measurement_interval + << "\n"; file << "}\n"; ensure_wrote(file, path); } { auto const path = run_dir / "measurements.jsonl"; - auto file = open_output_file(path); + auto file = open_output_file(path); for (auto const& measurement : result.measurements) { - file << "{\"step\":" << measurement.step << ",\"action\":" - << measurement.action << ",\"N4\":" << measurement.counts.N4 + file << "{\"step\":" << measurement.step + << ",\"action\":" << measurement.action + << ",\"N4\":" << measurement.counts.N4 << ",\"max_vertex_order\":" << measurement.max_vertex_order << ",\"valid\":" << (measurement.valid ? "true" : "false") << "}\n"; @@ -166,7 +163,7 @@ namespace cdt::four_d::output { auto const path = run_dir / "spatial_volume.csv"; - auto file = open_output_file(path); + auto file = open_output_file(path); file << "timeslice,N3\n"; auto const profile = result.triangulation.spatial_volume_profile(); for (std::size_t index = 0; index < profile.size(); ++index) @@ -178,7 +175,7 @@ namespace cdt::four_d::output { auto const path = run_dir / "spatial_volume_profiles.csv"; - auto file = open_output_file(path); + auto file = open_output_file(path); file << "step,timeslice,N3\n"; for (auto const& measurement : result.measurements) { @@ -194,7 +191,7 @@ namespace cdt::four_d::output { auto const path = run_dir / "simplex_counts.csv"; - auto file = open_output_file(path); + auto file = open_output_file(path); write_counts_csv_header(file); for (auto const& measurement : result.measurements) { @@ -209,16 +206,15 @@ namespace cdt::four_d::output { auto const path = run_dir / "move_statistics.csv"; - auto file = open_output_file(path); + auto file = open_output_file(path); file << "move,attempted,accepted,invalid\n"; for (std::size_t index = 0; index < result.move_stats.size(); ++index) { - auto const& stat = result.move_stats[index]; - auto const descriptor = - move_descriptor_4d(move_tracker::as_move_4d( - static_cast(index))); - file << descriptor.name << ',' << stat.attempted << ',' - << stat.accepted << ',' << stat.invalid << '\n'; + auto const& stat = result.move_stats[index]; + auto const descriptor = move_descriptor_4d( + move_tracker::as_move_4d(static_cast(index))); + file << descriptor.name << ',' << stat.attempted << ',' << stat.accepted + << ',' << stat.invalid << '\n'; } ensure_wrote(file, path); } @@ -230,7 +226,7 @@ namespace cdt::four_d::output "Action and volume traces have different lengths."); } auto const path = run_dir / "action_trace.csv"; - auto file = open_output_file(path); + auto file = open_output_file(path); file << "step,action,N4\n"; for (std::size_t index = 0; index < result.action_trace.size(); ++index) { @@ -254,11 +250,12 @@ namespace cdt::four_d::output if (!measured_profiles.empty()) { auto const profile_mean = phase::mean(measured_profiles); - auto const covariance = phase::covariance(measured_profiles, profile_mean); + auto const covariance = + phase::covariance(measured_profiles, profile_mean); auto const kernel = phase::effective_action_kernel(measured_profiles); auto const covariance_path = run_dir / "covariance.csv"; - auto covariance_file = open_output_file(covariance_path); + auto covariance_file = open_output_file(covariance_path); for (auto const& row : covariance) { for (std::size_t index = 0; index < row.size(); ++index) @@ -270,7 +267,7 @@ namespace cdt::four_d::output ensure_wrote(covariance_file, covariance_path); auto const effective_path = run_dir / "effective_action.csv"; - auto effective_file = open_output_file(effective_path); + auto effective_file = open_output_file(effective_path); for (auto const& row : kernel.inverse_covariance_diagonal_regularized) { for (std::size_t index = 0; index < row.size(); ++index) @@ -283,16 +280,16 @@ namespace cdt::four_d::output } { - auto const path = run_dir / "summary.json"; - auto file = open_output_file(path); - auto const counts = result.triangulation.counts(); - auto const report = result.triangulation.validate(); - auto const diagnostics = measured_profiles.empty() - ? phase::Diagnostics{} - : phase::diagnose(std::move(measured_profiles)); + auto const path = run_dir / "summary.json"; + auto file = open_output_file(path); + auto const counts = result.triangulation.counts(); + auto const report = result.triangulation.validate(); + auto const diagnostics = + measured_profiles.empty() + ? phase::Diagnostics{} + : phase::diagnose(std::move(measured_profiles)); file << "{\n"; - file << " \"chain_id\": \"" << json_escape(config.chain_id) - << "\",\n"; + file << " \"chain_id\": \"" << json_escape(config.chain_id) << "\",\n"; file << " \"N0\": " << counts.N0 << ",\n"; file << " \"N1\": " << counts.N1 << ",\n"; file << " \"N2\": " << counts.N2 << ",\n"; @@ -302,22 +299,21 @@ namespace cdt::four_d::output file << " \"N32\": " << counts.N32 << ",\n"; file << " \"N23\": " << counts.N23 << ",\n"; file << " \"N14\": " << counts.N14 << ",\n"; - file << " \"action\": " - << S4_bulk_action(counts, config.couplings) << ",\n"; + file << " \"action\": " << S4_bulk_action(counts, config.couplings) + << ",\n"; file << " \"maximum_vertex_order\": " << result.triangulation.max_vertex_order() << ",\n"; - file << " \"valid\": " << (report.valid() ? "true" : "false") - << ",\n"; + file << " \"valid\": " << (report.valid() ? "true" : "false") << ",\n"; file << " \"standard_cdt_candidate\": " << (report.valid() && report.standard_cdt_candidate ? "true" : "false") << ",\n"; - file << " \"phase_verdict\": \"" - << phase::to_string(diagnostics.verdict) << "\",\n"; - file << " \"autocorrelation_time\": " - << diagnostics.autocorrelation_time << ",\n"; - file << " \"held_out_likelihood\": " - << diagnostics.held_out_likelihood << ",\n"; + file << " \"phase_verdict\": \"" << phase::to_string(diagnostics.verdict) + << "\",\n"; + file << " \"autocorrelation_time\": " << diagnostics.autocorrelation_time + << ",\n"; + file << " \"held_out_likelihood\": " << diagnostics.held_out_likelihood + << ",\n"; file << " \"aic\": " << diagnostics.aic << ",\n"; file << " \"bic\": " << diagnostics.bic << "\n"; file << "}\n"; diff --git a/tests/Foliated_triangulation_4_test.cpp b/tests/Foliated_triangulation_4_test.cpp index fbc185cfc..5343c2630 100644 --- a/tests/Foliated_triangulation_4_test.cpp +++ b/tests/Foliated_triangulation_4_test.cpp @@ -1,24 +1,23 @@ #include "Foliated_triangulation_4.hpp" +#include + #include #include #include #include -#include - using namespace cdt::four_d; namespace move_tracker = cdt::move_tracker; namespace { [[nodiscard]] auto has_error(ValidationReport const& report, - std::string_view const error) -> bool + std::string_view const error) -> bool { return std::ranges::any_of( - report.errors, [error](std::string const& candidate) { - return candidate == error; - }); + report.errors, + [error](std::string const& candidate) { return candidate == error; }); } [[nodiscard]] auto isolated_simplex( @@ -26,17 +25,17 @@ namespace SimplexType4D const type = SimplexType4D::FOUR_ONE) -> Simplex4D { Simplex4D simplex; - simplex.id = id; + simplex.id = id; simplex.vertices = vertices; - simplex.type = type; + simplex.type = type; return simplex; } -} +} // namespace TEST_CASE("Abstract 4D periodic seed validates") { auto triangulation = FoliatedTriangulation4::periodic_seed(4); - auto counts = triangulation.counts(); + auto counts = triangulation.counts(); CHECK(triangulation.periodic()); CHECK_EQ(triangulation.timeslices(), 4); @@ -59,16 +58,16 @@ TEST_CASE("Abstract 4D periodic seed validates") TEST_CASE("Abstract 4D canonical hash is stable for copies") { auto triangulation = FoliatedTriangulation4::periodic_seed(3); - auto copy = triangulation; + auto copy = triangulation; CHECK_EQ(copy.canonical_hash(), triangulation.canonical_hash()); auto moved = triangulation; REQUIRE(moved.apply_move(move_tracker::MoveType4D::TWO_FOUR)); CHECK_NE(moved.canonical_hash(), triangulation.canonical_hash()); - auto const delta = - FoliatedTriangulation4::move_count_delta(move_tracker::MoveType4D::TWO_FOUR); + auto const delta = FoliatedTriangulation4::move_count_delta( + move_tracker::MoveType4D::TWO_FOUR); auto const before_counts = triangulation.counts(); - auto const after_counts = moved.counts(); + auto const after_counts = moved.counts(); CHECK_EQ(after_counts.N4, before_counts.N4 + delta.N4); CHECK_EQ(after_counts.N41, before_counts.N41 + delta.N41); CHECK_EQ(after_counts.N32, before_counts.N32 + delta.N32); @@ -77,7 +76,7 @@ TEST_CASE("Abstract 4D canonical hash is stable for copies") TEST_CASE("4D THREE_THREE toggles direction and is self-inverse") { auto triangulation = FoliatedTriangulation4::periodic_seed(3); - auto moved = triangulation; + auto moved = triangulation; REQUIRE(moved.apply_move(move_tracker::MoveType4D::THREE_THREE)); CHECK_NE(moved.three_three_forward(), triangulation.three_three_forward()); REQUIRE(moved.apply_move(move_tracker::MoveType4D::THREE_THREE)); @@ -86,15 +85,19 @@ TEST_CASE("4D THREE_THREE toggles direction and is self-inverse") TEST_CASE("4D time reversal maps profiles and vertex times cyclically") { - auto seed = FoliatedTriangulation4::periodic_seed(4); + auto seed = FoliatedTriangulation4::periodic_seed(4); + auto const seed_profile = FoliatedTriangulation4::Profile{1, 2, 3, 4}; + auto const seed_vertices = FoliatedTriangulation4::VertexContainer{ + Vertex4D{1, 0}, + Vertex4D{2, 1}, + Vertex4D{3, 2}, + Vertex4D{4, 3} + }; auto triangulation = FoliatedTriangulation4::from_checkpoint_state( - 4, seed.counts(), FoliatedTriangulation4::Profile{1, 2, 3, 4}, - FoliatedTriangulation4::VertexContainer{ - Vertex4D{1, 0}, Vertex4D{2, 1}, Vertex4D{3, 2}, Vertex4D{4, 3}}, - {}, true); + 4, seed.counts(), seed_profile, seed_vertices, {}, true); - auto reversed = triangulation.time_reversed(); - auto const profile = reversed.spatial_volume_profile(); + auto reversed = triangulation.time_reversed(); + auto const profile = reversed.spatial_volume_profile(); REQUIRE_EQ(profile.size(), 4); CHECK_EQ(profile[0], 1); CHECK_EQ(profile[1], 4); @@ -136,7 +139,7 @@ TEST_CASE("4D candidate validation is independent from the initializer") SUBCASE("abstract proposal inventories use aggregate fallback counts") { - auto counts = S4Counts{1, 2, 3, 4, 5, 1, 2, 1, 1}; + auto counts = S4Counts{1, 2, 3, 4, 5, 1, 2, 1, 1}; auto abstract = FoliatedTriangulation4::from_counts_for_validation( 2, counts, FoliatedTriangulation4::Profile{1, 1}); auto const inventory = abstract.proposal_inventory(); @@ -151,9 +154,9 @@ TEST_CASE("4D candidate validation is independent from the initializer") SUBCASE("complex-derived proposal inventories use class-resolved counts") { - auto counts = S4Counts{1, 2, 3, 4, 5, 1, 2, 1, 1}; + auto counts = S4Counts{1, 2, 3, 4, 5, 1, 2, 1, 1}; counts.class_resolved = S4ClassResolvedCounts{7, 8, 9, 10}; - auto exact = FoliatedTriangulation4::from_counts_for_validation( + auto exact = FoliatedTriangulation4::from_counts_for_validation( 2, counts, FoliatedTriangulation4::Profile{1, 1}); auto const exact_inventory = exact.proposal_inventory(); CHECK_EQ(exact_inventory.spatial_tetrahedra, 7); @@ -173,10 +176,13 @@ TEST_CASE("4D candidate validation is independent from the initializer") SUBCASE("vertex-time cache errors are reported") { - auto invalid = FoliatedTriangulation4::from_checkpoint_state( - 2, S4Counts{}, FoliatedTriangulation4::Profile{0, 0}, - FoliatedTriangulation4::VertexContainer{ - Vertex4D{1, 0}, Vertex4D{1, 1}}, + auto const empty_counts = S4Counts{}; + auto invalid = FoliatedTriangulation4::from_checkpoint_state( + 2, empty_counts, + FoliatedTriangulation4::Profile{ + 0, 0 + }, + FoliatedTriangulation4::VertexContainer{Vertex4D{1, 0}, Vertex4D{1, 1}}, {}, true); auto const report = invalid.validate(); CHECK_FALSE(report.valid()); @@ -184,16 +190,42 @@ TEST_CASE("4D candidate validation is independent from the initializer") CHECK(has_error(report, "Vertex-time cache is stale.")); } + SUBCASE("missing simplex vertices are reported") + { + auto const empty_counts = S4Counts{}; + auto invalid = FoliatedTriangulation4::from_checkpoint_state( + 2, empty_counts, + FoliatedTriangulation4::Profile{ + 0, 0 + }, + FoliatedTriangulation4::VertexContainer{Vertex4D{1, 0}}, + FoliatedTriangulation4::SimplexContainer{ + isolated_simplex(1, std::array{1, 2, 3, 4, 5})}, + true); + + auto const report = invalid.validate(); + + CHECK_FALSE(report.valid()); + CHECK(has_error(report, "A 4-simplex references a missing vertex.")); + } + SUBCASE("disconnected restored complexes are rejected") { + auto const counts = S4Counts{999, 0, 0, 0, 999, 0, 0, 0, 0}; + auto const vertices = FoliatedTriangulation4::VertexContainer{ + Vertex4D{ 1, 0}, + Vertex4D{ 2, 0}, + Vertex4D{ 3, 0}, + Vertex4D{ 4, 0}, + Vertex4D{ 5, 1}, + Vertex4D{ 6, 0}, + Vertex4D{ 7, 0}, + Vertex4D{ 8, 0}, + Vertex4D{ 9, 0}, + Vertex4D{10, 1} + }; auto invalid = FoliatedTriangulation4::from_checkpoint_state( - 2, S4Counts{999, 0, 0, 0, 999, 0, 0, 0, 0}, - FoliatedTriangulation4::Profile{2, 0}, - FoliatedTriangulation4::VertexContainer{ - Vertex4D{1, 0}, Vertex4D{2, 0}, Vertex4D{3, 0}, - Vertex4D{4, 0}, Vertex4D{5, 1}, Vertex4D{6, 0}, - Vertex4D{7, 0}, Vertex4D{8, 0}, Vertex4D{9, 0}, - Vertex4D{10, 1}}, + 2, counts, FoliatedTriangulation4::Profile{2, 0}, vertices, FoliatedTriangulation4::SimplexContainer{ isolated_simplex(1, std::array{1, 2, 3, 4, 5}), isolated_simplex(2, std::array{6, 7, 8, 9, 10})}, diff --git a/tests/Metropolis_4_test.cpp b/tests/Metropolis_4_test.cpp index 56b7543b8..48d1fd155 100644 --- a/tests/Metropolis_4_test.cpp +++ b/tests/Metropolis_4_test.cpp @@ -3,22 +3,23 @@ #include using namespace cdt::four_d; +using cdt::Int_precision; TEST_CASE("Metropolis4 same seed produces identical traces") { Metropolis4Config config; - config.seed = 1234; - config.couplings = S4Couplings{1.0L, 0.2L, 0.1L, 48, 0.001L}; + config.seed = 1234; + config.couplings = S4Couplings{1.0L, 0.2L, 0.1L, 48, 0.001L}; config.thermalization_steps = 2; config.measurement_interval = 2; Metropolis4 run_a(config); Metropolis4 run_b(config); - auto seed_a = FoliatedTriangulation4::periodic_seed(4); - auto seed_b = FoliatedTriangulation4::periodic_seed(4); + auto seed_a = FoliatedTriangulation4::periodic_seed(4); + auto seed_b = FoliatedTriangulation4::periodic_seed(4); - auto result_a = run_a.run(seed_a, 12); - auto result_b = run_b.run(seed_b, 12); + auto result_a = run_a.run(seed_a, 12); + auto result_b = run_b.run(seed_b, 12); CHECK_EQ(result_a.triangulation.canonical_hash(), result_b.triangulation.canonical_hash()); @@ -30,7 +31,7 @@ TEST_CASE("Metropolis4 same seed produces identical traces") } CHECK_EQ(result_a.measurements.size(), 5); - auto chain_config = config; + auto chain_config = config; chain_config.chain_id = "chain-1"; Metropolis4 chain_run(chain_config); CHECK_NE(run_a.rng_state(), chain_run.rng_state()); @@ -38,18 +39,17 @@ TEST_CASE("Metropolis4 same seed produces identical traces") auto different_seed_config = config; different_seed_config.seed = 1235; Metropolis4 run_c(different_seed_config); - auto result_c = - run_c.run(FoliatedTriangulation4::periodic_seed(4), 12); + auto result_c = run_c.run(FoliatedTriangulation4::periodic_seed(4), 12); CHECK_NE(result_a.action_trace, result_c.action_trace); } TEST_CASE("Metropolis4 records invalid proposals and accepted moves") { Metropolis4Config config; - config.seed = 7; + config.seed = 7; config.couplings = S4Couplings{1.0L, 0.2L, 0.1L, 48, 0.0L}; - Metropolis4 run(config); - auto result = run.run(FoliatedTriangulation4::periodic_seed(3), 20); + Metropolis4 run(config); + auto result = run.run(FoliatedTriangulation4::periodic_seed(3), 20); Int_precision attempted{0}; Int_precision accepted{0}; diff --git a/tests/Phase_analysis_test.cpp b/tests/Phase_analysis_test.cpp index b1d640325..9e6c97324 100644 --- a/tests/Phase_analysis_test.cpp +++ b/tests/Phase_analysis_test.cpp @@ -1,10 +1,11 @@ #include "Phase_analysis.hpp" +#include + #include +#include #include -#include - using namespace cdt::four_d::phase; namespace @@ -17,19 +18,20 @@ namespace { auto const width = static_cast( std::llround(4.0L * std::pow(volume, 0.25L))); - auto profile = cos3_reference(width | static_cast(1)); + auto profile = cos3_reference(width | static_cast(1)); auto const amplitude = std::pow(volume, 0.75L); for (auto& value : profile) { value = 1.0L + amplitude * value; } profiles.emplace_back(volume, profile); } return profiles; } -} +} // namespace -TEST_CASE("Synthetic cos cubed profiles are classified conservatively as C-like") +TEST_CASE( + "Synthetic cos cubed profiles are classified conservatively as C-like") { std::vector profiles; - auto base = cos3_reference(21); + auto base = cos3_reference(21); for (auto sample = 0; sample < 5; ++sample) { auto profile = base; @@ -65,11 +67,10 @@ TEST_CASE("Alternating-slice profiles are classified as C_b-like") CHECK_EQ(diagnostics.verdict, Verdict::c_b_like); } - TEST_CASE("Too few profiles are insufficient effective samples") { std::vector profiles(2, Profile(9, 1.0L)); - auto diagnostics = diagnose(profiles); + auto diagnostics = diagnose(profiles); CHECK_EQ(diagnostics.verdict, Verdict::insufficient_effective_samples); } @@ -77,24 +78,43 @@ TEST_CASE("Synthetic C_dS profiles pass finite-size scaling") { auto profiles = build_synthetic_c_ds_profiles(); - auto scaling = analyze_finite_size_scaling(profiles); + auto scaling = analyze_finite_size_scaling(profiles); CHECK(scaling.passed); CHECK(scaling.width_exponent == doctest::Approx(0.25L).epsilon(0.10)); CHECK(scaling.peak_exponent == doctest::Approx(0.75L).epsilon(0.10)); CHECK_GE(scaling.collapse_error, 0.0L); } -TEST_CASE("Finite-size scaling collapse error remains finite for sparse samples") +TEST_CASE( + "Finite-size scaling collapse error remains finite for sparse samples") { - auto scaling = analyze_finite_size_scaling( - std::vector>{{1.0L, Profile{}}}); + std::vector> profiles{ + {1.0L, Profile{}} + }; + + auto scaling = analyze_finite_size_scaling(profiles); + CHECK_FALSE(scaling.passed); CHECK(std::isfinite(scaling.collapse_error)); } -TEST_CASE("Full C_dS candidate gate requires profile shape and finite-size scaling") +TEST_CASE("Finite-size scaling ignores non-finite log-log samples") { auto profiles = build_synthetic_c_ds_profiles(); + profiles.emplace_back(std::numeric_limits::infinity(), + Profile{1.0L, 2.0L, 1.0L}); + + auto scaling = analyze_finite_size_scaling(profiles); + + CHECK(std::isfinite(scaling.width_exponent)); + CHECK(std::isfinite(scaling.peak_exponent)); + CHECK(std::isfinite(scaling.collapse_error)); +} + +TEST_CASE( + "Full C_dS candidate gate requires profile shape and finite-size scaling") +{ + auto profiles = build_synthetic_c_ds_profiles(); auto validation = diagnose_c_ds_finite_size(profiles, profiles.size()); CHECK_EQ(validation.verdict, Verdict::c_ds_supported); @@ -107,7 +127,8 @@ TEST_CASE("Covariance produces a regularized effective-action kernel") std::vector profiles{ Profile{1.0L, 2.0L, 1.0L}, Profile{1.0L, 3.0L, 1.0L}, - Profile{1.0L, 4.0L, 1.0L}}; + Profile{1.0L, 4.0L, 1.0L} + }; auto kernel = effective_action_kernel(profiles); REQUIRE_EQ(kernel.covariance.size(), 3); REQUIRE_EQ(kernel.inverse_covariance_diagonal_regularized.size(), 3); @@ -116,11 +137,13 @@ TEST_CASE("Covariance produces a regularized effective-action kernel") TEST_CASE("Profile statistics reject mismatched dimensions") { - std::vector profiles{Profile{1.0L, 2.0L}, Profile{1.0L}}; - CHECK_THROWS_AS(([&profiles] { - [[maybe_unused]] auto const result = mean(profiles); - }()), - std::invalid_argument); + std::vector profiles{ + Profile{1.0L, 2.0L}, + Profile{1.0L} + }; + CHECK_THROWS_AS( + ([&profiles] { [[maybe_unused]] auto const result = mean(profiles); }()), + std::invalid_argument); CHECK_THROWS_AS(([&profiles] { [[maybe_unused]] auto const result = covariance(profiles, Profile{1.0L, 2.0L}); diff --git a/tests/Random_test.cpp b/tests/Random_test.cpp index e21d50749..c75dde5bd 100644 --- a/tests/Random_test.cpp +++ b/tests/Random_test.cpp @@ -139,3 +139,13 @@ SCENARIO("Initialization point generation replays from its named stream" * REQUIRE_EQ(first_vertices, replay_vertices); } + +TEST_CASE("Random engine state rejects trailing non-whitespace") +{ + cdt::Random generator{cdt::RandomSeed{92}, cdt::random_streams::transitions}; + auto const state = generator.engine_state(); + + CHECK_THROWS_AS(generator.set_engine_state(state + " trailing"), + std::runtime_error); + CHECK_NOTHROW(generator.set_engine_state(state + " \t\n")); +} diff --git a/tests/Simulation_output_test.cpp b/tests/Simulation_output_test.cpp index 56a73fd74..6c9f5c4e1 100644 --- a/tests/Simulation_output_test.cpp +++ b/tests/Simulation_output_test.cpp @@ -1,11 +1,100 @@ #include "Simulation_output.hpp" -#include - #include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace cdt::four_d; using namespace cdt::four_d::output; +namespace +{ + struct TempDirectory + { + std::filesystem::path path; + + explicit TempDirectory(std::filesystem::path directory) + : path{std::move(directory)} + {} + + TempDirectory(TempDirectory const&) = delete; + auto operator=(TempDirectory const&) -> TempDirectory& = delete; + TempDirectory(TempDirectory&&) noexcept = default; + auto operator=(TempDirectory&&) noexcept -> TempDirectory& = default; + + ~TempDirectory() + { + std::error_code error; + std::filesystem::remove_all(path, error); + } + }; + + [[nodiscard]] auto make_temp_directory() -> TempDirectory + { + auto const suffix = std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()); + auto directory = std::filesystem::temp_directory_path() / + ("cdtpp-output-test-" + suffix); + std::filesystem::remove_all(directory); + std::filesystem::create_directories(directory); + return TempDirectory{directory}; + } + + [[nodiscard]] auto read_text(std::filesystem::path const& path) -> std::string + { + std::ifstream file(path); + return {std::istreambuf_iterator{file}, + std::istreambuf_iterator{}}; + } + + [[nodiscard]] auto output_config() -> Metropolis4Config + { + Metropolis4Config config; + config.seed = 7; + config.chain_id = "chain-0"; + config.couplings = S4Couplings{1.0L, 0.2L, 0.1L, 36, 0.001L}; + config.measurement_interval = 1; + return config; + } + + [[nodiscard]] auto output_manifest(std::string run_id = "run-0") + -> RunManifest + { + RunManifest manifest; + manifest.run_id = std::move(run_id); + manifest.git_commit = "test-commit"; + manifest.build_type = "Debug"; + manifest.compiler = "test-compiler"; + return manifest; + } + + [[nodiscard]] auto output_result() -> Metropolis4Result + { + Metropolis4Result result; + result.triangulation = FoliatedTriangulation4::periodic_seed(2); + auto const counts = result.triangulation.counts(); + result.action_trace = {1.0L, 2.0L}; + result.volume_trace = {counts.N4, counts.N4 + 1}; + auto const profile = result.triangulation.spatial_volume_profile(); + result.measurements.push_back(Metropolis4Measurement{ + 1, counts, 1.0L, result.triangulation.max_vertex_order(), true, + profile}); + result.measurements.push_back(Metropolis4Measurement{ + 2, counts, 2.0L, result.triangulation.max_vertex_order(), true, + profile}); + result.move_stats[0].attempted = 2; + result.move_stats[0].accepted = 1; + return result; + } +} // namespace + TEST_CASE("4D JSON output escapes control characters") { std::string controls; @@ -15,3 +104,54 @@ TEST_CASE("4D JSON output escapes control characters") CHECK_EQ(json_escape(controls), "\\u0001\\u001F"); CHECK_EQ(json_escape("\"\\\b\f\n\r\t"), "\\\"\\\\\\b\\f\\n\\r\\t"); } + +TEST_CASE("4D run output writes the public artifact set") +{ + auto directory = make_temp_directory(); + auto const config = output_config(); + auto const manifest = output_manifest(); + auto const result = output_result(); + + write_run_directory(directory.path, manifest, config, result); + + auto const run_dir = directory.path / manifest.run_id; + CHECK(std::filesystem::exists(run_dir / "manifest.json")); + CHECK(std::filesystem::exists(run_dir / "measurements.jsonl")); + CHECK(std::filesystem::exists(run_dir / "spatial_volume.csv")); + CHECK(std::filesystem::exists(run_dir / "spatial_volume_profiles.csv")); + CHECK(std::filesystem::exists(run_dir / "simplex_counts.csv")); + CHECK(std::filesystem::exists(run_dir / "move_statistics.csv")); + CHECK(std::filesystem::exists(run_dir / "action_trace.csv")); + CHECK(std::filesystem::exists(run_dir / "summary.json")); + CHECK(std::filesystem::exists(run_dir / "covariance.csv")); + CHECK(std::filesystem::exists(run_dir / "effective_action.csv")); + CHECK(std::filesystem::is_directory(run_dir / "checkpoint")); + CHECK(read_text(run_dir / "manifest.json").find("\"run_id\": \"run-0\"") != + std::string::npos); + CHECK(read_text(run_dir / "action_trace.csv").find("step,action,N4") != + std::string::npos); +} + +TEST_CASE("4D run output rejects invalid run ids") +{ + auto directory = make_temp_directory(); + auto const config = output_config(); + auto const result = output_result(); + + CHECK_THROWS_AS( + write_run_directory(directory.path, output_manifest("../outside"), config, + result), + std::invalid_argument); +} + +TEST_CASE("4D run output rejects mismatched action and volume traces") +{ + auto directory = make_temp_directory(); + auto const config = output_config(); + auto result = output_result(); + result.volume_trace.pop_back(); + + CHECK_THROWS_AS( + write_run_directory(directory.path, output_manifest(), config, result), + std::runtime_error); +} From 490979aa5a68a106c1391fead5db4f206330912d Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 2 Aug 2026 17:48:44 +0300 Subject: [PATCH 5/6] fix(4d): clear standard_cdt_candidate when structural checks fail validate() set standard_cdt_candidate unconditionally and never cleared it, so a report failing the standard-CDT structural checks still exposed standard_cdt_candidate == true to callers reading the field directly. Clear the flag in the four branches that emit standard-CDT structural errors: non-periodic time, slices not marked closed S3, slices not validated as connected S3, and a slice whose Euler characteristic is not S3's. The report.valid() && report.standard_cdt_candidate composition used by the CLI summary and JSON output is unchanged. Co-Authored-By: Claude Opus 5 --- include/Foliated_triangulation_4.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/include/Foliated_triangulation_4.hpp b/include/Foliated_triangulation_4.hpp index 91e4bf633..cc8525e3d 100644 --- a/include/Foliated_triangulation_4.hpp +++ b/include/Foliated_triangulation_4.hpp @@ -904,16 +904,19 @@ namespace cdt::four_d } if (!m_periodic) { + report.standard_cdt_candidate = false; report.errors.emplace_back( "Standard CDT candidate requires periodic time."); } if (!m_closed_s3_slices) { + report.standard_cdt_candidate = false; report.errors.emplace_back( "Spatial slices are not marked as closed S3."); } if (!topology_matches_closed_s3_slices()) { + report.standard_cdt_candidate = false; report.errors.emplace_back( "Spatial slices are not validated as connected S3 slices."); } @@ -921,6 +924,7 @@ namespace cdt::four_d { if (chi != 0) { + report.standard_cdt_candidate = false; report.errors.emplace_back( "A spatial slice does not have S3 Euler characteristic."); break; From e1d31c8d5d7376bd2720a13fe2fb67aad7108282 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 2 Aug 2026 19:12:15 +0300 Subject: [PATCH 6/6] fix(persistence): reject root-named path components validate_path_component rejected empty, dot, separator-bearing, and absolute values, but a drive-qualified value such as "C:" is none of those on Windows: it has a root name without a root directory, so is_absolute() is false and it contains no separator. operator/ discards its left operand whenever the right operand carries a root name, so "output" / "C:" collapses to "C:". A run_id or chain_id of "C:" therefore escaped the configured output root when composing run and checkpoint directories. Reject root names and root directories as well. Every composed component routes through this function, so run_id and chain_id are both covered at their existing call sites in Runtime_config.hpp and Simulation_output.hpp. POSIX is unaffected: "C:" has no root name there and stays a valid component, so the added test is guarded on _WIN32. Co-Authored-By: Claude Opus 5 --- include/Utilities.hpp | 9 ++++++++- tests/Simulation_output_test.cpp | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/include/Utilities.hpp b/include/Utilities.hpp index 4191c8042..648734840 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -97,12 +97,19 @@ namespace cdt::utilities FINAL_TRIANGULATION }; + /// @brief Reject values that are unsafe to append with `operator/`. + /// @details A drive-qualified value such as "C:" is neither absolute nor + /// separator-bearing, yet `operator/` discards the left operand whenever the + /// right operand carries a root name. Rejecting root names keeps composed + /// output and checkpoint paths anchored under their intended directory. inline void validate_path_component(std::string_view const name, std::string_view const value) { + auto const candidate = std::filesystem::path{std::string{value}}; if (value.empty() || value == "." || value == ".." || value.find_first_of("/\\") != std::string_view::npos || - std::filesystem::path{std::string{value}}.is_absolute()) + candidate.is_absolute() || candidate.has_root_name() || + candidate.has_root_directory()) { throw std::invalid_argument(std::string{name} + " must be a single path component."); diff --git a/tests/Simulation_output_test.cpp b/tests/Simulation_output_test.cpp index 6c9f5c4e1..4a4abdb66 100644 --- a/tests/Simulation_output_test.cpp +++ b/tests/Simulation_output_test.cpp @@ -142,6 +142,14 @@ TEST_CASE("4D run output rejects invalid run ids") write_run_directory(directory.path, output_manifest("../outside"), config, result), std::invalid_argument); + +#ifdef _WIN32 + // "C:" is neither absolute nor separator-bearing, but its root name would + // make operator/ discard the output root and escape the run directory. + CHECK_THROWS_AS(write_run_directory(directory.path, output_manifest("C:"), + config, result), + std::invalid_argument); +#endif } TEST_CASE("4D run output rejects mismatched action and volume traces")