From e2f7d70ae819d3f9e3ae8eeda83080d22fa4da5d Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Thu, 23 Jul 2026 10:46:34 -0700 Subject: [PATCH] feat(moves)!: add invariant-bearing move preparation Prepare every supported Pachner move as a handle-free applicable value before entering its mutation boundary. Replace string failures with typed move errors and outcomes so command and Metropolis accounting distinguish inapplicable proposals, execution failures, and accepted or rejected transitions. BREAKING CHANGE: Public move functions now return MoveResult with MoveError instead of std::expected. MoveCommand result customization now accepts only that typed result. Refs: #101 --- docs/api-boundary.md | 3 +- docs/ergodic-moves.md | 29 +- include/Ergodic_moves_3.hpp | 1142 ++++++++++++++++++-------- include/Metropolis.hpp | 40 +- include/Move_command.hpp | 43 +- include/Move_outcome.hpp | 113 +++ tests/CMakeLists.txt | 2 + tests/Ergodic_moves_3_audit_test.cpp | 189 +++++ tests/Ergodic_moves_3_test.cpp | 6 +- tests/Move_outcome_test.cpp | 156 ++++ tests/Public_api_consumer.cpp | 16 +- 11 files changed, 1349 insertions(+), 390 deletions(-) create mode 100644 include/Move_outcome.hpp create mode 100644 tests/Move_outcome_test.cpp diff --git a/docs/api-boundary.md b/docs/api-boundary.md index 568ca9653..64008c47f 100644 --- a/docs/api-boundary.md +++ b/docs/api-boundary.md @@ -19,7 +19,7 @@ required by that library's customization contract. | Header | Supported surface | Internal, customization, or experimental surface | | --- | --- | --- | | `Apply_move.hpp` | `cdt::apply_move` | None | -| `Ergodic_moves_3.hpp` | `cdt::ergodic_moves` aliases plus `null_move`, `do_*_move`, and `propose_*_move` | Every declaration in `cdt::ergodic_moves::detail`, including raw CGAL flips, cavity recognition, collection helpers, `bistellar_flip`, and `check_move` | +| `Ergodic_moves_3.hpp` | `cdt::ergodic_moves` aliases plus `null_move`, `do_*_move`, and `propose_*_move` | Every declaration in `cdt::ergodic_moves::detail`, including applicable-move preparation/execution, raw CGAL flips, cavity recognition, collection helpers, `bistellar_flip`, and `check_move` | | `Foliated_triangulation.hpp` | Root CGAL interop aliases and `Cell_type`; construction, inspection, repair, and `FoliatedTriangulation` declarations in `cdt::foliated_triangulations` | Generic constraints and repair limits in `cdt::detail`; the component declarations are the supported advanced triangulation API | | `Formatters.hpp` | `fmt::formatter>` | Supported external-library customization point | | `Geometry.hpp` | `cdt::Geometry` and `cdt::Geometry_3` | None | @@ -27,6 +27,7 @@ required by that library's customization contract. | `Metropolis.hpp` | `cdt::MoveStrategy` Metropolis specialization and `cdt::Metropolis_3` | Private members and nested types are implementation details | | `Move_always.hpp` | `cdt::MoveStrategy` move-always specialization and `cdt::MoveAlways_3` | Private members are implementation details | | `Move_command.hpp` | `cdt::MoveCommand` | Private queue and counter types are implementation details | +| `Move_outcome.hpp` | `cdt::ergodic_moves::MoveFailure`, `MoveError`, `MoveResult`, `MoveOutcome`, and `outcome_from` | `format_as` is the supported `fmt`/`spdlog` customization hook for `MoveError` | | `Move_strategy.hpp` | `cdt::Strategies` and `cdt::MoveStrategy` | None | | `Move_tracker.hpp` | Move enumeration, counters, conversion, and sampling in `cdt::move_tracker` | None | | `Mpfr_value.hpp` | Scoped MPFR value and operations in `cdt::mpfr_values` | None | diff --git a/docs/ergodic-moves.md b/docs/ergodic-moves.md index 146cd8f4b..4150d7cc8 100644 --- a/docs/ergodic-moves.md +++ b/docs/ergodic-moves.md @@ -32,11 +32,31 @@ unchanged. A successful move constructs a new `FoliatedTriangulation`, which rebuilds all handle-bearing caches and scalar geometry from the moved canonical triangulation. +Raw proposal sites do not flow directly into mutation. Each move has a +move-specific `detail::Applicable*Move` value constructed by a +`detail::prepare_*()` boundary. Construction proves the local topology, +causality, simplex classification, adjacency, and metadata requirements for +that move. The value stores owned point-value locators rather than CGAL handles; +execution resolves handles only at the mutation boundary and reports a +`STALE_CANDIDATE` if the prepared site is no longer present. The applicable +value is then consumed by `detail::execute()`, which does not rediscover the CDT +preconditions and leaves CGAL's checked flip or retriangulation operation as the +remaining fallible effect. + +The public high-level result is `MoveResult`, an allocation-free +`std::expected` whose error is `MoveError`. `MoveFailure` distinguishes no raw +candidate, invalid topology, causal invalidity, a stale prepared site, checked +execution failure, post-mutation invariant failure, and an unknown move. +`MoveOutcome` maps those typed failures and successful candidates into command +and Metropolis accounting, keeping an inapplicable proposal distinct from an +execution failure and from a valid Metropolis rejection. + CGAL 6.2 documents that checked three-dimensional flips preserve vertex handles and invalidate only affected cell handles. CDT++ never carries affected cell -handles past a successful flip. A copied triangulation has different handles; -the `(4,4)` and `(6,2)` implementations therefore capture stable point values -and re-resolve vertices and edges in their private copies before mutation. +handles in prepared values or past a successful flip. A copied triangulation +has different handles; every applicable-move implementation therefore captures +stable point values and re-resolves vertices, cells, or edges in the owning +candidate immediately before mutation. None of these moves is required to preserve the Euclidean empty-sphere (Delaunay) property of the representative coordinates. The scientific state is @@ -95,6 +115,9 @@ helper. - exact inverse round trips for `(2,3)/(3,2)`, `(2,6)/(6,2)`, and `(4,4)`; - malformed-handle, stale-metadata, wrong-cavity, non-applicable, and empty-state rejection checks with canonical failure-atomicity comparisons; and +- valid construction, forbidden default construction, and stale-locator + behavior for the applicable-move boundary, including structured failure + classification; and - replay of every inverse pair with seeds `0`, `1`, `2`, `92`, `106`, and `20260721`. diff --git a/include/Ergodic_moves_3.hpp b/include/Ergodic_moves_3.hpp index b544993d3..2b6532662 100644 --- a/include/Ergodic_moves_3.hpp +++ b/include/Ergodic_moves_3.hpp @@ -25,16 +25,23 @@ #include #include #include +#include +#include #include #include +#include +#include +#include +#include #include "Manifold.hpp" +#include "Move_outcome.hpp" #include "Move_tracker.hpp" namespace cdt::ergodic_moves { using Manifold = manifolds::Manifold_3; - using Expected = std::expected; + using Expected = MoveResult; using Cell_handle = Cell_handle_t<3>; using Cell_container = std::vector; using Edge_handle = Edge_handle_t<3>; @@ -45,6 +52,125 @@ namespace cdt::ergodic_moves namespace detail { + using Cell_points = std::array, 4>; + using Edge_points = std::array, 2>; + using Execution = std::expected; + + /// @brief Prepared (2,3) site whose CDT preconditions have been proven. + /// @details The locator owns point values rather than CGAL handles, so it + /// can be moved freely and never retains an invalidated cell handle. + class ApplicableTwoThreeMove + { + Cell_points m_cell; + Point_t<3> m_opposite; + + ApplicableTwoThreeMove(Cell_points cell, Point_t<3> opposite) noexcept + : m_cell{cell}, m_opposite{opposite} + {} + + friend auto prepare_two_three(Delaunay const& triangulation, + Cell_handle const& candidate) + -> std::expected; + friend auto execute(Delaunay& triangulation, + ApplicableTwoThreeMove const& move) -> Execution; + }; + + /// @brief Prepared (3,2) site whose causal edge cavity has been proven. + class ApplicableThreeTwoMove + { + Edge_points m_edge; + + explicit ApplicableThreeTwoMove(Edge_points edge) noexcept : m_edge{edge} + {} + + friend auto prepare_three_two(Delaunay const& triangulation, + Edge_handle const& candidate) + -> std::expected; + friend auto execute(Delaunay& triangulation, + ApplicableThreeTwoMove const& move) -> Execution; + }; + + /// @brief Prepared (2,6) spacelike facet between a (1,3)/(3,1) pair. + class ApplicableTwoSixMove + { + Cell_points m_bottom; + Point_t<3> m_opposite; + + ApplicableTwoSixMove(Cell_points bottom, Point_t<3> opposite) noexcept + : m_bottom{bottom}, m_opposite{opposite} + {} + + friend auto prepare_two_six(Delaunay const& triangulation, + Cell_handle const& candidate) + -> std::expected; + + public: + [[nodiscard]] auto bottom_points() const noexcept -> Cell_points const& + { return m_bottom; } + + [[nodiscard]] auto opposite_point() const noexcept -> Point_t<3> const& + { return m_opposite; } + }; + + /// @brief Prepared (6,2) degree-five vertex with the exact causal star. + class ApplicableSixTwoMove + { + Point_t<3> m_vertex; + + explicit ApplicableSixTwoMove(Point_t<3> vertex) noexcept + : m_vertex{vertex} + {} + + friend auto prepare_six_two(Delaunay const& triangulation, + Vertex_handle const& candidate) + -> std::expected; + + public: + [[nodiscard]] auto vertex_point() const noexcept -> Point_t<3> const& + { return m_vertex; } + }; + + /// @brief Prepared causal four-cell diamond for a (4,4) exchange. + class ApplicableFourFourMove + { + Edge_points m_edge; + Point_t<3> m_top; + Point_t<3> m_bottom; + + ApplicableFourFourMove(Edge_points edge, Point_t<3> top, + Point_t<3> bottom) noexcept + : m_edge{edge}, m_top{top}, m_bottom{bottom} + {} + + friend auto prepare_four_four(Delaunay const& triangulation, + Edge_handle const& candidate) + -> std::expected; + friend auto prepare_bistellar_flip(Delaunay const& triangulation, + Edge_handle const& candidate, + Vertex_handle const& top, + Vertex_handle const& bottom) + -> std::expected; + + public: + [[nodiscard]] auto edge_points() const noexcept -> Edge_points const& + { return m_edge; } + + [[nodiscard]] auto top_point() const noexcept -> Point_t<3> const& + { return m_top; } + + [[nodiscard]] auto bottom_point() const noexcept -> Point_t<3> const& + { return m_bottom; } + }; + + [[nodiscard]] auto constexpr move_error( + MoveFailure const reason, move_tracker::move_type const move) noexcept + -> std::unexpected + { + return std::unexpected{ + MoveError{.category = reason, .requested_move = move} + }; + } + /// @brief Compare preserved floating-point configuration state exactly. /// @details Move construction copies these values; arithmetic tolerance is /// inappropriate because any representation change indicates state drift. @@ -132,14 +258,62 @@ namespace cdt::ergodic_moves return points; } + [[nodiscard]] inline auto resolve_vertex(Delaunay const& triangulation, + Point_t<3> const& point) + -> std::optional + { + Vertex_handle vertex; + if (triangulation.is_vertex(point, vertex)) { return vertex; } + return std::nullopt; + } + + [[nodiscard]] inline auto resolve_cell(Delaunay const& triangulation, + Cell_points const& points) + -> std::optional + { + std::array vertices; + for (auto index = std::size_t{}; index < points.size(); ++index) + { + auto const vertex = resolve_vertex(triangulation, points[index]); + if (!vertex) { return std::nullopt; } + vertices[index] = *vertex; + } + + Cell_handle cell; + if (triangulation.is_cell(vertices[0], vertices[1], vertices[2], + vertices[3], cell)) + { + return cell; + } + return std::nullopt; + } + + [[nodiscard]] inline auto resolve_edge(Delaunay const& triangulation, + Edge_points const& points) + -> std::optional + { + auto const first = resolve_vertex(triangulation, points[0]); + auto const second = resolve_vertex(triangulation, points[1]); + if (!first || !second) { return std::nullopt; } + + Cell_handle cell; + int first_index{}; + int second_index{}; + if (triangulation.is_edge(*first, *second, cell, first_index, + second_index)) + { + return Edge_handle{cell, first_index, second_index}; + } + return std::nullopt; + } + [[nodiscard]] inline auto cell_precedes(Cell_handle const& left, Cell_handle const& right) -> bool { auto const left_points = canonical_cell_points(left); auto const right_points = canonical_cell_points(right); - return std::lexicographical_compare( - left_points.begin(), left_points.end(), right_points.begin(), - right_points.end(), point_less); + return std::ranges::lexicographical_compare(left_points, right_points, + point_less); } [[nodiscard]] inline auto edge_precedes(Edge_handle const& left, @@ -147,9 +321,8 @@ namespace cdt::ergodic_moves { auto const left_points = canonical_edge_points(left); auto const right_points = canonical_edge_points(right); - return std::lexicographical_compare( - left_points.begin(), left_points.end(), right_points.begin(), - right_points.end(), point_less); + return std::ranges::lexicographical_compare(left_points, right_points, + point_less); } inline void canonicalize(Cell_container& cells) @@ -201,8 +374,8 @@ namespace cdt::ergodic_moves std::uniform_int_distribution distribution{ 0, candidates.size() - 1}; auto const index = distribution(generator); - auto const nth = candidates.begin() + - static_cast(index); + auto const nth = + candidates.begin() + static_cast(index); std::ranges::nth_element(candidates, nth, comparator); return candidates[index]; } @@ -234,17 +407,55 @@ namespace cdt::ergodic_moves Cell_handle const& to_be_moved) -> bool; + [[nodiscard]] inline auto prepare_two_three(Delaunay const& triangulation, + Cell_handle const& candidate) + -> std::expected; + + [[nodiscard]] inline auto execute(Delaunay& triangulation, + ApplicableTwoThreeMove const& move) + -> Execution; + [[nodiscard]] inline auto try_32_move(Delaunay& triangulation, Edge_handle const& to_be_moved) -> bool; + [[nodiscard]] inline auto prepare_three_two(Delaunay const& triangulation, + Edge_handle const& candidate) + -> std::expected; + + [[nodiscard]] inline auto execute(Delaunay& triangulation, + ApplicableThreeTwoMove const& move) + -> Execution; + [[nodiscard]] inline auto find_adjacent_31_cell(Cell_handle const& cell) -> std::optional; + [[nodiscard]] inline auto prepare_two_six(Delaunay const& triangulation, + Cell_handle const& candidate) + -> std::expected; + + template + requires std::predicate + [[nodiscard]] inline auto execute( + Delaunay& triangulation, ApplicableTwoSixMove const& move, + Post_mutation_validator post_mutation_validator) -> Execution; + [[nodiscard]] inline auto is_62_movable(Delaunay const& triangulation, Vertex_handle const& candidate) -> bool; + [[nodiscard]] inline auto prepare_six_two(Delaunay const& triangulation, + Vertex_handle const& candidate) + -> std::expected; + + template + requires std::predicate + [[nodiscard]] inline auto execute( + Delaunay const& source_triangulation, ApplicableSixTwoMove const& move, + Generator& generator, Post_mutation_validator post_mutation_validator) + -> std::expected; + template [[nodiscard]] inline auto try_62_move(Delaunay const& source_triangulation, Vertex_handle const source_candidate, @@ -259,6 +470,23 @@ namespace cdt::ergodic_moves Delaunay const& triangulation, Edge_handle const& candidate) -> std::optional; + [[nodiscard]] inline auto prepare_four_four(Delaunay const& triangulation, + Edge_handle const& candidate) + -> std::expected; + + [[nodiscard]] inline auto prepare_bistellar_flip( + Delaunay const& triangulation, Edge_handle const& candidate, + Vertex_handle const& top, Vertex_handle const& bottom) + -> std::expected; + + template + requires std::predicate + [[nodiscard]] inline auto execute( + Delaunay const& source_triangulation, + ApplicableFourFourMove const& move, + Post_mutation_validator post_mutation_validator) + -> std::expected; + [[nodiscard]] inline auto bistellar_flip( Delaunay const& source_triangulation, Edge_handle source_edge, Vertex_handle source_top, Vertex_handle source_bottom) @@ -284,36 +512,38 @@ namespace cdt::ergodic_moves [[nodiscard]] inline auto null_move(Manifold const& t_manifold) -> Expected { return t_manifold; } // null_move - /// @brief Perform a checked Triangulation_3 flip on a causal CDT facet - /// @details On success, affected cell handles are invalidated; vertex handles - /// remain valid. Rejection occurs before mutation. - /// @param triangulation The triangulation containing the cell to flip - /// @param to_be_moved The cell on which to try the move - /// @returns True if move succeeded - /// @see - /// https://doc.cgal.org/latest/TDS_3/classTriangulationDataStructure__3.html#a2ad2941984c1eac5561665700bfd60b4 - [[nodiscard]] inline auto detail::try_23_move(Delaunay& triangulation, - Cell_handle const& to_be_moved) - -> bool + /// @brief Parse a raw (2,2) cell into an applicable causal (2,3) move. + /// @details Success proves that the selected finite cell has correct + /// metadata, has a correctly classified (3,1) or (1,3) neighbor, and that the + /// two vertices opposite their shared facet span adjacent slices. The + /// returned value owns only stable point locators and must be executed + /// against the same unmodified triangulation. + [[nodiscard]] inline auto detail::prepare_two_three( + Delaunay const& triangulation, Cell_handle const& candidate) + -> std::expected { - if (to_be_moved == nullptr || triangulation.dimension() != 3 || - !triangulation.tds().is_cell(to_be_moved) || - !foliated_triangulations::is_cell_type_correct<3>(to_be_moved) || - foliated_triangulations::expected_cell_type<3>(to_be_moved) != + using enum move_tracker::move_type; + if (candidate == nullptr || triangulation.dimension() != 3 || + !triangulation.tds().is_cell(candidate)) + { + return move_error(MoveFailure::INVALID_TOPOLOGY, TWO_THREE); + } + if (!foliated_triangulations::is_cell_type_correct<3>(candidate) || + foliated_triangulations::expected_cell_type<3>(candidate) != Cell_type::TWO_TWO) { - return false; + return move_error(MoveFailure::CAUSAL_INVALIDITY, TWO_THREE); } + std::array facet_indices{0, 1, 2, 3}; std::ranges::sort(facet_indices, [&](auto const left, auto const right) { - return detail::point_less(to_be_moved->vertex(left)->point(), - to_be_moved->vertex(right)->point()); + return detail::point_less(candidate->vertex(left)->point(), + candidate->vertex(right)->point()); }); - auto flipped = false; - // Try every facet of the (2,2) cell + for (auto const i : facet_indices) { - auto const neighbor = to_be_moved->neighbor(i); + auto const neighbor = candidate->neighbor(i); if (triangulation.is_infinite(neighbor)) { continue; } auto const neighbor_type = @@ -327,9 +557,9 @@ namespace cdt::ergodic_moves // A causal (2,3) move must replace the facet with a timelike edge. // CGAL also permits topological flips that create a spacelike edge. - auto const mirror_index = neighbor->index(to_be_moved); + auto const mirror_index = neighbor->index(candidate); auto const first_time = - static_cast(to_be_moved->vertex(i)->info()); + static_cast(candidate->vertex(i)->info()); auto const second_time = static_cast(neighbor->vertex(mirror_index)->info()); auto const time_difference = first_time > second_time @@ -337,13 +567,57 @@ namespace cdt::ergodic_moves : second_time - first_time; if (time_difference != 1) { continue; } - if (triangulation.flip(to_be_moved, i)) + return ApplicableTwoThreeMove{canonical_cell_points(candidate), + candidate->vertex(i)->point()}; + } + + return move_error(MoveFailure::CAUSAL_INVALIDITY, TWO_THREE); + } + + /// @brief Consume a prepared (2,3) value at the mutation boundary. + /// @details Only point-to-handle resolution is repeated. CDT applicability is + /// carried by the input type; CGAL remains responsible for its checked + /// geometric flip. A successful flip invalidates affected cell handles, none + /// of which are stored in the applicable value. + [[nodiscard]] inline auto detail::execute(Delaunay& triangulation, + ApplicableTwoThreeMove const& move) + -> Execution + { + using enum move_tracker::move_type; + auto const cell = resolve_cell(triangulation, move.m_cell); + auto const opposite = resolve_vertex(triangulation, move.m_opposite); + if (!cell || !opposite) + { + return move_error(MoveFailure::STALE_CANDIDATE, TWO_THREE); + } + + auto opposite_index = -1; + for (auto index = 0; index < 4; ++index) + { + if ((*cell)->vertex(index) == *opposite) { - flipped = true; + opposite_index = index; break; } } - return flipped; + if (opposite_index < 0) + { + return move_error(MoveFailure::STALE_CANDIDATE, TWO_THREE); + } + if (!triangulation.flip(*cell, opposite_index)) + { + return move_error(MoveFailure::EXECUTION_FAILURE, TWO_THREE); + } + return {}; + } + + /// @brief Compatibility seam that prepares and immediately executes (2,3). + [[nodiscard]] inline auto detail::try_23_move(Delaunay& triangulation, + Cell_handle const& to_be_moved) + -> bool + { + auto const prepared = prepare_two_three(triangulation, to_be_moved); + return prepared && execute(triangulation, *prepared).has_value(); } // try_23_move /// @brief Perform a (2,3) move @@ -374,16 +648,31 @@ namespace cdt::ergodic_moves detail::canonicalize(two_two); // Shuffle the container to create a random sequence of (2,2) cells std::ranges::shuffle(two_two, generator); - // Try a (2,3) move on successive cells in the sequence - if (std::ranges::any_of(two_two, [&](auto& cell) { - return detail::try_23_move(triangulation, cell); - })) + if (two_two.empty()) { - return detail::make_manifold(std::move(triangulation), t_manifold); + return detail::move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::TWO_THREE); } - // We've run out of (2,2) cells - std::string const msg = "No (2,3) move possible.\n"; - return std::unexpected(msg); + + auto last_error = + MoveError{.category = MoveFailure::NO_CANDIDATE, + .requested_move = move_tracker::move_type::TWO_THREE}; + for (auto const& cell : two_two) + { + auto const prepared = detail::prepare_two_three(triangulation, cell); + if (!prepared) + { + last_error = prepared.error(); + continue; + } + auto const executed = detail::execute(triangulation, *prepared); + if (executed) + { + return detail::make_manifold(std::move(triangulation), t_manifold); + } + last_error = executed.error(); + } + return std::unexpected{last_error}; } /// @brief Propose one (2,3) site for Metropolis-Hastings. @@ -399,11 +688,16 @@ namespace cdt::ergodic_moves foliated_triangulations::collect_cells<3>(triangulation), Cell_type::TWO_TWO); auto const candidate = detail::canonical_random_element(two_two, generator); - if (candidate && detail::try_23_move(triangulation, *candidate)) + if (!candidate) { - return detail::make_manifold(std::move(triangulation), t_manifold); + return detail::move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::TWO_THREE); } - return std::unexpected("Selected (2,3) proposal site is not movable.\n"); + auto const prepared = detail::prepare_two_three(triangulation, *candidate); + if (!prepared) { return std::unexpected{prepared.error()}; } + auto const executed = detail::execute(triangulation, *prepared); + if (!executed) { return std::unexpected{executed.error()}; } + return detail::make_manifold(std::move(triangulation), t_manifold); } namespace detail @@ -446,21 +740,50 @@ namespace cdt::ergodic_moves } } // namespace detail - /// @brief Perform a checked (3,2) flip on a causal CDT edge cavity. - /// @details On success, affected cell handles are invalidated; vertex handles - /// remain valid. Rejection occurs before mutation. - /// @param triangulation The triangulation containing the edge to flip - /// @param to_be_moved The edge on which to try the move - /// @returns True if the CDT cavity is admissible and CGAL performs the flip - /// @see - /// https://doc.cgal.org/latest/TDS_3/classTriangulationDataStructure__3.html#a5837d666e4198f707f862003c1ffa033 + /// @brief Parse a raw edge into an applicable causal (3,2) move. + /// @details Success proves a finite timelike degree-three cavity containing + /// exactly two (2,2) cells and one consistently oriented (3,1) or (1,3) + /// cell. + [[nodiscard]] inline auto detail::prepare_three_two( + Delaunay const& triangulation, Edge_handle const& candidate) + -> std::expected + { + using enum move_tracker::move_type; + if (!is_well_formed_edge(candidate) || triangulation.dimension() != 3 || + !triangulation.tds().is_edge(candidate.first, candidate.second, + candidate.third)) + { + return move_error(MoveFailure::INVALID_TOPOLOGY, THREE_TWO); + } + if (!is_32_movable(triangulation, candidate)) + { + return move_error(MoveFailure::CAUSAL_INVALIDITY, THREE_TWO); + } + return ApplicableThreeTwoMove{canonical_edge_points(candidate)}; + } + + /// @brief Consume a prepared (3,2) value at the checked CGAL flip boundary. + [[nodiscard]] inline auto detail::execute(Delaunay& triangulation, + ApplicableThreeTwoMove const& move) + -> Execution + { + using enum move_tracker::move_type; + auto const edge = resolve_edge(triangulation, move.m_edge); + if (!edge) { return move_error(MoveFailure::STALE_CANDIDATE, THREE_TWO); } + if (!triangulation.flip(edge->first, edge->second, edge->third)) + { + return move_error(MoveFailure::EXECUTION_FAILURE, THREE_TWO); + } + return {}; + } + + /// @brief Compatibility seam that prepares and immediately executes (3,2). [[nodiscard]] inline auto detail::try_32_move(Delaunay& triangulation, Edge_handle const& to_be_moved) -> bool { - if (!detail::is_32_movable(triangulation, to_be_moved)) { return false; } - return triangulation.flip(to_be_moved.first, to_be_moved.second, - to_be_moved.third); + auto const prepared = prepare_three_two(triangulation, to_be_moved); + return prepared && execute(triangulation, *prepared).has_value(); } // try_32_move /// @brief Perform a (3,2) move @@ -486,16 +809,31 @@ namespace cdt::ergodic_moves detail::canonicalize(timelike_edges); // Shuffle the container to create a random sequence of edges std::ranges::shuffle(timelike_edges, generator); - // Try a (3,2) move on successive timelike edges in the sequence - if (std::ranges::any_of(timelike_edges, [&](auto& edge) { - return detail::try_32_move(triangulation, edge); - })) + if (timelike_edges.empty()) { - return detail::make_manifold(std::move(triangulation), t_manifold); + return detail::move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::THREE_TWO); } - // We've run out of edges to try - std::string const msg = "No (3,2) move possible.\n"; - return std::unexpected(msg); + + auto last_error = + MoveError{.category = MoveFailure::NO_CANDIDATE, + .requested_move = move_tracker::move_type::THREE_TWO}; + for (auto const& edge : timelike_edges) + { + auto const prepared = detail::prepare_three_two(triangulation, edge); + if (!prepared) + { + last_error = prepared.error(); + continue; + } + auto const executed = detail::execute(triangulation, *prepared); + if (executed) + { + return detail::make_manifold(std::move(triangulation), t_manifold); + } + last_error = executed.error(); + } + return std::unexpected{last_error}; } // do_32_move() /// @brief Propose one (3,2) site for Metropolis-Hastings. @@ -510,11 +848,16 @@ namespace cdt::ergodic_moves foliated_triangulations::collect_edges<3>(triangulation), true); auto const candidate = detail::canonical_random_element(timelike_edges, generator); - if (candidate && detail::try_32_move(triangulation, *candidate)) + if (!candidate) { - return detail::make_manifold(std::move(triangulation), t_manifold); + return detail::move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::THREE_TWO); } - return std::unexpected("Selected (3,2) proposal site is not movable.\n"); + auto const prepared = detail::prepare_three_two(triangulation, *candidate); + if (!prepared) { return std::unexpected{prepared.error()}; } + auto const executed = detail::execute(triangulation, *prepared); + if (!executed) { return std::unexpected{executed.error()}; } + return detail::make_manifold(std::move(triangulation), t_manifold); } /// @brief Find a (2,6) move location @@ -556,6 +899,114 @@ namespace cdt::ergodic_moves return candidates.front(); } // find_26_move() + /// @brief Parse a raw (1,3) cell into an applicable (2,6) move. + /// @details Success proves a correctly labelled adjacent (3,1) cell and a + /// common spacelike facet whose three vertices share one time value. + [[nodiscard]] inline auto detail::prepare_two_six( + Delaunay const& triangulation, Cell_handle const& candidate) + -> std::expected + { + using enum move_tracker::move_type; + if (candidate == nullptr || triangulation.dimension() != 3 || + !triangulation.tds().is_cell(candidate)) + { + return move_error(MoveFailure::INVALID_TOPOLOGY, TWO_SIX); + } + + auto const neighboring_31_index = find_adjacent_31_cell(candidate); + if (!neighboring_31_index) + { + return move_error(MoveFailure::CAUSAL_INVALIDITY, TWO_SIX); + } + + auto const top = candidate->neighbor(*neighboring_31_index); + auto common_face_index = std::numeric_limits::max(); + if (top == nullptr || !candidate->has_neighbor(top, common_face_index)) + { + return move_error(MoveFailure::INVALID_TOPOLOGY, TWO_SIX); + } + + auto const first = (common_face_index + 1) % 4; + auto const second = (common_face_index + 2) % 4; + auto const third = (common_face_index + 3) % 4; + if (candidate->vertex(first)->info() != candidate->vertex(second)->info() || + candidate->vertex(second)->info() != candidate->vertex(third)->info()) + { + return move_error(MoveFailure::CAUSAL_INVALIDITY, TWO_SIX); + } + + return ApplicableTwoSixMove{canonical_cell_points(candidate), + candidate->vertex(common_face_index)->point()}; + } + + /// @brief Consume a prepared (2,6) value on an unobservable candidate. + /// @details Local mutation is deliberate. Any postcondition failure discards + /// the private triangulation at the high-level value boundary. + template + requires std::predicate + [[nodiscard]] inline auto detail::execute( + Delaunay& triangulation, ApplicableTwoSixMove const& move, + Post_mutation_validator post_mutation_validator) -> Execution + { + using enum move_tracker::move_type; + static auto constexpr incident_cell_count = std::size_t{6}; + + auto const bottom = resolve_cell(triangulation, move.bottom_points()); + auto const opposite = resolve_vertex(triangulation, move.opposite_point()); + if (!bottom || !opposite) + { + return move_error(MoveFailure::STALE_CANDIDATE, TWO_SIX); + } + + auto common_face_index = -1; + for (auto index = 0; index < 4; ++index) + { + if ((*bottom)->vertex(index) == *opposite) + { + common_face_index = index; + break; + } + } + if (common_face_index < 0) + { + return move_error(MoveFailure::STALE_CANDIDATE, TWO_SIX); + } + + auto const first = (common_face_index + 1) % 4; + auto const second = (common_face_index + 2) % 4; + auto const third = (common_face_index + 3) % 4; + auto const v_1 = (*bottom)->vertex(first); + auto const v_2 = (*bottom)->vertex(second); + auto const v_3 = (*bottom)->vertex(third); + auto const center = + triangulation.tds().insert_in_facet(*bottom, common_face_index); + + Cell_container incident_cells; + triangulation.tds().incident_cells(center, + std::back_inserter(incident_cells)); + if (incident_cells.size() != incident_cell_count || + !std::ranges::all_of(incident_cells, + [&triangulation](auto const& cell) { + return triangulation.tds().is_cell(cell); + })) + { + return move_error(MoveFailure::INVARIANT_VIOLATION, TWO_SIX); + } + + std::array face_points{v_1->point(), v_2->point(), v_3->point()}; + std::ranges::sort(face_points, point_less); + center->set_point( + CGAL::centroid(face_points[0], face_points[1], face_points[2])); + center->info() = v_1->info(); + + if (!post_mutation_validator(static_cast(triangulation)) || + !triangulation.tds().is_valid(center, true, 1)) + { + return move_error(MoveFailure::INVARIANT_VIOLATION, TWO_SIX); + } + return {}; + } + namespace detail { template Expected { - static auto constexpr INCIDENT_CELLS_FOR_6_2_MOVE = 6; Delaunay triangulation{t_manifold.delaunay_snapshot()}; auto one_three = foliated_triangulations::filter_cells<3>( foliated_triangulations::collect_cells<3>(triangulation), Cell_type::ONE_THREE); + if (one_three.empty()) + { + return move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::TWO_SIX); + } + if (only_first_site) { auto const candidate = detail::canonical_random_element(one_three, generator); if (!candidate) { - return std::unexpected("No (2,6) proposal site is available.\n"); + return move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::TWO_SIX); } one_three = {*candidate}; } @@ -598,99 +1055,24 @@ namespace cdt::ergodic_moves // try. std::ranges::shuffle(one_three, generator); } + + auto last_error = + MoveError{.category = MoveFailure::NO_CANDIDATE, + .requested_move = move_tracker::move_type::TWO_SIX}; for (auto const& bottom : one_three) { - if (auto neighboring_31_index = find_adjacent_31_cell(bottom); - neighboring_31_index) + auto const prepared = prepare_two_six(triangulation, bottom); + if (!prepared) { - Cell_handle const top = bottom->neighbor(neighboring_31_index.value()); - // Calculate the common face with respect to the bottom cell - auto common_face_index = std::numeric_limits::max(); - if (!bottom->has_neighbor(top, common_face_index)) - { - std::string const msg = "Bottom cell does not have a neighbor.\n"; - return std::unexpected(msg); - } - - // Get indices of vertices of common face with respect to bottom cell - // A face is denoted by the index of the opposite vertex - // Thus, the indices of the vertices of the face are all other indices - // except the common_face_index - // CGAL uses bitwise operations like (common_face_index +1) & 3 - // We use % 4 which is equivalent and doesn't trigger clang-tidy - auto const i_1 = (common_face_index + 1) % 4; - auto const i_2 = (common_face_index + 2) % 4; - auto const i_3 = (common_face_index + 3) % 4; - - // Get vertices of common face from indices - auto const v_1 = bottom->vertex(i_1); - auto const v_2 = bottom->vertex(i_2); - auto const v_3 = bottom->vertex(i_3); - - // Timeslice of vertices should be same - if (v_1->info() != v_2->info() || v_2->info() != v_3->info()) - { - std::string const msg = "Vertices have different timeslices.\n"; - return std::unexpected(msg); - } - - // Do the (2,6) move - // Insert new vertex - Vertex_handle const v_center = - triangulation.tds().insert_in_facet(bottom, *neighboring_31_index); - - // Checks - Cell_container incident_cells; - triangulation.tds().incident_cells(v_center, - std::back_inserter(incident_cells)); - // the (2,6) center vertex should be bounded by 6 simplices - if (incident_cells.size() != INCIDENT_CELLS_FOR_6_2_MOVE) - { - std::string const msg = - "Center vertex is not bounded by 6 simplices.\n"; - return std::unexpected(msg); - } - - // Each incident cell should be combinatorially and geometrically - // valid - if (auto check_cells = - std::ranges::all_of(incident_cells, - [&triangulation](auto const& cell) { - return triangulation.tds().is_cell(cell); - }); - !check_cells) - { - std::string const msg = "A cell is invalid.\n"; - return std::unexpected(msg); - } - - // Now assign a geometric point to the center vertex - std::array face_points{v_1->point(), v_2->point(), v_3->point()}; - std::ranges::sort(face_points, detail::point_less); - auto const center_point = - CGAL::centroid(face_points[0], face_points[1], face_points[2]); - v_center->set_point(center_point); - - // Assign a timevalue to the new vertex - auto timevalue = v_1->info(); - v_center->info() = timevalue; - - // Final checks - // is_valid() checks for combinatorial and geometric validity - if (!post_mutation_validator( - static_cast(triangulation)) || - !triangulation.tds().is_valid(v_center, true, 1)) - { - std::string const msg = "v_center is invalid.\n"; - return std::unexpected(msg); - } - - return make_manifold(std::move(triangulation), t_manifold); + last_error = prepared.error(); + continue; } + auto const executed = + execute(triangulation, *prepared, post_mutation_validator); + if (!executed) { return std::unexpected{executed.error()}; } + return make_manifold(std::move(triangulation), t_manifold); } - // We've run out of (1,3) simplices to try - std::string const msg = "No (2,6) move possible.\n"; - return std::unexpected(msg); + return std::unexpected{last_error}; } // do_26_move_impl() /// @brief Perform a (2,6) move @@ -793,6 +1175,27 @@ namespace cdt::ergodic_moves } // find_62_moves() + /// @brief Parse a raw vertex into an applicable causal (6,2) move. + /// @details Success proves degree five, six finite incident cells, and the + /// exact three-(3,1)/three-(1,3) causal composition with correct metadata. + [[nodiscard]] inline auto detail::prepare_six_two( + Delaunay const& triangulation, Vertex_handle const& candidate) + -> std::expected + { + using enum move_tracker::move_type; + if (candidate == nullptr || triangulation.dimension() != 3 || + !triangulation.tds().is_vertex(candidate) || + triangulation.is_infinite(candidate)) + { + return move_error(MoveFailure::INVALID_TOPOLOGY, SIX_TWO); + } + if (!is_62_movable(triangulation, candidate)) + { + return move_error(MoveFailure::CAUSAL_INVALIDITY, SIX_TWO); + } + return ApplicableSixTwoMove{candidate->point()}; + } + namespace detail { template std::optional; + } // namespace detail - // Internal validation seam used to test rejection after mutation. + /// @brief Consume a prepared (6,2) value on a private triangulation copy. template requires std::predicate - [[nodiscard]] inline auto detail::try_62_move_impl( - Delaunay const& source_triangulation, - Vertex_handle const source_candidate, Generator& generator, - Post_mutation_validator post_mutation_validator) - -> std::optional + [[nodiscard]] inline auto detail::execute( + Delaunay const& source_triangulation, ApplicableSixTwoMove const& move, + Generator& generator, Post_mutation_validator post_mutation_validator) + -> std::expected { - if (!source_triangulation.tds().is_vertex(source_candidate) || - source_triangulation.is_infinite(source_candidate)) + using enum move_tracker::move_type; + Delaunay triangulation{source_triangulation}; + auto const copied_candidate = foliated_triangulations::find_vertex<3>( + triangulation, move.vertex_point()); + if (!copied_candidate) { - return std::nullopt; + return move_error(MoveFailure::STALE_CANDIDATE, SIX_TWO); } - auto const candidate_point = source_candidate->point(); - Delaunay triangulation{source_triangulation}; - auto const copied_candidate = - foliated_triangulations::find_vertex<3>(triangulation, candidate_point); - if (!copied_candidate) { return std::nullopt; } - auto const candidate = *copied_candidate; auto& tds = triangulation.tds(); auto const old_cells = triangulation.number_of_finite_cells(); @@ -843,12 +1243,18 @@ namespace cdt::ergodic_moves auto const second_time = edge.first->vertex(edge.third)->info(); return first_time != second_time; }; - auto const flipped = std::ranges::any_of( - incident_edges, - [&](auto const& edge) { return is_timelike(edge) && tds.flip(edge); }); + auto flipped = false; + for (auto const& edge : incident_edges) + { + if (is_timelike(edge) && tds.flip(edge)) + { + flipped = true; + break; + } + } if (!flipped || tds.degree(candidate) != 4 || !tds.is_valid()) { - return std::nullopt; + return move_error(MoveFailure::EXECUTION_FAILURE, SIX_TWO); } tds.remove_from_maximal_dimension_simplex(candidate); @@ -857,7 +1263,7 @@ namespace cdt::ergodic_moves triangulation.number_of_finite_cells() + 4 != old_cells || triangulation.number_of_vertices() + 1 != old_vertices) { - return std::nullopt; + return move_error(MoveFailure::INVARIANT_VIOLATION, SIX_TWO); } for (auto const cell : triangulation.finite_cell_handles()) @@ -865,12 +1271,31 @@ namespace cdt::ergodic_moves auto const type = foliated_triangulations::expected_cell_type<3>(cell); if (type == Cell_type::ACAUSAL || type == Cell_type::UNCLASSIFIED) { - return std::nullopt; + return move_error(MoveFailure::INVARIANT_VIOLATION, SIX_TWO); } cell->info() = static_cast(type); } return triangulation; + } // execute() + + // Internal validation seam used to test rejection after mutation. + template + requires std::predicate + [[nodiscard]] inline auto detail::try_62_move_impl( + Delaunay const& source_triangulation, + Vertex_handle const source_candidate, Generator& generator, + Post_mutation_validator post_mutation_validator) + -> std::optional + { + auto const prepared = + prepare_six_two(source_triangulation, source_candidate); + if (!prepared) { return std::nullopt; } + auto moved = execute(source_triangulation, *prepared, generator, + post_mutation_validator); + if (!moved) { return std::nullopt; } + return std::move(*moved); } // try_62_move_impl() /// @brief Apply the combinatorial (6,2) retriangulation on a private copy. @@ -924,18 +1349,32 @@ namespace cdt::ergodic_moves detail::canonicalize(vertices); // Shuffle the container to create a random sequence of vertices std::ranges::shuffle(vertices, generator); - // Try a (6,2) move on successive vertices in the sequence + if (vertices.empty()) + { + return detail::move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::SIX_TWO); + } + + auto last_error = + MoveError{.category = MoveFailure::NO_CANDIDATE, + .requested_move = move_tracker::move_type::SIX_TWO}; for (auto const& vertex : vertices) { - if (!detail::is_62_movable(triangulation, vertex)) { continue; } - if (auto moved = detail::try_62_move(triangulation, vertex, generator)) + auto const prepared = detail::prepare_six_two(triangulation, vertex); + if (!prepared) + { + last_error = prepared.error(); + continue; + } + auto moved = detail::execute(triangulation, *prepared, generator, + detail::accept_post_mutation); + if (moved) { return detail::make_manifold(std::move(*moved), t_manifold); } + last_error = moved.error(); } - // We've run out of vertices to try - std::string const msg = "No (6,2) move possible.\n"; - return std::unexpected(msg); + return std::unexpected{last_error}; } // do_62_move() /// @brief Propose one vertex as a (6,2) site for Metropolis-Hastings. @@ -947,15 +1386,17 @@ namespace cdt::ergodic_moves auto vertices = foliated_triangulations::collect_vertices<3>(triangulation); auto const candidate = detail::canonical_random_element(vertices, generator); - if (candidate && detail::is_62_movable(triangulation, *candidate)) + if (!candidate) { - if (auto moved = - detail::try_62_move(triangulation, *candidate, generator)) - { - return detail::make_manifold(std::move(*moved), t_manifold); - } + return detail::move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::SIX_TWO); } - return std::unexpected("Selected (6,2) proposal site is not movable.\n"); + auto const prepared = detail::prepare_six_two(triangulation, *candidate); + if (!prepared) { return std::unexpected{prepared.error()}; } + auto moved = detail::execute(triangulation, *prepared, generator, + detail::accept_post_mutation); + if (!moved) { return std::unexpected{moved.error()}; } + return detail::make_manifold(std::move(*moved), t_manifold); } /// @brief Find all cells incident to the edge @@ -1008,90 +1449,98 @@ namespace cdt::ergodic_moves return std::nullopt; } // find_bistellar_flip_location() - namespace detail + /// @brief Parse a raw spacelike edge into an applicable (4,4) move. + /// @details Success proves the finite four-cell causal diamond and identifies + /// its distinct top and bottom vertices without retaining CGAL handles. + [[nodiscard]] inline auto detail::prepare_four_four( + Delaunay const& triangulation, Edge_handle const& candidate) + -> std::expected { - template - requires std::predicate - [[nodiscard]] inline auto bistellar_flip_impl( - Delaunay const& source_triangulation, Edge_handle const source_edge, - Vertex_handle const source_top, Vertex_handle const source_bottom, - Post_mutation_validator post_mutation_validator) - -> std::optional; - } // namespace detail + using enum move_tracker::move_type; + if (!is_well_formed_edge(candidate) || + !triangulation.tds().is_edge(candidate.first, candidate.second, + candidate.third)) + { + return move_error(MoveFailure::INVALID_TOPOLOGY, FOUR_FOUR); + } - // Internal validation seam used to test rejection after mutation. - template - requires std::predicate - [[nodiscard]] inline auto detail::bistellar_flip_impl( - Delaunay const& source_triangulation, Edge_handle const source_edge, - Vertex_handle const source_top, Vertex_handle const source_bottom, - Post_mutation_validator post_mutation_validator) - -> std::optional - { - if (!is_well_formed_edge(source_edge) || source_top == nullptr || - source_bottom == nullptr || - !source_triangulation.tds().is_edge( - source_edge.first, source_edge.second, source_edge.third) || - !source_triangulation.tds().is_vertex(source_top) || - !source_triangulation.tds().is_vertex(source_bottom) || - source_triangulation.is_infinite(source_top) || - source_triangulation.is_infinite(source_bottom)) + auto const incident_cells = + find_bistellar_flip_location(triangulation, candidate); + if (!incident_cells) { - return std::nullopt; + return move_error(MoveFailure::CAUSAL_INVALIDITY, FOUR_FOUR); + } + + auto const first = candidate.first->vertex(candidate.second); + auto const second = candidate.first->vertex(candidate.third); + Vertex_handle top = nullptr; + Vertex_handle bottom = nullptr; + for (auto const& cell : *incident_cells) + { + for (auto index = 0; index < 4; ++index) + { + auto const vertex = cell->vertex(index); + if (vertex == first || vertex == second) { continue; } + if (top == nullptr || vertex_precedes(top, vertex)) { top = vertex; } + if (bottom == nullptr || vertex_precedes(vertex, bottom)) + { + bottom = vertex; + } + } } - // CGAL copy construction duplicates its vertices and cells, invalidating - // all handles from the source triangulation. Capture stable point values, - // then resolve the corresponding handles in the private copy before - // attempting the mutation. - auto const pivot_from_1_point = - source_edge.first->vertex(source_edge.second)->point(); - auto const pivot_from_2_point = - source_edge.first->vertex(source_edge.third)->point(); - auto const top_point = source_top->point(); - auto const bottom_point = source_bottom->point(); + if (top == nullptr || bottom == nullptr || top == bottom || top == first || + top == second || bottom == first || bottom == second) + { + return move_error(MoveFailure::CAUSAL_INVALIDITY, FOUR_FOUR); + } - Delaunay triangulation{source_triangulation}; - auto const pivot_from_1_handle = foliated_triangulations::find_vertex<3>( - triangulation, pivot_from_1_point); - auto const pivot_from_2_handle = foliated_triangulations::find_vertex<3>( - triangulation, pivot_from_2_point); - auto const top_handle = - foliated_triangulations::find_vertex<3>(triangulation, top_point); - auto const bottom_handle = - foliated_triangulations::find_vertex<3>(triangulation, bottom_point); - if (!pivot_from_1_handle || !pivot_from_2_handle || !top_handle || - !bottom_handle) + auto const incident_count = [&](Vertex_handle const vertex) { + return std::ranges::count_if( + *incident_cells, + [&](Cell_handle const cell) { return cell->has_vertex(vertex); }); + }; + if (incident_count(top) != 2 || incident_count(bottom) != 2) { - return std::nullopt; + return move_error(MoveFailure::CAUSAL_INVALIDITY, FOUR_FOUR); } - auto const pivot_from_1 = *pivot_from_1_handle; - auto const pivot_from_2 = *pivot_from_2_handle; - auto const top = *top_handle; - auto const bottom = *bottom_handle; + return ApplicableFourFourMove{canonical_edge_points(candidate), + top->point(), bottom->point()}; + } - Cell_handle copied_edge_cell = nullptr; - int copied_edge_first_index{}; - int copied_edge_second_index{}; - if (!triangulation.is_edge(pivot_from_1, pivot_from_2, copied_edge_cell, - copied_edge_first_index, - copied_edge_second_index)) + /// @brief Prepare the generic topological seam used by bistellar_flip(). + /// @details Unlike prepare_four_four(), this internal compatibility boundary + /// proves only the CGAL four-cell diamond and caller-supplied boundary + /// vertices. High-level CDT moves must use the stronger causal preparation. + [[nodiscard]] inline auto detail::prepare_bistellar_flip( + Delaunay const& triangulation, Edge_handle const& candidate, + Vertex_handle const& top, Vertex_handle const& bottom) + -> std::expected + { + using enum move_tracker::move_type; + if (!is_well_formed_edge(candidate) || top == nullptr || + bottom == nullptr || + !triangulation.tds().is_edge(candidate.first, candidate.second, + candidate.third) || + !triangulation.tds().is_vertex(top) || + !triangulation.tds().is_vertex(bottom) || + triangulation.is_infinite(top) || triangulation.is_infinite(bottom)) { - return std::nullopt; + return move_error(MoveFailure::INVALID_TOPOLOGY, FOUR_FOUR); } - Edge_handle const edge{copied_edge_cell, copied_edge_first_index, - copied_edge_second_index}; - auto const incident_cells = incident_cells_from_edge(triangulation, edge); + auto const first = candidate.first->vertex(candidate.second); + auto const second = candidate.first->vertex(candidate.third); + auto const incident_cells = + incident_cells_from_edge(triangulation, candidate); if (!incident_cells || incident_cells->size() != 4 || top == bottom || - top == pivot_from_1 || top == pivot_from_2 || bottom == pivot_from_1 || - bottom == pivot_from_2 || + top == first || top == second || bottom == first || bottom == second || std::ranges::any_of(*incident_cells, [&](auto const& cell) { return triangulation.is_infinite(cell) || !cell->is_valid(); })) { - return std::nullopt; + return move_error(MoveFailure::INVALID_TOPOLOGY, FOUR_FOUR); } auto const incident_count = [&](Vertex_handle const vertex) { @@ -1101,9 +1550,46 @@ namespace cdt::ergodic_moves }; if (incident_count(top) != 2 || incident_count(bottom) != 2) { - return std::nullopt; + return move_error(MoveFailure::INVALID_TOPOLOGY, FOUR_FOUR); + } + + return ApplicableFourFourMove{canonical_edge_points(candidate), + top->point(), bottom->point()}; + } + + namespace detail + { + template + requires std::predicate + [[nodiscard]] inline auto bistellar_flip_impl( + Delaunay const& source_triangulation, Edge_handle const source_edge, + Vertex_handle const source_top, Vertex_handle const source_bottom, + Post_mutation_validator post_mutation_validator) + -> std::optional; + + } // namespace detail + + /// @brief Consume a prepared (4,4) value on a private triangulation copy. + template + requires std::predicate + [[nodiscard]] inline auto detail::execute( + Delaunay const& source_triangulation, ApplicableFourFourMove const& move, + Post_mutation_validator post_mutation_validator) + -> std::expected + { + using enum move_tracker::move_type; + Delaunay triangulation{source_triangulation}; + auto const edge = resolve_edge(triangulation, move.edge_points()); + auto const top = resolve_vertex(triangulation, move.top_point()); + auto const bottom = resolve_vertex(triangulation, move.bottom_point()); + if (!edge || !top || !bottom) + { + return move_error(MoveFailure::STALE_CANDIDATE, FOUR_FOUR); } + auto const pivot_from_1 = edge->first->vertex(edge->second); + auto const pivot_from_2 = edge->first->vertex(edge->third); + // A 3D 4-to-4 bistellar move is the composition of CGAL's checked TDS // 2-to-3 facet flip and checked 3-to-2 edge flip. The TDS operations are // intentional: either geometrically checked Triangulation_3 facet flip @@ -1115,11 +1601,11 @@ namespace cdt::ergodic_moves int pivot_from_1_index{}; int pivot_from_2_index{}; int boundary_index{}; - if (!triangulation.is_facet(pivot_from_1, pivot_from_2, bottom, + if (!triangulation.is_facet(pivot_from_1, pivot_from_2, *bottom, boundary_facet_cell, pivot_from_1_index, pivot_from_2_index, boundary_index)) { - return std::nullopt; + return move_error(MoveFailure::STALE_CANDIDATE, FOUR_FOUR); } auto constexpr cell_index_sum = 0 + 1 + 2 + 3; auto const boundary_facet_index = cell_index_sum - pivot_from_1_index - @@ -1127,7 +1613,7 @@ namespace cdt::ergodic_moves if (!triangulation.tds().flip( Delaunay::Facet{boundary_facet_cell, boundary_facet_index})) { - return std::nullopt; + return move_error(MoveFailure::EXECUTION_FAILURE, FOUR_FOUR); } Cell_handle old_edge_cell = nullptr; @@ -1138,13 +1624,13 @@ namespace cdt::ergodic_moves !triangulation.tds().flip(Delaunay::Edge{ old_edge_cell, old_edge_first_index, old_edge_second_index})) { - return std::nullopt; + return move_error(MoveFailure::EXECUTION_FAILURE, FOUR_FOUR); } if (!post_mutation_validator(static_cast(triangulation)) || !triangulation.tds().is_valid()) { - return std::nullopt; + return move_error(MoveFailure::INVARIANT_VIOLATION, FOUR_FOUR); } for (auto const cell : triangulation.finite_cell_handles()) @@ -1154,6 +1640,24 @@ namespace cdt::ergodic_moves } return triangulation; + } // execute() + + // Internal validation seam used to test rejection after mutation. + template + requires std::predicate + [[nodiscard]] inline auto detail::bistellar_flip_impl( + Delaunay const& source_triangulation, Edge_handle const source_edge, + Vertex_handle const source_top, Vertex_handle const source_bottom, + Post_mutation_validator post_mutation_validator) + -> std::optional + { + auto const prepared = prepare_bistellar_flip( + source_triangulation, source_edge, source_top, source_bottom); + if (!prepared) { return std::nullopt; } + auto moved = + execute(source_triangulation, *prepared, post_mutation_validator); + if (!moved) { return std::nullopt; } + return std::move(*moved); } // bistellar_flip_impl() /// @brief Perform a bistellar flip on triangulation via the given edge @@ -1240,58 +1744,32 @@ namespace cdt::ergodic_moves detail::canonicalize(spacelike_edges); // Shuffle the container to pick a random sequence of edges to try std::ranges::shuffle(spacelike_edges, generator); + if (spacelike_edges.empty()) + { + return detail::move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::FOUR_FOUR); + } + + auto last_error = + MoveError{.category = MoveFailure::NO_CANDIDATE, + .requested_move = move_tracker::move_type::FOUR_FOUR}; for (auto const& edge : spacelike_edges) { - // Obtain all incident cells - if (auto const incident_cells = - detail::find_bistellar_flip_location(triangulation, edge); - incident_cells) + auto const prepared = detail::prepare_four_four(triangulation, edge); + if (!prepared) { - // Get edge vertices - auto const& v1 = edge.first->vertex(edge.second); - auto const& v2 = edge.first->vertex(edge.third); - - // Find top and bottom vertices - Vertex_handle top = nullptr; - Vertex_handle bottom = nullptr; - - // Analyze cells to find the top and bottom vertices - for (auto const& cell : *incident_cells) - { - for (int i = 0; i < 4; ++i) - { - auto vertex = cell->vertex(i); - if (vertex != v1 && vertex != v2) - { - // Use timevalue to determine if it's a top or bottom vertex - if (top == nullptr || detail::vertex_precedes(top, vertex)) - { - top = vertex; - } - if (bottom == nullptr || detail::vertex_precedes(vertex, bottom)) - { - bottom = vertex; - } - } - } - } - - // Try the bistellar flip - if (auto flipped_triangulation = - detail::bistellar_flip(triangulation, edge, top, bottom); - flipped_triangulation.has_value()) - { - return detail::make_manifold(std::move(*flipped_triangulation), - t_manifold); - } - - // The flip failed; continue trying other edges. + last_error = prepared.error(); + continue; + } + auto flipped = detail::execute(triangulation, *prepared, + detail::accept_post_mutation); + if (flipped) + { + return detail::make_manifold(std::move(*flipped), t_manifold); } - // Try next edge + last_error = flipped.error(); } - // We've run out of edges to try - std::string const msg = "No (4,4) move possible.\n"; - return std::unexpected(msg); + return std::unexpected{last_error}; } // do_44_move() /// @brief Propose one spacelike edge as a (4,4) site. @@ -1308,45 +1786,19 @@ namespace cdt::ergodic_moves detail::canonical_random_element(spacelike_edges, generator); if (!candidate) { - return std::unexpected( - "No spacelike edge is available for a (4,4) proposal.\n"); - } - - auto const incident_cells = - detail::find_bistellar_flip_location(triangulation, *candidate); - if (!incident_cells) - { - return std::unexpected("Selected (4,4) proposal site is not movable.\n"); - } - - auto const& v1 = candidate->first->vertex(candidate->second); - auto const& v2 = candidate->first->vertex(candidate->third); - Vertex_handle top = nullptr; - Vertex_handle bottom = nullptr; - for (auto const& cell : *incident_cells) - { - for (int index = 0; index < 4; ++index) - { - auto const vertex = cell->vertex(index); - if (vertex == v1 || vertex == v2) { continue; } - if (top == nullptr || detail::vertex_precedes(top, vertex)) - { - top = vertex; - } - if (bottom == nullptr || detail::vertex_precedes(vertex, bottom)) - { - bottom = vertex; - } - } + return detail::move_error(MoveFailure::NO_CANDIDATE, + move_tracker::move_type::FOUR_FOUR); } - if (auto flipped = - detail::bistellar_flip(triangulation, *candidate, top, bottom)) + auto const prepared = detail::prepare_four_four(triangulation, *candidate); + if (!prepared) { return std::unexpected{prepared.error()}; } + auto flipped = + detail::execute(triangulation, *prepared, detail::accept_post_mutation); + if (flipped) { return detail::make_manifold(std::move(*flipped), t_manifold); } - return std::unexpected( - "Selected (4,4) proposal site could not be flipped.\n"); + return std::unexpected{flipped.error()}; } /// @brief Check tracked move deltas and the complete CDT manifold invariant diff --git a/include/Metropolis.hpp b/include/Metropolis.hpp index c348d90cb..e250bb9ff 100644 --- a/include/Metropolis.hpp +++ b/include/Metropolis.hpp @@ -109,16 +109,9 @@ namespace cdt /// @brief Checkpoint events from the latest completed invocation Int_precision m_checkpoint_events{}; - enum class Transition_outcome : std::uint8_t - { - CANDIDATE_FAILED, - ACCEPTED, - REJECTED - }; - - static void record_transition(RunStatistics& statistics, - move_tracker::move_type const move, - Transition_outcome const outcome) noexcept + static void record_transition( + RunStatistics& statistics, move_tracker::move_type const move, + ergodic_moves::MoveOutcome const outcome) noexcept { auto const append = [&statistics](std::uint8_t const value) { statistics.transition_trace ^= value; @@ -388,7 +381,7 @@ namespace cdt private: [[nodiscard]] auto propose_candidate(ManifoldType const& current, move_tracker::move_type const move) - -> std::expected + -> ergodic_moves::MoveResult { using enum move_tracker::move_type; switch (move) @@ -404,7 +397,11 @@ namespace cdt case FOUR_FOUR: return ergodic_moves::propose_44_move(current, m_generator); } - return std::unexpected("Unknown 3D Pachner move.\n"); + return std::unexpected{ + ergodic_moves::MoveError{ + .category = ergodic_moves::MoveFailure::UNKNOWN_MOVE, + .requested_move = move} + }; } [[nodiscard]] auto make_reproducibility_metadata( @@ -447,13 +444,20 @@ namespace cdt ++command_results.attempted[move]; auto candidate = propose_candidate(current, move); - if (!candidate || - !ergodic_moves::detail::check_move(current, *candidate, move)) + if (!candidate) { ++command_results.failed[move]; ++statistics.rejected[move]; record_transition(statistics, move, - Transition_outcome::CANDIDATE_FAILED); + ergodic_moves::outcome_from(candidate.error())); + return false; + } + if (!ergodic_moves::detail::check_move(current, *candidate, move)) + { + ++command_results.failed[move]; + ++statistics.rejected[move]; + record_transition(statistics, move, + ergodic_moves::MoveOutcome::EXECUTION_FAILED); return false; } @@ -465,12 +469,14 @@ namespace cdt swap(*candidate, current); statistics.geometry = current.get_geometry(); ++statistics.accepted[move]; - record_transition(statistics, move, Transition_outcome::ACCEPTED); + record_transition(statistics, move, + ergodic_moves::MoveOutcome::METROPOLIS_ACCEPTED); return true; } ++statistics.rejected[move]; - record_transition(statistics, move, Transition_outcome::REJECTED); + record_transition(statistics, move, + ergodic_moves::MoveOutcome::METROPOLIS_REJECTED); return false; } diff --git a/include/Move_command.hpp b/include/Move_command.hpp index b565a4a6e..d47d71f05 100644 --- a/include/Move_command.hpp +++ b/include/Move_command.hpp @@ -19,8 +19,9 @@ namespace cdt { template > - requires(ManifoldType::dimension == 3) + typename ResultType = ergodic_moves::MoveResult> + requires(ManifoldType::dimension == 3 && + std::same_as>) class MoveCommand { using Queue = std::deque; @@ -131,25 +132,25 @@ namespace cdt auto move_type = m_moves.back(); // Record attempted move ++m_attempted[as_integer(move_type)]; - if (auto result = apply_random_move(std::as_const(m_manifold), - move_type, generator); - result) + auto result = + apply_random_move(std::as_const(m_manifold), move_type, generator); + auto outcome = result ? ergodic_moves::MoveOutcome::SUCCEEDED + : ergodic_moves::outcome_from(result.error()); + if (result && + !ergodic_moves::detail::check_move(m_manifold, *result, move_type)) { - if (ergodic_moves::detail::check_move(m_manifold, *result, move_type)) - { - swap(result.value(), m_manifold); - ++m_succeeded[as_integer(move_type)]; - } - else - { - spdlog::warn( - "Move violated a manifold invariant or geometry delta.\n"); - ++m_failed[as_integer(move_type)]; - } + outcome = ergodic_moves::MoveOutcome::EXECUTION_FAILED; + spdlog::warn( + "Move violated a manifold invariant or geometry delta.\n"); + } + + if (outcome == ergodic_moves::MoveOutcome::SUCCEEDED) + { + swap(result.value(), m_manifold); + ++m_succeeded[as_integer(move_type)]; } else { - // Routine inapplicable sites are represented by the failed counter. ++m_failed[as_integer(move_type)]; } // Remove move from queue @@ -161,7 +162,7 @@ namespace cdt template static auto apply_random_move(ManifoldType const& manifold, move_tracker::move_type const move, - Generator& generator) -> ExpectedType + Generator& generator) -> ResultType { using enum move_tracker::move_type; switch (move) @@ -172,7 +173,11 @@ namespace cdt case SIX_TWO: return ergodic_moves::do_62_move(manifold, generator); case FOUR_FOUR: return ergodic_moves::do_44_move(manifold, generator); } - return std::unexpected("Unknown Pachner move."); + return std::unexpected{ + ergodic_moves::MoveError{ + .category = ergodic_moves::MoveFailure::UNKNOWN_MOVE, + .requested_move = move} + }; } /** diff --git a/include/Move_outcome.hpp b/include/Move_outcome.hpp new file mode 100644 index 000000000..e0a01496a --- /dev/null +++ b/include/Move_outcome.hpp @@ -0,0 +1,113 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL + + Copyright © 2026 Adam Getchell + ******************************************************************************/ + +/// @file Move_outcome.hpp +/// @brief Structured Pachner-move failures and transition outcomes + +#ifndef CDT_PLUSPLUS_MOVE_OUTCOME_HPP +#define CDT_PLUSPLUS_MOVE_OUTCOME_HPP + +#include +#include +#include + +#include "Move_tracker.hpp" + +namespace cdt::ergodic_moves +{ + /// @brief Actionable reasons a raw move request cannot produce a new state. + enum class MoveFailure : std::uint8_t + { + NO_CANDIDATE, + INVALID_TOPOLOGY, + CAUSAL_INVALIDITY, + STALE_CANDIDATE, + EXECUTION_FAILURE, + INVARIANT_VIOLATION, + UNKNOWN_MOVE + }; + + /// @brief Typed error returned by move preparation or private execution. + /// @details The representation is allocation-free. Human-readable text is + /// derived at the presentation boundary rather than stored in the hot path. + struct MoveError + { + MoveFailure category; + move_tracker::move_type requested_move; + + /// @returns The structured rejection or execution-failure category. + [[nodiscard]] auto constexpr reason() const noexcept -> MoveFailure + { return category; } + + /// @returns The requested Pachner move. + [[nodiscard]] auto constexpr move() const noexcept + -> move_tracker::move_type + { return requested_move; } + + /// @returns A stable diagnostic for logs and command-line presentation. + [[nodiscard]] auto constexpr message() const noexcept -> std::string_view + { + using enum MoveFailure; + switch (category) + { + case NO_CANDIDATE: return "No raw proposal site is available."; + case INVALID_TOPOLOGY: + return "The selected proposal site is not part of the triangulation."; + case CAUSAL_INVALIDITY: + return "The selected proposal site violates a CDT move invariant."; + case STALE_CANDIDATE: + return "The prepared proposal site no longer exists."; + case EXECUTION_FAILURE: + return "CGAL rejected execution of the prepared move."; + case INVARIANT_VIOLATION: + return "The executed move violated a manifold postcondition."; + case UNKNOWN_MOVE: return "The requested Pachner move is unknown."; + } + return "The move failed for an unknown reason."; + } + + auto operator==(MoveError const&) const -> bool = default; + }; + + /// @brief Value returned by a fallible Pachner-move transformation. + template + using MoveResult = std::expected; + + /// @brief Typed state used to route proposal and execution accounting. + enum class MoveOutcome : std::uint8_t + { + INAPPLICABLE = 0, + METROPOLIS_ACCEPTED = 1, + METROPOLIS_REJECTED = 2, + EXECUTION_FAILED = 3, + SUCCEEDED = 4 + }; + + /// @brief Classify a structured move error for counter accounting. + [[nodiscard]] auto constexpr outcome_from(MoveError const error) noexcept + -> MoveOutcome + { + using enum MoveFailure; + switch (error.reason()) + { + case NO_CANDIDATE: + case INVALID_TOPOLOGY: + case CAUSAL_INVALIDITY: return MoveOutcome::INAPPLICABLE; + case STALE_CANDIDATE: + case EXECUTION_FAILURE: + case INVARIANT_VIOLATION: + case UNKNOWN_MOVE: return MoveOutcome::EXECUTION_FAILED; + } + return MoveOutcome::EXECUTION_FAILED; + } + + /// @brief Enable direct formatting through fmt/spdlog. + [[nodiscard]] auto constexpr format_as(MoveError const error) noexcept + -> std::string_view + { return error.message(); } +} // namespace cdt::ergodic_moves + +#endif // CDT_PLUSPLUS_MOVE_OUTCOME_HPP diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1024da018..c3dad4b91 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable( Metropolis_test.cpp Move_always_test.cpp Move_command_test.cpp + Move_outcome_test.cpp Move_run_test.cpp Move_tracker_test.cpp Random_test.cpp @@ -82,6 +83,7 @@ set( Metropolis.hpp Move_always.hpp Move_command.hpp + Move_outcome.hpp Move_run.hpp Move_strategy.hpp Move_tracker.hpp diff --git a/tests/Ergodic_moves_3_audit_test.cpp b/tests/Ergodic_moves_3_audit_test.cpp index 54bd15efb..7fc5f01f2 100644 --- a/tests/Ergodic_moves_3_audit_test.cpp +++ b/tests/Ergodic_moves_3_audit_test.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -27,6 +28,28 @@ using namespace cdt; +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v< + ergodic_moves::detail::ApplicableTwoThreeMove>); +static_assert(std::is_trivially_copyable_v< + ergodic_moves::detail::ApplicableThreeTwoMove>); +static_assert( + std::is_trivially_copyable_v); +static_assert( + std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v< + ergodic_moves::detail::ApplicableFourFourMove>); +static_assert(!std::is_default_constructible_v< + ergodic_moves::detail::ApplicableTwoThreeMove>); +static_assert(!std::is_default_constructible_v< + ergodic_moves::detail::ApplicableThreeTwoMove>); +static_assert(!std::is_default_constructible_v< + ergodic_moves::detail::ApplicableTwoSixMove>); +static_assert(!std::is_default_constructible_v< + ergodic_moves::detail::ApplicableSixTwoMove>); +static_assert(!std::is_default_constructible_v< + ergodic_moves::detail::ApplicableFourFourMove>); + namespace { using Manifold = manifolds::Manifold_3; @@ -576,6 +599,18 @@ namespace check_independent_invariants(*inverse); CHECK_EQ(canonical_state(*inverse), before); } + + template + void check_stale_candidate(Result const& result, + move_tracker::move_type const expected_move) + { + REQUIRE_FALSE(result.has_value()); + CHECK_EQ(result.error().reason(), + ergodic_moves::MoveFailure::STALE_CANDIDATE); + CHECK_EQ(result.error().move(), expected_move); + CHECK_EQ(ergodic_moves::outcome_from(result.error()), + ergodic_moves::MoveOutcome::EXECUTION_FAILED); + } } // namespace SCENARIO("Every 2+1D CDT move has the literature-derived local delta" * @@ -1032,3 +1067,157 @@ TEST_CASE("Move validation rejects exact configuration-state drift" * CHECK_FALSE(ergodic_moves::detail::check_move( before, after, move_tracker::move_type::FOUR_FOUR)); } + +SCENARIO("Applicable moves carry validation evidence without CGAL handles" * + doctest::test_suite("ergodic-audit")) +{ + GIVEN("A valid raw (2,3) cell") + { + auto triangulation = make_23_fixture().delaunay_snapshot(); + auto two_two = foliated_triangulations::filter_cells<3>( + foliated_triangulations::collect_cells<3>(triangulation), + Cell_type::TWO_TWO); + REQUIRE_EQ(two_two.size(), 1); + + auto const prepared = ergodic_moves::detail::prepare_two_three( + triangulation, two_two.front()); + REQUIRE(prepared.has_value()); + + WHEN("the proof value is consumed against the unchanged owner") + { + auto const executed = + ergodic_moves::detail::execute(triangulation, *prepared); + THEN("the checked flip succeeds without repeating CDT validation") + { CHECK(executed.has_value()); } + } + + WHEN("the owning triangulation no longer contains the prepared site") + { + triangulation = Delaunay{}; + auto const before = canonical_triangulation(triangulation); + auto const executed = + ergodic_moves::detail::execute(triangulation, *prepared); + THEN( + "the stable locator reports a stale proof instead of dereferencing a handle") + { + check_stale_candidate(executed, move_tracker::move_type::TWO_THREE); + CHECK_EQ(canonical_triangulation(triangulation), before); + } + } + } + + GIVEN("A prepared (3,2) edge whose owning triangulation is gone") + { + cdt::Random random{1032}; + auto const expanded = ergodic_moves::do_23_move(make_23_fixture(), random); + REQUIRE(expanded.has_value()); + + auto triangulation = expanded->delaunay_snapshot(); + auto const edge = find_inverse_32_edge(triangulation); + REQUIRE(edge.has_value()); + auto const prepared = + ergodic_moves::detail::prepare_three_two(triangulation, *edge); + REQUIRE(prepared.has_value()); + + triangulation = Delaunay{}; + auto const before = canonical_triangulation(triangulation); + auto const executed = + ergodic_moves::detail::execute(triangulation, *prepared); + + THEN("execution reports the originating (3,2) move as stale") + { + check_stale_candidate(executed, move_tracker::move_type::THREE_TWO); + CHECK_EQ(canonical_triangulation(triangulation), before); + } + } + + GIVEN("A prepared (2,6) facet whose owning triangulation is gone") + { + auto triangulation = make_26_fixture().delaunay_snapshot(); + auto one_three = foliated_triangulations::filter_cells<3>( + foliated_triangulations::collect_cells<3>(triangulation), + Cell_type::ONE_THREE); + REQUIRE_EQ(one_three.size(), 1); + auto const prepared = ergodic_moves::detail::prepare_two_six( + triangulation, one_three.front()); + REQUIRE(prepared.has_value()); + + triangulation = Delaunay{}; + auto const before = canonical_triangulation(triangulation); + auto const executed = ergodic_moves::detail::execute( + triangulation, *prepared, ergodic_moves::detail::accept_post_mutation); + + THEN("execution reports the originating (2,6) move as stale") + { + check_stale_candidate(executed, move_tracker::move_type::TWO_SIX); + CHECK_EQ(canonical_triangulation(triangulation), before); + } + } + + GIVEN("A prepared (6,2) vertex whose owning triangulation is gone") + { + cdt::Random setup_random{1062}; + auto const expanded = + ergodic_moves::do_26_move(make_26_fixture(), setup_random); + REQUIRE(expanded.has_value()); + + auto triangulation = expanded->delaunay_snapshot(); + auto vertices = foliated_triangulations::collect_vertices<3>(triangulation); + auto const candidate = + std::ranges::find_if(vertices, [&](auto const& vertex) { + return ergodic_moves::detail::is_62_movable(triangulation, vertex); + }); + REQUIRE(candidate != vertices.end()); + auto const prepared = + ergodic_moves::detail::prepare_six_two(triangulation, *candidate); + REQUIRE(prepared.has_value()); + + triangulation = Delaunay{}; + auto const before = canonical_triangulation(triangulation); + cdt::Random execution_random{1063}; + auto const executed = ergodic_moves::detail::execute( + triangulation, *prepared, execution_random, + ergodic_moves::detail::accept_post_mutation); + + THEN("execution reports the originating (6,2) move as stale") + { + check_stale_candidate(executed, move_tracker::move_type::SIX_TWO); + CHECK_EQ(canonical_triangulation(triangulation), before); + } + } + + GIVEN("A prepared (4,4) diamond whose owning triangulation is gone") + { + auto triangulation = make_44_fixture().delaunay_snapshot(); + auto const pivot = find_44_pivot(triangulation); + REQUIRE(pivot.has_value()); + auto const prepared = + ergodic_moves::detail::prepare_four_four(triangulation, *pivot); + REQUIRE(prepared.has_value()); + + triangulation = Delaunay{}; + auto const before = canonical_triangulation(triangulation); + auto const executed = ergodic_moves::detail::execute( + triangulation, *prepared, ergodic_moves::detail::accept_post_mutation); + + THEN("execution reports the originating (4,4) move as stale") + { + check_stale_candidate(executed, move_tracker::move_type::FOUR_FOUR); + CHECK_EQ(canonical_triangulation(triangulation), before); + } + } + + GIVEN("A causal manifold with no raw (2,3) proposal site") + { + cdt::Random random{101}; + auto const result = ergodic_moves::do_23_move(make_26_fixture(), random); + THEN("the boundary distinguishes absence from an invalid selected site") + { + REQUIRE_FALSE(result.has_value()); + CHECK_EQ(result.error().reason(), + ergodic_moves::MoveFailure::NO_CANDIDATE); + CHECK_EQ(ergodic_moves::outcome_from(result.error()), + ergodic_moves::MoveOutcome::INAPPLICABLE); + } + } +} diff --git a/tests/Ergodic_moves_3_test.cpp b/tests/Ergodic_moves_3_test.cpp index 883d60844..a306f2265 100644 --- a/tests/Ergodic_moves_3_test.cpp +++ b/tests/Ergodic_moves_3_test.cpp @@ -285,7 +285,8 @@ SCENARIO( THEN("The move is not performed") { CHECK_FALSE(result); - CHECK_EQ(result.error(), "No (3,2) move possible.\n"); + CHECK_EQ(result.error().reason(), + ergodic_moves::MoveFailure::CAUSAL_INVALIDITY); } } } @@ -440,7 +441,8 @@ SCENARIO( THEN("The move is not performed") { CHECK_FALSE(result); - CHECK_EQ(result.error(), "No (6,2) move possible.\n"); + CHECK_EQ(result.error().reason(), + ergodic_moves::MoveFailure::CAUSAL_INVALIDITY); } } } diff --git a/tests/Move_outcome_test.cpp b/tests/Move_outcome_test.cpp new file mode 100644 index 000000000..606ddfb82 --- /dev/null +++ b/tests/Move_outcome_test.cpp @@ -0,0 +1,156 @@ +/******************************************************************************* + Causal Dynamical Triangulations in C++ using CGAL + + Copyright © 2026 Adam Getchell + ******************************************************************************/ + +/// @file Move_outcome_test.cpp +/// @brief Tests for structured Pachner-move failures and outcomes + +#include "Move_outcome.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + using cdt::ergodic_moves::MoveError; + using cdt::ergodic_moves::MoveFailure; + using cdt::ergodic_moves::MoveOutcome; + using cdt::move_tracker::move_type; + + struct Failure_case + { + MoveFailure failure; + move_type move; + MoveOutcome outcome; + std::string_view message; + }; + + auto constexpr failure_cases = std::array{ + Failure_case{.failure = MoveFailure::NO_CANDIDATE, + .move = move_type::TWO_THREE, + .outcome = MoveOutcome::INAPPLICABLE, + .message = "No raw proposal site is available." }, + Failure_case{ + .failure = MoveFailure::INVALID_TOPOLOGY, + .move = move_type::THREE_TWO, + .outcome = MoveOutcome::INAPPLICABLE, + .message = + "The selected proposal site is not part of the triangulation." }, + Failure_case{ + .failure = MoveFailure::CAUSAL_INVALIDITY, + .move = move_type::TWO_SIX, + .outcome = MoveOutcome::INAPPLICABLE, + .message = + "The selected proposal site violates a CDT move invariant." }, + Failure_case{ .failure = MoveFailure::STALE_CANDIDATE, + .move = move_type::SIX_TWO, + .outcome = MoveOutcome::EXECUTION_FAILED, + .message = "The prepared proposal site no longer exists." }, + Failure_case{ .failure = MoveFailure::EXECUTION_FAILURE, + .move = move_type::FOUR_FOUR, + .outcome = MoveOutcome::EXECUTION_FAILED, + .message = "CGAL rejected execution of the prepared move." }, + Failure_case{ + .failure = MoveFailure::INVARIANT_VIOLATION, + .move = move_type::TWO_THREE, + .outcome = MoveOutcome::EXECUTION_FAILED, + .message = "The executed move violated a manifold postcondition."}, + Failure_case{ .failure = MoveFailure::UNKNOWN_MOVE, + .move = move_type::FOUR_FOUR, + .outcome = MoveOutcome::EXECUTION_FAILED, + .message = "The requested Pachner move is unknown." } + }; + + auto constexpr sample_error = + MoveError{.category = MoveFailure::STALE_CANDIDATE, + .requested_move = move_type::SIX_TWO}; +} // namespace + +static_assert(std::same_as, + std::expected>); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_destructible_v); +static_assert(std::is_nothrow_copy_constructible_v); +static_assert(std::is_nothrow_copy_assignable_v); +static_assert(sample_error.reason() == MoveFailure::STALE_CANDIDATE); +static_assert(sample_error.move() == move_type::SIX_TWO); +static_assert(sample_error.message() == + "The prepared proposal site no longer exists."); +static_assert(cdt::ergodic_moves::outcome_from(sample_error) == + MoveOutcome::EXECUTION_FAILED); +static_assert(cdt::ergodic_moves::format_as(sample_error) == + sample_error.message()); +static_assert(sample_error == sample_error); +static_assert(noexcept(sample_error.reason())); +static_assert(noexcept(sample_error.move())); +static_assert(noexcept(sample_error.message())); +static_assert(noexcept(sample_error == sample_error)); +static_assert(noexcept(cdt::ergodic_moves::outcome_from(sample_error))); +static_assert(noexcept(cdt::ergodic_moves::format_as(sample_error))); +static_assert(static_cast(MoveOutcome::INAPPLICABLE) == 0); +static_assert(static_cast(MoveOutcome::METROPOLIS_ACCEPTED) == 1); +static_assert(static_cast(MoveOutcome::METROPOLIS_REJECTED) == 2); +static_assert(static_cast(MoveOutcome::EXECUTION_FAILED) == 3); +static_assert(static_cast(MoveOutcome::SUCCEEDED) == 4); + +TEST_CASE("Move failures have stable diagnostics and accounting outcomes" * + doctest::test_suite("move_outcome")) +{ + for (auto const& test_case : failure_cases) + { + CAPTURE(static_cast(test_case.failure)); + CAPTURE(static_cast(test_case.move)); + + auto const error = MoveError{.category = test_case.failure, + .requested_move = test_case.move}; + + CHECK_EQ(error.reason(), test_case.failure); + CHECK_EQ(error.move(), test_case.move); + CHECK_EQ(error.message(), test_case.message); + CHECK_EQ(cdt::ergodic_moves::format_as(error), test_case.message); + CHECK_EQ(fmt::format("{}", error), test_case.message); + CHECK_EQ(cdt::ergodic_moves::outcome_from(error), test_case.outcome); + } +} + +TEST_CASE("Move error equality includes failure and move identity" * + doctest::test_suite("move_outcome")) +{ + auto const reference = MoveError{.category = MoveFailure::NO_CANDIDATE, + .requested_move = move_type::TWO_THREE}; + auto const same = MoveError{.category = MoveFailure::NO_CANDIDATE, + .requested_move = move_type::TWO_THREE}; + auto const different_failure = + MoveError{.category = MoveFailure::INVALID_TOPOLOGY, + .requested_move = move_type::TWO_THREE}; + auto const different_move = MoveError{.category = MoveFailure::NO_CANDIDATE, + .requested_move = move_type::THREE_TWO}; + + CHECK_EQ(reference, same); + CHECK_NE(reference, different_failure); + CHECK_NE(reference, different_move); +} + +TEST_CASE("Out-of-range move failures fail closed" * + doctest::test_suite("move_outcome")) +{ + auto const error = MoveError{.category = static_cast( + std::numeric_limits::max()), + .requested_move = move_type::TWO_SIX}; + + CHECK_EQ(error.move(), move_type::TWO_SIX); + CHECK_EQ(error.message(), "The move failed for an unknown reason."); + CHECK_EQ(cdt::ergodic_moves::format_as(error), error.message()); + CHECK_EQ(cdt::ergodic_moves::outcome_from(error), + MoveOutcome::EXECUTION_FAILED); +} diff --git a/tests/Public_api_consumer.cpp b/tests/Public_api_consumer.cpp index e1026b3ed..fd8eb895c 100644 --- a/tests/Public_api_consumer.cpp +++ b/tests/Public_api_consumer.cpp @@ -14,8 +14,13 @@ #include "Runtime_config.hpp" #include "S3Action.hpp" -using Manifold = cdt::manifolds::Manifold_3; -using Move_result = std::expected; +using Manifold = cdt::manifolds::Manifold_3; +using Move_command = cdt::MoveCommand; +using Move_result = cdt::ergodic_moves::MoveResult; + +template +concept Supported_move_command_result = + requires { typename cdt::MoveCommand; }; static_assert(std::same_as); static_assert(std::uniform_random_bit_generator); @@ -34,7 +39,12 @@ static_assert( static_assert( std::same_as>); -static_assert(std::is_constructible_v, Manifold>); +static_assert(std::is_constructible_v); +static_assert( + std::same_as>); +static_assert(Supported_move_command_result); +static_assert( + !Supported_move_command_result>); static_assert(requires { { cdt::MoveRunCadence::parse(1, 1)