From 55cdbd4e1d8a01823a6677f08f01580228db785b Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Wed, 6 May 2026 19:48:26 -0700 Subject: [PATCH 1/3] fix!(cdt): enforce Delaunay initialization invariants - Require constructor-grade Level 1-4 Delaunay validation before publishing labeled, strip, and toroidal CDT meshes. - Split evolved-state validation from initialization so completed simulations enforce structural PL-manifold, topology, foliation, causality, and cell-classification invariants without requiring Delaunay-ness. - Add validation cadence controls, checkpoint invariant checks, typed validation error categories, and regression coverage for the blocked periodic toroidal observables behavior. BREAKING CHANGE: CDT validation and error APIs now use stricter constructor checks and typed error categories, including CdtValidationCheck, DelaunayValidationLevel, CheckpointResumeReason, CdtTopology, and MoveType fields in public error variants. --- Cargo.toml | 3 + README.md | 2 +- docs/code_organization.md | 17 +- docs/dev/commands.md | 1 + docs/dev/rust.md | 1 + docs/dev/testing.md | 22 ++ docs/dev/tooling-alignment.md | 1 + docs/foliation.md | 19 +- justfile | 4 + src/cdt/ergodic_moves.rs | 155 ++++------- src/cdt/metropolis.rs | 208 +++++++++++---- src/cdt/observables.rs | 4 +- src/cdt/results.rs | 18 +- src/cdt/triangulation.rs | 120 ++++++--- src/cdt/triangulation/builders.rs | 389 ++++++++++++++++------------ src/cdt/triangulation/foliation.rs | 44 ++-- src/cdt/triangulation/moves.rs | 2 +- src/cdt/triangulation/validation.rs | 137 ++++++---- src/config.rs | 11 +- src/errors.rs | 188 +++++++++++++- src/geometry/backends/delaunay.rs | 198 +++++++++++++- src/geometry/generators.rs | 175 +++++++++++++ src/lib.rs | 14 +- tests/integration_tests.rs | 14 +- tests/physics_integration.rs | 87 +++++++ tests/proptest_foliation.rs | 47 ++-- tests/regressions.rs | 85 ++++++ 27 files changed, 1456 insertions(+), 510 deletions(-) create mode 100644 tests/physics_integration.rs create mode 100644 tests/regressions.rs diff --git a/Cargo.toml b/Cargo.toml index d8f2a07..44f6e08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,9 @@ path = "src/main.rs" name = "cdt_benchmarks" harness = false +[features] +slow-tests = [ ] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] diff --git a/README.md b/README.md index b0d3df2..e0a3ef0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The library leverages high-performance [Delaunay triangulation] backends and pro ## ✨ Features -- [x] Explicit 1+1 CDT strip and toroidal S¹×S¹ constructors with foliation invariants +- [x] Delaunay-built 1+1 CDT strip and periodic toroidal S¹×S¹ constructors with foliation invariants - [x] Foliation-aware topology, causality, and cell-classification validation - [x] Proposal-before-mutation Metropolis-Hastings simulation with rollback on failed accepted moves - [x] Regge action calculation with configurable coupling constants diff --git a/docs/code_organization.md b/docs/code_organization.md index 1495984..4828d22 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -119,8 +119,10 @@ causal-triangulations/ │ │ └── rust_style.rs │ ├── cli.rs │ ├── integration_tests.rs +│ ├── physics_integration.rs │ ├── proptest_foliation.rs -│ └── proptest_metropolis.rs +│ ├── proptest_metropolis.rs +│ └── regressions.rs ├── .bencher.toml ├── .codecov.yml ├── .coderabbit.yml @@ -175,16 +177,16 @@ Assigns each vertex to a discrete time slice, enabling classification of edges a This is CDT domain logic layered over the geometry backend interface. It may use `DelaunayBackend2D` and crate-owned Delaunay handles, but it does not reach through to upstream `delaunay::` APIs directly. - Owns the `CdtTriangulation` wrapper, `CdtMetadata`, `SimulationEvent`, metadata validation, cached simplex-count accessors, and common backend-agnostic wrapper methods -- `from_cdt_strip(vertices_per_slice, num_slices)` — explicit open-boundary 1+1 CDT strip with strict Up/Down cell classification -- `from_toroidal_cdt(vertices_per_slice, num_slices)` — explicit S¹×S¹ toroidal CDT (χ = 0); requires `vertices_per_slice ≥ 3` and `num_slices ≥ 3` +- `from_cdt_strip(vertices_per_slice, num_slices)` — Delaunay-built open-boundary 1+1 CDT strip with strict Up/Down cell classification and upstream Level 1–4 Delaunay validation before wrapping +- `from_toroidal_cdt(vertices_per_slice, num_slices)` — periodic Delaunay S¹×S¹ toroidal CDT (χ = 0) with upstream Level 1–4 validation before wrapping; requires `vertices_per_slice ≥ 3` and `num_slices ≥ 3` - `assign_foliation_by_y(num_slices)` — bin existing vertices into time slices - Query methods: `time_label`, `edge_type`, `vertices_at_time`, `slice_sizes`, `has_foliation` -- Validation: `validate_topology()` (χ expectation depends on `CdtTopology`), `validate_foliation()` (structural; closed S¹ spacelike rings on toroidal), `validate_causality()` (no edge spans >1 slice), `validate_cell_classification()` (strict Up/Down cell classification and validation pass) +- Validation: constructors require upstream Delaunay Level 1–4 validation for initial meshes; `validate()` is the post-move/final-state contract and requires upstream structural validity plus CDT topology, foliation, causality, and strict Up/Down cell classification - Mutable backend access is not exposed. CDT code mutates Delaunay state only through narrow crate-internal operations (`flip_edge`, `subdivide_face`, `remove_vertex`, `set_vertex_data`) that invalidate cached counts and foliation synchronization bookkeeping on success. The implementation is split into child modules under `src/cdt/triangulation/`: -- `builders.rs` — Delaunay-backed random/seeded/labeled builders plus explicit strip and toroidal CDT builders +- `builders.rs` — Delaunay-backed random/seeded/labeled builders plus strip and periodic toroidal CDT builders - `foliation.rs` — foliation assignment, slice and label queries, volume profiles, cell/edge classification, and foliation synchronization - `moves.rs` — narrow crate-internal Delaunay mutation hooks used by ergodic moves - `validation.rs` — full CDT validation and Delaunay-backed causality checks @@ -232,13 +234,14 @@ The implementation is split into child modules under `src/cdt/triangulation/`: - `generate_delaunay2` — builds a 2D Delaunay triangulation with optional seed - `build_delaunay2_with_data` — builds from coordinate + vertex-data pairs - `build_delaunay2_from_cells` / `build_delaunay2_with_topology` — builds from explicit cell connectivity (no Delaunay point insertion); the latter also accepts `TopologyGuarantee` and `GlobalTopology` metadata so non-sphere Euler characteristics validate correctly -- `build_toroidal_delaunay2` — convenience wrapper for explicit toroidal meshes (χ = 0) +- `build_toroidal_delaunay2` — convenience wrapper for explicit toroidal meshes (χ = 0; no point-insertion Delaunay guarantee) +- `build_periodic_toroidal_delaunay2` — builds true periodic toroidal Delaunay meshes through the upstream image-point constructor - `random_delaunay2`, `seeded_delaunay2` — convenience wrappers - `DelaunayTriangulation2D` — type alias for the concrete 2D triangulation type Together with `backends/delaunay.rs`, this module is the only place that directly imports from the `delaunay` crate. -The CDT strip and toroidal constructors keep their internal cell working sets as fixed triangles (`[usize; 3]`) and reserve storage up front. They currently adapt those triangles to `Vec>` at the generator boundary because the explicit-cell generator API still accepts Vec-backed cell index lists; a future generator cleanup should accept fixed triangle cells directly to remove that per-triangle allocation. +The toroidal CDT constructor builds from labeled lattice vertices and delegates to the upstream periodic image-point constructor, then validates the resulting Delaunay triangulation before CDT foliation, causality, topology, and cell-classification checks run. ### `util.rs` — Numeric helpers diff --git a/docs/dev/commands.md b/docs/dev/commands.md index e1e976d..f5a34e5 100644 --- a/docs/dev/commands.md +++ b/docs/dev/commands.md @@ -288,6 +288,7 @@ just test-python # pytest | Run lints | `just check` | | Run unit tests | `just test` | | Run integration tests | `just test-integration` | +| Run slow tests | `just test-slow` | | Run all tests | `just test-all` | | Run Python tests | `just test-python` | | Run examples | `just examples` | diff --git a/docs/dev/rust.md b/docs/dev/rust.md index 254f3fc..0caa3d0 100644 --- a/docs/dev/rust.md +++ b/docs/dev/rust.md @@ -210,6 +210,7 @@ Focused preludes under `prelude::` must remain small, orthogonal, and purpose-sp - `prelude::triangulation` for CDT wrappers, foliation classification, topology metadata, and triangulation queries - `prelude::moves` for local ergodic move kernels, move results, move types, and move statistics - `prelude::action` for standalone action configuration and Regge action calculations +- `prelude::errors` for crate error types and typed error-category enums needed to pattern-match failures - `prelude::simulation` for Metropolis/action simulation workflows, proposal types, simulation result types, and telemetry needed to inspect or debug simulations - `prelude::observables` for user-facing analysis APIs that measure triangulations or derived physical observables, such as volume profiles, Hausdorff-dimension estimators, and spectral-dimension estimators diff --git a/docs/dev/testing.md b/docs/dev/testing.md index c535335..64b7218 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -76,6 +76,22 @@ Integration tests should validate: --- +### Regression Tests + +Location: + +```text +tests/regressions.rs +``` + +Regression tests capture specific previously observed bugs or blocking upstream limitations. Each regression test should document: + +- the issue, blocker, or failure mode it guards +- the user-visible symptom that exposed the bug +- how expectations should change when the underlying fix lands + +--- + ### Python Tests Location: @@ -149,6 +165,12 @@ Run integration tests: just test-integration ``` +Run feature-gated slow integration tests: + +```bash +just test-slow +``` + Run all tests: ```bash diff --git a/docs/dev/tooling-alignment.md b/docs/dev/tooling-alignment.md index 11a894d..7f7873a 100644 --- a/docs/dev/tooling-alignment.md +++ b/docs/dev/tooling-alignment.md @@ -32,6 +32,7 @@ Some differences remain because CDT has different workflows and project invarian - CDT runs examples through `scripts/run_all_examples.sh`, which discovers current examples dynamically and applies a timeout. Its `--validate` mode checks stable semantic output markers for known Cargo examples without requiring exact numeric output. - CDT keeps `archive-changelog` so completed release series move under `docs/archive/changelog/`; MCMC does not yet archive old changelog sections. - CDT keeps a dedicated `performance.yml` workflow and local `perf-*` recipes. MCMC does not have matching CDT benchmark-baseline tooling. +- CDT exposes feature-gated long-running Rust checks through the `slow-tests` Cargo feature and the `just test-slow` recipe, keeping normal CI fast while giving stabilization work a named path for heavier integration coverage. - CDT has a repository rule SARIF workflow for the local Semgrep rules. A Codacy workflow was not ported because it depends on project-specific `CODACY_PROJECT_TOKEN` setup and would duplicate the existing repository-rule SARIF signal until Codacy is configured for this repository. - CDT Semgrep rules include geometry-backend isolation, foliation/topology validation, focused prelude imports, Python support-script discipline, and typed error policies. These are repository-specific and should not be weakened while porting generic rules. - CDT and MCMC both require Python `>=3.12` for repository-managed support tooling. diff --git a/docs/foliation.md b/docs/foliation.md index bb90634..d061bad 100644 --- a/docs/foliation.md +++ b/docs/foliation.md @@ -35,20 +35,31 @@ Vertex data is set at construction time via `VertexBuilder::data(t)`. For post-c ## Time Label Assignment -For `from_cdt_strip()` and `from_toroidal_cdt()`, time labels are assigned directly while building vertices. Vertex `(i, t)` receives label `t`, so each slice starts with exactly `vertices_per_slice` vertices and every constructed triangle spans adjacent slices. +For `from_cdt_strip()` and `from_toroidal_cdt()`, time labels are assigned directly while building vertices. Vertex `(i, t)` receives label `t`, so each slice starts with exactly `vertices_per_slice` vertices. The constructors require their Delaunay-built cells to span adjacent slices before returning. `assign_foliation_by_y()` uses band-based bucketing and writes labels through the same CDT-owned label-write path. -## Grid Construction (`from_cdt_strip`) +## Delaunay Construction -The open-boundary strip constructor places vertices on a grid with: +The open-boundary strip constructor places vertices in a lightly perturbed layered grid with: - **Spatial extent**: 1.0, with `vertices_per_slice` evenly spaced vertices per slice - **Temporal gap**: 1.0, with integer y-coordinates `0, 1, 2, ...` -- **Connectivity**: each quad between adjacent slices is split into one Up `(2,1)` and one Down `(1,2)` triangle +- **Connectivity**: produced by Delaunay point insertion, then checked for strict Up/Down cell classification Parameters: `vertices_per_slice ≥ 4`, `num_slices ≥ 2`. +The toroidal constructor places vertices on a unit lattice in an `N × T` periodic domain and uses the upstream periodic image-point Delaunay constructor. It then checks the requested `V = N·T`, `E = 3·N·T`, `F = 2·N·T` toroidal counts and strict CDT classification. + +## Initialization vs Evolution Validation + +Initial CDT constructors are stricter than post-move validation: + +- **Initialization**: `from_cdt_strip()` and `from_toroidal_cdt()` must pass upstream Delaunay Level 1-4 validation before returning. This certifies the starting mesh as a valid, well-behaved PL-manifold and Delaunay triangulation. +- **After ergodic moves / simulation completion**: `CdtTriangulation::validate()` requires upstream structural validity plus CDT topology, foliation, causality, and cell-classification invariants. It intentionally does not require Level 4 Delaunay-ness, because the CDT move kernels are not expected to preserve the Delaunay empty-circumsphere predicate. + +If the move set is ever changed to preserve Delaunay-ness, final-state validation should be tightened to include Level 4 as well. + ## Edge Classification `EdgeType` is an enum: diff --git a/justfile b/justfile index c565db6..92e13a3 100644 --- a/justfile +++ b/justfile @@ -226,6 +226,7 @@ help-workflows: @echo "Testing:" @echo " just test # Lib and doc tests only (fast, used by CI)" @echo " just test-integration # Integration tests (tests/)" + @echo " just test-slow # Feature-gated slow integration tests" @echo " just test-all # All tests (lib + doc + integration + Python)" @echo " just test-python # Python tests only (pytest)" @echo " just test-release # All tests in release mode" @@ -636,6 +637,9 @@ test-doc: test-integration: cargo test --tests --verbose +test-slow: + cargo test --tests --features slow-tests --verbose + test-examples: cargo test --examples --verbose diff --git a/src/cdt/ergodic_moves.rs b/src/cdt/ergodic_moves.rs index e79b598..f1c59f2 100644 --- a/src/cdt/ergodic_moves.rs +++ b/src/cdt/ergodic_moves.rs @@ -9,7 +9,7 @@ //! - edge flips: retained as an API-compatible alias for the 2D (2,2) move use crate::config::CdtTopology; -use crate::errors::CdtError; +use crate::errors::{CdtError, CdtValidationCheck}; use crate::geometry::CdtTriangulation2D; use crate::geometry::backends::delaunay::{DelaunayFaceHandle, DelaunayVertexHandle}; use crate::geometry::traits::{EdgeAdjacentFaces, TriangulationQuery}; @@ -410,7 +410,7 @@ impl ErgodicsSystem { let Some(point) = centroid(triangulation, &face) else { return MoveResult::Rejected(CdtError::ValidationFailed { - check: "ergodic Move13Add candidate geometry".to_string(), + check: CdtValidationCheck::ErgodicMoveCandidateGeometry, detail: format!( "face {:?} could not be converted to a 2D centroid", face.cell_key() @@ -646,10 +646,6 @@ impl ErgodicsSystem { return MoveResult::HardFailure(err); } - if let Some(rejection) = toroidal_invariant_rejection(triangulation) { - return rejection; - } - self.stats.record_success(move_type); MoveResult::Success } @@ -975,30 +971,12 @@ fn removal_is_causal(triangulation: &CdtTriangulation2D, vertex: &DelaunayVertex label_count(triangulation, label) > 1 } -/// Preserves toroidal post-move invariant errors as rollbackable rejections. -fn toroidal_invariant_rejection(triangulation: &CdtTriangulation2D) -> Option { - if !matches!(triangulation.metadata().topology, CdtTopology::Toroidal) { - return None; - } - - if let Err(err) = triangulation.validate_topology() { - return Some(MoveResult::Rejected(err)); - } - - if let Err(err) = triangulation.validate_foliation() { - return Some(MoveResult::Rejected(err)); - } - - None -} - #[cfg(test)] mod tests { use super::*; - use crate::cdt::foliation::FoliationError; use crate::geometry::DelaunayBackend2D; use crate::geometry::generators::{build_delaunay2_from_cells, build_delaunay2_with_data}; - use approx::{abs_diff_eq, assert_relative_eq}; + use approx::assert_relative_eq; use std::collections::HashSet; /// Builds the minimal foliated triangle fixture used by `(1,3)` tests. @@ -1160,7 +1138,7 @@ mod tests { &mut triangulation, snapshot, MoveResult::HardFailure(CdtError::ValidationFailed { - check: "test rollback".to_string(), + check: CdtValidationCheck::ErgodicMoveCandidateGeometry, detail: "simulated post-mutation failure".to_string(), }), ); @@ -1180,61 +1158,11 @@ mod tests { #[test] fn unwraps_toroidal_centroid() { - let triangulation = - CdtTriangulation2D::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); - let face = triangulation - .geometry() - .faces() - .find(|face| { - let vertices = triangulation - .geometry() - .face_vertices(face) - .expect("face vertices"); - let mut zero_x = 0; - let mut boundary_x = 0; - let mut zero_y = 0; - let mut next_y = 0; - for vertex in vertices { - let coords = triangulation - .geometry() - .vertex_coordinates(&vertex) - .expect("vertex coordinates"); - if abs_diff_eq!(coords[0], 0.0, epsilon = 1e-12) { - zero_x += 1; - } - if abs_diff_eq!(coords[0], 0.75, epsilon = 1e-12) { - boundary_x += 1; - } - if abs_diff_eq!(coords[1], 0.0, epsilon = 1e-12) { - zero_y += 1; - } - if abs_diff_eq!(coords[1], 1.0 / 3.0, epsilon = 1e-12) { - next_y += 1; - } - } - zero_x == 1 && boundary_x == 2 && zero_y == 2 && next_y == 1 - }) - .expect("wrap-around face"); - - let point = centroid(&triangulation, &face).expect("toroidal centroid"); - - assert_relative_eq!(point[0], 5.0 / 6.0, epsilon = 1e-12); - assert_relative_eq!(point[1], 1.0 / 9.0, epsilon = 1e-12); - } - - #[test] - fn toroidal_invariant_rejection_accepts_valid_torus() { - let triangulation = - CdtTriangulation2D::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); - - assert_eq!(toroidal_invariant_rejection(&triangulation), None); - } - - #[test] - fn toroidal_invariant_rejection_ignores_open_boundary_topology() { - let triangulation = single_triangle(); + let point = toroidal_centroid(&[[0.0, 0.0], [3.0, 0.0], [3.0, 1.0]], [4.0, 3.0]) + .expect("toroidal centroid"); - assert_eq!(toroidal_invariant_rejection(&triangulation), None); + assert_relative_eq!(point[0], 10.0 / 3.0, epsilon = 1e-12); + assert_relative_eq!(point[1], 1.0 / 3.0, epsilon = 1e-12); } #[test] @@ -1251,32 +1179,7 @@ mod tests { euler_characteristic: 1, expected_euler_characteristics, .. - }) if topology == "toroidal" && expected_euler_characteristics == [0] - )); - } - - #[test] - fn toroidal_invariant_rejection_reports_foliation_mismatch() { - let mut triangulation = - CdtTriangulation2D::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); - let vertex = triangulation - .geometry() - .vertices() - .find(|vertex| triangulation.time_label(vertex) == Some(1)) - .expect("fixture has a slice-1 vertex"); - - triangulation - .set_vertex_data(&vertex, Some(0)) - .expect("fixture vertex label can be edited"); - - assert!(matches!( - toroidal_invariant_rejection(&triangulation), - Some(MoveResult::Rejected(CdtError::Foliation( - FoliationError::LabelMismatch { .. } - | FoliationError::SpacelikeDegreeViolation { .. } - | FoliationError::SpacelikeSubgraphSizeMismatch { .. } - | FoliationError::SpacelikeNonClosedRing { .. } - ))) + }) if topology == CdtTopology::Toroidal && expected_euler_characteristics == [0] )); } @@ -1329,6 +1232,46 @@ mod tests { ); } + #[test] + fn periodic_toroidal_move_13_reports_backend_offset_limitation() { + let mut system = ErgodicsSystem::with_seed(7); + let mut triangulation = + CdtTriangulation2D::from_toroidal_cdt(8, 8).expect("build toroidal CDT"); + let counts_before = ( + triangulation.vertex_count(), + triangulation.edge_count(), + triangulation.face_count(), + ); + + let result = system.attempt_13_move(&mut triangulation); + + assert!( + matches!( + result, + MoveResult::Rejected(CdtError::BackendMutationFailed { + ref operation, + ref detail, + .. + }) if operation == "subdivide_face" + && detail.contains("periodic external cell") + && detail.contains("aligned periodic offsets") + ), + "periodic toroidal Move13Add should expose the upstream periodic-offset flip limitation, got {result:?}" + ); + assert_eq!( + ( + triangulation.vertex_count(), + triangulation.edge_count(), + triangulation.face_count(), + ), + counts_before, + "rejected periodic toroidal mutation should roll back geometry" + ); + triangulation + .validate() + .expect("rejected periodic toroidal move should preserve evolved CDT invariants"); + } + #[test] fn edge_flip_uses_own_stats() { let mut system = ErgodicsSystem::new(); diff --git a/src/cdt/metropolis.rs b/src/cdt/metropolis.rs index 8d331d5..ada54ba 100644 --- a/src/cdt/metropolis.rs +++ b/src/cdt/metropolis.rs @@ -15,7 +15,7 @@ use crate::cdt::ergodic_moves::{ErgodicsSystem, MoveResult, MoveStatistics, Move use crate::cdt::results::{Measurement, SimulationResultsBackend}; use crate::cdt::triangulation::SimulationEvent; use crate::config::validate_schedule; -use crate::errors::{CdtError, CdtResult}; +use crate::errors::{CdtError, CdtResult, CheckpointResumeReason}; use crate::geometry::CdtTriangulation2D; use crate::util::saturating_usize_to_u32; use markov_chain_monte_carlo::{Chain, ChainCheckpoint, DelayedProposal, Target}; @@ -1010,7 +1010,7 @@ impl MetropolisAlgorithm { .checked_add(self.config.steps) .ok_or_else(|| { checkpoint_resume_failed( - "step count overflow", + CheckpointResumeReason::StepCountOverflow, "resumed step count exceeds u32::MAX", ) })?; @@ -1050,7 +1050,7 @@ impl MetropolisAlgorithm { for _ in 0..additional_steps { let step = state.current_step.checked_add(1).ok_or_else(|| { checkpoint_resume_failed( - "step count overflow", + CheckpointResumeReason::StepCountOverflow, "resumed step count exceeds u32::MAX", ) })?; @@ -1075,15 +1075,25 @@ impl MetropolisRunState { checkpoint.config.temperature, ) .map_err(|err| { - checkpoint_resume_failed("checkpoint target configuration", err.to_string()) + checkpoint_resume_failed( + CheckpointResumeReason::CheckpointTargetConfiguration, + err.to_string(), + ) + })?; + let chain = Chain::from_checkpoint(checkpoint.chain, &target).map_err(|err| { + checkpoint_resume_failed(CheckpointResumeReason::McmcChainRestore, err.to_string()) })?; - let chain = Chain::from_checkpoint(checkpoint.chain, &target) - .map_err(|err| checkpoint_resume_failed("mcmc chain restore", err.to_string()))?; let triangulation = chain.into_state(); + triangulation.validate_evolved_cdt().map_err(|err| { + checkpoint_resume_failed( + CheckpointResumeReason::TriangulationInvariants, + err.to_string(), + ) + })?; let actual_action = action_for(&checkpoint.action_config, &triangulation); if !actions_match(actual_action, checkpoint.current_action) { return Err(checkpoint_resume_failed( - "action mismatch", + CheckpointResumeReason::ActionMismatch, format!( "checkpoint action mismatch: stored {}, recomputed {}", checkpoint.current_action, actual_action @@ -1114,6 +1124,7 @@ impl MetropolisRunState { config: MetropolisConfig, action_config: ActionConfig, ) -> CdtResult { + self.triangulation.validate_evolved_cdt()?; let (accepted, rejected) = chain_counters(&self.move_stats)?; Ok(CdtMcmcCheckpoint { chain: ChainCheckpoint::new(self.triangulation, accepted, rejected), @@ -1186,6 +1197,7 @@ fn run_one_step( step: step.into(), action_change: applied_action - action_before, }); + validate_evolved_cdt_if_due(state)?; } Ok(AcceptedMoveResult::NoApplicableSite { .. }) => { // A move type can be Metropolis-accepted even when bounded @@ -1229,10 +1241,22 @@ fn run_one_step( Ok(()) } +/// Runs the expensive full evolved-state validation only when the backend policy is due. +fn validate_evolved_cdt_if_due(state: &MetropolisRunState) -> CdtResult<()> { + if state + .triangulation + .geometry() + .should_check_delaunay_after(state.move_stats.total_accepted()) + { + state.triangulation.validate_evolved_cdt()?; + } + Ok(()) +} + /// Builds a structured checkpoint-resume error. -fn checkpoint_resume_failed(reason: &'static str, detail: impl Into) -> CdtError { +fn checkpoint_resume_failed(reason: CheckpointResumeReason, detail: impl Into) -> CdtError { CdtError::CheckpointResumeFailed { - reason: reason.to_string(), + reason, detail: detail.into(), } } @@ -1248,25 +1272,25 @@ fn validate_resume_compatible( ) -> CdtResult<()> { if algorithm.action_config != checkpoint.action_config { return Err(checkpoint_resume_failed( - "incompatible action configuration", + CheckpointResumeReason::IncompatibleActionConfiguration, "action configuration differs from checkpoint", )); } if algorithm.config.temperature.to_bits() != checkpoint.config.temperature.to_bits() { return Err(checkpoint_resume_failed( - "incompatible temperature", + CheckpointResumeReason::IncompatibleTemperature, "temperature differs from checkpoint", )); } if algorithm.config.thermalization_steps != checkpoint.config.thermalization_steps { return Err(checkpoint_resume_failed( - "incompatible thermalization schedule", + CheckpointResumeReason::IncompatibleThermalizationSchedule, "thermalization schedule differs from checkpoint", )); } if algorithm.config.measurement_frequency != checkpoint.config.measurement_frequency { return Err(checkpoint_resume_failed( - "incompatible measurement frequency", + CheckpointResumeReason::IncompatibleMeasurementFrequency, "measurement frequency differs from checkpoint", )); } @@ -1279,18 +1303,23 @@ fn validate_resume_compatible( /// are redundant by design; this catches tampered or partially written /// checkpoint payloads before any resumed sampling occurs. fn validate_checkpoint_counters(checkpoint: &CdtMcmcCheckpoint) -> CdtResult<()> { - checkpoint - .config - .validate() - .map_err(|err| checkpoint_resume_failed("checkpoint configuration", err.to_string()))?; + checkpoint.config.validate().map_err(|err| { + checkpoint_resume_failed( + CheckpointResumeReason::CheckpointConfiguration, + err.to_string(), + ) + })?; checkpoint.action_config.validate().map_err(|err| { - checkpoint_resume_failed("checkpoint action configuration", err.to_string()) + checkpoint_resume_failed( + CheckpointResumeReason::CheckpointActionConfiguration, + err.to_string(), + ) })?; let (accepted, rejected) = chain_counters(&checkpoint.move_stats)?; if checkpoint.chain.accepted() != accepted || checkpoint.chain.rejected() != rejected { return Err(checkpoint_resume_failed( - "chain counter mismatch", + CheckpointResumeReason::ChainCounterMismatch, "chain counters do not match move statistics", )); } @@ -1298,13 +1327,13 @@ fn validate_checkpoint_counters(checkpoint: &CdtMcmcCheckpoint) -> CdtResult<()> != usize::try_from(checkpoint.current_step).unwrap_or(usize::MAX) { return Err(checkpoint_resume_failed( - "chain step mismatch", + CheckpointResumeReason::ChainStepMismatch, "chain step count does not match checkpoint step", )); } if checkpoint.steps.len() != checkpoint.chain.total_steps() { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, "step telemetry length does not match chain step count", )); } @@ -1318,7 +1347,7 @@ fn validate_checkpoint_steps(checkpoint: &CdtMcmcCheckpoint) -> CdtResult<()> { let accepted_steps = checkpoint.steps.iter().filter(|step| step.accepted).count(); if accepted_steps != checkpoint.chain.accepted() { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!( "accepted step count mismatch: got {}, expected {}", accepted_steps, @@ -1330,13 +1359,13 @@ fn validate_checkpoint_steps(checkpoint: &CdtMcmcCheckpoint) -> CdtResult<()> { for (index, step) in checkpoint.steps.iter().enumerate() { let expected_step = u32::try_from(index + 1).map_err(|_| { checkpoint_resume_failed( - "step telemetry overflow", + CheckpointResumeReason::StepTelemetryOverflow, "step telemetry index exceeds u32::MAX", ) })?; if step.step != expected_step { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!( "step telemetry must be sequential: got step {}, expected {}", step.step, expected_step @@ -1345,7 +1374,7 @@ fn validate_checkpoint_steps(checkpoint: &CdtMcmcCheckpoint) -> CdtResult<()> { } if !step.action_before.is_finite() { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!("step {} has non-finite action_before", step.step), )); } @@ -1353,13 +1382,13 @@ fn validate_checkpoint_steps(checkpoint: &CdtMcmcCheckpoint) -> CdtResult<()> { && !delta_action.is_finite() { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!("step {} has non-finite delta_action", step.step), )); } if step.accepted && step.delta_action.is_none() { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!("accepted step {} is missing delta_action", step.step), )); } @@ -1369,7 +1398,7 @@ fn validate_checkpoint_steps(checkpoint: &CdtMcmcCheckpoint) -> CdtResult<()> { && !actions_match(action_after, step.action_before + delta_action) { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!( "step {} action_after does not match delta_action", step.step @@ -1379,19 +1408,19 @@ fn validate_checkpoint_steps(checkpoint: &CdtMcmcCheckpoint) -> CdtResult<()> { } (true, Some(_)) => { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!("step {} has non-finite action_after", step.step), )); } (true, None) => { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!("accepted step {} is missing action_after", step.step), )); } (false, Some(_)) => { return Err(checkpoint_resume_failed( - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, format!("rejected step {} unexpectedly has action_after", step.step), )); } @@ -1408,13 +1437,13 @@ fn validate_checkpoint_measurements(checkpoint: &CdtMcmcCheckpoint) -> CdtResult ) .map_err(|_| { checkpoint_resume_failed( - "measurement telemetry overflow", + CheckpointResumeReason::MeasurementTelemetryOverflow, "scheduled measurement count exceeds usize::MAX", ) })?; if checkpoint.measurements.len() != expected_measurements { return Err(checkpoint_resume_failed( - "measurement telemetry mismatch", + CheckpointResumeReason::MeasurementTelemetryMismatch, format!( "scheduled measurement count mismatch: got {}, expected {}", checkpoint.measurements.len(), @@ -1430,13 +1459,13 @@ fn validate_checkpoint_measurements(checkpoint: &CdtMcmcCheckpoint) -> CdtResult .and_then(|step| u32::try_from(step).ok()) .ok_or_else(|| { checkpoint_resume_failed( - "measurement telemetry overflow", + CheckpointResumeReason::MeasurementTelemetryOverflow, "scheduled measurement step exceeds u32::MAX", ) })?; if measurement.step != expected_step { return Err(checkpoint_resume_failed( - "measurement telemetry mismatch", + CheckpointResumeReason::MeasurementTelemetryMismatch, format!( "measurement telemetry must follow the sampling schedule: got step {}, expected {}", measurement.step, expected_step @@ -1445,7 +1474,7 @@ fn validate_checkpoint_measurements(checkpoint: &CdtMcmcCheckpoint) -> CdtResult } if !measurement.action.is_finite() { return Err(checkpoint_resume_failed( - "measurement telemetry mismatch", + CheckpointResumeReason::MeasurementTelemetryMismatch, format!( "measurement at step {} has non-finite action", measurement.step @@ -1466,20 +1495,20 @@ fn chain_counters(move_stats: &MoveStatistics) -> CdtResult<(usize, usize)> { let accepted = move_stats.total_accepted(); let rejected = attempted.checked_sub(accepted).ok_or_else(|| { checkpoint_resume_failed( - "move statistics invariant", + CheckpointResumeReason::MoveStatisticsInvariant, "accepted move count exceeds attempted move count", ) })?; Ok(( usize::try_from(accepted).map_err(|_| { checkpoint_resume_failed( - "counter conversion overflow", + CheckpointResumeReason::CounterConversionOverflow, "accepted move count exceeds usize::MAX", ) })?, usize::try_from(rejected).map_err(|_| { checkpoint_resume_failed( - "counter conversion overflow", + CheckpointResumeReason::CounterConversionOverflow, "rejected move count exceeds usize::MAX", ) })?, @@ -1645,7 +1674,7 @@ fn apply_accepted_move( /// The move kernels keep causal, geometric, and backend failures orthogonal; this /// wrapper adds the Metropolis step, move type, and retry context callers need to /// debug a failed accepted application. -fn accepted_move_error( +const fn accepted_move_error( step: u32, move_type: MoveType, attempts: usize, @@ -1653,7 +1682,7 @@ fn accepted_move_error( ) -> CdtError { CdtError::MetropolisMoveApplicationFailed { step, - move_type: format!("{move_type:?}"), + move_type, attempts, last_failure, } @@ -1685,6 +1714,7 @@ mod tests { use markov_chain_monte_carlo::Chain; use rand::rngs::StdRng; use serde_json::{from_str, to_string, to_value}; + use std::num::NonZeroUsize; fn assert_optional_relative_eq(left: Option, right: Option) { match (left, right) { @@ -1696,7 +1726,7 @@ mod tests { fn short_checkpoint() -> CdtMcmcCheckpoint { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); MetropolisAlgorithm::new( MetropolisConfig::new(1.0, 2, 0, 1).with_seed(13), ActionConfig::default(), @@ -1705,9 +1735,23 @@ mod tests { .expect("short prefix run should checkpoint") } + fn empty_run_state(triangulation: CdtTriangulation2D) -> MetropolisRunState { + MetropolisRunState { + triangulation, + current_step: 0, + current_action: 0.0, + acceptance_rng: simulation_rng(Some(1)), + ergodics: ErgodicsSystem::with_seed(2), + move_stats: MoveStatistics::new(), + steps: Vec::new(), + measurements: Vec::new(), + elapsed_time: Duration::ZERO, + } + } + fn assert_checkpoint_resume_failed( result: CdtResult, - expected_reason: &str, + expected_reason: CheckpointResumeReason, expected_detail: &str, ) { let Err(CdtError::CheckpointResumeFailed { reason, detail }) = result else { @@ -1720,6 +1764,56 @@ mod tests { ); } + #[test] + fn full_validation_cadence_uses_delaunay_check_policy() { + let mut triangulation = + CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); + triangulation.set_delaunay_check_interval(NonZeroUsize::new(1)); + let vertex = triangulation + .geometry() + .vertices() + .find(|vertex| triangulation.time_label(vertex) == Some(1)) + .expect("fixture has a slice-1 vertex"); + triangulation + .set_vertex_data(&vertex, Some(0)) + .expect("fixture vertex label can be edited"); + + let mut state = empty_run_state(triangulation); + state.move_stats.record_success(MoveType::Move22); + + assert!( + validate_evolved_cdt_if_due(&state).is_err(), + "EveryN(1) should run full validation after the first accepted move" + ); + } + + #[test] + fn end_only_validation_policy_defers_until_checkpoint() { + let mut triangulation = + CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); + triangulation.set_delaunay_check_interval(None); + let vertex = triangulation + .geometry() + .vertices() + .find(|vertex| triangulation.time_label(vertex) == Some(1)) + .expect("fixture has a slice-1 vertex"); + triangulation + .set_vertex_data(&vertex, Some(0)) + .expect("fixture vertex label can be edited"); + + let mut state = empty_run_state(triangulation); + state.move_stats.record_success(MoveType::Move22); + + validate_evolved_cdt_if_due(&state) + .expect("EndOnly should skip cadence validation on accepted moves"); + assert!( + state + .into_checkpoint(MetropolisConfig::default(), ActionConfig::default()) + .is_err(), + "mandatory checkpoint validation should still catch the invalid final state" + ); + } + #[test] fn test_metropolis_config() { let config = MetropolisConfig::new(2.0, 500, 50, 5); @@ -1828,7 +1922,7 @@ mod tests { #[test] fn serialized_checkpoint_resumes_from_stored_rng_state() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let action_config = ActionConfig::default(); let prefix = MetropolisAlgorithm::new( MetropolisConfig::new(1.0, 4, 0, 1).with_seed(13), @@ -1916,7 +2010,7 @@ mod tests { assert_checkpoint_resume_failed( algorithm.resume_from_checkpoint(checkpoint), - "incompatible action configuration", + CheckpointResumeReason::IncompatibleActionConfiguration, "action configuration", ); } @@ -1931,7 +2025,7 @@ mod tests { assert_checkpoint_resume_failed( algorithm.resume_from_checkpoint(checkpoint), - "incompatible measurement frequency", + CheckpointResumeReason::IncompatibleMeasurementFrequency, "measurement frequency", ); } @@ -1947,7 +2041,7 @@ mod tests { assert_checkpoint_resume_failed( algorithm.resume_from_checkpoint(checkpoint), - "chain counter mismatch", + CheckpointResumeReason::ChainCounterMismatch, "chain counters", ); } @@ -1963,7 +2057,7 @@ mod tests { assert_checkpoint_resume_failed( algorithm.resume_from_checkpoint(checkpoint), - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, "step telemetry length", ); } @@ -1979,7 +2073,7 @@ mod tests { assert_checkpoint_resume_failed( algorithm.resume_from_checkpoint(checkpoint), - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, "step telemetry must be sequential", ); } @@ -2003,7 +2097,7 @@ mod tests { assert_checkpoint_resume_failed( algorithm.resume_from_checkpoint(checkpoint), - "step telemetry mismatch", + CheckpointResumeReason::StepTelemetryMismatch, "accepted step count mismatch", ); } @@ -2019,7 +2113,7 @@ mod tests { assert_checkpoint_resume_failed( algorithm.resume_from_checkpoint(checkpoint), - "measurement telemetry mismatch", + CheckpointResumeReason::MeasurementTelemetryMismatch, "scheduled measurement count mismatch", ); } @@ -2035,7 +2129,7 @@ mod tests { assert_checkpoint_resume_failed( algorithm.resume_from_checkpoint(checkpoint), - "action mismatch", + CheckpointResumeReason::ActionMismatch, "checkpoint action mismatch", ); } @@ -2050,7 +2144,7 @@ mod tests { panic!("expected checkpoint configuration failure"); }; - assert_eq!(reason, "checkpoint configuration"); + assert_eq!(reason, CheckpointResumeReason::CheckpointConfiguration); assert!(detail.contains("temperature")); } @@ -2065,22 +2159,22 @@ mod tests { panic!("expected impossible move statistics to fail"); }; - assert_eq!(reason, "move statistics invariant"); + assert_eq!(reason, CheckpointResumeReason::MoveStatisticsInvariant); assert!(detail.contains("accepted move count exceeds attempted move count")); } #[test] fn explicit_cdt_volume_profiles_count_time_slabs() { - let strip = CdtTriangulation::from_cdt_strip(4, 3).expect("create explicit strip"); + let strip = CdtTriangulation::from_cdt_strip(4, 3).expect("create Delaunay strip"); assert_eq!(strip.volume_profile(), vec![6, 6, 0]); - let torus = CdtTriangulation::from_toroidal_cdt(3, 3).expect("create explicit torus"); + let torus = CdtTriangulation::from_toroidal_cdt(3, 3).expect("create periodic torus"); assert_eq!(torus.volume_profile(), vec![6, 6, 6]); } #[test] fn measurement_records_volume_profile_for_foliated_triangulation() { - let triangulation = CdtTriangulation::from_cdt_strip(4, 3).expect("create explicit strip"); + let triangulation = CdtTriangulation::from_cdt_strip(4, 3).expect("create Delaunay strip"); let measurement = measurement_for(0, 1.0, &triangulation); assert_eq!(measurement.volume_profile, vec![6, 6, 0]); diff --git a/src/cdt/observables.rs b/src/cdt/observables.rs index 7fd7238..29673f3 100644 --- a/src/cdt/observables.rs +++ b/src/cdt/observables.rs @@ -380,7 +380,7 @@ mod tests { #[test] fn hausdorff_estimate_uses_dual_graph_ball_growth() { - let triangulation = CdtTriangulation::from_cdt_strip(4, 3).expect("create explicit strip"); + let triangulation = CdtTriangulation::from_cdt_strip(4, 3).expect("create Delaunay strip"); let estimate = estimate_hausdorff_dimension(&triangulation) .expect("strip dual graph should have enough radii for a fit"); @@ -399,7 +399,7 @@ mod tests { #[test] fn spectral_estimate_uses_dual_graph_return_probability() { let triangulation = - CdtTriangulation::from_toroidal_cdt(6, 6).expect("create explicit torus"); + CdtTriangulation::from_toroidal_cdt(6, 6).expect("create periodic torus"); let estimate = estimate_spectral_dimension(&triangulation) .expect("torus dual graph should have enough diffusion data"); diff --git a/src/cdt/results.rs b/src/cdt/results.rs index 5d4a85c..813854f 100644 --- a/src/cdt/results.rs +++ b/src/cdt/results.rs @@ -755,7 +755,7 @@ mod tests { #[test] fn writes_measurements_csv_with_matching_step_telemetry() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let results = results_with( MetropolisConfig::new(1.0, 2, 1, 1), vec![MonteCarloStep { @@ -791,7 +791,7 @@ mod tests { #[test] fn writes_summary_json_with_config_and_aggregates() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let results = results_with( MetropolisConfig::new(1.0, 1, 0, 1), vec![MonteCarloStep { @@ -831,7 +831,7 @@ mod tests { #[test] fn summary_json_average_action_uses_equilibrium_measurements() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let results = results_with( MetropolisConfig::new(1.0, 2, 1, 1), vec![], @@ -901,7 +901,7 @@ mod tests { Measurement::new(15, 3.0, 5, 7, 3).with_volume_profile(vec![1, 2, 0]), ]; let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let results = results_with(config, steps, measurements, triangulation); assert_relative_eq!(results.acceptance_rate(), 2.0 / 3.0); @@ -918,7 +918,7 @@ mod tests { #[test] fn volume_observables_treat_missing_profile_entries_as_zero() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let results = results_with( MetropolisConfig::new(1.0, 20, 10, 5), vec![], @@ -939,7 +939,7 @@ mod tests { #[test] fn volume_observables_are_empty_when_profiles_are_empty() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let results = results_with( MetropolisConfig::new(1.0, 20, 10, 5), vec![], @@ -957,7 +957,7 @@ mod tests { #[test] fn volume_fluctuations_are_empty_for_single_equilibrium_measurement() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let results = results_with( MetropolisConfig::new(1.0, 20, 10, 5), vec![], @@ -975,7 +975,7 @@ mod tests { #[test] fn summaries_are_empty_for_no_steps_or_measurements() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); let results = results_with( MetropolisConfig::new(1.0, 20, 10, 5), vec![], @@ -993,7 +993,7 @@ mod tests { #[test] fn dimension_estimates_delegate_to_final_triangulation() { let triangulation = - CdtTriangulation::from_toroidal_cdt(6, 6).expect("explicit torus should build"); + CdtTriangulation::from_toroidal_cdt(6, 6).expect("periodic torus should build"); let results = results_with( MetropolisConfig::new(1.0, 1, 0, 1), vec![], diff --git a/src/cdt/triangulation.rs b/src/cdt/triangulation.rs index cc49d10..7aabd9c 100644 --- a/src/cdt/triangulation.rs +++ b/src/cdt/triangulation.rs @@ -186,11 +186,7 @@ impl<'de> Deserialize<'de> for CdtTriangulation { impl CdtTriangulation { fn validate_checkpoint_invariants(&self) -> CdtResult<()> { - self.validate_topology()?; - self.validate_foliation()?; - self.validate_causality()?; - self.validate_cell_classification()?; - Ok(()) + self.validate_evolved_cdt() } } @@ -283,7 +279,7 @@ impl CdtTriangulation { /// provided_value, /// expected, /// } if field == "timeslices" - /// && topology == "toroidal" + /// && topology == CdtTopology::Toroidal /// && provided_value == "2" /// && expected == "≥ 3" /// )); @@ -311,7 +307,7 @@ impl CdtTriangulation { /// euler_characteristic: 1, /// expected_euler_characteristics, /// .. - /// } if topology == "toroidal" && expected_euler_characteristics == vec![0] + /// } if topology == CdtTopology::Toroidal && expected_euler_characteristics == vec![0] /// )); /// ``` pub fn with_topology( @@ -357,7 +353,7 @@ impl CdtTriangulation { if time_slices == 0 { return Err(CdtError::InvalidTriangulationMetadata { field: "timeslices".to_string(), - topology: Self::topology_label(topology).to_string(), + topology, provided_value: "0".to_string(), expected: "≥ 1".to_string(), }); @@ -366,7 +362,7 @@ impl CdtTriangulation { if matches!(topology, CdtTopology::Toroidal) && time_slices < 3 { return Err(CdtError::InvalidTriangulationMetadata { field: "timeslices".to_string(), - topology: Self::topology_label(topology).to_string(), + topology, provided_value: time_slices.to_string(), expected: "≥ 3".to_string(), }); @@ -375,14 +371,6 @@ impl CdtTriangulation { Ok(()) } - /// Human-readable topology label for metadata diagnostics. - const fn topology_label(topology: CdtTopology) -> &'static str { - match topology { - CdtTopology::OpenBoundary => "open boundary", - CdtTopology::Toroidal => "toroidal", - } - } - /// Validates CDT metadata against backend and topology invariants. fn validate_metadata(&self) -> CdtResult<()> { Self::check_time_slices(self.metadata.topology, self.metadata.time_slices)?; @@ -391,7 +379,7 @@ impl CdtTriangulation { if usize::from(self.metadata.dimension) != backend_dimension { return Err(CdtError::InvalidTriangulationMetadata { field: "dimension".to_string(), - topology: Self::topology_label(self.metadata.topology).to_string(), + topology: self.metadata.topology, provided_value: self.metadata.dimension.to_string(), expected: format!("backend dimension ({backend_dimension})"), }); @@ -593,7 +581,7 @@ impl CdtTriangulation { if !expected.contains(&euler_char) { return Err(CdtError::TopologyMismatch { - topology: Self::topology_label(self.metadata.topology).to_string(), + topology: self.metadata.topology, euler_characteristic: euler_char, expected_euler_characteristics: expected.to_vec(), vertices: self.geometry.vertex_count(), @@ -657,7 +645,7 @@ impl CdtTriangulation { /// /// ``` /// use causal_triangulations::prelude::errors::CdtError; - /// use causal_triangulations::prelude::triangulation::CdtTriangulation; + /// use causal_triangulations::prelude::triangulation::{CdtTopology, CdtTriangulation}; /// /// let mut tri = CdtTriangulation::from_toroidal_cdt(4, 3) /// .expect("build toroidal triangulation"); @@ -671,7 +659,7 @@ impl CdtTriangulation { /// provided_value, /// expected, /// } if field == "timeslices" - /// && topology == "toroidal" + /// && topology == CdtTopology::Toroidal /// && provided_value == "2" /// && expected == "≥ 3" /// )); @@ -715,6 +703,7 @@ mod tests { use crate::cdt::metropolis::{MetropolisAlgorithm, MetropolisConfig}; use crate::geometry::generators::build_delaunay2_with_data; use serde_json::{from_str, to_string}; + use std::num::NonZeroUsize; use std::thread; use std::time::{Duration, Instant}; @@ -747,11 +736,11 @@ mod tests { result, Err(CdtError::InvalidTriangulationMetadata { ref field, - ref topology, + topology, ref provided_value, ref expected, }) if field == "timeslices" - && topology == "open boundary" + && topology == CdtTopology::OpenBoundary && provided_value == "0" && expected == "≥ 1" )); @@ -766,11 +755,11 @@ mod tests { result, Err(CdtError::InvalidTriangulationMetadata { ref field, - ref topology, + topology, ref provided_value, ref expected, }) if field == "dimension" - && topology == "open boundary" + && topology == CdtTopology::OpenBoundary && provided_value == "3" && expected == "backend dimension (2)" )); @@ -1315,11 +1304,11 @@ mod tests { assert!(matches!( result, Err(CdtError::TopologyMismatch { - ref topology, + topology, euler_characteristic: 0, ref expected_euler_characteristics, .. - }) if topology == "open boundary" && expected_euler_characteristics == &[1, 2] + }) if topology == CdtTopology::OpenBoundary && expected_euler_characteristics == &[1, 2] )); } @@ -1335,11 +1324,11 @@ mod tests { assert!(matches!( result, Err(CdtError::TopologyMismatch { - ref topology, + topology, euler_characteristic: 1, ref expected_euler_characteristics, .. - }) if topology == "toroidal" && expected_euler_characteristics == &[0] + }) if topology == CdtTopology::Toroidal && expected_euler_characteristics == &[0] )); } @@ -1352,11 +1341,11 @@ mod tests { result, Err(CdtError::InvalidTriangulationMetadata { ref field, - ref topology, + topology, ref provided_value, ref expected, }) if field == "timeslices" - && topology == "toroidal" + && topology == CdtTopology::Toroidal && provided_value == "2" && expected == "≥ 3" )); @@ -1375,11 +1364,11 @@ mod tests { result, Err(CdtError::InvalidTriangulationMetadata { ref field, - ref topology, + topology, ref provided_value, ref expected, }) if field == "timeslices" - && topology == "toroidal" + && topology == CdtTopology::Toroidal && provided_value == "2" && expected == "≥ 3" )); @@ -1388,7 +1377,7 @@ mod tests { #[test] fn strip_checkpoint_roundtrip_preserves_foliation_and_classification() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit CDT strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay CDT strip should build"); let json = to_string(&triangulation).expect("checkpoint should serialize"); let restored: CdtTriangulation = @@ -1410,11 +1399,42 @@ mod tests { ); } + #[test] + fn checkpoint_invariants_reject_structurally_invalid_geometry() { + let valid = CdtTriangulation::from_cdt_strip(4, 3).expect("valid strip should build"); + let triangulation = CdtTriangulation { + geometry: valid.geometry().with_cleared_neighbors_for_test(), + metadata: valid.metadata.clone(), + cache: GeometryCache::default(), + foliation: valid.foliation.clone(), + foliation_synced_at_modification: valid.foliation_synced_at_modification, + }; + let invariant_error = triangulation + .validate_checkpoint_invariants() + .expect_err("checkpoint invariants should reject invalid geometry"); + assert!( + matches!( + invariant_error, + CdtError::DelaunayValidationFailed { + level, + .. + } if level == crate::DelaunayValidationLevel::Three + ), + "unexpected checkpoint invariant error: {invariant_error:?}" + ); + let json = to_string(&triangulation).expect("invalid fixture should serialize"); + let restored: CdtTriangulation = + from_str(&json).expect("backend serde may rebuild neighbor links"); + restored + .validate_checkpoint_invariants() + .expect("roundtrip should restore valid structural geometry"); + } + #[test] fn toroidal_checkpoint_roundtrip_preserves_topology_and_labels() { let triangulation = - CdtTriangulation::from_toroidal_cdt(4, 3).expect("explicit torus should build"); - let labels_before: Vec<_> = triangulation + CdtTriangulation::from_toroidal_cdt(4, 3).expect("periodic torus should build"); + let mut labels_before: Vec<_> = triangulation .geometry() .vertices() .map(|vertex| triangulation.time_label(&vertex)) @@ -1423,25 +1443,47 @@ mod tests { let json = to_string(&triangulation).expect("checkpoint should serialize"); let restored: CdtTriangulation = from_str(&json).expect("checkpoint should deserialize"); - let labels_after: Vec<_> = restored + let mut labels_after: Vec<_> = restored .geometry() .vertices() .map(|vertex| restored.time_label(&vertex)) .collect(); + labels_before.sort_unstable(); + labels_after.sort_unstable(); restored .validate_checkpoint_invariants() .expect("restored torus should validate checkpoint invariants"); assert_eq!(restored.metadata().topology, CdtTopology::Toroidal); - assert_eq!(restored.geometry().periodic_domain(), Some([1.0, 1.0])); + assert_eq!(restored.geometry().periodic_domain(), Some([4.0, 3.0])); assert_eq!(restored.slice_sizes(), triangulation.slice_sizes()); assert_eq!(labels_after, labels_before); } + #[test] + fn checkpoint_roundtrip_preserves_delaunay_check_interval() { + let mut triangulation = + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay CDT strip should build"); + triangulation.set_delaunay_check_interval(NonZeroUsize::new(8)); + + let json = to_string(&triangulation).expect("checkpoint should serialize"); + let restored: CdtTriangulation = + from_str(&json).expect("checkpoint should deserialize"); + + assert!( + !restored.geometry().should_check_delaunay_after(7), + "EveryN(8) should not be due before the eighth accepted mutation" + ); + assert!( + restored.geometry().should_check_delaunay_after(8), + "EveryN(8) should be preserved across checkpoint roundtrip" + ); + } + #[test] fn mcmc_checkpoint_roundtrip_preserves_history_and_invariants() { let triangulation = - CdtTriangulation::from_cdt_strip(4, 3).expect("explicit CDT strip should build"); + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay CDT strip should build"); let algorithm = MetropolisAlgorithm::new( MetropolisConfig::new(1.0, 4, 0, 1).with_seed(13), ActionConfig::default(), diff --git a/src/cdt/triangulation/builders.rs b/src/cdt/triangulation/builders.rs index 2a3e82c..4000239 100644 --- a/src/cdt/triangulation/builders.rs +++ b/src/cdt/triangulation/builders.rs @@ -8,22 +8,24 @@ use crate::config::CdtTopology; use crate::errors::{CdtError, CdtResult}; use crate::geometry::DelaunayBackend2D; use crate::geometry::generators::{ - build_delaunay2_from_cells, build_toroidal_delaunay2, generate_delaunay2, + build_delaunay2_with_data, build_periodic_toroidal_delaunay2, generate_delaunay2, }; use crate::geometry::traits::TriangulationQuery; -/// Rewrites explicit toroidal builder failures with CDT-level generation context. +/// Rewrites toroidal builder failures with CDT-level generation context. /// /// The lower geometry builder reports failures in terms of its input shape; this /// helper preserves the underlying diagnostic while normalizing the public error -/// fields to the toroidal CDT constructor's vertex count, domain, and first attempt. +/// fields to the toroidal CDT constructor's vertex count and first attempt. pub(super) fn remap_toroidal_generation_error(error: CdtError, total_vertices: u32) -> CdtError { match error { CdtError::DelaunayGenerationFailed { - underlying_error, .. + coordinate_range, + underlying_error, + .. } => CdtError::DelaunayGenerationFailed { vertex_count: total_vertices, - coordinate_range: (0.0, 1.0), + coordinate_range, attempt: 1, underlying_error, }, @@ -31,7 +33,7 @@ pub(super) fn remap_toroidal_generation_error(error: CdtError, total_vertices: u } } -/// Rewrites explicit strip builder failures with CDT-level generation context. +/// Rewrites Delaunay strip builder failures with CDT-level generation context. fn remap_strip_generation_error( error: CdtError, total_vertices: u32, @@ -50,7 +52,7 @@ fn remap_strip_generation_error( } } -/// Builds a CDT-level generation error for explicit strip construction failures. +/// Builds a CDT-level generation error for Delaunay strip construction failures. const fn strip_generation_error( total_vertices: u32, coordinate_max: f64, @@ -64,7 +66,7 @@ const fn strip_generation_error( } } -/// Verifies that the explicit strip builder returned the requested mesh size. +/// Verifies that the Delaunay strip builder returned the requested mesh size. #[expect( clippy::too_many_arguments, reason = "count mismatch diagnostics preserve both requested CDT parameters and expected builder counts" @@ -84,7 +86,7 @@ pub(super) fn validate_strip_counts( total_vertices, coordinate_max, format!( - "build_delaunay2_from_cells()/from_cdt_strip() produced {} vertices, expected {} for vertices_per_slice={} and num_slices={}", + "build_delaunay2_with_data()/from_cdt_strip() produced {} vertices, expected {} for vertices_per_slice={} and num_slices={}", backend.vertex_count(), total_vertices, vertices_per_slice, @@ -97,7 +99,7 @@ pub(super) fn validate_strip_counts( total_vertices, coordinate_max, format!( - "build_delaunay2_from_cells()/from_cdt_strip() produced {} faces, expected {} for vertices_per_slice={} and num_slices={}", + "build_delaunay2_with_data()/from_cdt_strip() produced {} faces, expected {} for vertices_per_slice={} and num_slices={}", backend.face_count(), total_cells, vertices_per_slice, @@ -109,28 +111,34 @@ pub(super) fn validate_strip_counts( Ok(()) } -/// Builds a CDT-level generation error for explicit toroidal construction failures. -const fn toroidal_generation_error(total_vertices: u32, underlying_error: String) -> CdtError { +/// Builds a CDT-level generation error for periodic toroidal construction failures. +const fn toroidal_generation_error( + total_vertices: u32, + coordinate_range: (f64, f64), + underlying_error: String, +) -> CdtError { CdtError::DelaunayGenerationFailed { vertex_count: total_vertices, - coordinate_range: (0.0, 1.0), + coordinate_range, attempt: 1, underlying_error, } } -/// Verifies that the explicit toroidal builder returned the requested mesh size. +/// Verifies that the periodic toroidal builder returned the requested mesh size. pub(super) fn validate_toroidal_counts( backend: &DelaunayBackend2D, total_vertices: u32, expected_vertices: usize, expected_faces: usize, + coordinate_range: (f64, f64), ) -> CdtResult<()> { if backend.vertex_count() != expected_vertices || backend.face_count() != expected_faces { return Err(toroidal_generation_error( total_vertices, + coordinate_range, format!( - "explicit toroidal builder produced {} vertices and {} faces, expected {} vertices and {} faces", + "periodic toroidal builder produced {} vertices and {} faces, expected {} vertices and {} faces", backend.vertex_count(), backend.face_count(), total_vertices, @@ -286,6 +294,10 @@ impl CdtTriangulation { /// Returns [`CdtError::UnsupportedDimension`] if `dimension != 2`. /// Returns [`CdtError::ValidationFailed`] if any vertex is unlabeled or /// has a time label outside `0..time_slices`, or if any time slice is empty. + /// Returns [`CdtError::DelaunayValidationFailed`] if the backend fails the + /// upstream Level 1-4 Delaunay validator. Returns topology, foliation, + /// causality, or classification errors if the labels do not form a strict + /// CDT mesh. /// /// # Examples /// @@ -329,24 +341,27 @@ impl CdtTriangulation { let mut tri = Self::try_new(backend, time_slices, dimension)?; tri.foliation = Some(foliation); tri.mark_foliation_synchronized(); + tri.validate_initial_delaunay_cdt()?; Ok(tri) } - /// Construct a true 1+1 CDT strip by explicit layered connectivity. + /// Construct a Delaunay-backed true 1+1 CDT strip from layered points. /// /// Places `vertices_per_slice` vertices on each open spatial slice and - /// connects adjacent time slices into quads. Each quad is split into one - /// Up `(2,1)` triangle and one Down `(1,2)` triangle, so every finite face - /// is classifiable by construction. + /// builds a Delaunay triangulation from the labeled coordinates. The + /// resulting finite faces must all classify as Up `(2,1)` or Down `(1,2)` + /// triangles before the constructor succeeds. /// /// # Errors /// /// Returns [`CdtError::InvalidGenerationParameters`] if `vertices_per_slice < 4`, /// `num_slices < 2`, or the derived vertex or cell count overflows `u32`. /// Returns [`CdtError::DelaunayGenerationFailed`] if constructor storage cannot - /// be reserved, if the underlying explicit builder rejects the mesh, or if - /// `build_delaunay2_from_cells()` returns a vertex or face count that does not - /// match the requested strip. Returns [`CdtError::Foliation`], + /// be reserved, if the underlying Delaunay builder rejects the points, if + /// `build_delaunay2_with_data()` returns a vertex or face count that does not + /// match the requested strip. Returns [`CdtError::DelaunayValidationFailed`] + /// if the constructed backend does not satisfy the Level 1-4 Delaunay + /// validator. Returns [`CdtError::Foliation`], /// [`CdtError::CausalityViolation`], or [`CdtError::ValidationFailed`] if the /// constructed strip fails CDT validation. /// @@ -365,7 +380,7 @@ impl CdtTriangulation { /// ``` #[expect( clippy::too_many_lines, - reason = "explicit strip construction includes fallible allocation handling and post-build validation" + reason = "Delaunay strip construction includes fallible allocation handling and post-build validation" )] pub fn from_cdt_strip(vertices_per_slice: u32, num_slices: u32) -> CdtResult { if vertices_per_slice < 4 { @@ -409,7 +424,7 @@ impl CdtTriangulation { expected_range: "product ≤ u32::MAX".to_string(), })?; - let coordinate_max = f64::from(num_slices - 1).max(1.0); + let coordinate_max = f64::from(num_slices).max(2.0); let generation_failed = |underlying_error: String| { strip_generation_error(total_vertices, coordinate_max, underlying_error) }; @@ -423,9 +438,11 @@ impl CdtTriangulation { .map_err(|err| generation_failed(err.to_string()))?; let t_count = usize::try_from(num_slices).map_err(|err| generation_failed(err.to_string()))?; - let index = |i: usize, t: usize| -> usize { t * n + i }; let spacing = 1.0_f64 / f64::from(vertices_per_slice - 1); + let side_jitter = spacing / 4.0; + let interior_jitter = spacing / (16.0 * f64::from(num_slices)); + let vertical_jitter = 1.0_f64 / (64.0 * f64::from(num_slices)); let mut vertex_specs: Vec<([f64; 2], u32)> = Vec::new(); vertex_specs .try_reserve_exact(expected_vertices) @@ -436,45 +453,38 @@ impl CdtTriangulation { })?; for t in 0..num_slices { for i in 0..vertices_per_slice { - vertex_specs.push(([f64::from(i) * spacing, f64::from(t)], t)); - } - } - - let mut cells: Vec<[usize; 3]> = Vec::new(); - cells.try_reserve_exact(expected_faces).map_err(|err| { - generation_failed(format!( - "from_cdt_strip() failed to reserve {expected_faces} triangle cells for vertices_per_slice={vertices_per_slice}, num_slices={num_slices}: {err}" - )) - })?; - for t in 0..(t_count - 1) { - let t_next = t + 1; - for i in 0..(n - 1) { - let i_next = i + 1; - cells.push([index(i, t), index(i_next, t), index(i, t_next)]); - cells.push([index(i_next, t), index(i_next, t_next), index(i, t_next)]); + let temporal_index = f64::from(t); + let temporal_span = f64::from(num_slices - 1); + let side_arc = side_jitter * temporal_index * f64::from(num_slices - 1 - t) + / temporal_span.powi(2); + let x = if i == 0 || i == vertices_per_slice - 1 { + let boundary = f64::from(i).mul_add(spacing, side_jitter); + if i == 0 { + boundary - side_arc + } else { + boundary + side_arc + } + } else { + let sign = if (i + t).is_multiple_of(2) { 1.0 } else { -1.0 }; + f64::from(i).mul_add(spacing, side_jitter) + sign * interior_jitter + }; + let spatial_index = f64::from(i); + let arc = vertical_jitter * spatial_index * f64::from(vertices_per_slice - 1 - i) + / f64::from((vertices_per_slice - 1).pow(2)); + let base_y = f64::from(t) + vertical_jitter; + let y = if t == 0 { + base_y - arc + } else if t == num_slices - 1 { + base_y + arc + } else { + let sign = if (i + t).is_multiple_of(2) { 1.0 } else { -1.0 }; + (sign * arc).mul_add(0.5, base_y) + }; + vertex_specs.push(([x, y], t)); } } - // delaunay 0.7.6 accepts explicit cells as Vec-backed index lists. - // Keep the strip working set compact, then adapt fallibly at the API boundary. - let mut cell_specs: Vec> = Vec::new(); - cell_specs.try_reserve_exact(expected_faces).map_err(|err| { - generation_failed(format!( - "from_cdt_strip() failed to reserve {expected_faces} builder cell specs for build_delaunay2_from_cells(): {err}" - )) - })?; - for cell in &cells { - let mut cell_spec = Vec::new(); - cell_spec.try_reserve_exact(3).map_err(|err| { - generation_failed(format!( - "from_cdt_strip() failed to reserve a build_delaunay2_from_cells() triangle cell spec: {err}" - )) - })?; - cell_spec.extend_from_slice(cell); - cell_specs.push(cell_spec); - } - - let dt = build_delaunay2_from_cells(&vertex_specs, &cell_specs) + let dt = build_delaunay2_with_data(&vertex_specs) .map_err(|err| remap_strip_generation_error(err, total_vertices, coordinate_max))?; let backend = DelaunayBackend2D::from_triangulation(dt); @@ -488,7 +498,6 @@ impl CdtTriangulation { num_slices, coordinate_max, )?; - let slice_sizes = vec![n; t_count]; let foliation = Foliation::from_slice_sizes(slice_sizes, num_slices).map_err(CdtError::from)?; @@ -496,26 +505,22 @@ impl CdtTriangulation { let mut tri = Self::try_new(backend, num_slices, 2)?; tri.foliation = Some(foliation); tri.mark_foliation_synchronized(); - - tri.validate_foliation()?; - tri.validate_causality_delaunay()?; - tri.validate_topology()?; - tri.classify_all_cells()?; + tri.validate_initial_delaunay_cdt()?; Ok(tri) } /// Construct a foliated 1+1 CDT on a torus (S¹×S¹). /// - /// Places `vertices_per_slice` vertices per time slice, uniformly spaced - /// on S¹ (spatial coordinate periodic in `[0, 1)`). Time slices wrap: - /// slice `num_slices - 1` connects back to slice `0`. Each quad between - /// adjacent slices is split into one Up (2,1) and one Down (1,2) triangle. + /// Places `vertices_per_slice` vertices per time slice on a unit lattice + /// in an `N × T` toroidal domain. Time slices wrap: slice + /// `num_slices - 1` connects back to slice `0`. /// - /// The triangulation is built by explicit combinatorial connectivity via - /// [`crate::geometry::generators::build_toroidal_delaunay2`], - /// which sets `TopologyGuarantee::Pseudomanifold` and - /// `GlobalTopology::Toroidal` so the underlying validator expects χ = 0. + /// The triangulation is built through + /// [`crate::geometry::generators::build_periodic_toroidal_delaunay2`], + /// which uses the upstream periodic image-point constructor and then + /// requires full Delaunay Level 1-4 validation before the CDT wrapper is + /// returned. /// /// # Mesh structure /// @@ -523,9 +528,9 @@ impl CdtTriangulation { /// has `N · T` vertices, `3 · N · T` edges, and `2 · N · T` triangles /// (`V − E + F = 0`, the Euler characteristic of the torus). Each pair of /// adjacent slices `(t, t+1) mod T` and each spatial pair `(i, i+1) mod N` - /// contribute exactly one Up `(i, t), (i+1, t), (i, t+1)` and one Down - /// `(i+1, t), (i+1, t+1), (i, t+1)` triangle, so every triangle has - /// exactly one spacelike edge and two timelike edges by construction. + /// contribute two Delaunay triangles, and every triangle must classify as + /// Up `(2,1)` or Down `(1,2)`, with exactly one spacelike edge and two + /// timelike edges. /// /// # Arguments /// @@ -537,10 +542,12 @@ impl CdtTriangulation { /// /// Returns [`CdtError::InvalidGenerationParameters`] if `vertices_per_slice < 3` /// or `num_slices < 3`, or if the derived vertex or face count overflows `u32`. - /// Returns [`CdtError::DelaunayGenerationFailed`] if the underlying explicit - /// builder rejects the mesh, if constructor storage cannot be reserved, or if - /// the builder returns a vertex or face count that does not match the requested - /// toroidal CDT. Returns [`CdtError::Foliation`], + /// Returns [`CdtError::DelaunayGenerationFailed`] if upstream periodic + /// Delaunay construction rejects the mesh, if constructor storage cannot be + /// reserved, or if the builder returns a vertex or face count that does not + /// match the requested toroidal CDT. Returns + /// [`CdtError::DelaunayValidationFailed`] if full Delaunay validation fails. + /// Returns [`CdtError::Foliation`], /// [`CdtError::CausalityViolation`], or [`CdtError::ValidationFailed`] if the /// constructed triangulation fails CDT validation. /// @@ -557,10 +564,6 @@ impl CdtTriangulation { /// Ok(()) /// } /// ``` - #[expect( - clippy::too_many_lines, - reason = "explicit toroidal construction includes fallible allocation handling and post-build validation" - )] pub fn from_toroidal_cdt(vertices_per_slice: u32, num_slices: u32) -> CdtResult { if vertices_per_slice < 3 { return Err(CdtError::InvalidGenerationParameters { @@ -596,8 +599,10 @@ impl CdtTriangulation { expected_range: "product ≤ u32::MAX".to_string(), })?; - let generation_failed = - |underlying_error: String| toroidal_generation_error(total_vertices, underlying_error); + let generation_failed = |underlying_error: String| { + let coordinate_max = f64::from(vertices_per_slice.max(num_slices) - 1); + toroidal_generation_error(total_vertices, (0.0, coordinate_max), underlying_error) + }; let expected_vertices = usize::try_from(total_vertices).map_err(|err| generation_failed(err.to_string()))?; @@ -609,14 +614,12 @@ impl CdtTriangulation { let t_count = usize::try_from(num_slices).map_err(|err| generation_failed(err.to_string()))?; - // Index helper: vertex (i, t) → i + t * N. Both axes are periodic. - let index = |i: usize, t: usize| -> usize { (t % t_count) * n + (i % n) }; - // --- Vertex coordinates (S¹ × S¹) --- // - // Spatial coordinate: x_i = i / N is periodic in [0, 1). - // Time coordinate: t_t = t / T is periodic in [0, 1) so the metadata - // domain matches what we pass to GlobalTopology::Toroidal. + // Use a unit square lattice in a toroidal domain of size N × T. This + // keeps neighboring spatial and temporal lattice spacings comparable for + // the periodic Delaunay constructor, independent of the requested aspect + // ratio. let n_f = f64::from(vertices_per_slice); let t_f = f64::from(num_slices); let mut vertex_specs: Vec<([f64; 2], u32)> = Vec::new(); @@ -629,55 +632,24 @@ impl CdtTriangulation { })?; for t in 0..num_slices { for i in 0..vertices_per_slice { - let x = f64::from(i) / n_f; - let y = f64::from(t) / t_f; + let x = f64::from(i); + let y = f64::from(t); vertex_specs.push(([x, y], t)); } } - // --- Explicit cells (Up + Down per (i, t) quad) --- - let mut cells: Vec<[usize; 3]> = Vec::new(); - cells.try_reserve_exact(expected_faces).map_err(|err| { - generation_failed(format!( - "from_toroidal_cdt() failed to reserve {expected_faces} triangle cells for vertices_per_slice={vertices_per_slice}, num_slices={num_slices}: {err}" - )) - })?; - for t in 0..t_count { - let t_next = (t + 1) % t_count; - for i in 0..n { - let i_next = (i + 1) % n; - // Up (2,1): two vertices on slice t, one on slice t+1. - cells.push([index(i, t), index(i_next, t), index(i, t_next)]); - // Down (1,2): one vertex on slice t, two on slice t+1. - cells.push([index(i_next, t), index(i_next, t_next), index(i, t_next)]); - } - } - - // delaunay 0.7.6 accepts explicit cells as Vec-backed index lists. - // Keep the toroidal working set compact, then adapt fallibly at the API boundary. - let mut cell_specs: Vec> = Vec::new(); - cell_specs.try_reserve_exact(expected_faces).map_err(|err| { - generation_failed(format!( - "from_toroidal_cdt() failed to reserve {expected_faces} builder cell specs for build_toroidal_delaunay2(): {err}" - )) - })?; - for cell in &cells { - let mut cell_spec = Vec::new(); - cell_spec.try_reserve_exact(3).map_err(|err| { - generation_failed(format!( - "from_toroidal_cdt() failed to reserve a build_toroidal_delaunay2() triangle cell spec: {err}" - )) - })?; - cell_spec.extend_from_slice(cell); - cell_specs.push(cell_spec); - } - - let domain = [1.0_f64, 1.0_f64]; - let dt = build_toroidal_delaunay2(&vertex_specs, &cell_specs, domain) + let domain = [n_f, t_f]; + let dt = build_periodic_toroidal_delaunay2(&vertex_specs, domain) .map_err(|e| remap_toroidal_generation_error(e, total_vertices))?; let backend = DelaunayBackend2D::from_triangulation(dt); - validate_toroidal_counts(&backend, total_vertices, expected_vertices, expected_faces)?; + validate_toroidal_counts( + &backend, + total_vertices, + expected_vertices, + expected_faces, + (0.0, n_f.max(t_f) - 1.0), + )?; let slice_sizes = vec![n; t_count]; let foliation = @@ -686,15 +658,7 @@ impl CdtTriangulation { let mut tri = Self::with_topology(backend, num_slices, 2, CdtTopology::Toroidal)?; tri.foliation = Some(foliation); tri.mark_foliation_synchronized(); - - // Propagate inner errors as-is so callers can pattern-match on the - // typed variant (e.g. `FoliationError::SpacelikeNonClosedRing` or - // `CausalityViolation`) instead of parsing a wrapped string. Each - // inner validator already produces a precise, structured error. - tri.validate_foliation()?; - tri.validate_causality_delaunay()?; - tri.validate_topology()?; - tri.classify_all_cells()?; + tri.validate_initial_delaunay_cdt()?; Ok(tri) } @@ -704,7 +668,7 @@ impl CdtTriangulation { mod tests { use super::*; use crate::cdt::foliation::{CellType, EdgeType, FoliationError}; - use crate::geometry::generators::build_delaunay2_with_data; + use crate::geometry::generators::{build_delaunay2_from_cells, build_delaunay2_with_data}; /// Builds a minimal labeled Delaunay backend for constructor tests. fn labeled_triangle_backend(labels: [u32; 3]) -> DelaunayBackend2D { @@ -717,13 +681,13 @@ mod tests { DelaunayBackend2D::from_triangulation(dt) } - /// Builds an explicit strip and verifies it is a strict CDT mesh. + /// Builds a Delaunay strip and verifies it is a strict CDT mesh. fn strict_strip( vertices_per_slice: u32, num_slices: u32, ) -> CdtTriangulation { let tri = CdtTriangulation::from_cdt_strip(vertices_per_slice, num_slices) - .expect("explicit strip construction should succeed"); + .expect("Delaunay strip construction should succeed"); assert_eq!( tri.vertex_count(), vertices_per_slice as usize * num_slices as usize @@ -737,13 +701,16 @@ mod tests { vec![vertices_per_slice as usize; num_slices as usize].as_slice() ); tri.validate_foliation() - .expect("explicit strip foliation should validate"); + .expect("Delaunay strip foliation should validate"); tri.validate_causality_delaunay() - .expect("explicit strip causality should validate"); + .expect("Delaunay strip causality should validate"); tri.validate_topology() - .expect("explicit strip topology should validate"); + .expect("Delaunay strip topology should validate"); + tri.geometry() + .validate_delaunay() + .expect("Delaunay strip should pass upstream Level 1-4 validation"); tri.validate_cell_classification() - .expect("all explicit strip cells should classify"); + .expect("all Delaunay strip cells should classify"); for face in tri.geometry().faces() { assert!(tri.cell_type(&face).is_some()); assert!(tri.cell_type_from_data(&face).is_some()); @@ -767,7 +734,7 @@ mod tests { remapped, CdtError::DelaunayGenerationFailed { vertex_count: 12, - coordinate_range: (0.0, 1.0), + coordinate_range: (-1.0, 1.0), attempt: 1, ref underlying_error, } if underlying_error == "builder failed" @@ -971,6 +938,7 @@ mod tests { assert!(tri.has_foliation()); assert_eq!(tri.slice_sizes(), &[2, 1]); assert!(tri.validate_foliation().is_ok()); + assert!(tri.validate_cell_classification().is_ok()); for vh in tri.geometry().vertices() { assert!(tri.time_label(&vh).is_some()); @@ -1033,6 +1001,57 @@ mod tests { )); } + #[test] + fn test_from_labeled_delaunay_rejects_non_cdt_cells() { + let dt = build_delaunay2_from_cells( + &[ + ([0.0, 0.0], 0), + ([1.0, 0.0], 0), + ([0.0, 1.0], 0), + ([1.0, 1.0], 1), + ], + &[vec![0, 1, 2], vec![1, 3, 2]], + ) + .expect("explicit cells should build before constructor validation"); + let backend = DelaunayBackend2D::from_triangulation(dt); + + let result = CdtTriangulation::from_labeled_delaunay(backend, 2, 2); + + assert!(matches!( + result, + Err(CdtError::ValidationFailed { + ref check, + ref detail, + }) if *check == crate::CdtValidationCheck::Causality + && detail.contains("invalid CDT triangle") + )); + } + + #[test] + fn test_from_labeled_delaunay_rejects_explicit_non_delaunay_cells() { + let dt = build_delaunay2_from_cells( + &[ + ([0.0, 0.0], 0), + ([1.0, 0.0], 0), + ([0.0, 1.0], 1), + ([0.2, 0.2], 1), + ], + &[vec![0, 1, 2], vec![1, 3, 2]], + ) + .expect("explicit cells should build before constructor validation"); + let backend = DelaunayBackend2D::from_triangulation(dt); + + let result = CdtTriangulation::from_labeled_delaunay(backend, 2, 2); + + assert!(matches!( + result, + Err(CdtError::DelaunayValidationFailed { + level, + .. + }) if level == crate::DelaunayValidationLevel::Four + )); + } + #[test] fn test_from_cdt_strip_all_vertices_labeled() { let tri = strict_strip(5, 3); @@ -1097,9 +1116,10 @@ mod tests { #[test] fn test_from_cdt_strip_builds_valid_mesh() { - let tri = CdtTriangulation::from_cdt_strip(4, 2).expect("explicit strip should build"); + let tri = CdtTriangulation::from_cdt_strip(4, 2).expect("Delaunay strip should build"); assert_eq!(tri.vertex_count(), 8); assert_eq!(tri.face_count(), 6); + assert!(tri.geometry().validate_delaunay().is_ok()); assert!(tri.validate_topology().is_ok()); assert!(tri.validate_foliation().is_ok()); assert!(tri.validate_causality_delaunay().is_ok()); @@ -1108,7 +1128,7 @@ mod tests { #[test] fn test_explicit_strip_count_validation_rejects_face_mismatch() { - let tri = CdtTriangulation::from_cdt_strip(4, 2).expect("explicit strip should build"); + let tri = CdtTriangulation::from_cdt_strip(4, 2).expect("Delaunay strip should build"); let result = validate_strip_counts(tri.geometry(), 8, 7, 8, 7, 4, 2, 1.0); assert!(matches!( @@ -1118,7 +1138,7 @@ mod tests { coordinate_range: (0.0, 1.0), attempt: 1, ref underlying_error, - }) if underlying_error.contains("build_delaunay2_from_cells()/from_cdt_strip()") + }) if underlying_error.contains("build_delaunay2_with_data()/from_cdt_strip()") && underlying_error.contains("produced 6 faces, expected 7") && underlying_error.contains("vertices_per_slice=4") && underlying_error.contains("num_slices=2") @@ -1179,25 +1199,58 @@ mod tests { } #[test] - fn test_from_toroidal_cdt_validate_passes() { + fn test_from_toroidal_cdt_initializes_delaunay_pl_manifold() { let tri = CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); - assert!(tri.validate_topology().is_ok()); - assert!(tri.validate_foliation().is_ok()); - assert!(tri.validate_causality().is_ok()); + assert_eq!(tri.vertex_count(), 12); + assert_eq!(tri.face_count(), 24); + assert_eq!(tri.geometry().periodic_domain(), Some([4.0, 3.0])); + tri.geometry() + .validate_delaunay() + .expect("initial toroidal CDT must pass upstream Level 1-4 validation"); + tri.validate_topology() + .expect("initial toroidal CDT must satisfy torus topology"); + tri.validate_foliation() + .expect("initial toroidal CDT must have valid time-slice foliation"); + tri.validate_causality() + .expect("initial toroidal CDT must only contain adjacent-slice edges"); + tri.validate_cell_classification() + .expect("initial toroidal CDT must classify every face as an Up or Down CDT cell"); } #[test] fn test_from_toroidal_cdt_each_slice_is_closed_s1() { let tri = CdtTriangulation::from_toroidal_cdt(6, 4).expect("build toroidal CDT"); tri.validate_foliation() - .expect("explicit toroidal CDT must satisfy closed-S¹ per-slice invariant"); + .expect("periodic toroidal CDT must satisfy closed-S¹ per-slice invariant"); } #[test] fn test_from_toroidal_cdt_invalid_params() { - assert!(CdtTriangulation::from_toroidal_cdt(2, 3).is_err()); - assert!(CdtTriangulation::from_toroidal_cdt(4, 1).is_err()); - assert!(CdtTriangulation::from_toroidal_cdt(4, 2).is_err()); + let few_vertices = CdtTriangulation::from_toroidal_cdt(2, 3); + assert!(matches!( + few_vertices, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Insufficient vertices per slice" + && provided_value == "2" + && expected_range == "≥ 3" + )); + + for slices in [1, 2] { + let few_slices = CdtTriangulation::from_toroidal_cdt(4, slices); + assert!(matches!( + few_slices, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Insufficient number of time slices" + && provided_value == &slices.to_string() + && expected_range == "≥ 3" + )); + } } #[test] @@ -1217,18 +1270,18 @@ mod tests { } #[test] - fn test_explicit_toroidal_count_validation_rejects_face_mismatch() { + fn test_periodic_toroidal_count_validation_rejects_face_mismatch() { let tri = CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); - let result = validate_toroidal_counts(tri.geometry(), 12, 12, 23); + let result = validate_toroidal_counts(tri.geometry(), 12, 12, 23, (0.0, 3.0)); assert!(matches!( result, Err(CdtError::DelaunayGenerationFailed { vertex_count: 12, - coordinate_range: (0.0, 1.0), + coordinate_range: (0.0, 3.0), attempt: 1, ref underlying_error, - }) if underlying_error.contains("explicit toroidal builder") + }) if underlying_error.contains("periodic toroidal builder") && underlying_error.contains("produced 12 vertices and 24 faces") && underlying_error.contains("expected 12 vertices and 23 faces") )); diff --git a/src/cdt/triangulation/foliation.rs b/src/cdt/triangulation/foliation.rs index fcef708..5558888 100644 --- a/src/cdt/triangulation/foliation.rs +++ b/src/cdt/triangulation/foliation.rs @@ -5,7 +5,7 @@ use super::CdtTriangulation; use crate::cdt::foliation::{CellType, EdgeType, Foliation, FoliationError, classify_cell}; use crate::config::CdtTopology; -use crate::errors::{CdtError, CdtResult}; +use crate::errors::{CdtError, CdtResult, CdtValidationCheck}; use crate::geometry::DelaunayBackend2D; use crate::geometry::backends::delaunay::{ DelaunayEdgeHandle, DelaunayFaceHandle, DelaunayVertexHandle, @@ -303,7 +303,7 @@ impl CdtTriangulation { .map(|vh| { let coords = self.geometry.vertex_coordinates(&vh).map_err(|e| { CdtError::ValidationFailed { - check: "foliation_assignment".to_string(), + check: CdtValidationCheck::FoliationAssignment, detail: format!( "failed to read coordinates for vertex {:?}: {e}", vh.vertex_key() @@ -312,7 +312,7 @@ impl CdtTriangulation { })?; if coords.len() < 2 { return Err(CdtError::ValidationFailed { - check: "foliation_assignment".to_string(), + check: CdtValidationCheck::FoliationAssignment, detail: format!( "vertex {:?} has {} coordinates, expected ≥ 2", vh.vertex_key(), @@ -847,7 +847,7 @@ impl CdtTriangulation { for face in self.geometry.faces() { if self.cell_type(&face).is_none() { return Err(CdtError::ValidationFailed { - check: "cell_classification".to_string(), + check: CdtValidationCheck::CellClassification, detail: format!( "face {:?} is not a strict CDT cell (expected Up or Down)", face.cell_key() @@ -889,7 +889,7 @@ impl CdtTriangulation { for face in &faces { let Some(ct) = self.cell_type(face) else { return Err(CdtError::ValidationFailed { - check: "cell_classification".to_string(), + check: CdtValidationCheck::CellClassification, detail: format!( "face {:?} is not a strict CDT cell (expected Up or Down)", face.cell_key() @@ -1015,13 +1015,13 @@ mod tests { DelaunayBackend2D::from_triangulation(dt) } - /// Builds an explicit strip and verifies it is a strict CDT mesh. + /// Builds a Delaunay strip and verifies it is a strict CDT mesh. fn strict_strip( vertices_per_slice: u32, num_slices: u32, ) -> CdtTriangulation { let tri = CdtTriangulation::from_cdt_strip(vertices_per_slice, num_slices) - .expect("explicit strip construction should succeed"); + .expect("Delaunay strip construction should succeed"); assert_eq!( tri.vertex_count(), vertices_per_slice as usize * num_slices as usize @@ -1035,11 +1035,11 @@ mod tests { vec![vertices_per_slice as usize; num_slices as usize].as_slice() ); tri.validate_foliation() - .expect("explicit strip foliation should validate"); + .expect("Delaunay strip foliation should validate"); tri.validate_causality_delaunay() - .expect("explicit strip causality should validate"); + .expect("Delaunay strip causality should validate"); tri.validate_cell_classification() - .expect("all explicit strip cells should classify"); + .expect("all Delaunay strip cells should classify"); tri } @@ -1208,11 +1208,11 @@ mod tests { result, Err(CdtError::InvalidTriangulationMetadata { ref field, - ref topology, + topology, ref provided_value, ref expected, }) if field == "timeslices" - && topology == "toroidal" + && topology == CdtTopology::Toroidal && provided_value == "2" && expected == "≥ 3" )); @@ -1257,7 +1257,7 @@ mod tests { for face in tri.geometry().faces() { let edge_types = tri .face_edge_types(&face) - .expect("explicit strip face should expose edge types"); + .expect("Delaunay strip face should expose edge types"); assert_eq!( edge_types .iter() @@ -1291,7 +1291,7 @@ mod tests { .faces() .next() .expect("Triangle should contain a face"); - assert_eq!(tri.cell_type_from_data(&face), None); + assert_eq!(tri.cell_type_from_data(&face), tri.cell_type(&face)); let live_ct = tri .cell_type(&face) .expect("Single face should be classifiable"); @@ -1316,18 +1316,14 @@ mod tests { #[test] fn classification_rejects_same_slice_triangle() { let backend = labeled_triangle_backend([0, 0, 0]); - let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 1, 2) - .expect("single-slice labels should build foliation"); assert!(matches!( - tri.validate_cell_classification(), - Err(CdtError::ValidationFailed { ref check, .. }) - if check == "cell_classification" - )); - assert!(matches!( - tri.classify_all_cells(), - Err(CdtError::ValidationFailed { ref check, .. }) - if check == "cell_classification" + CdtTriangulation::from_labeled_delaunay(backend, 1, 2), + Err(CdtError::ValidationFailed { ref check, ref detail }) + if *check == CdtValidationCheck::Causality + && detail.contains("invalid CDT triangle") + && detail.contains("spacelike=3") + && detail.contains("timelike=0") )); } diff --git a/src/cdt/triangulation/moves.rs b/src/cdt/triangulation/moves.rs index f70d71f..1e4d76a 100644 --- a/src/cdt/triangulation/moves.rs +++ b/src/cdt/triangulation/moves.rs @@ -85,7 +85,7 @@ mod tests { #[test] fn set_vertex_data_marks_foliation_stale_and_invalidates_cache() { - let mut tri = CdtTriangulation::from_cdt_strip(4, 2).expect("build explicit strip"); + let mut tri = CdtTriangulation::from_cdt_strip(4, 2).expect("build Delaunay strip"); let vertex = tri .geometry() .vertices() diff --git a/src/cdt/triangulation/validation.rs b/src/cdt/triangulation/validation.rs index 2aa1451..e09e2cc 100644 --- a/src/cdt/triangulation/validation.rs +++ b/src/cdt/triangulation/validation.rs @@ -3,19 +3,31 @@ //! Whole-triangulation validation and causality checks. use super::CdtTriangulation; -use crate::errors::{CdtError, CdtResult}; +use crate::errors::{CdtError, CdtResult, CdtValidationCheck, DelaunayValidationLevel}; use crate::geometry::DelaunayBackend2D; +use crate::geometry::backends::delaunay::DelaunayError; use crate::geometry::traits::TriangulationQuery; +use std::num::NonZeroUsize; impl CdtTriangulation { - /// Validate CDT properties (geometry, Delaunay, topology, causality, foliation). + /// Validate post-construction CDT properties. + /// + /// This is the invariant set required after ergodic moves and completed + /// simulations: upstream structural geometry validity plus CDT topology, + /// foliation, causality, and cell-classification checks. It intentionally + /// does not require the Level 4 Delaunay empty-circumsphere predicate, + /// because local CDT moves are not expected to preserve Delaunay-ness. + /// + /// Constructors that create initial simulation meshes perform the stricter + /// Level 1-4 Delaunay validation before returning. /// /// # Errors /// - /// Returns [`CdtError::ValidationFailed`] if backend geometry, Delaunay, - /// causality, or cell-classification checks fail. Returns topology or - /// foliation errors from the corresponding validators when those - /// invariants are violated. + /// Returns [`CdtError::DelaunayValidationFailed`] if backend structural + /// geometry fails upstream validation. Returns + /// [`CdtError::ValidationFailed`] if causality or cell-classification checks + /// fail, and returns topology or foliation errors from the corresponding + /// validators when those invariants are violated. /// /// # Examples /// @@ -29,30 +41,44 @@ impl CdtTriangulation { /// } /// ``` pub fn validate(&self) -> CdtResult<()> { - if !self.geometry.is_valid() { - return Err(CdtError::ValidationFailed { - check: "geometry".to_string(), - detail: format!( - "triangulation is not valid (V={}, E={}, F={})", - self.geometry.vertex_count(), - self.geometry.edge_count(), - self.geometry.face_count(), - ), - }); - } + self.validate_evolved_cdt() + } + + /// Configures how often simulation runs perform full evolved-state validation. + /// + /// This reuses the Delaunay crate's global check policy as the cadence knob: + /// `Some(n)` means validate the full CDT evolved-state contract after every + /// `n` accepted local mutations; `None` means skip cadence checks and rely on + /// mandatory final validation at checkpoint/result construction. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// use std::num::NonZeroUsize; + /// + /// let mut tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// tri.set_delaunay_check_interval(NonZeroUsize::new(8)); + /// # Ok::<(), causal_triangulations::CdtError>(()) + /// ``` + pub fn set_delaunay_check_interval(&mut self, interval: Option) { + self.geometry.set_delaunay_check_interval(interval); + } - if !self.geometry.is_delaunay() { - return Err(CdtError::ValidationFailed { - check: "Delaunay".to_string(), + /// Validates the post-move/final CDT invariant contract. + pub(crate) fn validate_evolved_cdt(&self) -> CdtResult<()> { + self.geometry + .validate_structural() + .map_err(|err| CdtError::DelaunayValidationFailed { + level: DelaunayValidationLevel::Three, detail: format!( - "triangulation does not satisfy Delaunay property (V={}, E={}, F={})", + "{}; triangulation counts: V={}, E={}, F={}", + validation_detail(err), self.geometry.vertex_count(), self.geometry.edge_count(), self.geometry.face_count(), ), - }); - } - + })?; self.validate_topology()?; self.validate_foliation()?; self.validate_causality()?; @@ -61,6 +87,24 @@ impl CdtTriangulation { Ok(()) } + /// Validates the initialization contract for labeled CDT constructors. + /// + /// Initial meshes must be genuine Delaunay triangulations and must satisfy + /// the stricter CDT foliation, topology, causality, and cell-classification + /// invariants before any simulation code can observe them. + pub(crate) fn validate_initial_delaunay_cdt(&mut self) -> CdtResult<()> { + self.geometry + .validate_delaunay() + .map_err(|err| CdtError::DelaunayValidationFailed { + level: DelaunayValidationLevel::Four, + detail: validation_detail(err), + })?; + self.validate_topology()?; + self.validate_foliation()?; + self.validate_causality()?; + self.classify_all_cells().map(|_| ()) + } + /// Validate causality constraints. /// /// If no foliation is present, succeeds vacuously (no causal structure @@ -142,14 +186,14 @@ impl CdtTriangulation { self.geometry.face_count(), ); CdtError::ValidationFailed { - check: "causality".to_string(), + check: CdtValidationCheck::Causality, detail: "failed to resolve face vertices".to_string(), } })?; if verts.len() != 3 { return Err(CdtError::ValidationFailed { - check: "causality".to_string(), + check: CdtValidationCheck::Causality, detail: format!( "face {:?} has {} vertices, expected 3", face.cell_key(), @@ -168,7 +212,7 @@ impl CdtTriangulation { face, ); CdtError::ValidationFailed { - check: "causality".to_string(), + check: CdtValidationCheck::Causality, detail: format!( "vertex {:?} has no time label in a foliated triangulation", verts[0].vertex_key(), @@ -185,7 +229,7 @@ impl CdtTriangulation { face, ); CdtError::ValidationFailed { - check: "causality".to_string(), + check: CdtValidationCheck::Causality, detail: format!( "vertex {:?} has no time label in a foliated triangulation", verts[1].vertex_key(), @@ -202,7 +246,7 @@ impl CdtTriangulation { face, ); CdtError::ValidationFailed { - check: "causality".to_string(), + check: CdtValidationCheck::Causality, detail: format!( "vertex {:?} has no time label in a foliated triangulation", verts[2].vertex_key(), @@ -230,7 +274,7 @@ impl CdtTriangulation { if !(spacelike == 1 && timelike == 2) { return Err(CdtError::ValidationFailed { - check: "causality".to_string(), + check: CdtValidationCheck::Causality, detail: format!( "invalid CDT triangle at face {:?}: spacelike={}, timelike={}", face.cell_key(), @@ -245,6 +289,14 @@ impl CdtTriangulation { } } +/// Extracts upstream validation diagnostics without duplicating wrapper context. +fn validation_detail(error: DelaunayError) -> String { + match error { + DelaunayError::ValidationFailed { detail, .. } => detail, + other => other.to_string(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -408,7 +460,7 @@ mod tests { assert!(matches!( tri.validate_causality_delaunay(), Err(CdtError::ValidationFailed { ref check, ref detail }) - if check == "causality" + if *check == CdtValidationCheck::Causality && detail.contains("has no time label in a foliated triangulation") )); } @@ -416,19 +468,16 @@ mod tests { #[test] fn validate_and_causality_reject_all_spacelike_triangle() { let backend = labeled_triangle_backend([0, 0, 0]); - let tri = CdtTriangulation::from_labeled_delaunay(backend, 1, 2) - .expect("single-slice labels should form foliation bookkeeping"); - - for result in [tri.validate_causality_delaunay(), tri.validate()] { - assert!(matches!( - result, - Err(CdtError::ValidationFailed { ref check, ref detail }) - if check == "causality" - && detail.contains("invalid CDT triangle") - && detail.contains("spacelike=3") - && detail.contains("timelike=0") - )); - } + let result = CdtTriangulation::from_labeled_delaunay(backend, 1, 2); + + assert!(matches!( + result, + Err(CdtError::ValidationFailed { ref check, ref detail }) + if *check == CdtValidationCheck::Causality + && detail.contains("invalid CDT triangle") + && detail.contains("spacelike=3") + && detail.contains("timelike=0") + )); } #[test] diff --git a/src/config.rs b/src/config.rs index a406bf9..c216cef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,7 +15,7 @@ use crate::errors::{CdtError, CdtResult}; use clap::{Parser, ValueEnum}; use dirs::home_dir; use serde::{Deserialize, Serialize}; -use std::fmt::Display; +use std::fmt::{self, Display}; use std::path::{Component, Path, PathBuf}; /// Topology of the spatial slices in the CDT triangulation. @@ -44,6 +44,15 @@ pub enum CdtTopology { Toroidal, } +impl fmt::Display for CdtTopology { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OpenBoundary => formatter.write_str("open boundary"), + Self::Toroidal => formatter.write_str("toroidal"), + } + } +} + /// Main configuration structure for CDT simulations. /// /// This combines all configuration options for the CDT simulation, diff --git a/src/errors.rs b/src/errors.rs index cc7307a..87b6e45 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -2,7 +2,148 @@ //! Error types for the CDT library. +use crate::cdt::ergodic_moves::MoveType; use crate::cdt::foliation::FoliationError; +use crate::config::CdtTopology; +use std::fmt; + +/// Highest cumulative upstream Delaunay validation level being enforced. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DelaunayValidationLevel { + /// Validate Level 1 only. + One, + /// Validate Levels 1 through 2. + Two, + /// Validate Levels 1 through 3. + Three, + /// Validate Levels 1 through 4. + Four, +} + +impl fmt::Display for DelaunayValidationLevel { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::One => formatter.write_str("Level 1"), + Self::Two => formatter.write_str("Level 1-2"), + Self::Three => formatter.write_str("Level 1-3"), + Self::Four => formatter.write_str("Level 1-4"), + } + } +} + +/// CDT validation check that failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CdtValidationCheck { + /// Generic backend geometry validation. + Geometry, + /// Foliation assignment from coordinates failed. + FoliationAssignment, + /// Causality validation failed. + Causality, + /// Strict CDT cell classification failed. + CellClassification, + /// Local ergodic move candidate geometry could not be interpreted. + ErgodicMoveCandidateGeometry, +} + +impl fmt::Display for CdtValidationCheck { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Geometry => formatter.write_str("geometry"), + Self::FoliationAssignment => formatter.write_str("foliation_assignment"), + Self::Causality => formatter.write_str("causality"), + Self::CellClassification => formatter.write_str("cell_classification"), + Self::ErgodicMoveCandidateGeometry => { + formatter.write_str("ergodic move candidate geometry") + } + } + } +} + +/// Category explaining why a checkpoint could not be resumed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CheckpointResumeReason { + /// Resumed step count would overflow. + StepCountOverflow, + /// Checkpoint target reconstruction failed. + CheckpointTargetConfiguration, + /// Generic MCMC chain restoration failed. + McmcChainRestore, + /// Restored triangulation failed invariant validation. + TriangulationInvariants, + /// Stored action disagrees with recomputed action. + ActionMismatch, + /// Action configuration differs from the checkpoint. + IncompatibleActionConfiguration, + /// Temperature differs from the checkpoint. + IncompatibleTemperature, + /// Thermalization schedule differs from the checkpoint. + IncompatibleThermalizationSchedule, + /// Measurement frequency differs from the checkpoint. + IncompatibleMeasurementFrequency, + /// Checkpoint simulation configuration failed validation. + CheckpointConfiguration, + /// Checkpoint action configuration failed validation. + CheckpointActionConfiguration, + /// Generic MCMC chain counters disagree with CDT move statistics. + ChainCounterMismatch, + /// Generic MCMC chain step count disagrees with checkpoint step. + ChainStepMismatch, + /// Step telemetry is internally inconsistent. + StepTelemetryMismatch, + /// Step telemetry index conversion overflowed. + StepTelemetryOverflow, + /// Measurement telemetry count or step conversion overflowed. + MeasurementTelemetryOverflow, + /// Measurement telemetry is internally inconsistent. + MeasurementTelemetryMismatch, + /// Move statistics violate internal accounting invariants. + MoveStatisticsInvariant, + /// Accepted or rejected counter conversion overflowed. + CounterConversionOverflow, +} + +impl fmt::Display for CheckpointResumeReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::StepCountOverflow => formatter.write_str("step count overflow"), + Self::CheckpointTargetConfiguration => { + formatter.write_str("checkpoint target configuration") + } + Self::McmcChainRestore => formatter.write_str("mcmc chain restore"), + Self::TriangulationInvariants => formatter.write_str("triangulation invariants"), + Self::ActionMismatch => formatter.write_str("action mismatch"), + Self::IncompatibleActionConfiguration => { + formatter.write_str("incompatible action configuration") + } + Self::IncompatibleTemperature => formatter.write_str("incompatible temperature"), + Self::IncompatibleThermalizationSchedule => { + formatter.write_str("incompatible thermalization schedule") + } + Self::IncompatibleMeasurementFrequency => { + formatter.write_str("incompatible measurement frequency") + } + Self::CheckpointConfiguration => formatter.write_str("checkpoint configuration"), + Self::CheckpointActionConfiguration => { + formatter.write_str("checkpoint action configuration") + } + Self::ChainCounterMismatch => formatter.write_str("chain counter mismatch"), + Self::ChainStepMismatch => formatter.write_str("chain step mismatch"), + Self::StepTelemetryMismatch => formatter.write_str("step telemetry mismatch"), + Self::StepTelemetryOverflow => formatter.write_str("step telemetry overflow"), + Self::MeasurementTelemetryOverflow => { + formatter.write_str("measurement telemetry overflow") + } + Self::MeasurementTelemetryMismatch => { + formatter.write_str("measurement telemetry mismatch") + } + Self::MoveStatisticsInvariant => formatter.write_str("move statistics invariant"), + Self::CounterConversionOverflow => formatter.write_str("counter conversion overflow"), + } + } +} /// Main error type for CDT operations. #[derive(Debug, Clone, PartialEq, thiserror::Error)] @@ -27,6 +168,14 @@ pub enum CdtError { /// Description of the underlying error that caused the failure underlying_error: String, }, + /// Upstream Delaunay validation rejected a geometry backend. + #[error("Delaunay validation failed [{level}]: {detail}")] + DelaunayValidationFailed { + /// Cumulative upstream validation level being enforced. + level: DelaunayValidationLevel, + /// Upstream validation diagnostic. + detail: String, + }, /// Invalid generation parameters detected before attempting triangulation #[error( "Invalid triangulation parameters: {issue} (got: {provided_value}, expected: {expected_range})" @@ -63,13 +212,13 @@ pub enum CdtError { }, /// Metropolis accepted a move, but a hard backend or invariant failure stopped application. #[error( - "Metropolis accepted {move_type} at step {step}, but applying it failed after {attempts} attempts; last failure: {last_failure}" + "Metropolis accepted {move_type:?} at step {step}, but applying it failed after {attempts} attempts; last failure: {last_failure}" )] MetropolisMoveApplicationFailed { /// Monte Carlo step whose accepted move could not be applied. step: u32, /// Accepted move type being applied. - move_type: String, + move_type: MoveType, /// Number of application attempts made before failing. attempts: usize, /// Most specific lower-level rejection or failure observed. @@ -83,7 +232,7 @@ pub enum CdtError { /// Name of the invalid metadata field. field: String, /// Topology whose invariant was violated. - topology: String, + topology: CdtTopology, /// Value stored in the triangulation metadata. provided_value: String, /// Expected constraint for the metadata field. @@ -92,8 +241,8 @@ pub enum CdtError { /// Validation of a constructed triangulation failed #[error("Validation failed [{check}]: {detail}")] ValidationFailed { - /// Name of the validation check that failed (e.g. "geometry", "topology", "Delaunay") - check: String, + /// Validation check that failed. + check: CdtValidationCheck, /// Human-readable description of the failure detail: String, }, @@ -103,7 +252,7 @@ pub enum CdtError { )] TopologyMismatch { /// Topology requested by CDT metadata. - topology: String, + topology: CdtTopology, /// Observed Euler characteristic from the backend. euler_characteristic: i32, /// Accepted Euler characteristics for the requested topology. @@ -228,7 +377,7 @@ pub enum CdtError { #[error("Failed to resume MCMC checkpoint [{reason}]: {detail}")] CheckpointResumeFailed { /// Structured reason category for the resume failure. - reason: String, + reason: CheckpointResumeReason, /// Human-readable reason resume could not proceed. detail: String, }, @@ -299,7 +448,7 @@ mod tests { fn test_invalid_triangulation_metadata_error() { let error = CdtError::InvalidTriangulationMetadata { field: "timeslices".to_string(), - topology: "toroidal".to_string(), + topology: CdtTopology::Toroidal, provided_value: "2".to_string(), expected: "≥ 3".to_string(), }; @@ -325,6 +474,19 @@ mod tests { ); } + #[test] + fn test_delaunay_validation_failed_error() { + let error = CdtError::DelaunayValidationFailed { + level: DelaunayValidationLevel::Four, + detail: "upstream validation failed".to_string(), + }; + let display = format!("{error}"); + assert_eq!( + display, + "Delaunay validation failed [Level 1-4]: upstream validation failed" + ); + } + #[test] fn test_unsupported_dimension_error() { let error = CdtError::UnsupportedDimension(3); @@ -352,7 +514,7 @@ mod tests { #[test] fn test_validation_failed_error() { let error = CdtError::ValidationFailed { - check: "geometry".to_string(), + check: CdtValidationCheck::Geometry, detail: "backend reported invalid triangulation structure".to_string(), }; let display = format!("{error}"); @@ -365,7 +527,7 @@ mod tests { #[test] fn test_topology_mismatch_error() { let error = CdtError::TopologyMismatch { - topology: "toroidal".to_string(), + topology: CdtTopology::Toroidal, euler_characteristic: 1, expected_euler_characteristics: vec![0], vertices: 3, @@ -449,7 +611,7 @@ mod tests { fn test_metropolis_move_application_failed_error() { let error = CdtError::MetropolisMoveApplicationFailed { step: 17, - move_type: "Move31Remove".to_string(), + move_type: MoveType::Move31Remove, attempts: 8, last_failure: "no geometrically valid candidate site found".to_string(), }; @@ -630,13 +792,13 @@ mod tests { #[test] fn test_checkpoint_resume_failed_error() { let error = CdtError::CheckpointResumeFailed { - reason: "incompatible temperature".to_string(), + reason: CheckpointResumeReason::IncompatibleTemperature, detail: "temperature differs from checkpoint".to_string(), }; let CdtError::CheckpointResumeFailed { reason, detail } = &error else { panic!("expected CheckpointResumeFailed variant"); }; - assert_eq!(reason, "incompatible temperature"); + assert_eq!(*reason, CheckpointResumeReason::IncompatibleTemperature); assert_eq!(detail, "temperature differs from checkpoint"); assert_eq!( format!("{error}"), diff --git a/src/geometry/backends/delaunay.rs b/src/geometry/backends/delaunay.rs index 39f309e..80a8fd2 100644 --- a/src/geometry/backends/delaunay.rs +++ b/src/geometry/backends/delaunay.rs @@ -9,6 +9,7 @@ //! (see `docs/dev/rust.md § Geometry Backend Isolation`). // cspell:ignore vkey +use crate::DelaunayValidationLevel; use crate::geometry::traits::{ EdgeAdjacentFaces, EdgeAdjacentFacesResult, FlipResult, GeometryBackend, SubdivisionResult, TriangulationMut, TriangulationQuery, @@ -24,11 +25,13 @@ use delaunay::geometry::traits::coordinate::Coordinate; use delaunay::prelude::TopologyGuarantee; use delaunay::prelude::VertexBuilder; use delaunay::prelude::triangulation::flips::BistellarFlips; +use delaunay::prelude::triangulation::repair::DelaunayCheckPolicy; use delaunay::topology::traits::{GlobalTopology, TopologyKind, ToroidalConstructionMode}; use delaunay::triangulation::DelaunayTriangulation; use serde::de::Error as DeError; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::HashMap; +use std::num::NonZeroUsize; type DelaunayKernel = AdaptiveKernel; type RawTriangulation = @@ -69,6 +72,8 @@ struct SerializedDelaunayBackend, global_topology: SerializableGlobalTopology, topology_guarantee: SerializableTopologyGuarantee, + #[serde(default)] + delaunay_check_policy: SerializableDelaunayCheckPolicy, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -96,6 +101,13 @@ enum SerializableTopologyGuarantee { PLManifoldStrict, } +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] +enum SerializableDelaunayCheckPolicy { + #[default] + EndOnly, + EveryN(usize), +} + impl From> for SerializableGlobalTopology { fn from(topology: GlobalTopology) -> Self { match topology { @@ -179,6 +191,27 @@ impl From for TopologyGuarantee { } } +impl From for SerializableDelaunayCheckPolicy { + fn from(policy: DelaunayCheckPolicy) -> Self { + match policy { + DelaunayCheckPolicy::EndOnly => Self::EndOnly, + DelaunayCheckPolicy::EveryN(n) => Self::EveryN(n.get()), + } + } +} + +impl SerializableDelaunayCheckPolicy { + fn into_delaunay_check_policy(self) -> Result { + match self { + Self::EndOnly => Ok(DelaunayCheckPolicy::EndOnly), + Self::EveryN(n) => NonZeroUsize::new(n).map_or_else( + || Err(E::custom("delaunay check interval must be non-zero")), + |interval| Ok(DelaunayCheckPolicy::EveryN(interval)), + ), + } + } +} + impl Serialize for DelaunayBackend where @@ -192,6 +225,7 @@ where tds: self.dt.tds().clone(), global_topology: self.dt.global_topology().into(), topology_guarantee: self.dt.topology_guarantee().into(), + delaunay_check_policy: self.dt.delaunay_check_policy().into(), } .serialize(serializer) } @@ -213,6 +247,11 @@ where serialized.topology_guarantee.into(), ); dt.set_global_topology(serialized.global_topology.into_global_topology()?); + dt.set_delaunay_check_policy( + serialized + .delaunay_check_policy + .into_delaunay_check_policy()?, + ); Ok(Self::from_triangulation(dt)) } } @@ -361,6 +400,15 @@ pub enum DelaunayError { /// Output shape or detail observed from the upstream result. actual: String, }, + + /// Upstream Delaunay backend validation failed. + #[error("Delaunay backend validation failed [{level}]: {detail}")] + ValidationFailed { + /// Cumulative upstream validation level being enforced. + level: DelaunayValidationLevel, + /// Underlying validation diagnostic. + detail: String, + }, } impl @@ -538,7 +586,141 @@ impl /// ``` #[must_use] pub fn is_delaunay(&self) -> bool { - self.dt.validate().is_ok() + self.validate_delaunay().is_ok() + } + + /// Validates the triangulation with the upstream full Delaunay validator. + /// + /// This delegates to [`DelaunayTriangulation::validate`], which performs + /// the cumulative Level 1-4 checks: neighbor pointer consistency, Euler + /// characteristic, coherent orientation, and the Delaunay in-sphere + /// predicate. + /// + /// # Errors + /// + /// Returns [`DelaunayError::ValidationFailed`] with the upstream diagnostic + /// when any Level 1-4 validation check fails. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::geometry::DelaunayBackend2D; + /// use causal_triangulations::geometry::generators::build_delaunay2_with_data; + /// + /// let dt = build_delaunay2_with_data(&[ + /// ([0.0, 0.0], 0_u32), + /// ([1.0, 0.0], 0), + /// ([0.5, 1.0], 1), + /// ]).unwrap(); + /// let backend = DelaunayBackend2D::from_triangulation(dt); + /// backend.validate_delaunay().unwrap(); + /// ``` + pub fn validate_delaunay(&self) -> Result<(), DelaunayError> { + self.dt + .validate() + .map_err(|err| DelaunayError::ValidationFailed { + level: DelaunayValidationLevel::Four, + detail: err.to_string(), + }) + } + + /// Validates structural TDS geometry invariants used by evolved CDT states. + /// + /// This delegates to the upstream TDS validator without requiring the Level + /// 4 empty-circumsphere predicate. CDT layers its own topology, foliation, + /// causality, and classification checks above this structural backend check + /// for evolved states, because ergodic CDT moves are not expected to + /// preserve Delaunay-ness. Use [`Self::validate_delaunay`] for + /// initialization-grade Level 1-4 validation. + /// + /// # Errors + /// + /// Returns [`DelaunayError::ValidationFailed`] with the upstream diagnostic + /// when any structural validation check fails. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::geometry::DelaunayBackend2D; + /// use causal_triangulations::geometry::generators::build_delaunay2_with_data; + /// + /// fn main() -> causal_triangulations::CdtResult<()> { + /// let dt = build_delaunay2_with_data(&[ + /// ([0.0, 0.0], 0_u32), + /// ([1.0, 0.0], 0), + /// ([0.5, 1.0], 1), + /// ])?; + /// let backend = DelaunayBackend2D::from_triangulation(dt); + /// + /// assert!(backend.validate_structural().is_ok()); + /// Ok(()) + /// } + /// ``` + pub fn validate_structural(&self) -> Result<(), DelaunayError> { + self.dt + .tds() + .validate() + .map_err(|err| DelaunayError::ValidationFailed { + level: DelaunayValidationLevel::Three, + detail: err.to_string(), + }) + } + + /// Configures global validation cadence using Delaunay's check policy. + /// + /// The policy is stored with checkpoints and is consumed by CDT sampling as a + /// cadence over accepted local mutations. `None` restores the upstream + /// end-only policy; `Some(n)` runs a full CDT evolved-state validation when + /// the accepted-move count is a multiple of `n`. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::geometry::DelaunayBackend2D; + /// use causal_triangulations::geometry::generators::build_delaunay2_with_data; + /// use std::num::NonZeroUsize; + /// + /// fn main() -> causal_triangulations::CdtResult<()> { + /// let dt = build_delaunay2_with_data(&[ + /// ([0.0, 0.0], 0_u32), + /// ([1.0, 0.0], 0), + /// ([0.5, 1.0], 1), + /// ])?; + /// let mut backend = DelaunayBackend2D::from_triangulation(dt); + /// + /// backend.set_delaunay_check_interval(NonZeroUsize::new(16)); + /// backend.set_delaunay_check_interval(None); + /// Ok(()) + /// } + /// ``` + pub fn set_delaunay_check_interval(&mut self, interval: Option) { + let policy = interval.map_or(DelaunayCheckPolicy::EndOnly, DelaunayCheckPolicy::EveryN); + self.dt.set_delaunay_check_policy(policy); + } + + /// Returns `true` when the current Delaunay check policy is due. + /// + /// CDT passes the accepted local-mutation count here to reuse the same + /// `EveryN` cadence semantics as the upstream Delaunay crate. + #[must_use] + pub(crate) fn should_check_delaunay_after(&self, completed_mutations: u64) -> bool { + usize::try_from(completed_mutations) + .is_ok_and(|count| self.dt.delaunay_check_policy().should_check(count)) + } + + /// Builds a clone with invalid neighbor links for checkpoint validation tests. + #[cfg(test)] + pub(crate) fn with_cleared_neighbors_for_test(&self) -> Self { + let mut tds = self.dt.tds().clone(); + tds.clear_all_neighbors(); + let mut dt = DelaunayTriangulation::from_tds_with_topology_guarantee( + tds, + AdaptiveKernel::new(), + self.dt.topology_guarantee(), + ); + dt.set_global_topology(self.dt.global_topology()); + dt.set_delaunay_check_policy(self.dt.delaunay_check_policy()); + Self::from_triangulation(dt) } /// Returns the high-level topology kind (`Euclidean`, `Toroidal`, etc.) of the @@ -574,7 +756,7 @@ impl /// use causal_triangulations::prelude::triangulation::*; /// /// let tri = CdtTriangulation::from_toroidal_cdt(3, 3).unwrap(); - /// assert_eq!(tri.geometry().periodic_domain(), Some([1.0, 1.0])); + /// assert_eq!(tri.geometry().periodic_domain(), Some([3.0, 3.0])); /// ``` #[must_use] pub const fn periodic_domain(&self) -> Option<[f64; D]> { @@ -1251,6 +1433,15 @@ mod tests { malformed_insert.to_string(), "flip_k1_insert returned unexpected output for face CellKey(3v1) at point [0.5, 0.5]: expected exactly one inserted-face vertex for the inserted point, got 2 inserted-face vertices including unexpected VertexKey(4v1)" ); + + let validation = DelaunayError::ValidationFailed { + level: DelaunayValidationLevel::Three, + detail: "orientation check failed".to_string(), + }; + assert_eq!( + validation.to_string(), + "Delaunay backend validation failed [Level 1-3]: orientation check failed" + ); } #[test] @@ -1273,6 +1464,9 @@ mod tests { let backend = DelaunayBackend::from_triangulation(dt); assert!(backend.is_valid(), "Triangulation should be valid"); + backend + .validate_delaunay() + .expect("full upstream Level 1-4 validation should pass"); assert!( backend.is_delaunay(), "Valid Delaunay triangulation should pass is_delaunay" diff --git a/src/geometry/generators.rs b/src/geometry/generators.rs index 772f062..b9e1846 100644 --- a/src/geometry/generators.rs +++ b/src/geometry/generators.rs @@ -429,6 +429,100 @@ pub fn build_toroidal_delaunay2( ) } +/// Builds a periodic 2D toroidal Delaunay triangulation from coordinate-data pairs. +/// +/// This uses the upstream periodic image-point constructor rather than explicit +/// cell assembly. The builder requests [`TopologyGuarantee::PLManifold`], so +/// the resulting toroidal mesh is suitable for the full Delaunay Level 1-4 +/// validation path exposed by +/// [`DelaunayBackend::validate_delaunay`](crate::geometry::backends::delaunay::DelaunayBackend::validate_delaunay). +/// +/// # Errors +/// +/// Returns [`crate::CdtError::InvalidGenerationParameters`] if any coordinate is +/// non-finite or either toroidal period is non-finite/non-positive. Returns +/// [`crate::CdtError::VertexBuildFailed`] if a vertex cannot be constructed, or +/// [`crate::CdtError::DelaunayGenerationFailed`] if upstream periodic Delaunay +/// construction rejects the point set. +/// +/// # Examples +/// +/// Build the minimal 3 × 3 periodic toroidal lattice used by +/// [`CdtTriangulation::from_toroidal_cdt`](crate::CdtTriangulation::from_toroidal_cdt) +/// and validate it with the upstream Level 1-4 checks: +/// +/// ``` +/// use causal_triangulations::CdtResult; +/// use causal_triangulations::prelude::geometry::*; +/// +/// fn main() -> CdtResult<()> { +/// const N: usize = 3; +/// const T: usize = 3; +/// let mut vertices: Vec<([f64; 2], u32)> = Vec::with_capacity(N * T); +/// +/// for t in 0..T { +/// for i in 0..N { +/// #[allow(clippy::cast_precision_loss)] +/// let coord = [i as f64, t as f64]; +/// let label = u32::try_from(t).expect("slice index fits in u32"); +/// vertices.push((coord, label)); +/// } +/// } +/// +/// let dt = build_periodic_toroidal_delaunay2(&vertices, [3.0, 3.0])?; +/// assert_eq!(dt.number_of_vertices(), N * T); +/// assert_eq!(dt.number_of_cells(), 2 * N * T); +/// +/// let backend = DelaunayBackend2D::from_triangulation(dt); +/// backend +/// .validate_delaunay() +/// .expect("periodic toroidal mesh passes Level 1-4 validation"); +/// Ok(()) +/// } +/// ``` +pub fn build_periodic_toroidal_delaunay2( + coords_with_data: &[([f64; 2], u32)], + domain: [f64; 2], +) -> CdtResult { + validate_toroidal_domain(domain)?; + validate_explicit_coordinates(coords_with_data)?; + + let vertices: Vec<_> = coords_with_data + .iter() + .enumerate() + .map(|(i, (coord, data))| { + let point = Point::::new(*coord); + VertexBuilder::::default() + .point(point) + .data(*data) + .build() + .map_err(|e| CdtError::VertexBuildFailed { + context: format!("periodic toroidal vertex {i}"), + underlying_error: e.to_string(), + }) + }) + .collect::>>()?; + + let vertex_count = u32::try_from(vertices.len()).unwrap_or(u32::MAX); + let coordinate_range = coords_with_data + .iter() + .flat_map(|(c, _)| c.iter().copied()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| { + (lo.min(v), hi.max(v)) + }); + + DelaunayTriangulationBuilder::from_vertices(&vertices) + .toroidal_periodic(domain) + .topology_guarantee(TopologyGuarantee::PLManifold) + .build::() + .map_err(|e| CdtError::DelaunayGenerationFailed { + vertex_count, + coordinate_range, + attempt: 1, + underlying_error: e.to_string(), + }) +} + // ========================================================================= // Test helpers (panicking convenience wrappers, compiled only during tests) // ========================================================================= @@ -468,6 +562,7 @@ pub(crate) fn seeded_delaunay2( mod tests { use super::*; use crate::errors::CdtError; + use crate::geometry::DelaunayBackend2D; use std::collections::HashMap; /// Produces an order-independent snapshot of vertices and cell connectivity for seeded tests. @@ -595,6 +690,86 @@ mod tests { assert_eq!(dt.number_of_cells(), 2 * N * T); } + #[test] + fn test_build_periodic_toroidal_delaunay2_3x3_validates_level_1_to_4() { + const N: usize = 3; + const T: usize = 3; + const DOMAIN: [f64; 2] = [3.0, 3.0]; + let mut vertices: Vec<([f64; 2], u32)> = Vec::with_capacity(N * T); + for t in 0..T { + for i in 0..N { + #[expect( + clippy::cast_precision_loss, + reason = "small deterministic test indices are converted to f64 lattice coordinates" + )] + let coord = [i as f64, t as f64]; + let label = u32::try_from(t).expect("slice index fits in u32"); + vertices.push((coord, label)); + } + } + + let dt = build_periodic_toroidal_delaunay2(&vertices, DOMAIN) + .expect("periodic 3×3 toroidal mesh should build"); + assert_eq!(dt.number_of_vertices(), N * T); + assert_eq!(dt.number_of_cells(), 2 * N * T); + + let backend = DelaunayBackend2D::from_triangulation(dt); + backend + .validate_delaunay() + .expect("periodic toroidal mesh must pass upstream Level 1-4 validation"); + } + + #[test] + fn test_build_periodic_toroidal_delaunay2_rejects_invalid_domain() { + let vertices = [([0.0, 0.0], 0u32), ([1.0, 0.0], 0), ([0.0, 1.0], 1)]; + + for (domain, expected_value) in [ + ([0.0, 3.0], "axis 0 period 0"), + ([-1.0, 3.0], "axis 0 period -1"), + ([3.0, f64::NAN], "axis 1 period NaN"), + ([f64::INFINITY, 3.0], "axis 0 period inf"), + ] { + let result = build_periodic_toroidal_delaunay2(&vertices, domain); + assert!( + matches!( + result, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Invalid toroidal domain" + && provided_value == expected_value + && expected_range == "finite and positive periods" + ), + "invalid periodic toroidal domain {domain:?} should be rejected, got {result:?}" + ); + } + } + + #[test] + fn test_build_periodic_toroidal_delaunay2_rejects_non_finite_coordinate() { + let vertices = [ + ([0.0, 0.0], 0u32), + ([1.0, f64::NEG_INFINITY], 0), + ([0.0, 1.0], 1), + ]; + + let result = build_periodic_toroidal_delaunay2(&vertices, [3.0, 3.0]); + assert!( + matches!( + result, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Non-finite vertex coordinate" + && provided_value == "vertex 1 axis 1 = -inf" + && expected_range == "finite coordinate values" + ), + "periodic toroidal non-finite coordinate should be rejected, got {result:?}" + ); + } + #[test] fn test_generate_delaunay2_valid_parameters() { let result = generate_delaunay2(4, (0.0, 10.0), None); diff --git a/src/lib.rs b/src/lib.rs index c300cbe..8af2879 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -140,7 +140,9 @@ pub use cdt::metropolis::{ pub use cdt::observables::{estimate_hausdorff_dimension, estimate_spectral_dimension}; pub use cdt::results::{Measurement, SimulationResultsBackend}; pub use config::{CdtConfig, CdtTopology, TestConfig}; -pub use errors::{CdtError, CdtResult}; +pub use errors::{ + CdtError, CdtResult, CdtValidationCheck, CheckpointResumeReason, DelaunayValidationLevel, +}; use crate::util::saturating_usize_to_u32; use std::env; @@ -192,17 +194,21 @@ pub mod prelude { /// /// ``` /// use causal_triangulations::prelude::errors::CdtError; + /// use causal_triangulations::prelude::moves::MoveType; /// /// let err = CdtError::MetropolisMoveApplicationFailed { /// step: 3, - /// move_type: "Move31Remove".to_string(), + /// move_type: MoveType::Move31Remove, /// attempts: 8, /// last_failure: "no geometrically valid candidate site found".to_string(), /// }; /// assert!(format!("{err}").contains("Metropolis accepted Move31Remove")); /// ``` pub mod errors { - pub use crate::errors::{CdtError, CdtResult}; + pub use crate::errors::{ + CdtError, CdtResult, CdtValidationCheck, CheckpointResumeReason, + DelaunayValidationLevel, + }; } /// Focused exports for CDT action calculations. @@ -351,7 +357,7 @@ pub mod prelude { pub use crate::geometry::generators::{ GlobalTopology, TopologyGuarantee, ToroidalConstructionMode, build_delaunay2_from_cells, build_delaunay2_with_data, build_delaunay2_with_topology, - build_toroidal_delaunay2, generate_delaunay2, + build_periodic_toroidal_delaunay2, build_toroidal_delaunay2, generate_delaunay2, }; pub use crate::geometry::operations::TriangulationOps; pub use crate::geometry::traits::{TriangulationMut, TriangulationQuery}; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index e503a54..9bb1d35 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -64,9 +64,8 @@ mod integration_tests { } #[test] - fn test_toroidal_metropolis_preserves_topology_after_many_accepted_moves() { + fn test_toroidal_metropolis_preserves_topology_after_many_attempted_moves() { const STEPS: u32 = 200; - const MIN_ACCEPTED_MOVES: usize = 50; let triangulation = CdtTriangulation::from_toroidal_cdt(8, 6).expect("build toroidal CDT"); assert_eq!(triangulation.metadata().topology, CdtTopology::Toroidal); @@ -84,15 +83,8 @@ mod integration_tests { .run(triangulation) .expect("toroidal simulation should preserve move invariants"); - let accepted_moves = results.steps.iter().filter(|step| step.accepted).count(); - assert!( - accepted_moves >= MIN_ACCEPTED_MOVES, - "expected at least {MIN_ACCEPTED_MOVES} accepted toroidal moves, got {accepted_moves}" - ); - assert!( - results.acceptance_rate() > 0.0, - "toroidal acceptance rate should be non-zero" - ); + assert_eq!(results.steps.len(), STEPS as usize); + assert_eq!(results.move_stats.total_attempted(), u64::from(STEPS)); assert_eq!( results.triangulation.metadata().topology, CdtTopology::Toroidal diff --git a/tests/physics_integration.rs b/tests/physics_integration.rs new file mode 100644 index 0000000..18c95e2 --- /dev/null +++ b/tests/physics_integration.rs @@ -0,0 +1,87 @@ +#![forbid(unsafe_code)] + +//! End-to-end physics integration tests for 1+1 CDT simulations. + +use causal_triangulations::{CdtConfig, CdtTopology, TestConfig, run_simulation}; + +/// Enables real simulation mode on canned test configs with a deterministic seed. +const fn simulated_config(mut config: CdtConfig, seed: u64) -> CdtConfig { + config.simulate = true; + config.seed = Some(seed); + config +} + +/// Verifies that an end-to-end run mutates geometry and preserves final invariants. +fn assert_physics_pipeline(config: &CdtConfig) { + let results = run_simulation(config).expect("physics integration run should succeed"); + + let acceptance_rate = results.acceptance_rate(); + assert!( + acceptance_rate > 0.05, + "acceptance rate too low: {acceptance_rate}" + ); + assert!( + acceptance_rate < 0.99, + "acceptance rate suspiciously high: {acceptance_rate}" + ); + + let first_action = results + .measurements + .first() + .expect("simulation should record measurements") + .action; + assert!( + results + .measurements + .iter() + .any(|measurement| (measurement.action - first_action).abs() > 1e-6), + "action never changed" + ); + + results + .triangulation + .validate() + .expect("triangulation invalid after simulation"); + + let profile = results.average_volume_profile(); + assert_eq!( + profile.len(), + usize::try_from(config.timeslices).expect("timeslices should fit usize"), + "volume profile should cover every time slice" + ); + assert!( + profile + .iter() + .take(occupied_time_slabs(config)) + .all(|&volume| volume > 0.0), + "empty time slice detected: {profile:?}" + ); + + let stats = &results.move_stats; + assert!(stats.moves_22_attempted > 0); + assert!(stats.total_acceptance_rate() > 0.0); +} + +/// Counts slabs expected to have volume in the measured CDT profile. +fn occupied_time_slabs(config: &CdtConfig) -> usize { + let slabs = match config.topology { + CdtTopology::OpenBoundary => config.timeslices.saturating_sub(1), + CdtTopology::Toroidal => config.timeslices, + }; + usize::try_from(slabs).expect("time slab count should fit usize") +} + +#[test] +fn small_cdt_simulation_has_nontrivial_physics_signal() { + let config = simulated_config(TestConfig::small(), 42); + + assert_physics_pipeline(&config); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn medium_cdt_simulation_has_nontrivial_physics_signal() { + let config = simulated_config(TestConfig::medium(), 42); + + assert_physics_pipeline(&config); +} diff --git a/tests/proptest_foliation.rs b/tests/proptest_foliation.rs index ce4a30c..c07dd1a 100644 --- a/tests/proptest_foliation.rs +++ b/tests/proptest_foliation.rs @@ -5,18 +5,21 @@ use causal_triangulations::prelude::triangulation::*; use proptest::prelude::*; #[test] -fn cdt_strip_builds_explicit_mesh() { - let tri = CdtTriangulation::from_cdt_strip(5, 3).expect("explicit CDT strip should build"); +fn cdt_strip_builds_delaunay_mesh() { + let tri = CdtTriangulation::from_cdt_strip(5, 3).expect("Delaunay CDT strip should build"); assert_eq!(tri.vertex_count(), 15); assert_eq!(tri.face_count(), 16); + tri.geometry() + .validate_delaunay() + .expect("Delaunay CDT strip should pass upstream Level 1-4 validation"); tri.validate_topology() - .expect("explicit CDT strip topology should validate"); + .expect("Delaunay CDT strip topology should validate"); tri.validate_foliation() - .expect("explicit CDT strip foliation should validate"); + .expect("Delaunay CDT strip foliation should validate"); tri.validate_causality_delaunay() - .expect("explicit CDT strip causality should validate"); + .expect("Delaunay CDT strip causality should validate"); tri.validate_cell_classification() - .expect("explicit CDT strip cells should classify"); + .expect("Delaunay CDT strip cells should classify"); } proptest! { @@ -61,8 +64,12 @@ proptest! { prop_assert_eq!(tri.dimension(), 2); } - /// Property: explicit toroidal construction preserves core topological and - /// foliation invariants for small generated N×T meshes. +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(8))] + /// Property: periodic toroidal construction preserves Delaunay, topological, + /// and foliation invariants for small generated N×T meshes. #[test] fn toroidal_cdt_static_invariants( vertices_per_slice in 3u32..8, @@ -78,12 +85,16 @@ proptest! { prop_assert!(tri.has_foliation()); let expected_slice_sizes = vec![vertices_per_slice as usize; num_slices as usize]; prop_assert_eq!(tri.slice_sizes(), expected_slice_sizes.as_slice()); + prop_assert!(tri.geometry().validate_delaunay().is_ok(), + "toroidal CDT must pass upstream Level 1-4 Delaunay validation"); prop_assert!(tri.validate_topology().is_ok()); prop_assert!(tri.validate_foliation().is_ok()); prop_assert!(tri.validate_causality().is_ok()); } +} - /// Property: Explicit CDT strip construction always produces valid foliation and causality. +proptest! { + /// Property: Delaunay CDT strip construction always produces valid foliation and causality. /// /// For any valid (vertices_per_slice, num_slices): /// - vertex count == vertices_per_slice × num_slices @@ -96,7 +107,7 @@ proptest! { num_slices in 2u32..6, ) { let tri = CdtTriangulation::from_cdt_strip(vertices_per_slice, num_slices) - .expect("valid explicit strip construction should pass"); + .expect("valid Delaunay strip construction should pass"); // Vertex count must match grid let expected_v = vertices_per_slice as usize * num_slices as usize; @@ -106,6 +117,8 @@ proptest! { // Must have foliation prop_assert!(tri.has_foliation(), "CDT strip must have foliation"); + prop_assert!(tri.geometry().validate_delaunay().is_ok(), + "CDT strip must pass upstream Level 1-4 Delaunay validation"); // Every slice has the right count let sizes = tri.slice_sizes(); @@ -120,13 +133,13 @@ proptest! { // Causality passes (no edges spanning >1 slice) prop_assert!(tri.validate_causality_delaunay().is_ok(), - "Causality should hold for explicit CDT strip with {} vertices/slice, {} slices", + "Causality should hold for Delaunay CDT strip with {} vertices/slice, {} slices", vertices_per_slice, num_slices); prop_assert!(tri.validate_cell_classification().is_ok(), - "Every explicit strip face should classify as Up or Down"); + "Every Delaunay strip face should classify as Up or Down"); } - /// Property: Every edge in an explicit CDT strip is classifiable and + /// Property: Every edge in a Delaunay CDT strip is classifiable and /// spacelike + timelike == total edges. /// #[test] @@ -135,7 +148,7 @@ proptest! { num_slices in 2u32..5, ) { let tri = CdtTriangulation::from_cdt_strip(vertices_per_slice, num_slices) - .expect("valid explicit strip construction should pass"); + .expect("valid Delaunay strip construction should pass"); let mut spacelike = 0usize; let mut timelike = 0usize; @@ -159,7 +172,7 @@ proptest! { prop_assert!(timelike > 0, "Should have timelike edges"); } - /// Property: Explicit CDT strip construction is deterministic for fixed inputs. + /// Property: Delaunay CDT strip construction is deterministic for fixed inputs. /// #[test] fn cdt_strip_determinism( @@ -167,9 +180,9 @@ proptest! { num_slices in 2u32..5, ) { let t1 = CdtTriangulation::from_cdt_strip(vertices_per_slice, num_slices) - .expect("valid explicit strip construction should pass"); + .expect("valid Delaunay strip construction should pass"); let t2 = CdtTriangulation::from_cdt_strip(vertices_per_slice, num_slices) - .expect("valid explicit strip construction should pass"); + .expect("valid Delaunay strip construction should pass"); prop_assert_eq!(t1.vertex_count(), t2.vertex_count()); prop_assert_eq!(t1.edge_count(), t2.edge_count()); diff --git a/tests/regressions.rs b/tests/regressions.rs new file mode 100644 index 0000000..009a70c --- /dev/null +++ b/tests/regressions.rs @@ -0,0 +1,85 @@ +#![forbid(unsafe_code)] + +//! Regression tests for previously observed CDT failures. + +use approx::{assert_relative_eq, relative_eq}; +use causal_triangulations::{ + ActionConfig, CdtTriangulation, MetropolisAlgorithm, MetropolisConfig, + estimate_hausdorff_dimension, estimate_spectral_dimension, +}; + +/// Asserts that optional floating-point observables remain unchanged while +/// still using tolerant comparisons for concrete numeric estimates. +fn assert_observable_unchanged(name: &str, before: Option, after: Option, message: &str) { + match (before, after) { + (Some(before), Some(after)) => { + assert!( + relative_eq!(before, after, epsilon = f64::EPSILON), + "{name}: {message}; before={before:?}, after={after:?}" + ); + } + (None, None) => {} + (before, after) => panic!("{name}: {message}; before={before:?}, after={after:?}"), + } +} + +#[test] +fn toroidal_observables_remain_static_until_periodic_move_support_lands() { + // Regression for causal-triangulations#122, which is blocked on + // delaunay#337: periodic toroidal candidate moves are currently rejected + // by the backend before they can mutate geometry. The observables example + // exposed this because initial and final Hausdorff/spectral estimates were + // identical. Once periodic mutation support lands, this test should flip + // to expect accepted moves and changed final observables. + let triangulation = + CdtTriangulation::from_toroidal_cdt(8, 8).expect("observables fixture should build"); + let initial_counts = ( + triangulation.vertex_count(), + triangulation.edge_count(), + triangulation.face_count(), + ); + let initial_profile = triangulation.volume_profile(); + let initial_hausdorff = estimate_hausdorff_dimension(&triangulation); + let initial_spectral = estimate_spectral_dimension(&triangulation); + + let metropolis_config = MetropolisConfig::new(1.0, 80, 20, 10).with_seed(7); + let results = + MetropolisAlgorithm::new(metropolis_config, ActionConfig::default()).run(triangulation); + let results = results.expect("toroidal observables regression run should complete"); + + assert_eq!(results.move_stats.total_attempted(), 80); + assert_eq!( + results.move_stats.total_accepted(), + 0, + "delaunay#337 / causal-triangulations#122 currently block successful periodic toroidal moves" + ); + assert_relative_eq!(results.acceptance_rate(), 0.0, epsilon = f64::EPSILON); + assert_eq!( + ( + results.triangulation.vertex_count(), + results.triangulation.edge_count(), + results.triangulation.face_count(), + ), + initial_counts, + "toroidal geometry should stay unchanged until periodic move support lands" + ); + assert_eq!(results.triangulation.volume_profile(), initial_profile); + assert_observable_unchanged( + "Hausdorff dimension", + initial_hausdorff, + results.hausdorff_dimension_estimate(), + "observables example should expose unchanged toroidal estimate while blocked", + ); + assert_observable_unchanged( + "spectral dimension", + initial_spectral, + results.spectral_dimension_estimate(), + "observables example should expose unchanged toroidal estimate while blocked", + ); + for (slice, fluctuation) in results.volume_fluctuations().into_iter().enumerate() { + assert!( + relative_eq!(fluctuation, 0.0, epsilon = f64::EPSILON), + "volume fluctuation for slice {slice} should stay zero while periodic moves are blocked; got {fluctuation}" + ); + } +} From 3f5a8579bbc0ac0a31045d9883da313ade470765 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Wed, 6 May 2026 21:09:31 -0700 Subject: [PATCH 2/3] fix!(geometry): reject structurally invalid Delaunay edits - Validate Delaunay-backed mutations against the Level 1-3 structural contract before publishing edited geometry. - Roll back insert, remove, flip, and subdivision edits that would leave invalid manifold structure or negative cell orientation. - Treat toroidal checkpoint restore as invalid until upstream periodic-offset serialization preserves the data needed for structural validation. - Strengthen foliation property coverage for toroidal cell classification and deterministic strip mesh structure. BREAKING CHANGE: Delaunay-backed mutation and checkpoint restore paths now reject structurally invalid geometry that could previously be observed after successful edits or deserialization. --- src/cdt/triangulation.rs | 29 ++---- src/cdt/triangulation/moves.rs | 21 ++++- src/errors.rs | 109 ++++++++++++++++++++++ src/geometry/backends/delaunay.rs | 145 ++++++++++++++++++++++++++++-- tests/proptest_foliation.rs | 65 +++++++++++++- 5 files changed, 333 insertions(+), 36 deletions(-) diff --git a/src/cdt/triangulation.rs b/src/cdt/triangulation.rs index 7aabd9c..54e9a03 100644 --- a/src/cdt/triangulation.rs +++ b/src/cdt/triangulation.rs @@ -1431,33 +1431,18 @@ mod tests { } #[test] - fn toroidal_checkpoint_roundtrip_preserves_topology_and_labels() { + fn toroidal_checkpoint_restore_rejects_missing_periodic_offsets() { let triangulation = CdtTriangulation::from_toroidal_cdt(4, 3).expect("periodic torus should build"); - let mut labels_before: Vec<_> = triangulation - .geometry() - .vertices() - .map(|vertex| triangulation.time_label(&vertex)) - .collect(); let json = to_string(&triangulation).expect("checkpoint should serialize"); - let restored: CdtTriangulation = - from_str(&json).expect("checkpoint should deserialize"); - let mut labels_after: Vec<_> = restored - .geometry() - .vertices() - .map(|vertex| restored.time_label(&vertex)) - .collect(); - labels_before.sort_unstable(); - labels_after.sort_unstable(); + let error = from_str::>(&json) + .expect_err("strict checkpoint validation should reject toroidal serde gaps"); - restored - .validate_checkpoint_invariants() - .expect("restored torus should validate checkpoint invariants"); - assert_eq!(restored.metadata().topology, CdtTopology::Toroidal); - assert_eq!(restored.geometry().periodic_domain(), Some([4.0, 3.0])); - assert_eq!(restored.slice_sizes(), triangulation.slice_sizes()); - assert_eq!(labels_after, labels_before); + assert!( + error.to_string().contains("Negative geometric orientation"), + "unexpected toroidal checkpoint rejection: {error}" + ); } #[test] diff --git a/src/cdt/triangulation/moves.rs b/src/cdt/triangulation/moves.rs index 1e4d76a..2b12c9a 100644 --- a/src/cdt/triangulation/moves.rs +++ b/src/cdt/triangulation/moves.rs @@ -57,6 +57,8 @@ impl CdtTriangulation { #[cfg(test)] mod tests { use super::*; + use crate::geometry::CdtTriangulation2D; + use crate::geometry::generators::build_delaunay2_from_cells; use crate::geometry::traits::{TriangulationMut, TriangulationQuery}; /// Computes the centroid of a live triangular face. @@ -83,6 +85,22 @@ mod tests { centroid } + /// Builds a two-triangle CDT fixture with a shared edge that can flip cleanly. + fn square_two_triangles() -> CdtTriangulation2D { + let dt = build_delaunay2_from_cells( + &[ + ([0.0, 0.0], 0), + ([1.0, 0.0], 0), + ([0.0, 1.0], 1), + ([1.0, 1.0], 1), + ], + &[vec![0, 1, 2], vec![1, 3, 2]], + ) + .expect("build square CDT"); + let backend = DelaunayBackend2D::from_triangulation(dt); + CdtTriangulation2D::from_labeled_delaunay(backend, 2, 2).expect("wrap square CDT") + } + #[test] fn set_vertex_data_marks_foliation_stale_and_invalidates_cache() { let mut tri = CdtTriangulation::from_cdt_strip(4, 2).expect("build Delaunay strip"); @@ -162,8 +180,7 @@ mod tests { #[test] fn flip_edge_invalidates_cached_counts_when_backend_accepts_flip() { - let mut tri = - CdtTriangulation::from_seeded_points(8, 1, 2, 53).expect("build triangulation"); + let mut tri = square_two_triangles(); let edge = tri .geometry() .edges() diff --git a/src/errors.rs b/src/errors.rs index 87b6e45..0d3752f 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -487,6 +487,115 @@ mod tests { ); } + #[test] + fn validation_level_display_covers_all_levels() { + assert_eq!(DelaunayValidationLevel::One.to_string(), "Level 1"); + assert_eq!(DelaunayValidationLevel::Two.to_string(), "Level 1-2"); + assert_eq!(DelaunayValidationLevel::Three.to_string(), "Level 1-3"); + assert_eq!(DelaunayValidationLevel::Four.to_string(), "Level 1-4"); + } + + #[test] + fn validation_check_display_covers_all_categories() { + assert_eq!(CdtValidationCheck::Geometry.to_string(), "geometry"); + assert_eq!( + CdtValidationCheck::FoliationAssignment.to_string(), + "foliation_assignment" + ); + assert_eq!(CdtValidationCheck::Causality.to_string(), "causality"); + assert_eq!( + CdtValidationCheck::CellClassification.to_string(), + "cell_classification" + ); + assert_eq!( + CdtValidationCheck::ErgodicMoveCandidateGeometry.to_string(), + "ergodic move candidate geometry" + ); + } + + #[test] + fn checkpoint_resume_reason_display_covers_all_categories() { + let cases = [ + ( + CheckpointResumeReason::StepCountOverflow, + "step count overflow", + ), + ( + CheckpointResumeReason::CheckpointTargetConfiguration, + "checkpoint target configuration", + ), + ( + CheckpointResumeReason::McmcChainRestore, + "mcmc chain restore", + ), + ( + CheckpointResumeReason::TriangulationInvariants, + "triangulation invariants", + ), + (CheckpointResumeReason::ActionMismatch, "action mismatch"), + ( + CheckpointResumeReason::IncompatibleActionConfiguration, + "incompatible action configuration", + ), + ( + CheckpointResumeReason::IncompatibleTemperature, + "incompatible temperature", + ), + ( + CheckpointResumeReason::IncompatibleThermalizationSchedule, + "incompatible thermalization schedule", + ), + ( + CheckpointResumeReason::IncompatibleMeasurementFrequency, + "incompatible measurement frequency", + ), + ( + CheckpointResumeReason::CheckpointConfiguration, + "checkpoint configuration", + ), + ( + CheckpointResumeReason::CheckpointActionConfiguration, + "checkpoint action configuration", + ), + ( + CheckpointResumeReason::ChainCounterMismatch, + "chain counter mismatch", + ), + ( + CheckpointResumeReason::ChainStepMismatch, + "chain step mismatch", + ), + ( + CheckpointResumeReason::StepTelemetryMismatch, + "step telemetry mismatch", + ), + ( + CheckpointResumeReason::StepTelemetryOverflow, + "step telemetry overflow", + ), + ( + CheckpointResumeReason::MeasurementTelemetryOverflow, + "measurement telemetry overflow", + ), + ( + CheckpointResumeReason::MeasurementTelemetryMismatch, + "measurement telemetry mismatch", + ), + ( + CheckpointResumeReason::MoveStatisticsInvariant, + "move statistics invariant", + ), + ( + CheckpointResumeReason::CounterConversionOverflow, + "counter conversion overflow", + ), + ]; + + for (reason, expected) in cases { + assert_eq!(reason.to_string(), expected); + } + } + #[test] fn test_unsupported_dimension_error() { let error = CdtError::UnsupportedDimension(3); diff --git a/src/geometry/backends/delaunay.rs b/src/geometry/backends/delaunay.rs index 80a8fd2..e7a8348 100644 --- a/src/geometry/backends/delaunay.rs +++ b/src/geometry/backends/delaunay.rs @@ -31,6 +31,7 @@ use delaunay::triangulation::DelaunayTriangulation; use serde::de::Error as DeError; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::HashMap; +use std::fmt::Display; use std::num::NonZeroUsize; type DelaunayKernel = AdaptiveKernel; @@ -501,6 +502,40 @@ impl self.interior_facets_by_edge = Self::build_interior_facets_by_edge(&self.dt); } + /// Validates a completed backend mutation and restores the previous snapshot on failure. + /// + /// This keeps local edit APIs from publishing geometry that violates the evolved-state + /// Level 1-3 structural contract. + fn validate_mutation_or_restore( + &mut self, + dt_before: RawTriangulation, + facets_before: HashMap, + operation: &'static str, + target: impl Display, + ) -> Result<(), DelaunayError> { + if let Err(err) = self.validate_structural() { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; + return Err(match err { + DelaunayError::ValidationFailed { level, detail } => { + DelaunayError::ValidationFailed { + level, + detail: format!( + "{operation} produced invalid structural geometry for {target}: {detail}" + ), + } + } + other => DelaunayError::ValidationFailed { + level: DelaunayValidationLevel::Three, + detail: format!( + "{operation} produced invalid structural geometry for {target}: {other}" + ), + }, + }); + } + Ok(()) + } + /// Returns whether the keyed edge is present in the triangulation. fn edge_exists(&self, edge: EdgeKey) -> bool { let v0 = edge.v0(); @@ -624,14 +659,16 @@ impl }) } - /// Validates structural TDS geometry invariants used by evolved CDT states. + /// Validates structural geometry invariants used by evolved CDT states. /// - /// This delegates to the upstream TDS validator without requiring the Level - /// 4 empty-circumsphere predicate. CDT layers its own topology, foliation, - /// causality, and classification checks above this structural backend check - /// for evolved states, because ergodic CDT moves are not expected to - /// preserve Delaunay-ness. Use [`Self::validate_delaunay`] for - /// initialization-grade Level 1-4 validation. + /// This delegates to the upstream triangulation validator, which performs + /// cumulative Level 1-3 TDS, topology, and manifold checks without + /// requiring the Level 4 empty-circumsphere predicate. CDT layers its own + /// topology, foliation, causality, and classification checks above this + /// structural backend check for evolved states, because ergodic CDT moves + /// are not expected to preserve Delaunay-ness. Use + /// [`Self::validate_delaunay`] for initialization-grade Level 1-4 + /// validation. /// /// # Errors /// @@ -658,7 +695,7 @@ impl /// ``` pub fn validate_structural(&self) -> Result<(), DelaunayError> { self.dt - .tds() + .as_triangulation() .validate() .map_err(|err| DelaunayError::ValidationFailed { level: DelaunayValidationLevel::Three, @@ -1159,6 +1196,12 @@ impl TriangulationMut } }; self.rebuild_interior_facet_index(); + self.validate_mutation_or_restore( + dt_before, + facets_before, + "insert_vertex", + format!("{coords:?}"), + )?; Ok(DelaunayVertexHandle { key }) } @@ -1185,6 +1228,12 @@ impl TriangulationMut } }; self.rebuild_interior_facet_index(); + self.validate_mutation_or_restore( + dt_before, + facets_before, + "flip_k1_remove", + format!("vertex {:?}", vertex.key), + )?; Ok(info .new_cells .iter() @@ -1274,6 +1323,12 @@ impl TriangulationMut }); } self.rebuild_interior_facet_index(); + self.validate_mutation_or_restore( + dt_before, + facets_before, + "flip_k2", + format!("edge {:?} -- {:?}", edge.key.v0(), edge.key.v1()), + )?; let affected_faces = info .new_cells .iter() @@ -1339,6 +1394,12 @@ impl TriangulationMut }); } self.rebuild_interior_facet_index(); + self.validate_mutation_or_restore( + dt_before, + facets_before, + "flip_k1_insert", + format!("face {:?} at point {:?}", face.key, point), + )?; Ok(SubdivisionResult::new( DelaunayVertexHandle { key: new_vertex }, info.new_cells @@ -1365,6 +1426,7 @@ impl TriangulationMut mod tests { use std::collections::HashSet; + use crate::geometry::DelaunayBackend2D; use crate::geometry::generators::{ build_delaunay2_from_cells, build_delaunay2_with_data, generate_delaunay2, random_delaunay2, seeded_delaunay2, @@ -1390,6 +1452,29 @@ mod tests { } } + #[test] + fn backend_deserialization_rejects_zero_delaunay_check_interval() { + let dt = + build_delaunay2_with_data(&[([0.0, 0.0], 0_u32), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]) + .expect("labeled triangle should build"); + let backend = DelaunayBackend2D::from_triangulation(dt); + let json = serde_json::to_string(&backend).expect("backend should serialize"); + let invalid_json = json.replace( + r#""delaunay_check_policy":"EndOnly""#, + r#""delaunay_check_policy":{"EveryN":0}"#, + ); + + let error = serde_json::from_str::(&invalid_json) + .expect_err("zero validation cadence must be rejected during deserialization"); + + assert!( + error + .to_string() + .contains("delaunay check interval must be non-zero"), + "unexpected deserialization error: {error}" + ); + } + #[test] fn test_delaunay_mutation_error_messages_preserve_context() { let insertion = DelaunayError::InsertionFailed { @@ -2117,6 +2202,50 @@ mod tests { )); } + #[test] + fn invalid_structural_flip_rolls_back_geometry() { + let dt = seeded_delaunay2(8, (0.0, 10.0), 53); + let mut backend = DelaunayBackend::from_triangulation(dt); + backend + .validate_structural() + .expect("seeded triangulation starts structurally valid"); + let counts_before = ( + backend.vertex_count(), + backend.edge_count(), + backend.face_count(), + ); + let edge = backend + .edges() + .find(|edge| backend.can_flip_edge(edge)) + .expect("seeded triangulation should contain a flippable edge"); + + let error = backend + .flip_edge(edge) + .expect_err("structurally invalid flip should be rejected"); + + assert!( + matches!( + error, + DelaunayError::ValidationFailed { + level: DelaunayValidationLevel::Three, + ref detail, + } if detail.contains("produced invalid structural geometry") + ), + "expected Level 1-3 structural validation failure, got {error}" + ); + assert_eq!( + ( + backend.vertex_count(), + backend.edge_count(), + backend.face_count(), + ), + counts_before + ); + backend + .validate_structural() + .expect("rollback should restore structurally valid geometry"); + } + #[test] fn clear_and_reserve_report_unsupported_without_mutating() { let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]) diff --git a/tests/proptest_foliation.rs b/tests/proptest_foliation.rs index c07dd1a..0c194c7 100644 --- a/tests/proptest_foliation.rs +++ b/tests/proptest_foliation.rs @@ -1,9 +1,68 @@ #![forbid(unsafe_code)] //! Property-based tests for CDT foliation construction and validation. +use causal_triangulations::geometry::DelaunayBackend2D; +use causal_triangulations::geometry::traits::GeometryBackend; use causal_triangulations::prelude::triangulation::*; use proptest::prelude::*; +#[derive(Debug, PartialEq, Eq)] +struct StripFingerprint { + vertices: Vec, + faces: Vec>, + slice_sizes: Vec, +} + +type VertexFingerprint = (Vec, Option); + +/// Captures a vertex by exact coordinate bits and time label for deterministic comparisons. +fn vertex_fingerprint( + tri: &CdtTriangulation2D, + vertex: &::VertexHandle, +) -> VertexFingerprint { + let coords = tri + .geometry() + .vertex_coordinates(vertex) + .expect("strip vertex coordinates should resolve") + .into_iter() + .map(f64::to_bits) + .collect(); + (coords, tri.time_label(vertex)) +} + +/// Builds a canonical strip mesh fingerprint that is stable across handle allocation order. +fn strip_fingerprint(tri: &CdtTriangulation2D) -> StripFingerprint { + let mut vertices = tri + .geometry() + .vertices() + .map(|vertex| vertex_fingerprint(tri, &vertex)) + .collect::>(); + vertices.sort(); + + let mut faces = tri + .geometry() + .faces() + .map(|face| { + let mut face_vertices = tri + .geometry() + .face_vertices(&face) + .expect("strip face vertices should resolve") + .into_iter() + .map(|vertex| vertex_fingerprint(tri, &vertex)) + .collect::>(); + face_vertices.sort(); + face_vertices + }) + .collect::>(); + faces.sort(); + + StripFingerprint { + vertices, + faces, + slice_sizes: tri.slice_sizes().to_vec(), + } +} + #[test] fn cdt_strip_builds_delaunay_mesh() { let tri = CdtTriangulation::from_cdt_strip(5, 3).expect("Delaunay CDT strip should build"); @@ -90,6 +149,7 @@ proptest! { prop_assert!(tri.validate_topology().is_ok()); prop_assert!(tri.validate_foliation().is_ok()); prop_assert!(tri.validate_causality().is_ok()); + prop_assert!(tri.validate_cell_classification().is_ok()); } } @@ -184,9 +244,6 @@ proptest! { let t2 = CdtTriangulation::from_cdt_strip(vertices_per_slice, num_slices) .expect("valid Delaunay strip construction should pass"); - prop_assert_eq!(t1.vertex_count(), t2.vertex_count()); - prop_assert_eq!(t1.edge_count(), t2.edge_count()); - prop_assert_eq!(t1.face_count(), t2.face_count()); - prop_assert_eq!(t1.slice_sizes(), t2.slice_sizes()); + prop_assert_eq!(strip_fingerprint(&t1), strip_fingerprint(&t2)); } } From f7c3605934f76ef37907e3b626a6b6a0f9bcfb1c Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Wed, 6 May 2026 22:15:05 -0700 Subject: [PATCH 3/3] fix(errors): normalize CDT validation check tokens - Emit `ergodic_move_candidate_geometry` consistently with the other `CdtValidationCheck` display identifiers. - Keep validation regression coverage deterministic for structural Delaunay rollback and checkpointed Metropolis runs. --- src/cdt/metropolis.rs | 27 +++++++++++++++++++++++++++ src/errors.rs | 4 ++-- src/geometry/backends/delaunay.rs | 21 +++++++++++++++++++-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/cdt/metropolis.rs b/src/cdt/metropolis.rs index ada54ba..be71513 100644 --- a/src/cdt/metropolis.rs +++ b/src/cdt/metropolis.rs @@ -1919,6 +1919,33 @@ mod tests { })); } + #[test] + fn run_with_checkpoint_returns_matching_results_and_checkpoint() { + let triangulation = + CdtTriangulation::from_cdt_strip(4, 3).expect("Delaunay strip should build"); + let algorithm = MetropolisAlgorithm::new( + MetropolisConfig::new(1.0, 3, 0, 1).with_seed(13), + ActionConfig::default(), + ); + + let (results, checkpoint) = algorithm + .run_with_checkpoint(triangulation) + .expect("checkpointed run should complete"); + + assert_eq!(checkpoint.current_step(), 3); + assert_eq!(results.steps.len(), checkpoint.steps().len()); + assert_eq!(&results.config, checkpoint.config()); + let checkpoint_results = checkpoint.into_results(); + assert_eq!( + results.triangulation.vertex_count(), + checkpoint_results.triangulation.vertex_count() + ); + checkpoint_results + .triangulation + .validate() + .expect("checkpoint triangulation should satisfy evolved invariants"); + } + #[test] fn serialized_checkpoint_resumes_from_stored_rng_state() { let triangulation = diff --git a/src/errors.rs b/src/errors.rs index 0d3752f..cdb756c 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -55,7 +55,7 @@ impl fmt::Display for CdtValidationCheck { Self::Causality => formatter.write_str("causality"), Self::CellClassification => formatter.write_str("cell_classification"), Self::ErgodicMoveCandidateGeometry => { - formatter.write_str("ergodic move candidate geometry") + formatter.write_str("ergodic_move_candidate_geometry") } } } @@ -509,7 +509,7 @@ mod tests { ); assert_eq!( CdtValidationCheck::ErgodicMoveCandidateGeometry.to_string(), - "ergodic move candidate geometry" + "ergodic_move_candidate_geometry" ); } diff --git a/src/geometry/backends/delaunay.rs b/src/geometry/backends/delaunay.rs index e7a8348..abe33a8 100644 --- a/src/geometry/backends/delaunay.rs +++ b/src/geometry/backends/delaunay.rs @@ -2218,10 +2218,27 @@ mod tests { .edges() .find(|edge| backend.can_flip_edge(edge)) .expect("seeded triangulation should contain a flippable edge"); + let facet = backend + .interior_facet_for_edge(edge.key) + .expect("flippable edge should resolve to an interior facet"); + let dt_before = backend.dt.clone(); + let facets_before = backend.interior_facets_by_edge.clone(); + + backend + .dt + .flip_k2(facet) + .expect("raw flip should succeed before structural validation"); + backend.rebuild_interior_facet_index(); + let mut backend = backend.with_cleared_neighbors_for_test(); let error = backend - .flip_edge(edge) - .expect_err("structurally invalid flip should be rejected"); + .validate_mutation_or_restore( + dt_before, + facets_before, + "flip_k2", + format!("edge {:?} -- {:?}", edge.key.v0(), edge.key.v1()), + ) + .expect_err("corrupted post-flip geometry should be rejected"); assert!( matches!(