diff --git a/README.md b/README.md index 4260693..be598b6 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,8 @@ use causal_triangulations::prelude::simulation::{MetropolisAlgorithm, Metropolis use causal_triangulations::prelude::triangulation::CdtTriangulation; fn main() -> CdtResult<()> { - // Create triangulation from random points - let triangulation = CdtTriangulation::from_random_points(20, 1, 2)?; + // Create a foliated open-boundary CDT strip. + let triangulation = CdtTriangulation::from_cdt_strip(8, 4)?; // Configure and run the Monte Carlo simulation. let metropolis_config = MetropolisConfig::new(1.0, 1000, 100, 10); diff --git a/docs/code_organization.md b/docs/code_organization.md index 65fb4dd..df0e0ab 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -193,7 +193,7 @@ The implementation is split into child modules under `src/cdt/triangulation/`: - `OpenBoundary` (default) — finite strip with boundary, χ ∈ {1, 2} - `Toroidal` — periodic in space and time, S¹×S¹, χ = 0 - Wired through `CdtConfig.topology`, `CdtConfigOverrides.topology`, the CLI `--topology` flag, and `CdtMetadata.topology` -- `run_simulation()` dispatches on topology: `Toroidal` → `from_toroidal_cdt`, `OpenBoundary` → `from_seeded_points` / `from_random_points` +- `run_simulation()` dispatches on topology: `Toroidal` → `from_toroidal_cdt`, `OpenBoundary` → `from_cdt_strip`; `vertices` is the total vertex count and must divide evenly across `timeslices` ### `cdt/metropolis.rs` — Metropolis move ordering diff --git a/docs/testing.md b/docs/testing.md index d1ae151..23855fa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,6 +12,8 @@ This document summarizes the repository's current test coverage and the main gap The issue #105 toroidal regression is covered by `tests/integration_tests.rs::test_toroidal_metropolis_preserves_topology_after_many_accepted_moves`, which runs a seeded S¹×S¹ Metropolis simulation, requires at least 100 accepted moves, and verifies topology, foliation, causality, cell classification, and χ = 0 at the end. +Open-boundary simulation entrypoints are covered by crate-root tests that require `run_simulation()` to build a foliated regular CDT strip, preserve adjacent-slice causality, classify cells, and report a non-empty volume profile. Configuration tests also reject open-boundary totals that cannot be split into equal-size spatial slices. + ## Remaining Gaps ### Ergodic Move Sampling diff --git a/examples/basic_cdt.rs b/examples/basic_cdt.rs index 189c3ab..c8c027a 100644 --- a/examples/basic_cdt.rs +++ b/examples/basic_cdt.rs @@ -23,13 +23,13 @@ fn main() -> CdtResult<()> { info!("Starting basic CDT example"); // Configuration parameters - let vertices = 64; + let vertices_per_slice = 16; let timeslices = 4; - let dimension = 2; + let vertices = vertices_per_slice * timeslices; // Create initial triangulation info!("Creating initial triangulation with {vertices} vertices and {timeslices} timeslices"); - let triangulation = CdtTriangulation::from_random_points(vertices, timeslices, dimension)?; + let triangulation = CdtTriangulation::from_cdt_strip(vertices_per_slice, timeslices)?; info!( "Initial triangulation: {} vertices, {} edges, {} faces", diff --git a/src/cdt/ergodic_moves.rs b/src/cdt/ergodic_moves.rs index cb95d80..2205a0b 100644 --- a/src/cdt/ergodic_moves.rs +++ b/src/cdt/ergodic_moves.rs @@ -1196,21 +1196,20 @@ mod tests { } #[test] - fn toroidal_invariant_rejection_reports_topology_mismatch() { + fn checked_toroidal_wrapper_rejects_topology_mismatch() { let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]) .expect("build labeled triangle"); let backend = DelaunayBackend2D::from_triangulation(dt); - let triangulation = CdtTriangulation2D::with_topology(backend, 3, 2, CdtTopology::Toroidal) - .expect("toroidal metadata should be internally valid"); + let result = CdtTriangulation2D::with_topology(backend, 3, 2, CdtTopology::Toroidal); assert!(matches!( - toroidal_invariant_rejection(&triangulation), - Some(MoveResult::Rejected(CdtError::TopologyMismatch { + result, + Err(CdtError::TopologyMismatch { topology, euler_characteristic: 1, expected_euler_characteristics, .. - })) if topology == "toroidal" && expected_euler_characteristics == [0] + }) if topology == "toroidal" && expected_euler_characteristics == [0] )); } diff --git a/src/cdt/metropolis.rs b/src/cdt/metropolis.rs index e0127a2..8b67dfe 100644 --- a/src/cdt/metropolis.rs +++ b/src/cdt/metropolis.rs @@ -250,13 +250,16 @@ impl Target for CdtTarget { /// ``` /// use causal_triangulations::prelude::action::ActionConfig; /// use causal_triangulations::prelude::moves::MoveType; -/// use causal_triangulations::prelude::simulation::{CdtProposal, CdtTriangulation}; +/// use causal_triangulations::prelude::simulation::{ +/// CdtProposal, CdtProposalError, CdtTriangulation, +/// }; /// use markov_chain_monte_carlo::DelayedProposal; /// use rand::{SeedableRng, rngs::StdRng}; /// -/// # fn main() -> Result<(), Box> { -/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53)?; -/// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7)?; +/// # fn main() -> Result<(), CdtProposalError> { +/// let tri = CdtTriangulation::from_cdt_strip(4, 3).expect("valid CDT strip"); +/// let mut proposal = +/// CdtProposal::with_seed(ActionConfig::default(), 7).expect("valid action config"); /// let mut rng = StdRng::seed_from_u64(11); /// /// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else { @@ -326,13 +329,16 @@ impl CdtProposalPlan { /// /// ``` /// use causal_triangulations::prelude::action::ActionConfig; -/// use causal_triangulations::prelude::simulation::{CdtProposal, CdtTriangulation}; +/// use causal_triangulations::prelude::simulation::{ +/// CdtProposal, CdtProposalError, CdtTriangulation, +/// }; /// use markov_chain_monte_carlo::DelayedProposal; /// use rand::{SeedableRng, rngs::StdRng}; /// -/// # fn main() -> Result<(), Box> { -/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53)?; -/// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7)?; +/// # fn main() -> Result<(), CdtProposalError> { +/// let tri = CdtTriangulation::from_cdt_strip(4, 3).expect("valid CDT strip"); +/// let mut proposal = +/// CdtProposal::with_seed(ActionConfig::default(), 7).expect("valid action config"); /// let mut rng = StdRng::seed_from_u64(11); /// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else { /// return Ok(()); @@ -472,13 +478,15 @@ impl Error for CdtProposalError { /// /// ``` /// use causal_triangulations::prelude::action::ActionConfig; -/// use causal_triangulations::prelude::simulation::{CdtProposal, CdtTriangulation}; +/// use causal_triangulations::prelude::simulation::{ +/// CdtProposal, CdtProposalError, CdtTriangulation, +/// }; /// use markov_chain_monte_carlo::DelayedProposal; /// use rand::{SeedableRng, rngs::StdRng}; /// -/// # fn main() -> Result<(), Box> { -/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53)?; -/// let mut proposal = CdtProposal::new(ActionConfig::default())?; +/// # fn main() -> Result<(), CdtProposalError> { +/// let tri = CdtTriangulation::from_cdt_strip(4, 3).expect("valid CDT strip"); +/// let mut proposal = CdtProposal::new(ActionConfig::default()).expect("valid action config"); /// let mut rng = StdRng::seed_from_u64(7); /// /// let plan = proposal.propose_plan(&tri, &mut rng)?; @@ -921,7 +929,7 @@ fn apply_accepted_move( ) -> Result { let mut last_rejection = None; for attempt in 1..=ACCEPTED_MOVE_RETRIES { - let snapshot = triangulation.clone(); + let counts_before = simplex_counts(triangulation); let result = attempt_move(moves, move_type, triangulation); let rejection = match result { MoveResult::Success => { @@ -929,7 +937,6 @@ fn apply_accepted_move( return Ok(AcceptedMoveResult::Applied { action_after }); } MoveResult::HardFailure(err) => { - *triangulation = snapshot; return Err(MoveApplicationError { attempt, source: err, @@ -939,7 +946,11 @@ fn apply_accepted_move( MoveResult::GeometricViolation => CdtProposalSiteRejection::GeometricViolation, MoveResult::Rejected(err) => CdtProposalSiteRejection::Kernel(err), }; - *triangulation = snapshot; + debug_assert_eq!( + simplex_counts(triangulation), + counts_before, + "failed move kernels must roll back simplex counts before returning" + ); last_rejection = Some(rejection); } diff --git a/src/cdt/triangulation.rs b/src/cdt/triangulation.rs index 545344d..9ea7e9e 100644 --- a/src/cdt/triangulation.rs +++ b/src/cdt/triangulation.rs @@ -133,7 +133,7 @@ impl CdtTriangulation { /// Wraps an existing geometry backend and tags it with [`CdtTopology`]. /// The backend itself is not modified; pass a backend whose Euler /// characteristic and metadata invariants match the supplied topology, - /// otherwise this constructor or [`Self::validate_topology`] will reject it. + /// otherwise this constructor rejects it. /// /// Toroidal triangulations must use at least three time slices so the /// periodic neighbors `t - 1` and `t + 1` remain distinct. @@ -141,7 +141,9 @@ impl CdtTriangulation { /// # Errors /// /// Returns [`CdtError::InvalidTriangulationMetadata`] when the requested - /// topology metadata is internally inconsistent. + /// topology metadata is internally inconsistent. Returns + /// [`CdtError::TopologyMismatch`] when the backend Euler characteristic does + /// not match the requested topology. /// /// # Examples /// @@ -192,6 +194,32 @@ impl CdtTriangulation { /// && expected == "≥ 3" /// )); /// ``` + /// + /// ```rust + /// use causal_triangulations::prelude::errors::CdtError; + /// use causal_triangulations::prelude::geometry::*; + /// use causal_triangulations::prelude::triangulation::*; + /// + /// let dt = build_delaunay2_with_data(&[ + /// ([0.0, 0.0], 0), + /// ([1.0, 0.0], 0), + /// ([0.5, 1.0], 1), + /// ]) + /// .expect("build labeled triangle"); + /// let backend = DelaunayBackend2D::from_triangulation(dt); + /// + /// let err = CdtTriangulation::with_topology(backend, 3, 2, CdtTopology::Toroidal) + /// .expect_err("a planar triangle cannot be published as toroidal"); + /// assert!(matches!( + /// err, + /// CdtError::TopologyMismatch { + /// topology, + /// euler_characteristic: 1, + /// expected_euler_characteristics, + /// .. + /// } if topology == "toroidal" && expected_euler_characteristics == vec![0] + /// )); + /// ``` pub fn with_topology( geometry: B, time_slices: u32, @@ -201,6 +229,7 @@ impl CdtTriangulation { let mut tri = Self::wrap_unchecked(geometry, time_slices, dimension, topology); tri.apply_time_slices(time_slices)?; tri.validate_metadata()?; + tri.validate_topology()?; Ok(tri) } @@ -1198,15 +1227,13 @@ mod tests { #[test] fn test_validate_topology_toroidal_rejects_chi_nonzero() { - // Build a non-toroidal triangulation, then label its metadata as - // Toroidal so validate_topology() expects χ=0 but gets χ1. + // Build a non-toroidal triangulation, then try to label its metadata as + // Toroidal. The checked public constructor must reject it immediately. let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]) .expect("Should build labeled triangle"); let backend = DelaunayBackend2D::from_triangulation(dt); - let tri = CdtTriangulation::with_topology(backend, 3, 2, CdtTopology::Toroidal) - .expect("valid toroidal metadata"); - let result = tri.validate_topology(); + let result = CdtTriangulation::with_topology(backend, 3, 2, CdtTopology::Toroidal); assert!(matches!( result, Err(CdtError::TopologyMismatch { diff --git a/src/cdt/triangulation/builders.rs b/src/cdt/triangulation/builders.rs index cc4f57f..2a3e82c 100644 --- a/src/cdt/triangulation/builders.rs +++ b/src/cdt/triangulation/builders.rs @@ -178,17 +178,20 @@ impl CdtTriangulation { Ok(slice_sizes) } - /// Creates a CDT triangulation with a Delaunay backend from random points. + /// Creates an unfoliated triangulation with a Delaunay backend from random points. /// - /// This is the recommended way to create triangulations for simulations. + /// This is useful for raw geometry tests and experiments. It does not + /// assign time labels or CDT cell classifications, so production CDT + /// simulations should prefer [`CdtTriangulation::from_cdt_strip`] or + /// [`CdtTriangulation::from_toroidal_cdt`]. /// /// # Errors /// /// Returns [`CdtError::UnsupportedDimension`] if `dimension != 2`. /// Returns [`CdtError::InvalidGenerationParameters`] if `vertices < 3`. /// Returns [`CdtError::DelaunayGenerationFailed`] if random point generation - /// or Delaunay construction fails, and propagates validation errors from - /// [`CdtTriangulation::try_new`]. + /// or Delaunay construction fails. Propagates metadata validation errors + /// from [`CdtTriangulation::try_new`], including `time_slices == 0`. /// /// # Examples /// @@ -198,6 +201,7 @@ impl CdtTriangulation { /// fn main() -> CdtResult<()> { /// let tri = CdtTriangulation::from_random_points(5, 2, 2)?; /// assert_eq!(tri.time_slices(), 2); + /// assert!(!tri.has_foliation()); /// Ok(()) /// } /// ``` @@ -220,18 +224,22 @@ impl CdtTriangulation { Self::try_new(backend, time_slices, dimension) } - /// Creates a CDT triangulation with a Delaunay backend from a fixed random seed. + /// Creates an unfoliated triangulation with a Delaunay backend from a fixed random seed. /// - /// Use this builder for examples, tests, benchmarks, and reproducible - /// simulations that need deterministic input geometry. + /// Use this builder for raw geometry examples, tests, and benchmarks that + /// need deterministic input geometry. It does not assign time labels or + /// CDT cell classifications, so production CDT simulations should prefer + /// [`CdtTriangulation::from_cdt_strip`] or + /// [`CdtTriangulation::from_toroidal_cdt`]. /// /// # Errors /// /// Returns [`CdtError::UnsupportedDimension`] if `dimension != 2`. /// Returns [`CdtError::InvalidGenerationParameters`] if `vertices < 3`. /// Returns [`CdtError::DelaunayGenerationFailed`] if seeded point - /// generation or Delaunay construction fails, and propagates validation - /// errors from [`CdtTriangulation::try_new`]. + /// generation or Delaunay construction fails. Propagates metadata + /// validation errors from [`CdtTriangulation::try_new`], including + /// `time_slices == 0`. /// /// # Examples /// @@ -241,6 +249,7 @@ impl CdtTriangulation { /// fn main() -> CdtResult<()> { /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; /// assert_eq!(tri.vertex_count(), 5); + /// assert!(!tri.has_foliation()); /// Ok(()) /// } /// ``` diff --git a/src/config.rs b/src/config.rs index 133d0f4..396396c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -537,9 +537,11 @@ impl CdtConfig { /// # Errors /// /// Returns a structured error describing the invalid configuration entry. - /// Toroidal topology additionally requires `timeslices ≥ 3`, - /// `vertices ≥ 3 · timeslices`, and `vertices` evenly divisible by - /// `timeslices` so each spatial slice carries the same `N ≥ 3` vertices. + /// Open-boundary topology additionally requires `timeslices ≥ 2`, + /// `vertices ≥ 4 · timeslices`, and `vertices` evenly divisible by + /// `timeslices` so each spatial slice carries the same `N ≥ 4` vertices. + /// Toroidal topology requires the analogous `timeslices ≥ 3` and `N ≥ 3` + /// constraints. /// /// # Examples /// @@ -568,43 +570,48 @@ impl CdtConfig { validate_coupling("coupling_2", self.coupling_2)?; validate_coupling("cosmological_constant", self.cosmological_constant)?; - if matches!(self.topology, CdtTopology::Toroidal) { - // Toroidal topology requires T ≥ 3 (T = 2 makes adjacent slices - // share two timelike sides per spatial edge, producing a - // non-manifold mesh) and vertices distributed evenly among - // slices with at least 3 per slice (any fewer and the spatial - // ring degenerates). - if self.timeslices < 3 { - return Err(invalid_config( - "timeslices", - &self.timeslices, - &"≥ 3 for toroidal topology", - )); - } - if !self.vertices.is_multiple_of(self.timeslices) { - return Err(invalid_config_parts( - "vertices", - self.vertices.to_string(), - format!( - "divisible by timeslices ({}) for toroidal topology", - self.timeslices - ), - )); - } - let min_total = self.timeslices.checked_mul(3).ok_or_else(|| { + let (minimum_slices, minimum_vertices_per_slice, topology_label) = match self.topology { + CdtTopology::OpenBoundary => (2, 4, "open-boundary topology"), + CdtTopology::Toroidal => (3, 3, "toroidal topology"), + }; + + if self.timeslices < minimum_slices { + return Err(invalid_config_parts( + "timeslices", + self.timeslices.to_string(), + format!("≥ {minimum_slices} for {topology_label}"), + )); + } + if !self.vertices.is_multiple_of(self.timeslices) { + return Err(invalid_config_parts( + "vertices", + self.vertices.to_string(), + format!( + "divisible by timeslices ({}) for {topology_label}", + self.timeslices + ), + )); + } + let min_total = self + .timeslices + .checked_mul(minimum_vertices_per_slice) + .ok_or_else(|| { invalid_config_parts( "timeslices", self.timeslices.to_string(), - "3 · timeslices must fit in u32 for toroidal topology".to_string(), + format!( + "{minimum_vertices_per_slice} · timeslices must fit in u32 for {topology_label}" + ), ) })?; - if self.vertices < min_total { - return Err(invalid_config_parts( - "vertices", - self.vertices.to_string(), - format!("≥ 3 · timeslices ({min_total}) for toroidal topology"), - )); - } + if self.vertices < min_total { + return Err(invalid_config_parts( + "vertices", + self.vertices.to_string(), + format!( + "≥ {minimum_vertices_per_slice} · timeslices ({min_total}) for {topology_label}" + ), + )); } validate_schedule( @@ -721,8 +728,8 @@ mod tests { #[test] fn test_config_new() { - let config = CdtConfig::new(32, 3); - assert_eq!(config.vertices, 32); + let config = CdtConfig::new(36, 3); + assert_eq!(config.vertices, 36); assert_eq!(config.timeslices, 3); assert_eq!(config.dimension(), 2); assert!(!config.simulate); @@ -748,12 +755,12 @@ mod tests { reason = "validation test exercises the full structured configuration error matrix" )] fn test_config_validation() { - let valid_config = CdtConfig::new(32, 3); + let valid_config = CdtConfig::new(36, 3); assert!(valid_config.validate().is_ok()); let invalid_vertices = CdtConfig { vertices: 2, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!(matches!( invalid_vertices.validate(), @@ -766,7 +773,7 @@ mod tests { let invalid_timeslices = CdtConfig { timeslices: 0, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!(matches!( invalid_timeslices.validate(), @@ -779,7 +786,7 @@ mod tests { let invalid_temperature = CdtConfig { temperature: -1.0, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!(matches!( invalid_temperature.validate(), @@ -794,7 +801,7 @@ mod tests { let invalid_measurement_frequency = CdtConfig { measurement_frequency: 0, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!(matches!( invalid_measurement_frequency.validate(), @@ -809,7 +816,7 @@ mod tests { let invalid_steps = CdtConfig { steps: 0, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!(matches!( invalid_steps.validate(), @@ -822,7 +829,7 @@ mod tests { let invalid_dimension = CdtConfig { dimension: Some(4), - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!(matches!( invalid_dimension.validate(), @@ -838,7 +845,7 @@ mod tests { ("coupling_2", f64::INFINITY), ("cosmological_constant", f64::NEG_INFINITY), ] { - let mut invalid_action_coupling = CdtConfig::new(32, 3); + let mut invalid_action_coupling = CdtConfig::new(36, 3); match setting { "coupling_0" => invalid_action_coupling.coupling_0 = value, "coupling_2" => invalid_action_coupling.coupling_2 = value, @@ -859,7 +866,7 @@ mod tests { coupling_0: -1.5, coupling_2: 0.0, cosmological_constant: 2.25, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!( finite_action_couplings.validate().is_ok(), @@ -868,7 +875,7 @@ mod tests { let measurement_frequency_exceeds_steps = CdtConfig { measurement_frequency: 2_000, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!(matches!( measurement_frequency_exceeds_steps.validate(), @@ -885,7 +892,7 @@ mod tests { steps: 11, thermalization_steps: 10, measurement_frequency: 10, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!( boundary_aligned_measurement.validate().is_ok(), @@ -896,7 +903,7 @@ mod tests { steps: 10, thermalization_steps: 10, measurement_frequency: 5, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!( boundary_aligned_final_measurement.validate().is_ok(), @@ -907,7 +914,7 @@ mod tests { steps: 20, thermalization_steps: 15, measurement_frequency: 10, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!( final_step_measurement.validate().is_ok(), @@ -918,7 +925,7 @@ mod tests { steps: 19, thermalization_steps: 15, measurement_frequency: 10, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; match insufficient_measurements.validate() { Err(CdtError::InvalidConfiguration { @@ -942,7 +949,7 @@ mod tests { steps: 10, thermalization_steps: 11, measurement_frequency: 1, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert!(matches!( thermalization_exceeds_steps.validate(), @@ -959,7 +966,7 @@ mod tests { steps: u32::MAX, thermalization_steps: u32::MAX, measurement_frequency: 2, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; match overflowed_post_thermalization_boundary.validate() { Err(CdtError::InvalidConfiguration { @@ -1048,18 +1055,6 @@ mod tests { && expected == "≥ 3 · timeslices (9) for toroidal topology" )); - // OpenBoundary must NOT enforce divisibility/T≥3 rules. - let open_boundary_indivisible = CdtConfig { - topology: CdtTopology::OpenBoundary, - vertices: 11, - timeslices: 3, - ..CdtConfig::new(11, 3) - }; - assert!( - open_boundary_indivisible.validate().is_ok(), - "OpenBoundary should not require vertex/timeslice divisibility" - ); - let toroidal_min_total_overflow = CdtConfig { topology: CdtTopology::Toroidal, vertices: u32::MAX, @@ -1078,11 +1073,76 @@ mod tests { )); } + #[test] + fn test_config_validation_open_boundary_regular_slices() { + let valid_open_boundary = CdtConfig { + topology: CdtTopology::OpenBoundary, + vertices: 12, + timeslices: 3, + ..CdtConfig::new(12, 3) + }; + assert!( + valid_open_boundary.validate().is_ok(), + "valid open-boundary config should validate" + ); + + let open_boundary_too_few_slices = CdtConfig { + topology: CdtTopology::OpenBoundary, + vertices: 4, + timeslices: 1, + ..CdtConfig::new(4, 1) + }; + assert!(matches!( + open_boundary_too_few_slices.validate(), + Err(CdtError::InvalidConfiguration { + setting, + provided_value, + expected, + }) if setting == "timeslices" + && provided_value == "1" + && expected == "≥ 2 for open-boundary topology" + )); + + let open_boundary_indivisible = CdtConfig { + topology: CdtTopology::OpenBoundary, + vertices: 11, + timeslices: 3, + ..CdtConfig::new(11, 3) + }; + assert!(matches!( + open_boundary_indivisible.validate(), + Err(CdtError::InvalidConfiguration { + setting, + provided_value, + expected, + }) if setting == "vertices" + && provided_value == "11" + && expected == "divisible by timeslices (3) for open-boundary topology" + )); + + let open_boundary_too_few_per_slice = CdtConfig { + topology: CdtTopology::OpenBoundary, + vertices: 9, + timeslices: 3, + ..CdtConfig::new(9, 3) + }; + assert!(matches!( + open_boundary_too_few_per_slice.validate(), + Err(CdtError::InvalidConfiguration { + setting, + provided_value, + expected, + }) if setting == "vertices" + && provided_value == "9" + && expected == "≥ 4 · timeslices (12) for open-boundary topology" + )); + } + #[test] fn test_dimension_defaults_to_two_when_unspecified() { let config = CdtConfig { dimension: None, - ..CdtConfig::new(32, 3) + ..CdtConfig::new(36, 3) }; assert_eq!(config.dimension(), 2); } diff --git a/src/geometry/backends/delaunay.rs b/src/geometry/backends/delaunay.rs index 3384f75..dba908c 100644 --- a/src/geometry/backends/delaunay.rs +++ b/src/geometry/backends/delaunay.rs @@ -37,9 +37,9 @@ type RawVertex = Vertex; /// # Mutation support /// /// The [`TriangulationMut`] methods (`insert_vertex`, `remove_vertex`, `flip_edge`, etc.) -/// are backed by the upstream Delaunay edit API where possible. `move_vertex()` is not yet -/// implemented and returns [`DelaunayError::NotImplemented`]. The `clear()` and -/// `reserve_capacity()` methods are currently no-ops that emit a `log::warn!` diagnostic. +/// are backed by the upstream Delaunay edit API where possible. `move_vertex()`, `clear()`, +/// and `reserve_capacity()` are not yet implemented and return +/// [`DelaunayError::NotImplemented`]. #[derive(Debug, Clone)] pub struct DelaunayBackend { /// The underlying Delaunay triangulation from the delaunay crate @@ -136,6 +136,17 @@ pub enum DelaunayError { expected: usize, }, + /// Coordinate value cannot be used by geometric predicates. + #[error("non-finite coordinate for {operation}: axis {axis} = {value}")] + NonFiniteCoordinate { + /// Backend operation that received the coordinate. + operation: &'static str, + /// Coordinate axis. + axis: usize, + /// Supplied non-finite value. + value: f64, + }, + /// Vertex construction failed before a backend mutation could be attempted. #[error("failed to build vertex for {operation}: {detail}")] VertexBuildFailed { @@ -199,6 +210,15 @@ impl actual: coords.len(), expected: D, })?; + for (axis, value) in coords.iter().copied().enumerate() { + if !value.is_finite() { + return Err(DelaunayError::NonFiniteCoordinate { + operation, + axis, + value, + }); + } + } let mut builder = VertexBuilder::::default().point(Point::new(coords)); if let Some(data) = data { builder = builder.data(data); @@ -979,16 +999,14 @@ impl TriangulationMut )) } - fn clear(&mut self) { - // TODO: Implement clear operation. - log::warn!("DelaunayBackend::clear() is not yet implemented; triangulation unchanged"); + fn clear(&mut self) -> Result<(), Self::Error> { + Err(DelaunayError::NotImplemented { operation: "clear" }) } - fn reserve_capacity(&mut self, vertices: usize, faces: usize) { - // TODO: Implement capacity reservation. - log::warn!( - "DelaunayBackend::reserve_capacity(vertices={vertices}, faces={faces}) is not yet implemented" - ); + fn reserve_capacity(&mut self, _vertices: usize, _faces: usize) -> Result<(), Self::Error> { + Err(DelaunayError::NotImplemented { + operation: "reserve_capacity", + }) } } @@ -1685,6 +1703,14 @@ mod tests { expected: 2, }) )); + assert!(matches!( + backend.insert_vertex(&[f64::NAN, 0.0]), + Err(DelaunayError::NonFiniteCoordinate { + operation: "insert_vertex", + axis: 0, + value, + }) if value.is_nan() + )); let bogus_vertex = VertexKey::from(KeyData::from_ffi(u64::MAX)); assert!(matches!( @@ -1710,21 +1736,36 @@ mod tests { Err(DelaunayError::NonFlippableEdge { reason, .. }) if reason.contains("interior 2D facet"), )); + } - let counts_before_noops = ( + #[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)]) + .expect("labeled triangle should build"); + let mut backend = DelaunayBackend::from_triangulation(dt); + let counts_before = ( backend.vertex_count(), backend.edge_count(), backend.face_count(), ); - backend.clear(); - backend.reserve_capacity(32, 64); + + assert!(matches!( + backend.clear(), + Err(DelaunayError::NotImplemented { operation: "clear" }) + )); + assert!(matches!( + backend.reserve_capacity(32, 64), + Err(DelaunayError::NotImplemented { + operation: "reserve_capacity" + }) + )); assert_eq!( ( backend.vertex_count(), backend.edge_count(), backend.face_count(), ), - counts_before_noops + counts_before ); } diff --git a/src/geometry/backends/mock.rs b/src/geometry/backends/mock.rs index 07f604d..42967bb 100644 --- a/src/geometry/backends/mock.rs +++ b/src/geometry/backends/mock.rs @@ -55,6 +55,17 @@ pub enum MockError { #[error("Invalid operation: {0}")] Operation(String), + /// Storage reservation failed. + #[error("Reservation failed for {operation} requesting {requested_capacity} slots: {detail}")] + ReservationFailed { + /// Operation being attempted. + operation: &'static str, + /// Capacity requested for the targeted storage map. + requested_capacity: usize, + /// Allocation failure detail. + detail: String, + }, + /// Coordinate arity does not match the mock backend dimension. #[error("Invalid coordinate dimension for {operation}: got {actual}, expected {expected}")] InvalidCoordinateDimension { @@ -540,17 +551,40 @@ impl TriangulationMut for MockBackend { Ok(SubdivisionResult::new(new_vertex, new_faces, face)) } - fn clear(&mut self) { + fn clear(&mut self) -> Result<(), Self::Error> { self.vertices.clear(); self.edges.clear(); self.faces.clear(); self.next_vertex_id = 0; self.next_edge_id = 0; self.next_face_id = 0; + Ok(()) } - fn reserve_capacity(&mut self, _vertices: usize, _faces: usize) { - // No-op for HashMap-based storage + fn reserve_capacity(&mut self, vertices: usize, faces: usize) -> Result<(), Self::Error> { + self.vertices + .try_reserve(vertices) + .map_err(|err| MockError::ReservationFailed { + operation: "reserve_capacity(vertices)", + requested_capacity: vertices, + detail: err.to_string(), + })?; + self.faces + .try_reserve(faces) + .map_err(|err| MockError::ReservationFailed { + operation: "reserve_capacity(faces)", + requested_capacity: faces, + detail: err.to_string(), + })?; + let edges = vertices.saturating_add(faces); + self.edges + .try_reserve(edges) + .map_err(|err| MockError::ReservationFailed { + operation: "reserve_capacity(edges)", + requested_capacity: edges, + detail: err.to_string(), + })?; + Ok(()) } } @@ -694,7 +728,9 @@ mod tests { #[test] fn test_mock_backend_basic_mutations_update_state() { let mut backend = MockBackend::create_triangle(); - backend.reserve_capacity(8, 4); + backend + .reserve_capacity(8, 4) + .expect("mock backend should reserve storage"); let vertex = backend .insert_vertex(&[2.0, 3.0]) @@ -754,7 +790,7 @@ mod tests { .expect("mock vertex removal should succeed"); assert!(removed_faces.is_empty()); - backend.clear(); + backend.clear().expect("mock backend should clear storage"); assert_eq!(backend.vertex_count(), 0); assert_eq!(backend.edge_count(), 0); assert_eq!(backend.face_count(), 0); @@ -764,6 +800,22 @@ mod tests { assert_eq!(backend.faces().count(), 0); } + #[test] + fn test_mock_backend_reservation_failure_reports_requested_capacity() { + let mut backend = MockBackend::create_triangle(); + + let result = backend.reserve_capacity(usize::MAX, 0); + + assert!(matches!( + result, + Err(MockError::ReservationFailed { + operation: "reserve_capacity(vertices)", + requested_capacity: usize::MAX, + detail, + }) if !detail.is_empty() + )); + } + #[test] fn test_mock_backend_flips_interior_edge() { let mut backend = MockBackend::new(2); diff --git a/src/geometry/traits.rs b/src/geometry/traits.rs index ca2ff12..417e739 100644 --- a/src/geometry/traits.rs +++ b/src/geometry/traits.rs @@ -308,9 +308,66 @@ pub trait TriangulationMut: TriangulationQuery { point: &[Self::Coordinate], ) -> Result, Self::Error>; - /// Clear all elements from the triangulation - fn clear(&mut self); + /// Remove every vertex, edge, and face from the triangulation. + /// + /// A successful call leaves the backend in an empty, reusable state. Backends + /// whose storage model cannot be cleared in place should report that through + /// their associated error type instead of silently leaving geometry behind. + /// + /// # Errors + /// + /// Returns an error when the backend cannot clear its current storage or + /// when the operation is unsupported by that backend. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::geometry::{ + /// MockBackend, MockError, TriangulationMut, TriangulationQuery, + /// }; + /// + /// fn main() -> Result<(), MockError> { + /// let mut backend = MockBackend::create_triangle(); + /// assert!(backend.vertex_count() > 0); + /// + /// backend.clear()?; + /// + /// assert_eq!(backend.vertex_count(), 0); + /// assert_eq!(backend.edge_count(), 0); + /// assert_eq!(backend.face_count(), 0); + /// Ok(()) + /// } + /// ``` + fn clear(&mut self) -> Result<(), Self::Error>; - /// Reserve capacity for vertices and faces - fn reserve_capacity(&mut self, vertices: usize, faces: usize); + /// Reserve storage capacity for at least `vertices` vertices and `faces` faces. + /// + /// Implementations may reserve additional derived storage, such as edge or + /// adjacency buffers. Backends that cannot expose a meaningful reservation + /// operation should report that through their associated error type instead + /// of treating the request as a successful no-op. + /// + /// # Errors + /// + /// Returns an error when allocation fails, the backend cannot honor the + /// requested capacity, or the operation is unsupported by that backend. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::geometry::{ + /// MockBackend, MockError, TriangulationMut, TriangulationQuery, + /// }; + /// + /// fn main() -> Result<(), MockError> { + /// let mut backend = MockBackend::create_triangle(); + /// let counts = (backend.vertex_count(), backend.edge_count(), backend.face_count()); + /// + /// backend.reserve_capacity(16, 8)?; + /// + /// assert_eq!(counts, (backend.vertex_count(), backend.edge_count(), backend.face_count())); + /// Ok(()) + /// } + /// ``` + fn reserve_capacity(&mut self, vertices: usize, faces: usize) -> Result<(), Self::Error>; } diff --git a/src/lib.rs b/src/lib.rs index a48907d..acb6302 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -138,8 +138,8 @@ pub use geometry::traits::TriangulationQuery; /// use causal_triangulations::prelude::*; /// /// fn main() -> CdtResult<()> { -/// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; -/// assert!(tri.validate().is_ok()); +/// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; +/// assert!(tri.validate_foliation().is_ok()); /// Ok(()) /// } /// ``` @@ -202,8 +202,8 @@ pub mod prelude { /// use causal_triangulations::prelude::triangulation::*; /// /// fn main() -> CdtResult<()> { - /// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53)?; - /// assert!(tri.vertex_count() >= 3); + /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// assert_eq!(tri.slice_sizes(), &[4, 4, 4]); /// Ok(()) /// } /// ``` @@ -319,7 +319,7 @@ pub mod prelude { DelaunayBackend, DelaunayEdgeHandle, DelaunayError, DelaunayFaceHandle, DelaunayVertexHandle, }; - pub use crate::geometry::backends::mock::MockBackend; + pub use crate::geometry::backends::mock::{MockBackend, MockError}; pub use crate::geometry::generators::{ GlobalTopology, TopologyGuarantee, ToroidalConstructionMode, build_delaunay2_from_cells, build_delaunay2_with_data, build_delaunay2_with_topology, @@ -334,6 +334,11 @@ pub mod prelude { /// /// This function uses the trait-based geometry backend system, which provides /// better abstraction and testability compared to legacy approaches. +/// Open-boundary runs construct a regular foliated strip with +/// [`CdtTriangulation::from_cdt_strip`]; toroidal runs construct a periodic +/// mesh with [`CdtTriangulation::from_toroidal_cdt`]. In both cases, +/// [`CdtConfig::vertices`] is the total vertex count and must divide evenly +/// across [`CdtConfig::timeslices`]. /// /// # Arguments /// @@ -345,11 +350,14 @@ pub mod prelude { /// /// # Errors /// -/// Returns [`CdtError::InvalidConfiguration`] if the configuration fails validation -/// (e.g., invalid measurement frequency or inconsistent parameters). +/// Returns [`CdtError::InvalidConfiguration`] if the configuration fails validation, +/// including invalid measurement schedules, unsupported open-boundary or +/// toroidal slice counts, or a total vertex count that cannot be divided into +/// regular spatial slices. /// Returns [`CdtError::UnsupportedDimension`] if a validated configuration requests /// a simulation dimension other than 2. -/// Returns triangulation generation errors from the underlying triangulation creation. +/// Returns triangulation generation, topology, foliation, or Metropolis errors +/// from the selected construction and simulation path. /// /// # Examples /// @@ -363,7 +371,7 @@ pub mod prelude { /// measurement_frequency: 1, /// seed: Some(7), /// simulate: false, -/// ..CdtConfig::new(5, 2) +/// ..CdtConfig::new(8, 2) /// }; /// let results = run_simulation(&config)?; /// assert_eq!(results.measurements.len(), 1); @@ -391,10 +399,9 @@ pub fn run_simulation(config: &CdtConfig) -> CdtResult // Create initial triangulation based on topology. // - // `config.vertices` is always the *total* vertex count. For - // [`CdtTopology::Toroidal`], `validate()` has already checked that - // `vertices % timeslices == 0` and `vertices >= 3 * timeslices`, so we - // can safely divide to recover N = vertices/T per slice. + // `config.vertices` is always the *total* vertex count. `validate()` has + // already checked the topology-specific divisibility and per-slice lower + // bounds, so we can safely divide to recover N = vertices/T per slice. let triangulation = match config.topology { CdtTopology::Toroidal => { log::info!("Constructing toroidal CDT (S¹×S¹)"); @@ -402,12 +409,9 @@ pub fn run_simulation(config: &CdtConfig) -> CdtResult CdtTriangulation::from_toroidal_cdt(vertices_per_slice, timeslices)? } CdtTopology::OpenBoundary => { - if let Some(seed) = config.seed { - log::info!("RNG seed: {seed}"); - CdtTriangulation::from_seeded_points(vertices, timeslices, 2, seed)? - } else { - CdtTriangulation::from_random_points(vertices, timeslices, 2)? - } + log::info!("Constructing open-boundary CDT strip"); + let vertices_per_slice = vertices / timeslices; + CdtTriangulation::from_cdt_strip(vertices_per_slice, timeslices)? } }; @@ -470,7 +474,7 @@ mod tests { fn create_test_config() -> CdtConfig { CdtConfig { dimension: Some(2), - vertices: 32, + vertices: 36, timeslices: 3, temperature: 1.0, steps: 10, @@ -491,6 +495,21 @@ mod tests { assert!(config.dimension.is_some()); let results = run_simulation(&config).expect("Failed to run triangulation"); assert!(results.triangulation.face_count() > 0); + assert!(results.triangulation.has_foliation()); + assert_eq!(results.triangulation.slice_sizes(), &[12, 12, 12]); + assert!(!results.triangulation.volume_profile().is_empty()); + results + .triangulation + .validate_foliation() + .expect("open-boundary run should build a valid foliation"); + results + .triangulation + .validate_causality() + .expect("open-boundary run should preserve adjacent-slice causality"); + results + .triangulation + .validate_cell_classification() + .expect("open-boundary run should classify CDT cells"); assert!(!results.measurements.is_empty()); } @@ -601,6 +620,19 @@ mod tests { let results = run_simulation(&config).expect("simulation should run with real moves"); assert_eq!(results.steps.len(), usize::try_from(config.steps).unwrap()); + assert!(results.triangulation.has_foliation()); + results + .triangulation + .validate_foliation() + .expect("simulated open-boundary run should keep valid foliation"); + results + .triangulation + .validate_causality() + .expect("simulated open-boundary run should keep adjacent-slice causality"); + results + .triangulation + .validate_cell_classification() + .expect("simulated open-boundary run should keep CDT cell classification"); assert!(!results.measurements.is_empty()); } diff --git a/tests/cli.rs b/tests/cli.rs index f6ae417..887b9f4 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -13,7 +13,7 @@ use std::process::Command; fn exit_success() { let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); cmd.arg("-v"); - cmd.arg("32"); + cmd.arg("36"); cmd.arg("-t"); cmd.arg("3"); cmd.assert().success(); @@ -24,7 +24,7 @@ fn cdt_cli_args() { let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); cmd.arg("-v"); - cmd.arg("32"); + cmd.arg("36"); cmd.arg("-t"); cmd.arg("3"); cmd.env("RUST_LOG", "info"); @@ -48,7 +48,7 @@ fn cdt_cli_invalid_args() { let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); cmd.arg("-v"); - cmd.arg("32"); + cmd.arg("36"); cmd.arg("-t"); cmd.arg("3"); cmd.arg("-d"); @@ -64,7 +64,7 @@ fn cdt_cli_out_of_range_args() { let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); cmd.arg("-v"); - cmd.arg("32"); + cmd.arg("36"); cmd.arg("-t"); cmd.arg("3"); cmd.arg("-d"); @@ -81,7 +81,7 @@ fn cdt_cli_invalid_measurement_frequency_zero() { // but we test the error message for completeness let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); - cmd.arg("--vertices").arg("10"); + cmd.arg("--vertices").arg("12"); cmd.arg("--timeslices").arg("3"); cmd.arg("--measurement-frequency").arg("0"); @@ -94,7 +94,7 @@ fn cdt_cli_invalid_measurement_frequency_zero() { fn cdt_cli_invalid_measurement_frequency_too_large() { let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); - cmd.arg("--vertices").arg("10"); + cmd.arg("--vertices").arg("12"); cmd.arg("--timeslices").arg("3"); cmd.arg("--steps").arg("100"); cmd.arg("--measurement-frequency").arg("200"); @@ -109,7 +109,7 @@ fn cdt_cli_invalid_measurement_frequency_too_large() { fn cdt_cli_runs_simulation_with_real_moves() { let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); - cmd.arg("--vertices").arg("10"); + cmd.arg("--vertices").arg("12"); cmd.arg("--timeslices").arg("3"); cmd.arg("--steps").arg("20"); cmd.arg("--thermalization-steps").arg("15"); @@ -125,7 +125,7 @@ fn cdt_cli_runs_simulation_with_real_moves() { fn cdt_cli_rejects_missing_post_thermalization_measurement() { let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); - cmd.arg("--vertices").arg("10"); + cmd.arg("--vertices").arg("12"); cmd.arg("--timeslices").arg("3"); cmd.arg("--steps").arg("19"); cmd.arg("--thermalization-steps").arg("15"); @@ -168,7 +168,7 @@ fn cdt_cli_config_validation_comprehensive() { // Test a complex scenario with valid parameters to ensure our validation doesn't break normal usage let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cdt")); - cmd.arg("--vertices").arg("10"); + cmd.arg("--vertices").arg("12"); cmd.arg("--timeslices").arg("3"); cmd.arg("--steps").arg("50"); cmd.arg("--measurement-frequency").arg("5"); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 5912852..ebbdc44 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -20,8 +20,14 @@ mod integration_tests { #[test] fn test_complete_cdt_simulation_workflow_runs_moves() { // Test full CDT simulation pipeline - let triangulation = CdtTriangulation::from_seeded_points(32, 3, 2, 42) - .expect("Failed to create initial triangulation"); + let triangulation = + CdtTriangulation::from_cdt_strip(8, 4).expect("Failed to create initial triangulation"); + triangulation + .validate_foliation() + .expect("initial strip foliation is valid"); + triangulation + .validate_causality() + .expect("initial strip causality is valid"); let config = MetropolisConfig::new(1.0, 10, 5, 2).with_seed(42); let action_config = ActionConfig::default(); @@ -225,10 +231,10 @@ mod integration_tests { #[test] fn test_seeded_simulation_reproducibility() { // Test that seeded simulation inputs consistently produce the same move trace. - let triangulation1 = CdtTriangulation::from_seeded_points(5, 1, 2, 53) - .expect("Failed to create first triangulation"); - let triangulation2 = CdtTriangulation::from_seeded_points(5, 1, 2, 53) - .expect("Failed to create second triangulation"); + let triangulation1 = + CdtTriangulation::from_cdt_strip(4, 3).expect("Failed to create first triangulation"); + let triangulation2 = + CdtTriangulation::from_cdt_strip(4, 3).expect("Failed to create second triangulation"); let config = MetropolisConfig::new(1.0, 10, 2, 2).with_seed(123); let action_config = ActionConfig::default(); diff --git a/tests/proptest_metropolis.rs b/tests/proptest_metropolis.rs index d90f638..592a56c 100644 --- a/tests/proptest_metropolis.rs +++ b/tests/proptest_metropolis.rs @@ -10,7 +10,7 @@ use proptest::prelude::*; /// Shared triangulation created once (fixed seed, cheap). fn test_triangulation() -> CdtTriangulation2D { - CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Fixed-seed triangulation") + CdtTriangulation::from_cdt_strip(4, 3).expect("regular open-boundary strip") } proptest! {