diff --git a/AGENTS.md b/AGENTS.md index 8927283..6fc5748 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Before modifying code, agents MUST read: - `AGENTS.md` (this file) - **All files in `docs/dev/*.md`** – repository development rules -- `docs/project.md` – module layout and architecture +- `docs/code_organization.md` – module layout and architecture The `docs/dev/` directory contains the authoritative development guidance for this repository. Agents must load every file in that directory before making changes. @@ -92,6 +92,14 @@ When using the `gh` CLI to view issues, PRs, or other GitHub objects: - **ALWAYS** use the patch editing mechanism provided by the agent - Shell text tools may be used for **read‑only analysis only** +### Public API Preludes + +- Keep `prelude::*` small and focused on common quick-start workflows. +- Keep scoped preludes minimal and orthogonal; do not duplicate specialized APIs across scoped preludes unless the overlap is intentionally documented. +- `prelude::observables` is the user-facing analysis surface for measuring triangulations and derived physical observables. +- `prelude::simulation` is for running, inspecting, and debugging simulations; it may expose telemetry and proposal/result types, but should not become the home for user-facing observable estimators. +- Detailed prelude boundary guidance lives in `docs/dev/rust.md`. + ### Commit Message Generation When generating commit messages: @@ -137,6 +145,8 @@ just ci Refer to `docs/dev/commands.md` for full details. +When adding or renaming Cargo examples, update `just validate-examples` markers as needed so CI keeps validating the user-facing example contracts. + For tooling-alignment work, update `docs/dev/tooling-alignment.md` with the comparison and rationale before adding or changing config, workflow, or repository-rule files. --- @@ -162,17 +172,17 @@ Key principle: - **MSRV**: 1.95.0 - **Edition**: 2024 - **Unsafe code**: forbidden (`#![forbid(unsafe_code)]`) -- **Architecture**: CDT physics layered over a pluggable geometry backend (`delaunay` crate). Direct `use delaunay::` imports are restricted to `src/geometry/` (`backends/delaunay.rs` and `generators.rs`); all other modules use the trait-based abstractions and `DelaunayBackend2D` type alias (see `docs/dev/rust.md § Geometry Backend Isolation`) -- **Modules**: `src/cdt/` (CDT logic: moves, action, Metropolis, foliation), `src/geometry/` (geometry abstractions and backends), `src/config.rs` (simulation configuration) -- **Foliation**: `src/cdt/foliation.rs` assigns per-vertex time labels via `VertexSecondaryMap`; `from_foliated_cylinder` constructs foliated triangulations; `validate_causality` enforces |Δt| ≤ 1 on edges. Design documented in `docs/foliation.md` -- **Ergodic moves**: `attempt_22_move`, `attempt_13_move`, `attempt_31_move`, `attempt_edge_flip` are currently placeholder implementations; full `delaunay::Tds` integration is planned +- **Architecture**: `src/geometry/` is the backend interface layer for the `delaunay` crate; `src/cdt/` is the CDT domain layer. Direct `use delaunay::` imports are restricted to `src/geometry/` (`backends/delaunay.rs` and `generators.rs`); CDT modules use the trait-based abstractions, crate-owned Delaunay handles, generator utilities, and `DelaunayBackend2D` type alias (see `docs/dev/rust.md § Geometry Backend Isolation`) +- **Modules**: `src/cdt/` (CDT logic: moves, action, Metropolis, foliation, observables, results, triangulation child modules), `src/geometry/` (geometry abstractions and backends), `src/config.rs` (simulation configuration) +- **Foliation**: `src/cdt/foliation.rs` defines foliation bookkeeping and edge/cell classification. Time labels are stored as vertex data; `from_cdt_strip` and `from_toroidal_cdt` construct labeled CDT triangulations; `validate_causality` enforces adjacent-slice edges (with circular distance on toroidal time). Design documented in `docs/foliation.md` +- **Ergodic moves**: `attempt_22_move`, `attempt_13_move`, `attempt_31_move`, `attempt_edge_flip` are Delaunay-backed, foliation-aware move kernels. They mutate through narrow CDT-owned edit operations, roll back failed finalized mutations, and preserve topology/foliation invariants - **Python scripts**: `scripts/` contains benchmark, changelog, and hardware utilities; tests in `scripts/tests/` run via pytest -- **When adding/removing files**: Update `docs/project.md` +- **When adding/removing files**: Update `docs/code_organization.md` Architecture details are documented in: ```text -docs/project.md +docs/code_organization.md ``` --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dca9a0c..c3851f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thank you for your interest in contributing to the [**causal-triangulations**][c - [Code of Conduct](#code-of-conduct) - [Getting Started](#getting-started) - [Development Environment Setup](#development-environment-setup) -- [Project Structure](#project-structure) +- [Code Organization](#code-organization) - [Development Workflow](#development-workflow) - [Just Command Runner](#just-command-runner) - [Code Style and Standards](#code-style-and-standards) @@ -145,36 +145,9 @@ info: downloading component 'clippy' This is normal and only happens once. -## Project Structure +## Code Organization -```text -causal-triangulations/ -├── src/ # Core library code -│ ├── cdt/ # CDT-specific implementations -│ │ ├── action.rs # Regge action calculations -│ │ ├── metropolis.rs # Monte Carlo simulation -│ │ ├── ergodic_moves.rs # Pachner moves -│ │ └── triangulation.rs # CDT triangulation wrapper -│ ├── geometry/ # Geometry abstraction layer -│ │ ├── backends/ # Geometry backend implementations -│ │ ├── mesh.rs # Mesh data structures -│ │ ├── operations.rs # High-level operations -│ │ └── traits.rs # Geometry traits -│ ├── config.rs # Configuration management -│ ├── errors.rs # Error types -│ ├── util.rs # Utility functions -│ ├── lib.rs # Library root -│ └── main.rs # CLI binary -├── examples/ # Usage examples -│ ├── basic_cdt.rs # Library usage example -│ └── scripts/ # Ready-to-use simulation scripts -├── tests/ # Test suite -│ ├── cli.rs # CLI integration tests -│ └── integration_tests.rs # System integration tests -├── benches/ # Performance benchmarks -├── docs/ # Documentation -└── justfile # Task automation -``` +The source/module layout and architecture-sensitive boundaries live in [docs/code_organization.md](docs/code_organization.md). Keep that file current when adding, removing, or moving source files, examples, or architecture-significant modules. ## Development Workflow @@ -203,14 +176,34 @@ just --list # Show all available commands just help-workflows # Detailed workflow guidance ``` +### Repository Tooling Map + +```text +.github/workflows/codeql.yml # CodeQL analysis for Actions and Rust +.github/workflows/semgrep-sarif.yml # Repository Semgrep rule SARIF upload +rustfmt.toml # Stable Rust formatting settings +cliff.toml # git-cliff changelog template and commit grouping +semgrep.yaml # Repository-owned Semgrep rules +docs/dev/python.md # Python script style and validation guidance +docs/dev/tooling-alignment.md # Tooling comparison and issue #112 decisions +docs/roadmap.md # High-level release direction and non-goals +tests/semgrep/ # Semgrep rule fixtures run by `just semgrep-test` +scripts/archive_changelog.py # Split completed changelog minor series into archive files +scripts/coverage_report.py # Cobertura coverage summary helper +scripts/postprocess_changelog.py # Markdown hygiene for git-cliff changelogs +scripts/tag_release.py # Annotated release tags from root or archived changelog sections +``` + ### Typical Development Cycle 1. **Start working on a feature/fix**: ```bash - git checkout -b feature/your-feature-name + git checkout -b fix/307-topology-validation ``` + Branch names should follow `{type}/{issue}-descriptor-or-two`, such as `fix/307-topology-validation` or `perf/315-bench-profile`. + 2. **Development cycle**: ```bash diff --git a/Cargo.toml b/Cargo.toml index d94c248..4aa26cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.0.1" authors = [ "Adam Getchell " ] categories = [ "science", "mathematics", "algorithms", "simulation" ] edition = "2024" +documentation = "https://docs.rs/causal-triangulations" homepage = "https://github.com/acgetchell/causal-triangulations" keywords = [ "quantum-gravity", diff --git a/README.md b/README.md index 535c8f3..4260693 100644 --- a/README.md +++ b/README.md @@ -14,21 +14,23 @@ The library leverages high-performance [Delaunay triangulation] backends and pro ## ✨ Features -- [x] 2D Causal Dynamical Triangulations with time-foliation (early implementation) -- [x] Metropolis-Hastings simulation loop with proposal-before-mutation move ordering -- [x] Regge action calculation with configurable coupling constants (experimental) -- [x] Ergodic moves (Alexander/Pachner moves) with causal constraints (experimental) -- [x] Command-line interface for simulation workflows (early) -- [x] Benchmarking and performance analysis infrastructure (in progress) -- [x] Cross-platform compatibility (Linux, macOS, Windows) +- [x] Explicit 1+1 CDT strip and 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 +- [x] Alexander/Pachner-style local move proposals with causal constraints +- [x] Volume-profile, Hausdorff-dimension, and spectral-dimension observables for CDT analysis +- [x] Focused public preludes for simulation, triangulation, geometry, action, and observables +- [x] Command-line interface, examples, Criterion benchmarks, and CI-aligned validation tooling +- [x] Cross-platform compatibility: Linux, macOS, Windows See [CHANGELOG.md](CHANGELOG.md) for release history. ## 🚧 Project Status -🚧 **Pre-release (0.0.x)** — This crate is under active development and **not yet ready for production use**. APIs, data structures, and module boundaries may change without notice. +🚧 **Pre-release (0.0.x)** — The 1+1 CDT foundation is implemented and tested, but this crate is still under active development and **not yet ready for production use**. APIs, data structures, and module boundaries may change before v0.1.0. -The library currently supports an initial 2D CDT implementation, with planned extensions to 3D and 4D. +The library currently supports validated 1+1 CDT construction, foliation checks, Metropolis sampling, and core observables. Higher-dimensional CDT support, full move-kernel maturity, visualization/export workflows, and advanced ensemble-analysis helpers remain roadmap work. See [`docs/roadmap.md`](docs/roadmap.md) for current direction, near-term candidates, and non-goals. @@ -56,7 +58,7 @@ The long-term design separates: - **Sampling** (MCMC algorithms) - **Physics** (CDT-specific dynamics and observables) -This crate focuses on the CDT (physics + domain) layer. +Within this crate, `src/geometry/` is the backend interface layer over `delaunay`, while `src/cdt/` is the CDT domain layer. ## 🤝 How to Contribute @@ -180,18 +182,7 @@ See [`benches/README.md`](benches/README.md) for benchmark details and [`docs/PE ## 🛣️ Roadmap -- [x] Integrate an existing Rust **Delaunay** triangulation library (e.g., [`delaunay`](https://crates.io/crates/delaunay)) -- [x] 2D Delaunay triangulation scaffold -- [ ] 1+1 foliation (causal time‑slicing) -- [ ] 2D ergodic moves (Alexander/Pachner moves with causal constraints, fully validated) -- [ ] 2D Metropolis–Hastings (stabilized on `markov-chain-monte-carlo` delayed proposals) -- [ ] Diffusion‑accelerated MCMC (exploration) -- [ ] Basic visualization hooks (export to common mesh formats) -- [ ] 3D Delaunay + 2+1 foliation + moves + M–H -- [ ] 4D Delaunay + 3+1 foliation + moves + M–H -- [ ] Mass initialization via **Constrained Delaunay** in 3D/4D -- [ ] Shortest paths & geodesic distance -- [ ] Curvature estimates / Einstein tensor (discrete Regge‑like observables) +The high-level roadmap, including 1+1 maturity work, future 2+1 and 3+1 CDT topology tracks, observables, dual/Voronoi geometry, visualization, and non-goals, lives in [`docs/roadmap.md`](docs/roadmap.md). ## Design notes @@ -199,7 +190,7 @@ See [`benches/README.md`](benches/README.md) for benchmark details and [`docs/PE - **Foliation‑aware data model**: explicit time labels; space‑like vs time‑like edges encoded in types. - **Testing**: unit + property tests for invariants (e.g., move reversibility, manifoldness). -For comprehensive guidelines on contributing, development environment setup, testing, and project structure, please see [CONTRIBUTING.md](CONTRIBUTING.md). +For comprehensive guidelines on contributing, development environment setup, testing, and code organization, please see [CONTRIBUTING.md](CONTRIBUTING.md). This includes information about: @@ -207,7 +198,7 @@ This includes information about: - Running benchmarks and performance analysis - Code style and standards - Submitting changes and pull requests -- Project structure and development tools +- Code organization and development tools ## 📚 References diff --git a/REFERENCES.md b/REFERENCES.md index 5c3ced9..7a1ec8b 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -3,7 +3,7 @@ title: "References and Citations" description: "Academic references and bibliographic citations used throughout the causal-triangulations library" keywords: ["references", "citations", "causal dynamical triangulations", "quantum gravity", "bibliography"] author: "Adam Getchell" -date: "2025-10-07" +date: "2026-05-06" category: "Documentation" tags: ["academic", "research", "citations", "physics", "quantum gravity"] layout: "page" @@ -38,6 +38,16 @@ This section contains the seminal papers and foundational work that established - Ambjørn, J., Görlich, A., Jurkiewicz, J., and Loll, R. "Nonperturbative Quantum Gravity." _Physics Reports_ 519, no. 4-5 (2012): 127-210. DOI: [10.1016/j.physrep.2012.03.007](https://doi.org/10.1016/j.physrep.2012.03.007). arXiv: [1203.3591](https://arxiv.org/abs/1203.3591) +### Volume Profiles and Dimensional Observables + +- Ambjørn, J., Jurkiewicz, J., and Loll, R. "Reconstructing the Universe." _Physical Review D_ 72, no. 6 (2005): 064014. DOI: [10.1103/PhysRevD.72.064014](https://doi.org/10.1103/PhysRevD.72.064014). arXiv: [hep-th/0505154](https://arxiv.org/abs/hep-th/0505154) + +- Ambjørn, J., Jurkiewicz, J., and Loll, R. "The Spectral Dimension of the Universe is Scale Dependent." _Physical Review Letters_ 95, no. 17 (2005): 171301. DOI: [10.1103/PhysRevLett.95.171301](https://doi.org/10.1103/PhysRevLett.95.171301). arXiv: [hep-th/0505113](https://arxiv.org/abs/hep-th/0505113) + +- Ambjørn, J., Budd, T., and Watabiki, Y. "Scale-dependent Hausdorff dimensions in 2d gravity." _Physics Letters B_ 736 (2014): 339-343. DOI: [10.1016/j.physletb.2014.07.047](https://doi.org/10.1016/j.physletb.2014.07.047). arXiv: [1406.6251](https://arxiv.org/abs/1406.6251) + +- van der Duin, J., Loll, R., Schiffer, M., and Silva, A. "Quantum gravity and effective topology." _The European Physical Journal C_ 86, no. 2 (2026): 102. DOI: [10.1140/epjc/s10052-026-15322-x](https://doi.org/10.1140/epjc/s10052-026-15322-x) + ## Monte Carlo Methods in Quantum Gravity These references provide the algorithmic foundations for Monte Carlo simulations in discrete quantum gravity. diff --git a/benches/cdt_benchmarks.rs b/benches/cdt_benchmarks.rs index f1ebfb6..019f1f9 100644 --- a/benches/cdt_benchmarks.rs +++ b/benches/cdt_benchmarks.rs @@ -332,27 +332,9 @@ fn bench_simulation_analysis(c: &mut Criterion) { }, ], measurements: vec![ - Measurement { - step: 0, - action: 12.5, - vertices: 15, - edges: 32, - triangles: 18, - }, - Measurement { - step: 10, - action: 11.8, - vertices: 16, - edges: 34, - triangles: 19, - }, - Measurement { - step: 20, - action: 12.1, - vertices: 15, - edges: 31, - triangles: 17, - }, + Measurement::new(0, 12.5, 15, 32, 18).with_volume_profile(vec![9, 9, 0]), + Measurement::new(10, 11.8, 16, 34, 19).with_volume_profile(vec![9, 10, 0]), + Measurement::new(20, 12.1, 15, 31, 17).with_volume_profile(vec![8, 9, 0]), ], elapsed_time: Duration::from_millis(37), triangulation, @@ -372,6 +354,34 @@ fn bench_simulation_analysis(c: &mut Criterion) { }); }); + group.bench_function("average_volume_profile", |b| { + b.iter(|| { + let profile = results.average_volume_profile(); + black_box(profile) + }); + }); + + group.bench_function("volume_fluctuations", |b| { + b.iter(|| { + let fluctuations = results.volume_fluctuations(); + black_box(fluctuations) + }); + }); + + group.bench_function("hausdorff_dimension_estimate", |b| { + b.iter(|| { + let estimate = results.hausdorff_dimension_estimate(); + black_box(estimate) + }); + }); + + group.bench_function("spectral_dimension_estimate", |b| { + b.iter(|| { + let estimate = results.spectral_dimension_estimate(); + black_box(estimate) + }); + }); + group.bench_function("equilibrium_measurements", |b| { b.iter(|| { let measurements = results.equilibrium_measurements(); diff --git a/docs/PERFORMANCE_TESTING.md b/docs/PERFORMANCE_TESTING.md index 563d87b..6cb8d70 100644 --- a/docs/PERFORMANCE_TESTING.md +++ b/docs/PERFORMANCE_TESTING.md @@ -73,7 +73,7 @@ just perf-check # Strict 5% threshold for critical changes just perf-check 5.0 -# Relaxed 15% threshold for experimental features +# Relaxed 15% threshold for exploratory changes just perf-check 15.0 ``` diff --git a/docs/code_organization.md b/docs/code_organization.md new file mode 100644 index 0000000..65fb4dd --- /dev/null +++ b/docs/code_organization.md @@ -0,0 +1,253 @@ +# Code Organization Guide + +This document lists the files checked into the repository and summarizes the architecture-sensitive module boundaries for the causal-triangulations crate. + +## Project Structure + +### Complete Directory Tree + +> **Tip**: regenerate this tree from tracked files: +> +> ```bash +> git --no-pager ls-files | LC_ALL=C sort | \ +> LC_ALL=C tree -a --charset UTF-8 --dirsfirst --noreport -F --fromfile +> ``` +> +> This keeps the directory tree synchronized with the files that GitHub will display. + +```text +causal-triangulations/ +├── .github/ +│ ├── workflows/ +│ │ ├── audit.yml +│ │ ├── ci.yml +│ │ ├── codecov.yml +│ │ ├── codeql.yml +│ │ ├── performance.yml +│ │ ├── rust-clippy.yml +│ │ └── semgrep-sarif.yml +│ ├── CODEOWNERS +│ └── dependabot.yml +├── benches/ +│ ├── README.md +│ └── cdt_benchmarks.rs +├── docs/ +│ ├── dev/ +│ │ ├── commands.md +│ │ ├── python.md +│ │ ├── rust.md +│ │ ├── testing.md +│ │ └── tooling-alignment.md +│ ├── CLI_EXAMPLES.md +│ ├── PERFORMANCE_TESTING.md +│ ├── RELEASING.md +│ ├── code_organization.md +│ ├── foliation.md +│ ├── metropolis.md +│ ├── moves.md +│ ├── roadmap.md +│ └── testing.md +├── examples/ +│ ├── scripts/ +│ │ ├── README.md +│ │ ├── basic_simulation.sh +│ │ ├── parameter_sweep.sh +│ │ └── performance_test.sh +│ ├── basic_cdt.rs +│ ├── find_good_seeds.rs +│ └── observables.rs +├── proptest-regressions/ +│ └── cdt/ +│ └── triangulation.txt +├── scripts/ +│ ├── tests/ +│ │ ├── __init__.py +│ │ ├── conftest.py +│ │ ├── test_archive_changelog.py +│ │ ├── test_benchmark_models.py +│ │ ├── test_benchmark_utils.py +│ │ ├── test_coverage_report.py +│ │ ├── test_hardware_utils.py +│ │ ├── test_postprocess_changelog.py +│ │ ├── test_subprocess_utils.py +│ │ └── test_tag_release.py +│ ├── README.md +│ ├── archive_changelog.py +│ ├── benchmark_models.py +│ ├── benchmark_utils.py +│ ├── coverage_report.py +│ ├── hardware_utils.py +│ ├── performance_analysis.py +│ ├── postprocess_changelog.py +│ ├── run_all_examples.sh +│ ├── subprocess_utils.py +│ └── tag_release.py +├── src/ +│ ├── cdt/ +│ │ ├── action.rs +│ │ ├── ergodic_moves.rs +│ │ ├── foliation.rs +│ │ ├── metropolis.rs +│ │ ├── observables.rs +│ │ ├── results.rs +│ │ ├── triangulation.rs +│ │ └── triangulation/ +│ │ ├── builders.rs +│ │ ├── foliation.rs +│ │ ├── moves.rs +│ │ └── validation.rs +│ ├── geometry/ +│ │ ├── backends/ +│ │ │ ├── delaunay.rs +│ │ │ └── mock.rs +│ │ ├── generators.rs +│ │ ├── operations.rs +│ │ └── traits.rs +│ ├── config.rs +│ ├── errors.rs +│ ├── lib.rs +│ ├── main.rs +│ └── util.rs +├── tests/ +│ ├── semgrep/ +│ │ ├── scripts/ +│ │ │ └── tests/ +│ │ │ └── python_exceptions.py +│ │ └── src/ +│ │ └── project_rules/ +│ │ └── rust_style.rs +│ ├── cli.rs +│ ├── integration_tests.rs +│ ├── proptest_foliation.rs +│ └── proptest_metropolis.rs +├── .bencher.toml +├── .codecov.yml +├── .coderabbit.yml +├── .gitignore +├── .python-version +├── .taplo.toml +├── .yamllint +├── AGENTS.md +├── CHANGELOG.md +├── CODE_OF_CONDUCT.md +├── CONTRIBUTING.md +├── Cargo.lock +├── Cargo.toml +├── LICENSE +├── README.md +├── REFERENCES.md +├── cliff.toml +├── clippy.toml +├── dprint.json +├── justfile +├── pyproject.toml +├── rust-toolchain.toml +├── rustfmt.toml +├── semgrep.yaml +├── ty.toml +├── typos.toml +└── uv.lock +``` + +## Architecture Layers + +The crate is split into two intentionally different layers: + +- `src/geometry/` is the backend interface layer. It is the only layer that talks directly to the `delaunay` crate, wrapping upstream types behind crate-owned traits, opaque handles, generators, and backend adapters. +- `src/cdt/` is the CDT domain layer. It owns causal triangulation semantics: foliation, topology and causality checks, ergodic moves, Regge action, Metropolis sampling, measurements, and observables. + +Code outside `src/geometry/` must not import `delaunay::` directly. CDT modules should depend on `TriangulationQuery` / `TriangulationMut`, crate-owned Delaunay handle wrappers, `DelaunayBackend2D`, and generator functions from `crate::geometry`. + +## Key Modules + +### `cdt/foliation.rs` — Foliation + +Assigns each vertex to a discrete time slice, enabling classification of edges as spacelike or timelike and triangles as up or down. See `docs/foliation.md` for design details. + +- `Foliation` — aggregate bookkeeping (per-slice vertex counts, total slices) +- `EdgeType` — `Spacelike` (same slice) or `Timelike` (adjacent slices) +- `CellType` — `Up` (2,1) or `Down` (1,2) triangle classification, encoded as `i32` cell data +- Time labels are stored directly as vertex data (`Vertex.data: Option`), mirroring CDT-plusplus’s `vertex->info()` + +### `cdt/triangulation.rs` — Foliation integration + +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` +- `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) +- 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 +- `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 + +### `config.rs` — `CdtTopology` enum + +- `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` + +### `cdt/metropolis.rs` — Metropolis move ordering + +`MetropolisAlgorithm::run()` proposes a move type, computes `ΔS` from the move's simplex-count delta, accepts or rejects the proposal, and only mutates the triangulation after acceptance. Accepted applications that fail are rolled back from a triangulation snapshot and retried at another random local site; retry exhaustion is recorded as a rejection, while hard backend failures remain structured errors. Toroidal move finalization rejects and rolls back candidate sites that would violate χ = 0 or the closed-S¹ per-slice foliation invariant. See `docs/metropolis.md` for the detailed ordering. + +### `cdt/results.rs` — Simulation outputs + +- `Measurement` records per-step action, simplex counts, and optional per-slice volume profiles. +- `SimulationResultsBackend` owns the final triangulation, Monte Carlo step telemetry, move statistics, and measurement history. +- Result methods summarize acceptance rate, average action, post-thermalization volume profiles, sample volume fluctuations, and final-state Hausdorff/spectral dimension estimates. + +### `cdt/observables.rs` — User-facing estimators + +- `estimate_hausdorff_dimension` — estimates Hausdorff dimension from combinatorial dual-graph geodesic ball growth, returning `None` when the triangulation is too small or live face adjacency cannot be resolved +- `estimate_spectral_dimension` — estimates spectral dimension from dual-graph diffusion return probability, returning `None` when the graph is too small or lacks enough positive return-probability samples for a fit +- `CdtTriangulation::volume_profile` measures per-slice triangle counts on a triangulation; `SimulationResultsBackend` provides aggregate volume-profile summaries for simulation outputs +- Import triangulation-focused analysis APIs through `prelude::observables`; use `prelude::simulation` when constructing or inspecting simulation result containers + +### `geometry/traits.rs` — Backend-neutral interface + +- `GeometryBackend` defines associated coordinate, handle, and error types for a geometry implementation +- `TriangulationQuery` is the read-only surface used by CDT logic for counts, handles, adjacency, coordinates, face vertices, and validation +- `TriangulationMut` is the narrow mutation surface used by CDT-owned move kernels through wrapper methods, not broad mutable backend exposure +- Result structs such as `FlipResult`, `EdgeAdjacentFaces`, and `SubdivisionResult` keep local topology operations backend-neutral + +### `geometry/backends/delaunay.rs` — Delaunay adapter + +- Wraps the upstream `delaunay` triangulation in `DelaunayBackend` +- Defines crate-owned opaque handles (`DelaunayVertexHandle`, `DelaunayEdgeHandle`, `DelaunayFaceHandle`) so CDT code does not depend on upstream key types +- Translates upstream Delaunay operations and errors into this crate's trait contracts +- Together with `geometry/generators.rs`, this is the only place that directly imports from the `delaunay` crate + +### `geometry/generators.rs` — Delaunay triangulation generators + +- `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) +- `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. + +### `util.rs` — Numeric helpers + +- `saturating_usize_to_i32` — crate-internal usize→i32 conversion for Euler characteristic arithmetic +- `saturating_usize_to_u32` — crate-internal usize→u32 conversion for simulation measurements and action inputs +- `y_to_time_bucket` — f64→Option via round(), for time-slice assignment +- `f64_band_to_u32` — f64→u32 clamped, for y-coordinate binning + +## Key Dependencies + +- `delaunay` (v0.7.6) — geometry backend (Delaunay triangulations, vertex data for time labels, `set_vertex_data_by_key` for O(1) label mutation) +- `markov-chain-monte-carlo` (v0.3) — MCMC framework (`DelayedProposal`, `Chain::step_delayed`, `Target`) +- `num-traits` — `ToPrimitive` and `NumCast` for checked or saturating numeric conversions diff --git a/docs/dev/commands.md b/docs/dev/commands.md index 4f4a962..34eaed7 100644 --- a/docs/dev/commands.md +++ b/docs/dev/commands.md @@ -104,7 +104,7 @@ This runs: - unit tests - integration tests - documentation builds -- example builds +- validated example runs - benchmark compilation ## Semgrep @@ -146,6 +146,7 @@ Validate with: ```bash just examples +just validate-examples ``` Examples must: @@ -154,6 +155,10 @@ Examples must: - run successfully - demonstrate correct API usage +`just validate-examples` additionally checks stable output markers for user-facing Cargo examples. Keep those markers semantic rather than exact numeric values so simulation output can evolve without making the example contract brittle. + +When adding or renaming a Cargo example, update `scripts/run_all_examples.sh` `validate_example_output()` with stable semantic output markers, or intentionally document why success-only validation is sufficient for that example. + --- ## Spell Checking @@ -277,17 +282,18 @@ just test-python # pytest ## Recommended Command Matrix -| Task | Command | -| --------------------- | ----------------------- | -| Format code | `just fix` | -| Run lints | `just check` | -| Run unit tests | `just test` | -| Run integration tests | `just test-integration` | -| Run all tests | `just test-all` | -| Run Python tests | `just test-python` | -| Run examples | `just examples` | -| Run full CI | `just ci` | -| Pre-commit check | `just commit-check` | +| Task | Command | +| --------------------- | ------------------------ | +| Format code | `just fix` | +| Run lints | `just check` | +| Run unit tests | `just test` | +| Run integration tests | `just test-integration` | +| Run all tests | `just test-all` | +| Run Python tests | `just test-python` | +| Run examples | `just examples` | +| Validate examples | `just validate-examples` | +| Run full CI | `just ci` | +| Pre-commit check | `just commit-check` | --- @@ -296,7 +302,7 @@ just test-python # pytest | Changed files | Command | | ------------- | -------------------------------------- | | `tests/` | `just test-integration` (or `just ci`) | -| `examples/` | `just examples` | +| `examples/` | `just validate-examples` | | `benches/` | `just bench-compile` | | `src/` | `just test` | | `scripts/` | `just test-python` | @@ -312,6 +318,7 @@ CI enforces: - clippy lints - documentation build - tests +- validated examples All warnings are treated as errors. diff --git a/docs/dev/rust.md b/docs/dev/rust.md index ed697fc..d57354d 100644 --- a/docs/dev/rust.md +++ b/docs/dev/rust.md @@ -80,6 +80,8 @@ Public APIs must **not panic**. Use explicit error propagation. +Production `src/` code must not use bare `unwrap()` or explicit `panic!`. Use `?`, typed errors, `Option`, or an intentional fallback instead. Tests, doctests, examples, and benchmark setup may fail fast when a broken fixture should stop execution immediately; prefer `expect("reason")` over bare `unwrap()` in user-facing examples so failures remain diagnosable. + ### Fallible public functions Return `Result`: @@ -196,13 +198,22 @@ Types that are part of the crate's **stable public API** (documented, intended f When adding a new public API type, add a corresponding `pub use` line in the re-export block at the top of `lib.rs`. -Focused preludes under `prelude::` must remain small, orthogonal, and purpose-specific. Use them in doctests, integration tests, examples, and benchmarks instead of deep module paths when demonstrating public workflows: +The broad `prelude::*` must stay small. It should cover common quick-start workflows such as CDT construction, basic configuration, simulation startup, query traits, and error handling. Do not use it as a dumping ground for every public type. + +Focused preludes under `prelude::` must remain small, orthogonal, and purpose-specific. Use them in doctests, integration tests, examples, and benchmarks instead of deep module paths when demonstrating public workflows. Avoid duplicating specialized APIs across scoped preludes unless the overlap is deliberate and documented: - `prelude::geometry` for backend construction, geometry generators, and geometry traits - `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::simulation` for Metropolis/action simulation workflows and simulation result types +- `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 + +Keep the simulation and observables boundaries crisp: + +- Export user-facing observable estimators from `prelude::observables`, not `prelude::simulation`. +- Keep simulation telemetry, proposal adapters, and result containers in `prelude::simulation`; do not export them from `prelude::observables` merely because an observable can be computed from them. +- Examples and doctests for measurements should prefer `prelude::observables::*`; examples and doctests for running MCMC should prefer `prelude::simulation::*`. --- @@ -275,6 +286,8 @@ Before adding a dependency, consider: ## Geometry Backend Isolation +`src/geometry/` is the backend interface layer. It is responsible for wrapping the upstream `delaunay` crate behind this crate's traits, opaque handles, generators, and backend adapters. `src/cdt/` is the CDT domain layer: it owns foliation, topology, causality, moves, action, simulation, results, and observables. + Direct `use delaunay::` imports are **restricted** to the `src/geometry/` subtree: - `src/geometry/backends/delaunay.rs` — wraps `delaunay` crate types behind trait-based handles diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 068e072..f38174e 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -199,12 +199,12 @@ Before proposing patches agents should run: just ci ``` -The `ci` recipe runs `check bench-compile test-all examples`, which enforces: +The `ci` recipe runs `check bench-compile test-all validate-examples`, which enforces: - **check** (via `lint`): formatting, clippy, documentation builds, markdown, spelling, config validation (JSON, TOML, YAML, GitHub Actions) - **bench-compile**: benchmarks compile without warnings under `-D warnings` - **test-all**: unit tests, doc tests, integration tests, and Python tests (pytest) -- **examples**: all example scripts run successfully +- **validate-examples**: all Cargo examples run successfully and user-facing examples emit stable output markers --- diff --git a/docs/dev/tooling-alignment.md b/docs/dev/tooling-alignment.md index dfe375d..dbae255 100644 --- a/docs/dev/tooling-alignment.md +++ b/docs/dev/tooling-alignment.md @@ -29,7 +29,7 @@ The broad-exception Semgrep rule now covers the full Python support-script tree. Some differences remain because CDT has different workflows and project invariants: -- CDT runs examples through `scripts/run_all_examples.sh`, which discovers current examples dynamically and applies a timeout. The MCMC `validate-examples` recipe checks fixed seeded output markers for a smaller, stable example set; CDT should only add marker checks once example output contracts are intentionally stable. +- 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 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. @@ -45,11 +45,11 @@ The useful updates ported from MCMC are: - CodeQL analysis for GitHub Actions and Rust, using `build-mode: none` for Rust; - the MCMC-style `cliff.toml` template and `just changelog-unreleased ` flow, adapted to keep CDT's changelog archive step and avoid temporary local release tags; - a Semgrep rule that rejects `NaN` and infinity defaults after failed floating-point conversions, with a regression fixture under `tests/semgrep/`. +- production-only Rust Semgrep rules that reject bare `unwrap()` and explicit `panic!` in non-test `src/` code while preserving idiomatic fail-fast usage in tests, doctests, examples, and benchmark setup. +- a `validate-examples` recipe that runs Cargo examples and verifies stable output markers for the user-facing example contracts. ## Deferred Updates These were evaluated but not ported in this pass: - `codacy.yml`: defer until the repository has an intentional Codacy project token and a decision about whether Codacy should upload repository-owned OpenGrep/Semgrep findings in addition to `.github/workflows/semgrep-sarif.yml`. -- `validate-examples`: defer until example outputs have stable, documented success markers. For now, `just examples` validates compilation and successful execution of all discovered examples. -- broad no-`unwrap`/`panic` Semgrep rules: defer because current production and doctest code intentionally uses many invariant checks and examples. These need a separate cleanup plan before becoming blocking policy. diff --git a/docs/foliation.md b/docs/foliation.md index 7c02eb9..bb90634 100644 --- a/docs/foliation.md +++ b/docs/foliation.md @@ -17,6 +17,8 @@ This implementation also supports open-boundary strip variants. `from_toroidal_c ## Architecture +Foliation is CDT domain logic. The implementation stores labels in the Delaunay-backed geometry through crate-owned wrapper APIs, but direct interaction with upstream `delaunay::` types remains confined to the `src/geometry/` backend interface layer. + Time labels are stored **directly as vertex data** in the Delaunay triangulation, using the `Vertex` type parameter. This mirrors CGAL's `vertex->info()` used in CDT-plusplus. The `Foliation` struct tracks only aggregate bookkeeping. ```text diff --git a/docs/moves.md b/docs/moves.md index 2ba3dbf..3aa2a00 100644 --- a/docs/moves.md +++ b/docs/moves.md @@ -55,8 +55,10 @@ Accepted moves mutate the triangulation through narrow CDT-owned edit operations Move validation follows a two-layer design: - **`delaunay` crate** — pure geometric operations (`flip_k2`, `flip_k1_insert`, `flip_k1_remove`) with no physics constraints -- **Geometry backend** — exposes the edit operations through `TriangulationMut` while preserving the CDT ↔ geometry boundary -- **CDT crate** — chooses candidate sites, checks causality and time-slice integrity, and resynchronizes foliation metadata after accepted moves +- **Geometry backend interface layer (`src/geometry/`)** — wraps upstream Delaunay operations behind crate-owned traits and handles +- **CDT domain layer (`src/cdt/`)** — chooses candidate sites, checks causality and time-slice integrity, and resynchronizes foliation metadata after accepted moves + +Move code lives in the CDT domain layer. It may call `DelaunayBackend2D` methods and trait-backed mutation hooks, but it must not import upstream `delaunay::` APIs directly. Public `attempt_*` methods snapshot only after a valid local site has been selected and mutation is about to begin; ordinary geometric or causal rejections do not clone the triangulation. If a selected mutation or required post-mutation synchronization fails, the method restores that snapshot before returning the non-success `MoveResult`. Toroidal post-move topology or closed-ring foliation failures are treated as rollbackable local-site rejections, because the candidate site was geometrically editable but would break the periodic CDT contract. diff --git a/docs/project.md b/docs/project.md deleted file mode 100644 index 877af0e..0000000 --- a/docs/project.md +++ /dev/null @@ -1,97 +0,0 @@ -# Project Structure - -``` -src/ -├── lib.rs # Public API and module exports -├── main.rs # CLI entry point -├── errors.rs # Error types (CdtError, CausalityViolation) -├── util.rs # Safe numeric conversions, random float -├── config.rs # Simulation configuration -├── geometry/ # Geometry abstraction layer -│ ├── traits.rs # Core geometry traits (GeometryBackend, etc.) -│ ├── operations.rs # High-level triangulation operations -│ ├── generators.rs # Delaunay triangulation generators (delaunay crate boundary) -│ └── backends/ # Pluggable geometry backends -│ ├── delaunay.rs # Delaunay crate wrapper (delaunay crate boundary) -│ └── mock.rs # Mock backend for testing -└── cdt/ # CDT physics and Monte Carlo logic - ├── triangulation.rs # CdtTriangulation core type, factory constructors, foliation queries - ├── foliation.rs # Foliation struct, EdgeType enum, per-vertex time labels - ├── action.rs # Regge action calculation - ├── metropolis.rs # Metropolis-Hastings algorithm (proposal-before-mutation loop) - └── ergodic_moves.rs # Ergodic moves (2,2), (1,3), (3,1) -``` - -Repository tooling: - -```text -.github/workflows/codeql.yml # CodeQL analysis for Actions and Rust -.github/workflows/semgrep-sarif.yml # Repository Semgrep rule SARIF upload -rustfmt.toml # Stable Rust formatting settings -cliff.toml # git-cliff changelog template and commit grouping -semgrep.yaml # Repository-owned Semgrep rules -docs/dev/python.md # Python script style and validation guidance -docs/dev/tooling-alignment.md # Tooling comparison and issue #112 decisions -docs/roadmap.md # High-level release direction and non-goals -tests/semgrep/ # Semgrep rule fixtures run by `just semgrep-test` -scripts/archive_changelog.py # Split completed changelog minor series into archive files -scripts/coverage_report.py # Cobertura coverage summary helper -scripts/postprocess_changelog.py # Markdown hygiene for git-cliff changelogs -scripts/tag_release.py # Annotated release tags from root or archived changelog sections -``` - -## Key Modules - -### `cdt/foliation.rs` — Foliation - -Assigns each vertex to a discrete time slice, enabling classification of edges as spacelike or timelike and triangles as up or down. See `docs/foliation.md` for design details. - -- `Foliation` — aggregate bookkeeping (per-slice vertex counts, total slices) -- `EdgeType` — `Spacelike` (same slice) or `Timelike` (adjacent slices) -- `CellType` — `Up` (2,1) or `Down` (1,2) triangle classification, encoded as `i32` cell data -- Time labels are stored directly as vertex data (`Vertex.data: Option`), mirroring CDT-plusplus’s `vertex->info()` - -### `cdt/triangulation.rs` — Foliation integration - -- `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` -- `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) -- 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. - -### `config.rs` — `CdtTopology` enum - -- `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` - -### `cdt/metropolis.rs` — Metropolis move ordering - -`MetropolisAlgorithm::run()` proposes a move type, computes `ΔS` from the move's simplex-count delta, accepts or rejects the proposal, and only mutates the triangulation after acceptance. Accepted applications that fail are rolled back from a triangulation snapshot and retried at another random local site; retry exhaustion is recorded as a rejection, while hard backend failures remain structured errors. Toroidal move finalization rejects and rolls back candidate sites that would violate χ = 0 or the closed-S¹ per-slice foliation invariant. See `docs/metropolis.md` for the detailed ordering. - -### `geometry/generators.rs` — Delaunay triangulation generators - -- `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) -- `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. - -### `util.rs` — Numeric helpers - -- `saturating_usize_to_i32` — safe usize→i32 for Euler characteristic arithmetic -- `y_to_time_bucket` — f64→Option via round(), for time-slice assignment -- `f64_band_to_u32` — f64→u32 clamped, for y-coordinate binning - -## Key Dependencies - -- `delaunay` (v0.7.6) — geometry backend (Delaunay triangulations, vertex data for time labels, `set_vertex_data_by_key` for O(1) label mutation) -- `markov-chain-monte-carlo` (v0.3) — MCMC framework (`DelayedProposal`, `Chain::step_delayed`, `Target`) -- `num-traits` — `ToPrimitive` for safe float→integer conversion diff --git a/docs/roadmap.md b/docs/roadmap.md index 2243ef9..5e411df 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,34 +8,61 @@ The v0.1.0 foundation work focuses on making the crate a usable, validated 1+1 C - [x] Trait-based geometry backend boundary around the `delaunay` crate - [x] Explicit open-boundary CDT strip construction -- [x] Explicit toroidal S1 x S1 CDT construction with chi = 0 validation +- [x] Explicit toroidal S¹×S¹ CDT construction with χ = 0 validation - [x] Per-vertex foliation labels, causality checks, and strict Up/Down cell classification - [x] Real 2D ergodic move kernels over Delaunay backend edit operations - [x] Proposal-before-mutation Metropolis loop with rollback and bounded local-site retries - [x] Toroidal Metropolis regression coverage requiring at least 100 accepted moves while preserving topology and foliation - [x] CLI and configuration support for open-boundary and toroidal topology selection +- [x] Volume-profile, Hausdorff-dimension, and spectral-dimension observables on the combinatorial dual graph - [x] Repository validation loop covering Rust, Python support scripts, Semgrep rules, documentation, examples, and benchmarks -## Near-Term Candidates +## 1+1 Maturity -Likely follow-up work: +Likely follow-up work before broadening the dimensional surface: - Weight move-type selection by available application sites to reduce uniform-sampling bias - Weight or enumerate accepted move-site retries so proposals bind to concrete local moves before acceptance - Broaden per-kernel toroidal tests around spatial and temporal wrap-around cells - Accept fixed triangle cells directly in explicit-cell generator APIs to remove per-triangle `Vec` adaptation - Add manual foliation assignment APIs with the same validation and synchronization guarantees as constructor-assigned labels -- Expand simulation observables and statistical output beyond the current action and simplex-count measurements -- Add tutorial-style examples for open-boundary strips, toroidal runs, and interpreting Metropolis acceptance behavior +- Add tutorial-style examples for open-boundary strips, toroidal runs, observables, and interpreting Metropolis acceptance behavior + +## Higher-Dimensional CDT Tracks + +The next CDT dimensions should advance as explicit topology tracks rather than a generic higher-dimensional bucket: + +- 2+1 CDT with spherical spatial slices (S²) and toroidal spatial slices (T²), including constructor fixtures, foliation validation, local move kernels, Metropolis sampling, and topology-specific regression tests +- 3+1 CDT with spherical spatial slices (S³) and toroidal spatial slices (T³), following the same staged path after the required geometry-backend operations and invariants are available +- Periodic-time variants where the topology contract is well defined and the backend can validate the corresponding Euler/Poincaré-style invariants cleanly +- Dimension-specific action terms, simplex-count bookkeeping, volume profiles, and acceptance diagnostics + +## Observables and Dual Geometry + +CDT observables should remain user-facing analysis APIs and should grow in lockstep with validation: + +- Extend volume observables from 1+1 slice profiles to spatial-volume profiles in 2+1 and 3+1 dimensions +- Add geodesic-distance distributions, shell-volume curves, two-point functions, and finite-size scaling helpers +- Add curvature-oriented Regge observables when the local simplex data is sufficiently validated +- Keep the current Hausdorff- and spectral-dimension estimators available on combinatorial dual adjacency graphs +- Reuse Voronoi tessellation support from the `delaunay` crate when it lands, so observables can opt into full dual/Voronoi cells rather than rebuilding only face- or cell-adjacency graphs +- Preserve a clear distinction between combinatorial dual graphs, geometric Voronoi tessellations, and visualization/export representations + +## Visualization and Workflow Support + +Visualization should help inspect CDT structure without turning this crate into a plotting package: + +- Export mesh and graph data in common interchange formats for external visualization tools +- Provide lightweight examples for rendering foliated triangulations, slice volumes, dual graphs, and sampled histories +- Add optional diagnostic outputs for move acceptance, topology preservation, volume evolution, and diffusion-return curves +- Keep publication-quality plotting and broad downstream statistical analysis in companion tools unless needed for core CDT validation ## Longer-Term Ideas Exploratory directions: -- Extend CDT construction, validation, and move kernels beyond 1+1 dimensions - Support additional topology and boundary-condition families when geometry backend invariants can validate them cleanly -- Add visualization or mesh-export workflows for inspecting generated triangulations and sampled histories -- Add finite-size scaling and ensemble-analysis helpers for CDT research workflows +- Add ensemble-analysis helpers for CDT research workflows beyond the core estimators - Integrate parallel-chain workflows while keeping random-stream management explicit - Explore alternative discrete gravity actions once the Regge-action path is well covered diff --git a/docs/testing.md b/docs/testing.md index 493f19c..d1ae151 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,7 +10,7 @@ This document summarizes the repository's current test coverage and the main gap - **Documentation tests**: public doctests run through `just test-doc` and as part of the broader CI path. - **Examples and benchmark compilation**: `just ci` compiles benchmarks and runs all example programs. -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 S1 x S1 Metropolis simulation, requires at least 100 accepted moves, and verifies topology, foliation, causality, cell classification, and chi = 0 at the end. +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. ## Remaining Gaps diff --git a/examples/observables.rs b/examples/observables.rs new file mode 100644 index 0000000..aba93b2 --- /dev/null +++ b/examples/observables.rs @@ -0,0 +1,59 @@ +#![forbid(unsafe_code)] + +//! Example: measuring CDT volume profiles and dimensional observables. +//! +//! This example builds an explicit foliated toroidal CDT, records per-slice +//! triangle counts, runs a short Metropolis simulation, and computes aggregate +//! volume-profile, Hausdorff-dimension, and spectral-dimension observables. + +use causal_triangulations::prelude::errors::CdtResult; +use causal_triangulations::prelude::observables::*; +use causal_triangulations::prelude::simulation::{ + ActionConfig, MetropolisAlgorithm, MetropolisConfig, +}; + +fn main() -> CdtResult<()> { + let triangulation = CdtTriangulation::from_toroidal_cdt(8, 8)?; + + let initial_profile = triangulation.volume_profile(); + println!("Initial volume profile N2(t): {initial_profile:?}"); + + let hausdorff = estimate_hausdorff_dimension(&triangulation).map_or_else( + || "not enough dual-graph data".to_string(), + |dimension| format!("{dimension:.3}"), + ); + println!("Initial Hausdorff-dimension estimate: {hausdorff}"); + + let spectral = estimate_spectral_dimension(&triangulation).map_or_else( + || "not enough dual-graph diffusion data".to_string(), + |dimension| format!("{dimension:.3}"), + ); + println!("Initial spectral-dimension estimate: {spectral}"); + + let metropolis_config = MetropolisConfig::new(1.0, 80, 20, 10).with_seed(7); + let action_config = ActionConfig::default(); + let results = MetropolisAlgorithm::new(metropolis_config, action_config).run(triangulation)?; + + println!( + "Average post-thermalization volume profile: {:?}", + results.average_volume_profile() + ); + println!( + "Post-thermalization volume fluctuations: {:?}", + results.volume_fluctuations() + ); + + let final_hausdorff = results.hausdorff_dimension_estimate().map_or_else( + || "not enough dual-graph data".to_string(), + |dimension| format!("{dimension:.3}"), + ); + println!("Final Hausdorff-dimension estimate: {final_hausdorff}"); + + let final_spectral = results.spectral_dimension_estimate().map_or_else( + || "not enough dual-graph diffusion data".to_string(), + |dimension| format!("{dimension:.3}"), + ); + println!("Final spectral-dimension estimate: {final_spectral}"); + + Ok(()) +} diff --git a/justfile b/justfile index a4d9fc9..34e387a 100644 --- a/justfile +++ b/justfile @@ -151,8 +151,8 @@ check-fast: cargo check # CI simulation: comprehensive validation (matches .github/workflows/ci.yml) -# Runs: checks + all tests (Rust + Python) + examples + bench compile -ci: check bench-compile test-all examples +# Runs: checks + all tests (Rust + Python) + validated examples + bench compile +ci: check bench-compile test-all validate-examples @echo "🎯 CI checks complete!" # CI with performance baseline @@ -200,6 +200,9 @@ doc-check: examples: ./scripts/run_all_examples.sh +validate-examples: + ./scripts/run_all_examples.sh --validate + # Fix (mutating): apply formatters/auto-fixes fix: toml-fmt fmt python-fix shell-fmt markdown-fix yaml-fix @echo "✅ Fixes applied!" @@ -227,8 +230,9 @@ help-workflows: @echo " just test-python # Python tests only (pytest)" @echo " just test-release # All tests in release mode" @echo " just test-cli # CLI integration tests only" - @echo " just test-examples # Run all examples" + @echo " just test-examples # Compile all examples as tests" @echo " just examples # Run all example scripts" + @echo " just validate-examples # Run examples and validate stable output markers" @echo " just coverage # Generate coverage report (HTML)" @echo " just coverage-ci # Generate coverage for CI (XML)" @echo "" diff --git a/scripts/run_all_examples.sh b/scripts/run_all_examples.sh index 50ebf83..5eacd33 100755 --- a/scripts/run_all_examples.sh +++ b/scripts/run_all_examples.sh @@ -24,12 +24,16 @@ DESCRIPTION: All examples run in release mode (--release). OPTIONS: - -h, --help Show this help message and exit + -h, --help Show this help message and exit + --validate Require stable output markers for known examples EXAMPLES: # Run all examples ./scripts/run_all_examples.sh + # Run all examples and validate stable output markers + ./scripts/run_all_examples.sh --validate + # Show help ./scripts/run_all_examples.sh --help @@ -49,6 +53,8 @@ EOF # Script to run all examples in the causal-triangulations project +VALIDATE_OUTPUT=false + # Parse command line arguments for arg in "$@"; do case $arg in @@ -56,6 +62,9 @@ for arg in "$@"; do show_help exit 0 ;; + --validate) + VALIDATE_OUTPUT=true + ;; *) error_exit "Unknown option: $arg. Use --help for usage information." ;; @@ -114,7 +123,6 @@ if [ ${#all_examples[@]} -eq 0 ]; then error_exit "No examples found under ${PROJECT_ROOT}/examples" fi -# Run all examples TIMEOUT_CMD="" if command -v timeout >/dev/null 2>&1; then TIMEOUT_CMD="timeout" @@ -122,19 +130,77 @@ elif command -v gtimeout >/dev/null 2>&1; then TIMEOUT_CMD="gtimeout" fi -for example in "${all_examples[@]}"; do - echo "=== Running $example ===" +run_cargo_example() { + local example="$1" + if [[ -n "$TIMEOUT_CMD" ]]; then DURATION="${EXAMPLE_TIMEOUT:-600s}" # If DURATION has no unit suffix, assume seconds case "$DURATION" in *[a-zA-Z]) ;; *) DURATION="${DURATION}s" ;; esac "$TIMEOUT_CMD" --preserve-status --signal=TERM --kill-after=10s "$DURATION" \ - cargo run --release --example "$example" || error_exit "Example $example failed!" + cargo run --release --example "$example" else - cargo run --release --example "$example" || error_exit "Example $example failed!" + cargo run --release --example "$example" + fi +} + +require_marker() { + local example="$1" + local output="$2" + local marker="$3" + + case "$output" in + *"$marker"*) ;; + *) + error_exit "Example $example output did not contain required marker: $marker" + ;; + esac +} + +validate_example_output() { + local example="$1" + local output="$2" + + case "$example" in + basic_cdt) + require_marker "$example" "$output" "Simulation completed!" + require_marker "$example" "$output" "Example completed successfully!" + ;; + observables) + require_marker "$example" "$output" "Initial volume profile" + require_marker "$example" "$output" "Final Hausdorff-dimension estimate" + require_marker "$example" "$output" "Final spectral-dimension estimate" + ;; + find_good_seeds) + require_marker "$example" "$output" "SEED VALIDATION" + require_marker "$example" "$output" "ADDITIONAL SEED TESTING" + ;; + *) + echo "No stable output markers configured for $example; success-only validation applied." + ;; + esac +} + +# Run all examples +for example in "${all_examples[@]}"; do + echo "=== Running $example ===" + if [[ "$VALIDATE_OUTPUT" == true ]]; then + if output=$(run_cargo_example "$example" 2>&1); then + printf '%s\n' "$output" + validate_example_output "$example" "$output" + else + printf '%s\n' "$output" + error_exit "Example $example failed!" + fi + else + run_cargo_example "$example" || error_exit "Example $example failed!" fi done echo echo "==============================================" -echo "All examples completed successfully!" +if [[ "$VALIDATE_OUTPUT" == true ]]; then + echo "All examples completed successfully with validated output markers!" +else + echo "All examples completed successfully!" +fi diff --git a/semgrep.yaml b/semgrep.yaml index 7b7ec3d..94f4473 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -125,6 +125,128 @@ rules: - pattern: $VALUE.unwrap_or_else(|| std::f64::INFINITY) - pattern: $VALUE.unwrap_or_else(|| std::f64::NEG_INFINITY) + - id: causal-triangulations.rust.no-bare-unwrap-in-src + languages: + - rust + severity: WARNING + message: "Use typed error handling or an explicit fallback instead of unwrap() in production src/." + metadata: + category: correctness + rationale: >- + Production library code should preserve recoverable failures and + document intentional fallbacks rather than panicking through bare + unwrap(). + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/src/**/*.rs" + patterns: + - pattern: $VALUE.unwrap() + - pattern-not-inside: | + mod tests { + ... + } + - pattern-not-inside: | + mod prop_tests { + ... + } + - pattern-not-inside: | + mod integration_tests { + ... + } + - pattern-not-inside: | + #[cfg(test)] + mod $MOD { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + mod $MOD { + ... + } + - pattern-not-inside: | + #[cfg(test)] + fn $FUNC(...) { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + fn $FUNC(...) { + ... + } + - pattern-not-inside: | + #[cfg(test)] + impl $TYPE { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + impl $TYPE { + ... + } + + - id: causal-triangulations.rust.no-panic-in-src + languages: + - rust + severity: WARNING + message: "Return a typed error or Option instead of panicking in production src/." + metadata: + category: correctness + rationale: >- + Public and production CDT APIs should report failures through typed + results unless an invariant violation is deliberately confined to tests. + paths: + include: + - "/src/**/*.rs" + - "/tests/semgrep/src/**/*.rs" + patterns: + - pattern-either: + - pattern: panic!(...) + - pattern: std::panic!(...) + - pattern: core::panic!(...) + - pattern-not-inside: | + mod tests { + ... + } + - pattern-not-inside: | + mod prop_tests { + ... + } + - pattern-not-inside: | + mod integration_tests { + ... + } + - pattern-not-inside: | + #[cfg(test)] + mod $MOD { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + mod $MOD { + ... + } + - pattern-not-inside: | + #[cfg(test)] + fn $FUNC(...) { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + fn $FUNC(...) { + ... + } + - pattern-not-inside: | + #[cfg(test)] + impl $TYPE { + ... + } + - pattern-not-inside: | + #[cfg(any(test, ...))] + impl $TYPE { + ... + } + - id: causal-triangulations.rust.no-direct-delaunay-imports-outside-geometry languages: - rust diff --git a/src/cdt/action.rs b/src/cdt/action.rs index 8f5d80d..2337315 100644 --- a/src/cdt/action.rs +++ b/src/cdt/action.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! 2D Regge Action calculation for Causal Dynamical Triangulations. //! //! This module implements the discrete Einstein-Hilbert action used in CDT, diff --git a/src/cdt/metropolis.rs b/src/cdt/metropolis.rs index 5147465..e0127a2 100644 --- a/src/cdt/metropolis.rs +++ b/src/cdt/metropolis.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Metropolis-Hastings algorithm for Causal Dynamical Triangulations. //! //! This module implements the Monte Carlo sampling algorithm used to sample @@ -10,18 +12,18 @@ use crate::cdt::action::ActionConfig; use crate::cdt::ergodic_moves::{ErgodicsSystem, MoveResult, MoveStatistics, MoveType}; +use crate::cdt::results::{Measurement, SimulationResultsBackend}; use crate::cdt::triangulation::SimulationEvent; use crate::config::validate_schedule; use crate::errors::{CdtError, CdtResult}; use crate::geometry::CdtTriangulation2D; -use crate::geometry::traits::TriangulationQuery; +use crate::util::saturating_usize_to_u32; use markov_chain_monte_carlo::{DelayedProposal, Target}; -use num_traits::cast::NumCast; use rand::{Rng, RngExt, SeedableRng, rngs::StdRng}; use std::error::Error; use std::fmt; use std::hint::cold_path; -use std::time::{Duration, Instant}; +use std::time::Instant; const ACCEPTED_MOVE_RETRIES: usize = 8; @@ -156,6 +158,19 @@ fn invalid_sim_config(setting: &str, provided_value: String, expected: String) - } } +/// Rejects temperatures that would make target log probabilities non-finite. +fn validate_temperature(temperature: f64) -> CdtResult<()> { + if temperature.is_finite() && temperature > 0.0 { + Ok(()) + } else { + Err(invalid_sim_config( + "temperature", + temperature.to_string(), + "finite and positive".to_string(), + )) + } +} + /// Result of a Monte Carlo step. #[derive(Debug, Clone)] pub struct MonteCarloStep { @@ -173,21 +188,6 @@ pub struct MonteCarloStep { pub delta_action: Option, } -/// Measurement data collected during simulation. -#[derive(Debug, Clone)] -pub struct Measurement { - /// Monte Carlo step when measurement was taken - pub step: u32, - /// Current action value - pub action: f64, - /// Number of vertices - pub vertices: u32, - /// Number of edges - pub edges: u32, - /// Number of triangles - pub triangles: u32, -} - // --------------------------------------------------------------------------- // MCMC trait implementations for CDT // --------------------------------------------------------------------------- @@ -204,31 +204,37 @@ pub struct CdtTarget { impl CdtTarget { /// Creates a new CDT target distribution. /// + /// # Errors + /// + /// Returns [`CdtError::InvalidConfiguration`] if the action couplings are + /// non-finite, or [`CdtError::InvalidSimulationConfiguration`] if + /// `temperature` is not finite and positive. + /// /// # Examples /// /// ``` /// use causal_triangulations::prelude::action::ActionConfig; /// use causal_triangulations::prelude::simulation::CdtTarget; /// - /// let _target = CdtTarget::new(ActionConfig::default(), 1.0); + /// let _target = CdtTarget::new(ActionConfig::default(), 1.0)?; + /// # Ok::<(), causal_triangulations::CdtError>(()) /// ``` - #[must_use] - pub const fn new(action_config: ActionConfig, temperature: f64) -> Self { - Self { + pub fn new(action_config: ActionConfig, temperature: f64) -> CdtResult { + action_config.validate()?; + validate_temperature(temperature)?; + Ok(Self { action_config, temperature, - } + }) } } impl Target for CdtTarget { fn log_prob(&self, state: &CdtTriangulation2D) -> f64 { - let g = state.geometry(); - let action = self.action_config.calculate_action( - u32::try_from(g.vertex_count()).unwrap_or_default(), - u32::try_from(g.edge_count()).unwrap_or_default(), - u32::try_from(g.face_count()).unwrap_or_default(), - ); + let counts = simplex_counts(state); + let action = + self.action_config + .calculate_action(counts.vertices, counts.edges, counts.triangles); -action / self.temperature } } @@ -243,27 +249,22 @@ impl Target for CdtTarget { /// /// ``` /// use causal_triangulations::prelude::action::ActionConfig; -/// use causal_triangulations::prelude::simulation::{ -/// CdtProposal, CdtProposalError, CdtTriangulation, -/// }; +/// use causal_triangulations::prelude::moves::MoveType; +/// use causal_triangulations::prelude::simulation::{CdtProposal, CdtTriangulation}; /// use markov_chain_monte_carlo::DelayedProposal; /// use rand::{SeedableRng, rngs::StdRng}; /// -/// # fn main() -> Result<(), CdtProposalError> { -/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53) -/// .expect("fixed seeded triangulation should build"); -/// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7); +/// # fn main() -> Result<(), Box> { +/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53)?; +/// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7)?; /// let mut rng = StdRng::seed_from_u64(11); /// -/// let plan = proposal -/// .propose_plan(&tri, &mut rng)? -/// .expect("CDT proposals always select a move type"); +/// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else { +/// return Ok(()); +/// }; /// assert!(matches!( /// plan.move_type(), -/// causal_triangulations::prelude::moves::MoveType::Move22 -/// | causal_triangulations::prelude::moves::MoveType::Move13Add -/// | causal_triangulations::prelude::moves::MoveType::Move31Remove -/// | causal_triangulations::prelude::moves::MoveType::EdgeFlip +/// MoveType::Move22 | MoveType::Move13Add | MoveType::Move31Remove | MoveType::EdgeFlip /// )); /// assert!(plan.action_before().is_finite()); /// if let (Some(delta), Some(action_after)) = (plan.delta_action(), plan.action_after()) { @@ -325,29 +326,23 @@ impl CdtProposalPlan { /// /// ``` /// use causal_triangulations::prelude::action::ActionConfig; -/// use causal_triangulations::prelude::simulation::{ -/// CdtProposal, CdtProposalError, CdtTriangulation, -/// }; +/// use causal_triangulations::prelude::simulation::{CdtProposal, CdtTriangulation}; /// use markov_chain_monte_carlo::DelayedProposal; /// use rand::{SeedableRng, rngs::StdRng}; /// -/// # fn main() -> Result<(), CdtProposalError> { -/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53) -/// .expect("fixed seeded triangulation should build"); -/// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7); +/// # fn main() -> Result<(), Box> { +/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53)?; +/// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7)?; /// let mut rng = StdRng::seed_from_u64(11); -/// let plan = proposal -/// .propose_plan(&tri, &mut rng)? -/// .expect("CDT proposals always select a move type"); +/// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else { +/// return Ok(()); +/// }; /// /// let info = proposal.info(&plan); /// assert_eq!(info.move_type, plan.move_type()); -/// match (info.delta_action, plan.delta_action()) { -/// (Some(info_delta), Some(plan_delta)) => { -/// approx::assert_relative_eq!(info_delta, plan_delta, epsilon = 1e-12); -/// } -/// (None, None) => {} -/// other => panic!("delta-action mismatch: {other:?}"), +/// assert_eq!(info.delta_action.is_some(), plan.delta_action().is_some()); +/// if let (Some(info_delta), Some(plan_delta)) = (info.delta_action, plan.delta_action()) { +/// approx::assert_relative_eq!(info_delta, plan_delta, epsilon = 1e-12); /// } /// # Ok(()) /// # } @@ -477,16 +472,13 @@ impl Error for CdtProposalError { /// /// ``` /// use causal_triangulations::prelude::action::ActionConfig; -/// use causal_triangulations::prelude::simulation::{ -/// CdtProposal, CdtProposalError, CdtTriangulation, -/// }; +/// use causal_triangulations::prelude::simulation::{CdtProposal, CdtTriangulation}; /// use markov_chain_monte_carlo::DelayedProposal; /// use rand::{SeedableRng, rngs::StdRng}; /// -/// # fn main() -> Result<(), CdtProposalError> { -/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53) -/// .expect("fixed seeded triangulation should build"); -/// let mut proposal = CdtProposal::new(ActionConfig::default()); +/// # fn main() -> Result<(), Box> { +/// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53)?; +/// let mut proposal = CdtProposal::new(ActionConfig::default())?; /// let mut rng = StdRng::seed_from_u64(7); /// /// let plan = proposal.propose_plan(&tri, &mut rng)?; @@ -507,20 +499,26 @@ impl CdtProposal { /// Delayed scoring is delegated to the target passed to /// [`DelayedProposal::proposed_log_prob`]. /// + /// # Errors + /// + /// Returns [`CdtError::InvalidConfiguration`] if the action couplings are + /// non-finite. + /// /// # Examples /// /// ``` /// use causal_triangulations::prelude::action::ActionConfig; /// use causal_triangulations::prelude::simulation::CdtProposal; /// - /// let _proposal = CdtProposal::new(ActionConfig::default()); + /// let _proposal = CdtProposal::new(ActionConfig::default())?; + /// # Ok::<(), causal_triangulations::CdtError>(()) /// ``` - #[must_use] - pub fn new(action_config: ActionConfig) -> Self { - Self { + pub fn new(action_config: ActionConfig) -> CdtResult { + action_config.validate()?; + Ok(Self { action_config, moves: ErgodicsSystem::new(), - } + }) } /// Creates a seeded delayed CDT proposal distribution. @@ -529,20 +527,26 @@ impl CdtProposal { /// [`DelayedProposal::propose_plan`] is still accepted for compatibility /// with generic MCMC drivers. /// + /// # Errors + /// + /// Returns [`CdtError::InvalidConfiguration`] if the action couplings are + /// non-finite. + /// /// # Examples /// /// ``` /// use causal_triangulations::prelude::action::ActionConfig; /// use causal_triangulations::prelude::simulation::CdtProposal; /// - /// let _proposal = CdtProposal::with_seed(ActionConfig::default(), 42); + /// let _proposal = CdtProposal::with_seed(ActionConfig::default(), 42)?; + /// # Ok::<(), causal_triangulations::CdtError>(()) /// ``` - #[must_use] - pub fn with_seed(action_config: ActionConfig, seed: u64) -> Self { - Self { + pub fn with_seed(action_config: ActionConfig, seed: u64) -> CdtResult { + action_config.validate()?; + Ok(Self { action_config, moves: ErgodicsSystem::with_seed(seed), - } + }) } } @@ -683,15 +687,16 @@ impl MetropolisAlgorithm { /// /// ``` /// use causal_triangulations::prelude::simulation::{ - /// ActionConfig, CdtError, CdtTriangulation, MetropolisAlgorithm, MetropolisConfig, + /// ActionConfig, CdtResult, CdtTriangulation, MetropolisAlgorithm, MetropolisConfig, /// }; /// - /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53).unwrap(); - /// let config = MetropolisConfig::new(1.0, 2, 1, 1).with_seed(7); - /// let results = MetropolisAlgorithm::new(config, ActionConfig::default()) - /// .run(tri) - /// .expect("run simulation"); - /// assert_eq!(results.steps.len(), 2); + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; + /// let config = MetropolisConfig::new(1.0, 2, 1, 1).with_seed(7); + /// let results = MetropolisAlgorithm::new(config, ActionConfig::default()).run(tri)?; + /// assert_eq!(results.steps.len(), 2); + /// Ok(()) + /// } /// ``` pub fn run( &self, @@ -817,9 +822,9 @@ fn simulation_rng(seed: Option) -> StdRng { /// makes integer saturation explicit at the simulation boundary. fn simplex_counts(triangulation: &CdtTriangulation2D) -> SimplexCounts { SimplexCounts { - vertices: u32::try_from(triangulation.vertex_count()).unwrap_or(u32::MAX), - edges: u32::try_from(triangulation.edge_count()).unwrap_or(u32::MAX), - triangles: u32::try_from(triangulation.face_count()).unwrap_or(u32::MAX), + vertices: saturating_usize_to_u32(triangulation.vertex_count()), + edges: saturating_usize_to_u32(triangulation.edge_count()), + triangles: saturating_usize_to_u32(triangulation.face_count()), } } @@ -844,6 +849,7 @@ fn measurement_for(step: u32, action: f64, triangulation: &CdtTriangulation2D) - vertices: counts.vertices, edges: counts.edges, triangles: counts.triangles, + volume_profile: triangulation.volume_profile(), } } @@ -980,145 +986,11 @@ fn attempt_move( } } -/// Results from a simulation using the new backend system. -#[derive(Debug)] -pub struct SimulationResultsBackend { - /// Configuration used for the simulation - pub config: MetropolisConfig, - /// Action configuration used - pub action_config: ActionConfig, - /// Metropolis-level ergodic move statistics - pub move_stats: MoveStatistics, - /// All Monte Carlo steps performed - pub steps: Vec, - /// Measurements taken during simulation - pub measurements: Vec, - /// Total simulation time - pub elapsed_time: Duration, - /// Final triangulation state - pub triangulation: CdtTriangulation2D, -} - -impl SimulationResultsBackend { - /// Calculates the acceptance rate for the simulation. - /// - /// # Examples - /// - /// ``` - /// use approx::assert_relative_eq; - /// use causal_triangulations::prelude::simulation::{ - /// ActionConfig, CdtTriangulation, MetropolisConfig, SimulationResultsBackend, - /// }; - /// use std::time::Duration; - /// - /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53).unwrap(); - /// let config = MetropolisConfig::new(1.0, 1, 0, 1).with_seed(7); - /// let results = SimulationResultsBackend { - /// config, - /// action_config: ActionConfig::default(), - /// move_stats: Default::default(), - /// steps: vec![], - /// measurements: vec![], - /// elapsed_time: Duration::from_millis(0), - /// triangulation: tri, - /// }; - /// assert_relative_eq!(results.acceptance_rate(), 0.0); - /// ``` - #[must_use] - pub fn acceptance_rate(&self) -> f64 { - if self.steps.is_empty() { - return 0.0; - } - - let accepted_count = self.steps.iter().filter(|step| step.accepted).count(); - let total_count = self.steps.len(); - - let accepted_f64 = NumCast::from(accepted_count).unwrap_or(0.0); - let total_f64 = NumCast::from(total_count).unwrap_or(1.0); - - accepted_f64 / total_f64 - } - - /// Calculates the average action over all measurements. - /// - /// # Examples - /// - /// ``` - /// use approx::assert_relative_eq; - /// use causal_triangulations::prelude::simulation::{ - /// ActionConfig, CdtTriangulation, MetropolisConfig, SimulationResultsBackend, - /// }; - /// use std::time::Duration; - /// - /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53).unwrap(); - /// let config = MetropolisConfig::new(1.0, 1, 0, 1).with_seed(7); - /// let results = SimulationResultsBackend { - /// config, - /// action_config: ActionConfig::default(), - /// move_stats: Default::default(), - /// steps: vec![], - /// measurements: vec![], - /// elapsed_time: Duration::from_millis(0), - /// triangulation: tri, - /// }; - /// assert_relative_eq!(results.average_action(), 0.0); - /// ``` - #[must_use] - pub fn average_action(&self) -> f64 { - if self.measurements.is_empty() { - return 0.0; - } - - let sum: f64 = self.measurements.iter().map(|m| m.action).sum(); - let count = self.measurements.len(); - - let count_f64 = NumCast::from(count).unwrap_or(1.0); - - sum / count_f64 - } - - /// Returns measurements after thermalization. - /// - /// Measurements are recorded for the initial state at step 0, then after - /// completed-move counts divisible by - /// [`MetropolisConfig::measurement_frequency`]. This accessor defines - /// equilibrium as `measurement.step >= thermalization_steps`, so a - /// measurement taken exactly on the thermalization boundary is included. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::simulation::{ - /// ActionConfig, CdtTriangulation, MetropolisConfig, SimulationResultsBackend, - /// }; - /// use std::time::Duration; - /// - /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53).unwrap(); - /// let config = MetropolisConfig::new(1.0, 2, 1, 1).with_seed(7); - /// let results = SimulationResultsBackend { - /// config, - /// action_config: ActionConfig::default(), - /// move_stats: Default::default(), - /// steps: vec![], - /// measurements: vec![], - /// elapsed_time: Duration::from_millis(0), - /// triangulation: tri, - /// }; - /// assert!(results.equilibrium_measurements().is_empty()); - /// ``` - #[must_use] - pub fn equilibrium_measurements(&self) -> Vec<&Measurement> { - self.measurements - .iter() - .filter(|m| m.step >= self.config.thermalization_steps) - .collect() - } -} - #[cfg(test)] mod tests { use super::*; use crate::cdt::triangulation::CdtTriangulation; + use crate::geometry::traits::TriangulationQuery; use approx::assert_relative_eq; use markov_chain_monte_carlo::Chain; @@ -1194,7 +1066,8 @@ mod tests { let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53) .expect("Failed to create triangulation"); - let target = CdtTarget::new(ActionConfig::default(), 1.0); + let target = + CdtTarget::new(ActionConfig::default(), 1.0).expect("valid target configuration"); let log_prob = Target::log_prob(&target, &triangulation); assert!(log_prob.is_finite(), "log_prob should be finite"); @@ -1234,6 +1107,38 @@ mod tests { })); } + #[test] + fn explicit_cdt_volume_profiles_count_time_slabs() { + let strip = CdtTriangulation::from_cdt_strip(4, 3).expect("create explicit strip"); + assert_eq!(strip.volume_profile(), vec![6, 6, 0]); + + let torus = CdtTriangulation::from_toroidal_cdt(3, 3).expect("create explicit 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 measurement = measurement_for(0, 1.0, &triangulation); + + assert_eq!(measurement.volume_profile, vec![6, 6, 0]); + assert_eq!( + measurement.volume_profile.iter().sum::(), + measurement.triangles + ); + } + + #[test] + fn volume_profile_is_empty_without_current_foliation() { + let triangulation = + CdtTriangulation::from_seeded_points(5, 2, 2, 53).expect("create seeded triangulation"); + let measurement = measurement_for(0, 1.0, &triangulation); + + assert!(!triangulation.has_foliation()); + assert!(triangulation.volume_profile().is_empty()); + assert!(measurement.volume_profile.is_empty()); + } + #[test] fn seeded_simulation_deterministic() { let run = |seed: u64| { @@ -1494,95 +1399,84 @@ mod tests { } #[test] - fn unseeded_config_uses_random_rng() { - let config = MetropolisConfig::new(1.0, 5, 1, 1); // no seed - assert!(config.seed.is_none()); + fn cdt_target_rejects_invalid_temperature() { + for temperature in [0.0, -1.0, f64::NAN, f64::INFINITY] { + let Err(err) = CdtTarget::new(ActionConfig::default(), temperature) else { + panic!("temperature {temperature:?} should be rejected"); + }; - let mut rng = simulation_rng(config.seed); - let draw = rng.random::(); - assert!((0.0..1.0).contains(&draw)); + match err { + CdtError::InvalidSimulationConfiguration { + setting, + provided_value: _, + expected, + } => { + assert_eq!(setting, "temperature"); + assert_eq!(expected, "finite and positive"); + } + other => panic!("Expected InvalidSimulationConfiguration, got {other:?}"), + } + } } #[test] - fn test_simulation_results() { - let config = MetropolisConfig::new(1.0, 20, 10, 5); - let steps = vec![ - MonteCarloStep { - step: 1, - move_type: MoveType::Move22, - accepted: true, - action_before: 3.0, - action_after: Some(2.5), - delta_action: Some(-0.5), - }, - MonteCarloStep { - step: 2, - move_type: MoveType::Move13Add, - accepted: false, - action_before: 2.5, - action_after: None, - delta_action: Some(0.8), - }, - MonteCarloStep { - step: 3, - move_type: MoveType::Move31Remove, - accepted: true, - action_before: 2.5, - action_after: Some(2.0), - delta_action: Some(-0.5), - }, - ]; - let measurements = vec![ - Measurement { - step: 0, - action: 1.0, - vertices: 3, - edges: 3, - triangles: 1, - }, - Measurement { - step: 10, - action: 2.0, - vertices: 4, - edges: 5, - triangles: 2, - }, - Measurement { - step: 15, - action: 3.0, - vertices: 5, - edges: 7, - triangles: 3, - }, - ]; + fn cdt_target_rejects_invalid_action_config() { + let Err(err) = CdtTarget::new(ActionConfig::new(f64::NAN, 1.0, 0.0), 1.0) else { + panic!("invalid action config should be rejected"); + }; - let triangulation = - CdtTriangulation::from_random_points(3, 1, 2).expect("Failed to create triangulation"); + match err { + CdtError::InvalidConfiguration { + setting, + provided_value: _, + expected, + } => { + assert_eq!(setting, "coupling_0"); + assert_eq!(expected, "finite"); + } + other => panic!("Expected InvalidConfiguration, got {other:?}"), + } + } - let results = SimulationResultsBackend { - config, - action_config: ActionConfig::default(), - move_stats: MoveStatistics::new(), - steps, - measurements, - elapsed_time: Duration::from_millis(100), - triangulation, + #[test] + fn cdt_proposal_rejects_invalid_action_config() { + let action_config = ActionConfig::new(1.0, f64::NEG_INFINITY, 0.0); + let Err(err) = CdtProposal::new(action_config.clone()) else { + panic!("invalid action config should be rejected"); }; - assert_relative_eq!(results.acceptance_rate(), 2.0 / 3.0); - assert_relative_eq!(results.average_action(), 2.0); + match err { + CdtError::InvalidConfiguration { + setting, + provided_value: _, + expected, + } => { + assert_eq!(setting, "coupling_2"); + assert_eq!(expected, "finite"); + } + other => panic!("Expected InvalidConfiguration, got {other:?}"), + } + + assert!(CdtProposal::with_seed(action_config, 7).is_err()); + } + + #[test] + fn unseeded_config_uses_random_rng() { + let config = MetropolisConfig::new(1.0, 5, 1, 1); // no seed + assert!(config.seed.is_none()); - let equilibrium = results.equilibrium_measurements(); - assert_eq!(equilibrium.len(), 2); - assert_eq!(equilibrium[0].step, 10); - assert_eq!(equilibrium[1].step, 15); + let mut rng = simulation_rng(config.seed); + let draw = rng.random::(); + assert!((0.0..1.0).contains(&draw)); } #[test] fn cdt_proposal_scores_delayed_plan() { let action_config = ActionConfig::default(); - let target = CdtTarget::new(action_config.clone(), 1.0); - let mut proposal = CdtProposal::with_seed(action_config, 7); + let target = + CdtTarget::new(action_config.clone(), 1.0).expect("valid target configuration"); + let mut proposal = + CdtProposal::with_seed(action_config, 7).expect("valid proposal configuration"); let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed"); let mut rng = StdRng::seed_from_u64(7); @@ -1607,8 +1501,10 @@ mod tests { #[test] fn cdt_proposal_scores_impossible_plan_as_negative_infinity() { let action_config = ActionConfig::default(); - let target = CdtTarget::new(action_config.clone(), 1.0); - let proposal = CdtProposal::with_seed(action_config, 7); + let target = + CdtTarget::new(action_config.clone(), 1.0).expect("valid target configuration"); + let proposal = + CdtProposal::with_seed(action_config, 7).expect("valid proposal configuration"); let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed"); let plan = CdtProposalPlan { move_type: MoveType::Move31Remove, @@ -1628,11 +1524,13 @@ mod tests { #[test] fn cdt_proposal_uses_delayed_chain() { let action_config = ActionConfig::default(); - let target = CdtTarget::new(action_config.clone(), 1.0); + let target = + CdtTarget::new(action_config.clone(), 1.0).expect("valid target configuration"); let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed"); let mut chain = Chain::new(triangulation, &target) .expect("initial state should have finite log probability"); - let mut proposal = CdtProposal::with_seed(action_config, 7); + let mut proposal = + CdtProposal::with_seed(action_config, 7).expect("valid proposal configuration"); let mut rng = StdRng::seed_from_u64(11); let step = chain @@ -1646,7 +1544,8 @@ mod tests { #[test] fn cdt_proposal_commit_applies_concrete_planned_state() { let action_config = ActionConfig::default(); - let mut proposal = CdtProposal::with_seed(action_config.clone(), 11); + let mut proposal = CdtProposal::with_seed(action_config.clone(), 11) + .expect("valid proposal configuration"); let mut triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, 53).expect("Failed to create"); let proposed_state = diff --git a/src/cdt/observables.rs b/src/cdt/observables.rs new file mode 100644 index 0000000..7fd7238 --- /dev/null +++ b/src/cdt/observables.rs @@ -0,0 +1,487 @@ +#![forbid(unsafe_code)] + +//! Observable estimators for CDT triangulations. +//! +//! This module contains user-facing analysis routines that measure fixed +//! triangulations or simulation outputs without owning the simulation loop. + +use crate::geometry::CdtTriangulation2D; +use crate::geometry::traits::TriangulationQuery; +use num_traits::cast::NumCast; +use std::collections::{HashMap, VecDeque}; +use std::mem; + +const MIN_SPECTRAL_DIFFUSION_STEP: usize = 2; +const MAX_SPECTRAL_DIFFUSION_STEP: usize = 16; +const SPECTRAL_SELF_LOOP_PROBABILITY: f64 = 0.5; + +/// Estimates the Hausdorff dimension from dual-graph geodesic ball growth. +/// +/// The estimator computes average ball volumes `` on the dual graph of +/// the supplied [`CdtTriangulation2D`] and returns the slope of a simple log-log +/// least squares fit to ` ~ r^d`. +/// Here "dual graph" means the combinatorial face-adjacency graph of the +/// triangulation, not a geometric Voronoi tessellation or circumcenter dual. +/// +/// Average ball volumes use a reachable-radius convention: a root contributes +/// to radius `r` only when at least one dual-graph face is reachable at that +/// distance. The implementation precomputes face adjacency once, then runs a +/// breadth-first search from every face, so its time complexity is +/// `O(F * (F + E_d))` for `F` faces and `E_d` dual edges. +/// +/// Returns `None` when the triangulation is too small, disconnected data leaves +/// fewer than two usable radii, or live face adjacency cannot be resolved. +/// +/// # References +/// +/// This observable follows the CDT use of spatial volume profiles and +/// graph-geodesic dimensional estimators discussed in: +/// +/// - J. Ambjørn, J. Jurkiewicz, and R. Loll, "Reconstructing the Universe", +/// *Physical Review D* 72, 064014 (2005), +/// DOI: . +/// - J. Ambjørn, T. Budd, and Y. Watabiki, "Scale-dependent Hausdorff +/// dimensions in 2d gravity", *Physics Letters B* 736, 339-343 (2014), +/// DOI: . +/// +/// The complete bibliography is maintained in the repository `REFERENCES.md`. +/// +/// # Examples +/// +/// ``` +/// use causal_triangulations::prelude::errors::CdtResult; +/// use causal_triangulations::prelude::observables::*; +/// +/// fn main() -> CdtResult<()> { +/// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; +/// assert!(estimate_hausdorff_dimension(&tri).is_some_and(f64::is_finite)); +/// Ok(()) +/// } +/// ``` +#[must_use] +pub fn estimate_hausdorff_dimension(triangulation: &CdtTriangulation2D) -> Option { + let ball_volumes = average_dual_ball_volumes(triangulation)?; + fit_log_log_slope(&ball_volumes) +} + +/// Estimates the spectral dimension from dual-graph diffusion return probability. +/// +/// The estimator runs a discrete random walk on the combinatorial dual graph of +/// the supplied [`CdtTriangulation2D`], averages the probability of returning to +/// the starting face after diffusion time `sigma`, and fits the slope of +/// `log(P(sigma))` against `log(sigma)`. The returned value is `-2` times that +/// slope, matching the CDT convention `P(sigma) ~ sigma^(-d_s/2)`. +/// +/// Here "dual graph" means the combinatorial face-adjacency graph of the +/// triangulation, not a geometric Voronoi tessellation or circumcenter dual. +/// The implementation precomputes face adjacency once, then evolves one +/// probability vector per root face up to a bounded diffusion time, so its time +/// complexity is `O(S * F * (F + E_d))` for `S` diffusion steps, `F` faces, and +/// `E_d` dual edges. +/// +/// Returns `None` when the triangulation is too small, fewer than two positive +/// return-probability samples are available for the fit, the fitted dimension is +/// not positive, or live face adjacency cannot be resolved. +/// +/// # References +/// +/// This observable follows the CDT diffusion-return estimator discussed in: +/// +/// - J. Ambjørn, J. Jurkiewicz, and R. Loll, "The Spectral Dimension of the +/// Universe is Scale Dependent", *Physical Review Letters* 95, 171301 (2005), +/// DOI: . +/// +/// The complete bibliography is maintained in the repository `REFERENCES.md`. +/// +/// # Examples +/// +/// ``` +/// use causal_triangulations::prelude::errors::CdtResult; +/// use causal_triangulations::prelude::observables::*; +/// +/// fn main() -> CdtResult<()> { +/// let tri = CdtTriangulation::from_toroidal_cdt(6, 6)?; +/// assert!(estimate_spectral_dimension(&tri).is_some_and(f64::is_finite)); +/// Ok(()) +/// } +/// ``` +#[must_use] +pub fn estimate_spectral_dimension(triangulation: &CdtTriangulation2D) -> Option { + let adjacency = dual_adjacency(triangulation)?; + estimate_spectral_dimension_from_adjacency(&adjacency) +} + +/// Builds the combinatorial face-adjacency graph for CDT dual observables. +fn dual_adjacency(triangulation: &CdtTriangulation2D) -> Option>> { + let faces: Vec<_> = triangulation.geometry().faces().collect(); + if faces.len() < 2 { + return Some(Vec::new()); + } + + let face_indices: HashMap<_, _> = faces + .iter() + .cloned() + .enumerate() + .map(|(index, face)| (face, index)) + .collect(); + + faces + .iter() + .map(|face| { + let neighbors = triangulation.geometry().face_neighbors(face).ok()?; + Some( + neighbors + .into_iter() + .filter_map(|neighbor| face_indices.get(&neighbor).copied()) + .collect(), + ) + }) + .collect() +} + +/// Computes average dual-graph ball volumes for each reachable graph radius. +fn average_dual_ball_volumes(triangulation: &CdtTriangulation2D) -> Option> { + dual_adjacency(triangulation) + .as_deref() + .map(average_dual_ball_volumes_from_adjacency) +} + +/// Computes reachable-radius average ball volumes from a dual adjacency list. +fn average_dual_ball_volumes_from_adjacency(adjacency: &[Vec]) -> Vec { + if adjacency.len() < 2 { + return Vec::new(); + } + + let mut sums = Vec::new(); + let mut counts = Vec::new(); + let mut distances = vec![None; adjacency.len()]; + let mut queue = VecDeque::new(); + let mut shell_counts = Vec::new(); + + for root in 0..adjacency.len() { + let Some(max_radius) = dual_graph_distances(adjacency, root, &mut distances, &mut queue) + else { + continue; + }; + + if shell_counts.len() <= max_radius { + shell_counts.resize(max_radius + 1, 0); + } + shell_counts[..=max_radius].fill(0); + for distance in distances.iter().flatten().copied() { + shell_counts[distance] += 1; + } + + if sums.len() <= max_radius { + sums.resize(max_radius + 1, 0.0); + counts.resize(max_radius + 1, 0_usize); + } + + let mut ball_volume = 0_usize; + for (radius, shell_count) in shell_counts + .iter() + .copied() + .enumerate() + .take(max_radius + 1) + { + ball_volume += shell_count; + sums[radius] += NumCast::from(ball_volume).unwrap_or(0.0); + counts[radius] += 1; + } + } + + sums.into_iter() + .zip(counts) + .map(|(sum, count)| { + let count_f64 = NumCast::from(count).unwrap_or(1.0); + sum / count_f64 + }) + .collect() +} + +/// Breadth-first face distances from one dual-graph root. +fn dual_graph_distances( + adjacency: &[Vec], + root: usize, + distances: &mut [Option], + queue: &mut VecDeque, +) -> Option { + if root >= adjacency.len() || distances.len() != adjacency.len() { + return None; + } + + distances.fill(None); + queue.clear(); + distances[root] = Some(0); + queue.push_back(root); + + let mut max_radius = 0; + while let Some(index) = queue.pop_front() { + let Some(distance) = distances[index] else { + continue; + }; + max_radius = max_radius.max(distance); + for &neighbor in &adjacency[index] { + if neighbor < distances.len() && distances[neighbor].is_none() { + distances[neighbor] = Some(distance + 1); + queue.push_back(neighbor); + } + } + } + + Some(max_radius) +} + +/// Fits the slope of `log(volume)` against `log(radius)`. +fn fit_log_log_slope(ball_volumes: &[f64]) -> Option { + fit_linear_slope( + ball_volumes + .iter() + .enumerate() + .skip(1) + .filter_map(|(radius, &volume)| { + // Exclude root-only samples: ln(1.0) = 0 biases the slope toward zero. + if volume > 1.0 && volume.is_finite() { + let radius_f64: f64 = NumCast::from(radius)?; + Some((radius_f64.ln(), volume.ln())) + } else { + None + } + }), + ) +} + +/// Estimates spectral dimension from a precomputed dual adjacency list. +fn estimate_spectral_dimension_from_adjacency(adjacency: &[Vec]) -> Option { + if adjacency.len() < 3 { + return None; + } + + let max_step = MAX_SPECTRAL_DIFFUSION_STEP.min(adjacency.len().saturating_sub(1)); + if max_step < MIN_SPECTRAL_DIFFUSION_STEP { + return None; + } + + let return_probabilities = average_return_probabilities(adjacency, max_step); + fit_spectral_dimension(&return_probabilities) +} + +/// Computes average random-walk return probabilities for diffusion steps. +fn average_return_probabilities(adjacency: &[Vec], max_step: usize) -> Vec { + let node_count = adjacency.len(); + if node_count == 0 { + return Vec::new(); + } + + let mut sums = vec![0.0; max_step + 1]; + let mut current = vec![0.0; node_count]; + let mut next = vec![0.0; node_count]; + + for root in 0..node_count { + current.fill(0.0); + current[root] = 1.0; + sums[0] += 1.0; + + for sum in sums.iter_mut().take(max_step + 1).skip(1) { + next.fill(0.0); + for (index, &probability) in current.iter().enumerate() { + if probability <= 0.0 { + continue; + } + + let stay = probability * SPECTRAL_SELF_LOOP_PROBABILITY; + let move_probability = + probability.mul_add(-SPECTRAL_SELF_LOOP_PROBABILITY, probability); + next[index] += stay; + + let live_neighbor_count = adjacency[index] + .iter() + .filter(|&&neighbor| neighbor < node_count) + .count(); + if live_neighbor_count == 0 { + next[index] += move_probability; + continue; + } + + let neighbor_count = NumCast::from(live_neighbor_count).unwrap_or(1.0); + let share = move_probability / neighbor_count; + for neighbor in adjacency[index] + .iter() + .copied() + .filter(|&neighbor| neighbor < node_count) + { + next[neighbor] += share; + } + } + + mem::swap(&mut current, &mut next); + *sum += current[root]; + } + } + + let node_count_f64 = NumCast::from(node_count).unwrap_or(1.0); + sums.into_iter().map(|sum| sum / node_count_f64).collect() +} + +/// Fits `d_s = -2 d log(P(sigma)) / d log(sigma)` from return probabilities. +fn fit_spectral_dimension(return_probabilities: &[f64]) -> Option { + let slope = fit_linear_slope( + return_probabilities + .iter() + .enumerate() + .skip(MIN_SPECTRAL_DIFFUSION_STEP) + .filter_map(|(step, &probability)| { + if probability > 0.0 && probability < 1.0 && probability.is_finite() { + let step_f64: f64 = NumCast::from(step)?; + Some((step_f64.ln(), probability.ln())) + } else { + None + } + }), + )?; + + let dimension = -2.0 * slope; + (dimension.is_finite() && dimension > 0.0).then_some(dimension) +} + +/// Fits the slope of `y = a + bx` from streaming `(x, y)` samples. +fn fit_linear_slope(samples: impl IntoIterator) -> Option { + let (count, x_total, y_total, square_total, product_total) = samples.into_iter().fold( + (0_usize, 0.0, 0.0, 0.0, 0.0), + |(count, x_total, y_total, square_total, product_total), (x, y)| { + ( + count + 1, + x_total + x, + y_total + y, + x.mul_add(x, square_total), + x.mul_add(y, product_total), + ) + }, + ); + + if count < 2 { + return None; + } + + let count_f64: f64 = NumCast::from(count).unwrap_or(1.0); + let denominator = count_f64.mul_add(square_total, -x_total * x_total); + if denominator <= f64::EPSILON { + return None; + } + + Some(count_f64.mul_add(product_total, -x_total * y_total) / denominator) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cdt::triangulation::CdtTriangulation; + use approx::assert_relative_eq; + + #[test] + fn hausdorff_estimate_uses_dual_graph_ball_growth() { + let triangulation = CdtTriangulation::from_cdt_strip(4, 3).expect("create explicit strip"); + let estimate = estimate_hausdorff_dimension(&triangulation) + .expect("strip dual graph should have enough radii for a fit"); + + assert!(estimate.is_finite()); + assert!(estimate > 0.0); + } + + #[test] + fn hausdorff_estimate_returns_none_for_too_little_dual_growth() { + let triangulation = + CdtTriangulation::from_random_points(3, 1, 2).expect("create minimal triangulation"); + + assert_eq!(estimate_hausdorff_dimension(&triangulation), None); + } + + #[test] + fn spectral_estimate_uses_dual_graph_return_probability() { + let triangulation = + CdtTriangulation::from_toroidal_cdt(6, 6).expect("create explicit torus"); + let estimate = estimate_spectral_dimension(&triangulation) + .expect("torus dual graph should have enough diffusion data"); + + assert!(estimate.is_finite()); + assert!(estimate > 0.0); + } + + #[test] + fn spectral_estimate_returns_none_for_too_little_diffusion_data() { + let adjacency = vec![vec![1], vec![0]]; + + assert_eq!(estimate_spectral_dimension_from_adjacency(&adjacency), None); + } + + #[test] + fn spectral_fit_recovers_power_law_return_probability() { + let probabilities = vec![1.0, 0.75, 0.5, 1.0 / 3.0, 0.25, 0.2]; + + let estimate = fit_spectral_dimension(&probabilities) + .expect("decreasing power-law return probabilities should fit"); + + assert_relative_eq!(estimate, 2.0, epsilon = 1e-12); + } + + #[test] + fn spectral_estimate_returns_none_for_stationary_isolated_graph() { + let adjacency = vec![vec![], vec![], vec![]]; + + let probabilities = average_return_probabilities(&adjacency, 4); + + for probability in probabilities { + assert_relative_eq!(probability, 1.0); + } + assert_eq!(estimate_spectral_dimension_from_adjacency(&adjacency), None); + } + + #[test] + fn dual_ball_averages_use_reachable_radius_convention() { + let path_graph = vec![vec![1], vec![0, 2], vec![1]]; + + let ball_volumes = average_dual_ball_volumes_from_adjacency(&path_graph); + + assert_eq!(ball_volumes.len(), 3); + assert_relative_eq!(ball_volumes[0], 1.0); + assert_relative_eq!(ball_volumes[1], 7.0 / 3.0); + assert_relative_eq!(ball_volumes[2], 3.0); + } + + #[test] + fn dual_graph_distances_ignore_out_of_bounds_neighbors() { + let adjacency = vec![vec![1, 99], vec![0]]; + let mut distances = vec![None; adjacency.len()]; + let mut queue = VecDeque::new(); + + let max_radius = dual_graph_distances(&adjacency, 0, &mut distances, &mut queue); + + assert_eq!(max_radius, Some(1)); + assert_eq!(distances, vec![Some(0), Some(1)]); + } + + #[test] + fn dual_graph_distances_reject_mismatched_buffers() { + let adjacency = vec![vec![1], vec![0]]; + let mut distances = vec![None; adjacency.len() - 1]; + let mut queue = VecDeque::new(); + + assert_eq!( + dual_graph_distances(&adjacency, 0, &mut distances, &mut queue), + None + ); + } + + #[test] + fn return_probabilities_follow_two_node_walk() { + let adjacency = vec![vec![1], vec![0]]; + + let probabilities = average_return_probabilities(&adjacency, 4); + + assert_relative_eq!(probabilities[0], 1.0); + assert_relative_eq!(probabilities[1], 0.5); + assert_relative_eq!(probabilities[2], 0.5); + assert_relative_eq!(probabilities[3], 0.5); + assert_relative_eq!(probabilities[4], 0.5); + } +} diff --git a/src/cdt/results.rs b/src/cdt/results.rs new file mode 100644 index 0000000..b373305 --- /dev/null +++ b/src/cdt/results.rs @@ -0,0 +1,669 @@ +#![forbid(unsafe_code)] + +//! Simulation result containers and post-simulation summaries. +//! +//! This module owns measurement records, complete simulation outputs, and +//! convenience analysis methods that summarize recorded measurements or inspect +//! the final triangulation state. + +use crate::cdt::action::ActionConfig; +use crate::cdt::ergodic_moves::MoveStatistics; +use crate::cdt::metropolis::{MetropolisConfig, MonteCarloStep}; +use crate::cdt::observables::{estimate_hausdorff_dimension, estimate_spectral_dimension}; +use crate::geometry::CdtTriangulation2D; +use num_traits::cast::NumCast; +use std::time::Duration; + +/// Measurement data collected during simulation. +/// +/// Use [`Self::new`] and builder-style methods such as +/// [`Self::with_volume_profile`] rather than struct literals outside this +/// crate; additional measurement fields may be added over time. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Measurement { + /// Monte Carlo step when measurement was taken + pub step: u32, + /// Current action value + pub action: f64, + /// Number of vertices + pub vertices: u32, + /// Number of edges + pub edges: u32, + /// Number of triangles + pub triangles: u32, + /// Per-slice triangle counts `N₂(t)` from + /// [`CdtTriangulation::volume_profile`](crate::cdt::triangulation::CdtTriangulation::volume_profile). + /// + /// Entry `t` counts classifiable CDT triangles assigned to time slab `t`; + /// the vector is empty when the measured triangulation has no current + /// foliation. + pub volume_profile: Vec, +} + +impl Measurement { + /// Creates a measurement with an empty volume profile. + /// + /// This constructor records scalar simulation counts. Attach per-slice + /// volume data with [`Self::with_volume_profile`] when the measured + /// triangulation has a foliation. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::simulation::Measurement; + /// + /// let measurement = Measurement::new(10, -12.5, 64, 180, 117); + /// assert_eq!(measurement.step, 10); + /// assert!(measurement.volume_profile.is_empty()); + /// ``` + #[must_use] + pub const fn new(step: u32, action: f64, vertices: u32, edges: u32, triangles: u32) -> Self { + Self { + step, + action, + vertices, + edges, + triangles, + volume_profile: Vec::new(), + } + } + + /// Returns this measurement with a per-slice volume profile attached. + /// + /// The profile entries are triangle counts `N₂(t)` by time slab, matching + /// [`CdtTriangulation::volume_profile`](crate::cdt::triangulation::CdtTriangulation::volume_profile). + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::simulation::Measurement; + /// + /// let measurement = + /// Measurement::new(20, -10.0, 12, 26, 12).with_volume_profile(vec![6, 6, 0]); + /// assert_eq!(measurement.volume_profile, vec![6, 6, 0]); + /// ``` + #[must_use] + pub fn with_volume_profile(mut self, volume_profile: Vec) -> Self { + self.volume_profile = volume_profile; + self + } +} + +/// Complete output from a Metropolis-Hastings CDT simulation. +/// +/// Values are produced by [`MetropolisAlgorithm::run`](crate::cdt::metropolis::MetropolisAlgorithm::run) +/// and include raw Monte Carlo steps, recorded measurements, final geometry, +/// and convenience methods for common post-simulation summaries. +#[derive(Debug)] +pub struct SimulationResultsBackend { + /// Configuration used for the simulation + pub config: MetropolisConfig, + /// Action configuration used + pub action_config: ActionConfig, + /// Metropolis-level ergodic move statistics + pub move_stats: MoveStatistics, + /// All Monte Carlo steps performed + pub steps: Vec, + /// Measurements taken during simulation + pub measurements: Vec, + /// Total simulation time + pub elapsed_time: Duration, + /// Final triangulation state + pub triangulation: CdtTriangulation2D, +} + +impl SimulationResultsBackend { + /// Calculates the acceptance rate for the simulation. + /// + /// # Examples + /// + /// ``` + /// use approx::assert_relative_eq; + /// use causal_triangulations::prelude::simulation::{ + /// ActionConfig, CdtResult, CdtTriangulation, MetropolisConfig, SimulationResultsBackend, + /// }; + /// use std::time::Duration; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; + /// let config = MetropolisConfig::new(1.0, 1, 0, 1).with_seed(7); + /// let results = SimulationResultsBackend { + /// config, + /// action_config: ActionConfig::default(), + /// move_stats: Default::default(), + /// steps: vec![], + /// measurements: vec![], + /// elapsed_time: Duration::from_millis(0), + /// triangulation: tri, + /// }; + /// assert_relative_eq!(results.acceptance_rate(), 0.0); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn acceptance_rate(&self) -> f64 { + if self.steps.is_empty() { + return 0.0; + } + + let accepted_count = self.steps.iter().filter(|step| step.accepted).count(); + let total_count = self.steps.len(); + + let accepted_f64 = NumCast::from(accepted_count).unwrap_or(0.0); + let total_f64 = NumCast::from(total_count).unwrap_or(1.0); + + accepted_f64 / total_f64 + } + + /// Calculates the average action over all measurements. + /// + /// # Examples + /// + /// ``` + /// use approx::assert_relative_eq; + /// use causal_triangulations::prelude::simulation::{ + /// ActionConfig, CdtResult, CdtTriangulation, MetropolisConfig, SimulationResultsBackend, + /// }; + /// use std::time::Duration; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; + /// let config = MetropolisConfig::new(1.0, 1, 0, 1).with_seed(7); + /// let results = SimulationResultsBackend { + /// config, + /// action_config: ActionConfig::default(), + /// move_stats: Default::default(), + /// steps: vec![], + /// measurements: vec![], + /// elapsed_time: Duration::from_millis(0), + /// triangulation: tri, + /// }; + /// assert_relative_eq!(results.average_action(), 0.0); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn average_action(&self) -> f64 { + if self.measurements.is_empty() { + return 0.0; + } + + let sum: f64 = self.measurements.iter().map(|m| m.action).sum(); + let count = self.measurements.len(); + + let count_f64 = NumCast::from(count).unwrap_or(1.0); + + sum / count_f64 + } + + /// Averages [`Measurement::volume_profile`] values after thermalization. + /// + /// The result has one entry per measured time slice. Missing entries in a + /// measurement are treated as zero, which keeps unfoliated simulations + /// represented by an empty profile rather than a partially inferred one. + /// + /// # Examples + /// + /// ``` + /// use approx::assert_relative_eq; + /// use causal_triangulations::prelude::simulation::{ + /// ActionConfig, CdtResult, CdtTriangulation, Measurement, MetropolisConfig, + /// SimulationResultsBackend, + /// }; + /// use std::time::Duration; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// let config = MetropolisConfig::new(1.0, 20, 10, 5).with_seed(7); + /// let results = SimulationResultsBackend { + /// config, + /// action_config: ActionConfig::default(), + /// move_stats: Default::default(), + /// steps: vec![], + /// measurements: vec![ + /// Measurement::new(0, 1.0, 12, 26, 12) + /// .with_volume_profile(vec![6, 6, 0]), + /// Measurement::new(10, 2.0, 12, 26, 12) + /// .with_volume_profile(vec![4, 8, 0]), + /// ], + /// elapsed_time: Duration::from_millis(0), + /// triangulation: tri, + /// }; + /// let profile = results.average_volume_profile(); + /// assert_relative_eq!(profile[0], 4.0); + /// assert_relative_eq!(profile[1], 8.0); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn average_volume_profile(&self) -> Vec { + let measurements = self.equilibrium_measurements(); + let profile_len = measurements + .iter() + .map(|measurement| measurement.volume_profile.len()) + .max() + .unwrap_or(0); + if measurements.is_empty() || profile_len == 0 { + return Vec::new(); + } + + let mut sums = vec![0.0; profile_len]; + for measurement in &measurements { + for (index, &volume) in measurement.volume_profile.iter().enumerate() { + let volume: f64 = volume.into(); + sums[index] += volume; + } + } + + let count = NumCast::from(measurements.len()).unwrap_or(1.0); + sums.into_iter().map(|sum| sum / count).collect() + } + + /// Computes per-slice standard deviations of [`Measurement::volume_profile`]. + /// + /// The sample standard deviation is evaluated over equilibrium + /// measurements, using the same post-thermalization selection as + /// [`Self::average_volume_profile`]. Returns an empty vector when fewer + /// than two equilibrium measurements are available. + /// + /// # Examples + /// + /// ``` + /// use approx::assert_relative_eq; + /// use causal_triangulations::prelude::simulation::{ + /// ActionConfig, CdtResult, CdtTriangulation, Measurement, MetropolisConfig, + /// SimulationResultsBackend, + /// }; + /// use std::time::Duration; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// let config = MetropolisConfig::new(1.0, 20, 10, 5).with_seed(7); + /// let results = SimulationResultsBackend { + /// config, + /// action_config: ActionConfig::default(), + /// move_stats: Default::default(), + /// steps: vec![], + /// measurements: vec![ + /// Measurement::new(10, 2.0, 12, 26, 12) + /// .with_volume_profile(vec![4, 8, 0]), + /// Measurement::new(15, 3.0, 12, 26, 12) + /// .with_volume_profile(vec![6, 6, 0]), + /// ], + /// elapsed_time: Duration::from_millis(0), + /// triangulation: tri, + /// }; + /// let fluctuations = results.volume_fluctuations(); + /// assert_relative_eq!(fluctuations[0], 2.0_f64.sqrt()); + /// assert_relative_eq!(fluctuations[1], 2.0_f64.sqrt()); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn volume_fluctuations(&self) -> Vec { + let measurements = self.equilibrium_measurements(); + let means = self.average_volume_profile(); + let n = measurements.len(); + if n < 2 || means.is_empty() { + return Vec::new(); + } + + let mut variances = vec![0.0; means.len()]; + for measurement in &measurements { + for (index, mean) in means.iter().enumerate() { + let volume = measurement + .volume_profile + .get(index) + .map_or(0.0, |&volume| { + let volume: f64 = volume.into(); + volume + }); + let delta = volume - mean; + variances[index] += delta * delta; + } + } + + let denominator = NumCast::from(n - 1).unwrap_or(1.0); + variances + .into_iter() + .map(|variance| (variance / denominator).sqrt()) + .collect() + } + + /// Estimates the Hausdorff dimension of the final triangulation. + /// + /// This is a single-state post-simulation observable computed from + /// `self.triangulation`, the final triangulation, using dual-graph geodesic + /// ball growth through [`estimate_hausdorff_dimension`]. It does not + /// average over equilibrium measurements; doing so would require storing + /// triangulation snapshots in [`Measurement`] or rerunning the chain. + /// For ensemble-style recorded data, see [`Self::average_volume_profile`]. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::simulation::{ + /// ActionConfig, CdtResult, CdtTriangulation, MetropolisConfig, SimulationResultsBackend, + /// }; + /// use std::time::Duration; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// let results = SimulationResultsBackend { + /// config: MetropolisConfig::new(1.0, 1, 0, 1), + /// action_config: ActionConfig::default(), + /// move_stats: Default::default(), + /// steps: vec![], + /// measurements: vec![], + /// elapsed_time: Duration::from_millis(0), + /// triangulation: tri, + /// }; + /// assert!(results + /// .hausdorff_dimension_estimate() + /// .is_some_and(f64::is_finite)); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn hausdorff_dimension_estimate(&self) -> Option { + estimate_hausdorff_dimension(&self.triangulation) + } + + /// Estimates the spectral dimension of the final triangulation. + /// + /// This is a single-state post-simulation observable computed from + /// `self.triangulation`, the final triangulation, using dual-graph + /// diffusion return probability through [`estimate_spectral_dimension`]. + /// It does not average over equilibrium measurements; doing so would + /// require storing triangulation snapshots in [`Measurement`] or rerunning + /// the chain. For ensemble-style recorded data, see + /// [`Self::average_volume_profile`]. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::simulation::{ + /// ActionConfig, CdtResult, CdtTriangulation, MetropolisConfig, SimulationResultsBackend, + /// }; + /// use std::time::Duration; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_toroidal_cdt(6, 6)?; + /// let results = SimulationResultsBackend { + /// config: MetropolisConfig::new(1.0, 1, 0, 1), + /// action_config: ActionConfig::default(), + /// move_stats: Default::default(), + /// steps: vec![], + /// measurements: vec![], + /// elapsed_time: Duration::from_millis(0), + /// triangulation: tri, + /// }; + /// assert!(results + /// .spectral_dimension_estimate() + /// .is_some_and(f64::is_finite)); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn spectral_dimension_estimate(&self) -> Option { + estimate_spectral_dimension(&self.triangulation) + } + + /// Returns measurements after thermalization. + /// + /// Measurements are recorded for the initial state at step 0, then after + /// completed-move counts divisible by + /// [`MetropolisConfig::measurement_frequency`]. This accessor defines + /// equilibrium as `measurement.step >= thermalization_steps`, so a + /// measurement taken exactly on the thermalization boundary is included. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::simulation::{ + /// ActionConfig, CdtResult, CdtTriangulation, MetropolisConfig, SimulationResultsBackend, + /// }; + /// use std::time::Duration; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; + /// let config = MetropolisConfig::new(1.0, 2, 1, 1).with_seed(7); + /// let results = SimulationResultsBackend { + /// config, + /// action_config: ActionConfig::default(), + /// move_stats: Default::default(), + /// steps: vec![], + /// measurements: vec![], + /// elapsed_time: Duration::from_millis(0), + /// triangulation: tri, + /// }; + /// assert!(results.equilibrium_measurements().is_empty()); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn equilibrium_measurements(&self) -> Vec<&Measurement> { + self.measurements + .iter() + .filter(|m| m.step >= self.config.thermalization_steps) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cdt::ergodic_moves::MoveType; + use crate::cdt::triangulation::CdtTriangulation; + use approx::assert_relative_eq; + + /// Builds a result container around deterministic geometry for summary-method tests. + fn results_with( + config: MetropolisConfig, + steps: Vec, + measurements: Vec, + triangulation: CdtTriangulation2D, + ) -> SimulationResultsBackend { + SimulationResultsBackend { + config, + action_config: ActionConfig::default(), + move_stats: MoveStatistics::new(), + steps, + measurements, + elapsed_time: Duration::from_millis(100), + triangulation, + } + } + + /// Asserts two equal-length floating-point slices using relative tolerance. + fn assert_slice_relative_eq(actual: &[f64], expected: &[f64]) { + assert_eq!(actual.len(), expected.len()); + for (&actual, &expected) in actual.iter().zip(expected) { + assert_relative_eq!(actual, expected, epsilon = 1e-12); + } + } + + /// Asserts two optional estimates match without exact floating-point comparison. + fn assert_optional_relative_eq(actual: Option, expected: Option) { + match (actual, expected) { + (Some(actual), Some(expected)) => { + assert_relative_eq!(actual, expected, epsilon = 1e-12); + } + (None, None) => {} + other => panic!("expected matching optional estimates, got {other:?}"), + } + } + + #[test] + fn measurement_builders_preserve_scalar_counts_and_profile() { + let measurement = Measurement::new(7, -3.5, 12, 26, 12).with_volume_profile(vec![6, 6, 0]); + + assert_eq!(measurement.step, 7); + assert_relative_eq!(measurement.action, -3.5); + assert_eq!(measurement.vertices, 12); + assert_eq!(measurement.edges, 26); + assert_eq!(measurement.triangles, 12); + assert_eq!(measurement.volume_profile, vec![6, 6, 0]); + } + + #[test] + fn summaries_use_post_thermalization_measurements() { + let config = MetropolisConfig::new(1.0, 20, 10, 5); + let steps = vec![ + MonteCarloStep { + step: 1, + move_type: MoveType::Move22, + accepted: true, + action_before: 3.0, + action_after: Some(2.5), + delta_action: Some(-0.5), + }, + MonteCarloStep { + step: 2, + move_type: MoveType::Move13Add, + accepted: false, + action_before: 2.5, + action_after: None, + delta_action: Some(0.8), + }, + MonteCarloStep { + step: 3, + move_type: MoveType::Move31Remove, + accepted: true, + action_before: 2.5, + action_after: Some(2.0), + delta_action: Some(-0.5), + }, + ]; + let measurements = vec![ + Measurement::new(0, 1.0, 3, 3, 1).with_volume_profile(vec![1, 0, 0]), + Measurement::new(10, 2.0, 4, 5, 2).with_volume_profile(vec![1, 1, 0]), + 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"); + let results = results_with(config, steps, measurements, triangulation); + + assert_relative_eq!(results.acceptance_rate(), 2.0 / 3.0); + assert_relative_eq!(results.average_action(), 2.0); + assert_slice_relative_eq(&results.average_volume_profile(), &[1.0, 1.5, 0.0]); + assert_slice_relative_eq(&results.volume_fluctuations(), &[0.0, 0.5_f64.sqrt(), 0.0]); + + let equilibrium = results.equilibrium_measurements(); + assert_eq!(equilibrium.len(), 2); + assert_eq!(equilibrium[0].step, 10); + assert_eq!(equilibrium[1].step, 15); + } + + #[test] + fn volume_observables_treat_missing_profile_entries_as_zero() { + let triangulation = + CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + let results = results_with( + MetropolisConfig::new(1.0, 20, 10, 5), + vec![], + vec![ + Measurement::new(10, 2.0, 4, 5, 2).with_volume_profile(vec![4, 8, 1]), + Measurement::new(15, 3.0, 5, 7, 3).with_volume_profile(vec![6]), + ], + triangulation, + ); + + assert_slice_relative_eq(&results.average_volume_profile(), &[5.0, 4.0, 0.5]); + assert_slice_relative_eq( + &results.volume_fluctuations(), + &[2.0_f64.sqrt(), 32.0_f64.sqrt(), 0.5_f64.sqrt()], + ); + } + + #[test] + fn volume_observables_are_empty_when_profiles_are_empty() { + let triangulation = + CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + let results = results_with( + MetropolisConfig::new(1.0, 20, 10, 5), + vec![], + vec![ + Measurement::new(10, 2.0, 4, 5, 2), + Measurement::new(15, 3.0, 5, 7, 3), + ], + triangulation, + ); + + assert!(results.average_volume_profile().is_empty()); + assert!(results.volume_fluctuations().is_empty()); + } + + #[test] + fn volume_fluctuations_are_empty_for_single_equilibrium_measurement() { + let triangulation = + CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + let results = results_with( + MetropolisConfig::new(1.0, 20, 10, 5), + vec![], + vec![ + Measurement::new(0, 1.0, 3, 3, 1).with_volume_profile(vec![1]), + Measurement::new(10, 2.0, 4, 5, 2).with_volume_profile(vec![2]), + ], + triangulation, + ); + + assert_slice_relative_eq(&results.average_volume_profile(), &[2.0]); + assert!(results.volume_fluctuations().is_empty()); + } + + #[test] + fn summaries_are_empty_for_no_steps_or_measurements() { + let triangulation = + CdtTriangulation::from_cdt_strip(4, 3).expect("explicit strip should build"); + let results = results_with( + MetropolisConfig::new(1.0, 20, 10, 5), + vec![], + vec![], + triangulation, + ); + + assert_relative_eq!(results.acceptance_rate(), 0.0); + assert_relative_eq!(results.average_action(), 0.0); + assert!(results.equilibrium_measurements().is_empty()); + assert!(results.average_volume_profile().is_empty()); + assert!(results.volume_fluctuations().is_empty()); + } + + #[test] + fn dimension_estimates_delegate_to_final_triangulation() { + let triangulation = + CdtTriangulation::from_toroidal_cdt(6, 6).expect("explicit torus should build"); + let results = results_with( + MetropolisConfig::new(1.0, 1, 0, 1), + vec![], + vec![], + triangulation, + ); + + assert_optional_relative_eq( + results.hausdorff_dimension_estimate(), + estimate_hausdorff_dimension(&results.triangulation), + ); + assert_optional_relative_eq( + results.spectral_dimension_estimate(), + estimate_spectral_dimension(&results.triangulation), + ); + } + + #[test] + fn dimension_estimates_return_none_for_tiny_final_triangulation() { + let triangulation = CdtTriangulation::from_seeded_points(3, 1, 2, 53) + .expect("seeded triangle should build"); + let results = results_with( + MetropolisConfig::new(1.0, 1, 0, 1), + vec![], + vec![], + triangulation, + ); + + assert!(results.hausdorff_dimension_estimate().is_none()); + assert!(results.spectral_dimension_estimate().is_none()); + } +} diff --git a/src/cdt/triangulation.rs b/src/cdt/triangulation.rs index d810b6b..545344d 100644 --- a/src/cdt/triangulation.rs +++ b/src/cdt/triangulation.rs @@ -1,27 +1,25 @@ +#![forbid(unsafe_code)] + //! CDT triangulation wrapper - backend-agnostic. //! //! This module provides CDT-specific triangulation data structures that work //! with any geometry backend implementing the trait interfaces. -use crate::cdt::foliation::{CellType, EdgeType, Foliation, FoliationError, classify_cell}; +use crate::cdt::foliation::Foliation; use crate::config::CdtTopology; use crate::errors::{CdtError, CdtResult}; +#[cfg(test)] use crate::geometry::DelaunayBackend2D; -use crate::geometry::backends::delaunay::{ - DelaunayEdgeHandle, DelaunayError, DelaunayFaceHandle, DelaunayVertexHandle, -}; #[cfg(test)] use crate::geometry::generators::build_delaunay2_with_data; -use crate::geometry::generators::{ - build_delaunay2_from_cells, build_toroidal_delaunay2, generate_delaunay2, -}; -use crate::geometry::traits::{ - FlipResult, SubdivisionResult, TriangulationMut, TriangulationQuery, -}; -use crate::util::f64_band_to_u32; -use std::collections::{HashMap, HashSet}; +use crate::geometry::traits::TriangulationQuery; use std::time::Instant; +mod builders; +mod foliation; +mod moves; +mod validation; + /// CDT-specific triangulation wrapper - completely geometry-agnostic #[derive(Debug, Clone)] pub struct CdtTriangulation { @@ -67,136 +65,6 @@ struct CachedValue { modification_count: u64, } -/// Rewrites explicit 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. -fn remap_toroidal_generation_error(error: CdtError, total_vertices: u32) -> CdtError { - match error { - CdtError::DelaunayGenerationFailed { - underlying_error, .. - } => CdtError::DelaunayGenerationFailed { - vertex_count: total_vertices, - coordinate_range: (0.0, 1.0), - attempt: 1, - underlying_error, - }, - other => other, - } -} - -/// Rewrites explicit strip builder failures with CDT-level generation context. -fn remap_strip_generation_error( - error: CdtError, - total_vertices: u32, - coordinate_max: f64, -) -> CdtError { - match error { - CdtError::DelaunayGenerationFailed { - underlying_error, .. - } => CdtError::DelaunayGenerationFailed { - vertex_count: total_vertices, - coordinate_range: (0.0, coordinate_max), - attempt: 1, - underlying_error, - }, - other => other, - } -} - -/// Builds a CDT-level generation error for explicit strip construction failures. -const fn strip_generation_error( - total_vertices: u32, - coordinate_max: f64, - underlying_error: String, -) -> CdtError { - CdtError::DelaunayGenerationFailed { - vertex_count: total_vertices, - coordinate_range: (0.0, coordinate_max), - attempt: 1, - underlying_error, - } -} - -/// Verifies that the explicit 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" -)] -fn validate_strip_counts( - backend: &DelaunayBackend2D, - total_vertices: u32, - total_cells: u32, - expected_vertices: usize, - expected_faces: usize, - vertices_per_slice: u32, - num_slices: u32, - coordinate_max: f64, -) -> CdtResult<()> { - if backend.vertex_count() != expected_vertices { - return Err(strip_generation_error( - total_vertices, - coordinate_max, - format!( - "build_delaunay2_from_cells()/from_cdt_strip() produced {} vertices, expected {} for vertices_per_slice={} and num_slices={}", - backend.vertex_count(), - total_vertices, - vertices_per_slice, - num_slices, - ), - )); - } - if backend.face_count() != expected_faces { - return Err(strip_generation_error( - total_vertices, - coordinate_max, - format!( - "build_delaunay2_from_cells()/from_cdt_strip() produced {} faces, expected {} for vertices_per_slice={} and num_slices={}", - backend.face_count(), - total_cells, - vertices_per_slice, - num_slices, - ), - )); - } - - Ok(()) -} - -/// Builds a CDT-level generation error for explicit toroidal construction failures. -const fn toroidal_generation_error(total_vertices: u32, underlying_error: String) -> CdtError { - CdtError::DelaunayGenerationFailed { - vertex_count: total_vertices, - coordinate_range: (0.0, 1.0), - attempt: 1, - underlying_error, - } -} - -/// Verifies that the explicit toroidal builder returned the requested mesh size. -fn validate_toroidal_counts( - backend: &DelaunayBackend2D, - total_vertices: u32, - expected_vertices: usize, - expected_faces: usize, -) -> CdtResult<()> { - if backend.vertex_count() != expected_vertices || backend.face_count() != expected_faces { - return Err(toroidal_generation_error( - total_vertices, - format!( - "explicit toroidal builder produced {} vertices and {} faces, expected {} vertices and {} faces", - backend.vertex_count(), - backend.face_count(), - total_vertices, - expected_faces, - ), - )); - } - - Ok(()) -} - /// Events in simulation history #[derive(Debug, Clone)] pub enum SimulationEvent { @@ -717,3838 +585,588 @@ impl CdtTriangulation { } } -impl CdtTriangulation { - /// Validate foliation consistency. - /// - /// If no foliation is present, succeeds vacuously. - /// Otherwise checks: - /// 1. The stored labeled-vertex count matches the geometry vertex count - /// 2. Every stored time slice is non-empty - /// 3. Live backend labels match stored per-slice bookkeeping - /// - /// # Errors - /// - /// Returns error if foliation structure is invalid. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::triangulation::*; - /// - /// let mut tri = CdtTriangulation::from_seeded_points(12, 3, 2, 42) - /// .expect("create seeded triangulation"); - /// tri.assign_foliation_by_y(3) - /// .expect("assign foliation from y-coordinates"); - /// assert!(tri.validate_foliation().is_ok()); - /// ``` - pub fn validate_foliation(&self) -> CdtResult<()> { - let Some(foliation) = &self.foliation else { - return Ok(()); - }; - - // Check that all vertices are labeled - let vertex_count = self.geometry.vertex_count(); - if foliation.labeled_vertex_count() != vertex_count { - return Err(FoliationError::LabelCountMismatch { - labeled: foliation.labeled_vertex_count(), - expected: vertex_count, - } - .into()); - } +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + use std::time::{Duration, Instant}; - // Check that every slice is non-empty - for (t, &size) in foliation.slice_sizes().iter().enumerate() { - if size == 0 { - return Err(FoliationError::EmptySlice { slice: t }.into()); - } - } + /// Builds a minimal labeled Delaunay backend for foliation and causality tests. + fn labeled_triangle_backend(labels: [u32; 3]) -> DelaunayBackend2D { + let dt = build_delaunay2_with_data(&[ + ([0.0, 0.0], labels[0]), + ([1.0, 0.0], labels[1]), + ([0.5, 1.0], labels[2]), + ]) + .expect("Should build labeled triangle"); + DelaunayBackend2D::from_triangulation(dt) + } - // Validate against live labels from canonical Delaunay vertex payload. - let mut live_slice_sizes = vec![0usize; foliation.slice_sizes().len()]; + /// Builds intentionally unchecked metadata for legacy validation tests. + fn unchecked_open_boundary( + backend: DelaunayBackend2D, + time_slices: u32, + dimension: u8, + ) -> CdtTriangulation { + CdtTriangulation::wrap_unchecked(backend, time_slices, dimension, CdtTopology::OpenBoundary) + } - for (vertex, vh) in self.geometry.vertices().enumerate() { - let Some(label) = self.geometry.vertex_data_by_key(vh.vertex_key()) else { - return Err(FoliationError::MissingVertexLabel { vertex }.into()); - }; + #[test] + fn test_try_new_rejects_zero_time_slices() { + let backend = labeled_triangle_backend([0, 0, 1]); + let result = CdtTriangulation::try_new(backend, 0, 2); - let slice = label as usize; - if slice >= live_slice_sizes.len() { - return Err(FoliationError::OutOfRangeVertexLabel { - vertex, - label, - expected_range_end: live_slice_sizes.len(), - } - .into()); - } + assert!(matches!( + result, + Err(CdtError::InvalidTriangulationMetadata { + ref field, + ref topology, + ref provided_value, + ref expected, + }) if field == "timeslices" + && topology == "open boundary" + && provided_value == "0" + && expected == "≥ 1" + )); + } - live_slice_sizes[slice] += 1; - } + #[test] + fn test_try_new_rejects_dimension_mismatch() { + let backend = labeled_triangle_backend([0, 0, 1]); + let result = CdtTriangulation::try_new(backend, 2, 3); - for (slice, (&expected, &actual)) in foliation - .slice_sizes() - .iter() - .zip(live_slice_sizes.iter()) - .enumerate() - { - if expected != actual { - return Err(FoliationError::LabelMismatch { - slice, - expected, - actual, - } - .into()); - } - } + assert!(matches!( + result, + Err(CdtError::InvalidTriangulationMetadata { + ref field, + ref topology, + ref provided_value, + ref expected, + }) if field == "dimension" + && topology == "open boundary" + && provided_value == "3" + && expected == "backend dimension (2)" + )); + } - // Toroidal topology adds two stronger structural invariants: - // - every spatial slice forms a closed S¹ (each vertex has exactly - // two spacelike neighbours and the subgraph of each slice is a - // single cycle); and - // - the time direction wraps: every slice has timelike adjacency to - // both `(t-1) mod T` and `(t+1) mod T`. χ = 0 alone does not - // distinguish a torus from a cylinder, so we check the wrap - // explicitly. - if matches!(self.metadata.topology, CdtTopology::Toroidal) { - self.validate_toroidal_spatial_rings()?; - self.validate_toroidal_temporal_wraparound()?; - } + #[test] + fn test_validate_topology_rejects_legacy_dimension_mismatch() { + let backend = labeled_triangle_backend([0, 0, 1]); + let tri = unchecked_open_boundary(backend, 2, 3); + let result = tri.validate_topology(); - Ok(()) + assert!(matches!( + result, + Err(CdtError::InvalidTriangulationMetadata { + ref field, + ref provided_value, + ref expected, + .. + }) if field == "dimension" + && provided_value == "3" + && expected == "backend dimension (2)" + )); } - /// Validates that the time direction wraps for toroidal topology. - /// - /// For each slice `t` (with `T = time_slices`), checks that the foliation - /// has at least one timelike edge to slice `(t - 1) mod T` and at least - /// one to slice `(t + 1) mod T`. Cylinders (open in time) have χ = 0 - /// like the torus, so this check is what actually distinguishes them. - fn validate_toroidal_temporal_wraparound(&self) -> CdtResult<()> { - let total = self.metadata.time_slices; - if total < 2 { - return Ok(()); - } - let num_slices = total as usize; - - // Collect, per slice, the set of slices reached via timelike (step - // distance == 1) edges. - let mut neighbor_slices: Vec> = vec![HashSet::new(); num_slices]; - for edge in self.geometry.edges() { - let Some((v0, v1)) = self.geometry.edge_endpoints(&edge) else { - continue; - }; - let Some(t0) = self.geometry.vertex_data_by_key(v0.vertex_key()) else { - continue; - }; - let Some(t1) = self.geometry.vertex_data_by_key(v1.vertex_key()) else { - continue; - }; - if t0 >= total || t1 >= total { - continue; - } - if self.time_step_distance(t0, t1) != 1 { - continue; - } - let s0 = t0 as usize; - let s1 = t1 as usize; - neighbor_slices[s0].insert(s1); - neighbor_slices[s1].insert(s0); - } - - for (slice, neighbors) in neighbor_slices.iter().enumerate() { - let prev = if slice == 0 { - num_slices - 1 - } else { - slice - 1 - }; - let next = (slice + 1) % num_slices; - if !neighbors.contains(&prev) { - return Err(FoliationError::MissingTemporalWrapAround { - slice, - missing_neighbor: prev, - } - .into()); - } - if !neighbors.contains(&next) { - return Err(FoliationError::MissingTemporalWrapAround { - slice, - missing_neighbor: next, - } - .into()); - } - } + #[test] + fn test_geometry_access() { + let triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - Ok(()) + // Test immutable access + let geometry = triangulation.geometry(); + assert!(geometry.vertex_count() > 0); + assert!(geometry.is_valid()); + assert_eq!(geometry.dimension(), 2); } - /// Validates that every spatial slice forms a closed S¹. - /// - /// For each time slice `t` we iterate over backend edges, count incident - /// spacelike edges (`|Δt| = 0`) per vertex, and walk the resulting - /// spacelike subgraph to verify it is a single cycle of length - /// `slice_sizes[t]`. - fn validate_toroidal_spatial_rings(&self) -> CdtResult<()> { - let Some(foliation) = &self.foliation else { - return Ok(()); - }; - - let num_slices = foliation.slice_sizes().len(); - let mut spacelike_neighbors: Vec>> = - vec![HashMap::new(); num_slices]; + #[test] + fn test_basic_properties() { + let triangulation = + CdtTriangulation::from_random_points(8, 4, 2).expect("Failed to create triangulation"); - for edge in self.geometry.edges() { - let Some((v0, v1)) = self.geometry.edge_endpoints(&edge) else { - continue; - }; - let Some(t0) = self.geometry.vertex_data_by_key(v0.vertex_key()) else { - continue; - }; - let Some(t1) = self.geometry.vertex_data_by_key(v1.vertex_key()) else { - continue; - }; - if t0 != t1 { - continue; - } - let slice = t0 as usize; - if slice >= num_slices { - continue; - } - spacelike_neighbors[slice] - .entry(v0.clone()) - .or_default() - .push(v1.clone()); - spacelike_neighbors[slice].entry(v1).or_default().push(v0); - } + // Test basic property getters + assert_eq!(triangulation.dimension(), 2); + assert_eq!(triangulation.time_slices(), 4); + assert_eq!(triangulation.vertex_count(), 8); - for (slice, adjacency) in spacelike_neighbors.iter().enumerate() { - let expected_size = foliation.slice_sizes()[slice]; - if adjacency.len() != expected_size { - return Err(FoliationError::SpacelikeSubgraphSizeMismatch { - slice, - observed: adjacency.len(), - expected: expected_size, - } - .into()); - } - for (vertex, neighbors) in adjacency { - if neighbors.len() != 2 { - return Err(FoliationError::SpacelikeDegreeViolation { - slice, - vertex: format!("{:?}", vertex.vertex_key()), - observed_degree: neighbors.len(), - } - .into()); - } - } + let edge_count = triangulation.edge_count(); + let face_count = triangulation.face_count(); - // Walk the cycle starting from any vertex; verify it visits every - // vertex of the slice and closes back on itself. - let Some(start) = adjacency.keys().next() else { - continue; - }; - let mut visited: HashSet = HashSet::new(); - visited.insert(start.clone()); - let mut prev = start.clone(); - let mut current = adjacency[start][0].clone(); - while current != *start { - if !visited.insert(current.clone()) { - // Non-simple cycle: a vertex was revisited before the walk - // returned to `start`. Surface this through the same - // typed variant as a short cycle — both indicate the - // spacelike subgraph isn't a single closed S¹. - return Err(FoliationError::SpacelikeNonClosedRing { - slice, - walked: visited.len(), - expected: expected_size, - } - .into()); - } - let neighbors = &adjacency[¤t]; - let next = if neighbors[0] == prev { - neighbors[1].clone() - } else { - neighbors[0].clone() - }; - prev = current; - current = next; - } - if visited.len() != expected_size { - return Err(FoliationError::SpacelikeNonClosedRing { - slice, - walked: visited.len(), - expected: expected_size, - } - .into()); - } - } + assert!(edge_count > 0, "Should have edges"); + assert!(face_count > 0, "Should have faces"); - Ok(()) + // For a triangulation, we expect certain relationships + assert!( + edge_count >= triangulation.vertex_count(), + "Usually E >= V for connected triangulation" + ); + assert!(face_count >= 1, "Should have at least one face"); } -} -// ============================================================================= -// Delaunay-specific factory functions and foliation methods -// ============================================================================= -impl CdtTriangulation { - /// Flips an edge and marks CDT-derived state stale when the backend mutation succeeds. - pub(crate) fn flip_edge( - &mut self, - edge: DelaunayEdgeHandle, - ) -> Result, DelaunayError> { - let result = self.geometry.flip_edge(edge)?; - self.bump_modification_count(); - Ok(result) - } + #[test] + fn test_metadata_initialization() { + let triangulation = + CdtTriangulation::from_random_points(6, 3, 2).expect("Failed to create triangulation"); - /// Subdivides a face and marks CDT-derived state stale when the backend mutation succeeds. - pub(crate) fn subdivide_face( - &mut self, - face: DelaunayFaceHandle, - point: &[f64], - ) -> Result, DelaunayError> { - let result = self.geometry.subdivide_face(face, point)?; - self.bump_modification_count(); - Ok(result) - } + // Check that metadata is properly initialized + assert_eq!(triangulation.dimension(), 2); + assert_eq!(triangulation.time_slices(), 3); - /// Removes a vertex and marks CDT-derived state stale when the backend mutation succeeds. - pub(crate) fn remove_vertex( - &mut self, - vertex: DelaunayVertexHandle, - ) -> Result, DelaunayError> { - let result = self.geometry.remove_vertex(vertex)?; - self.bump_modification_count(); - Ok(result) + // Metadata should be accessible through debug formatting + let debug_output = format!("{triangulation:?}"); + assert!(debug_output.contains("CdtTriangulation")); + assert!(debug_output.contains("CdtMetadata")); } - /// Updates a vertex time label and marks CDT-derived state stale on success. - pub(crate) fn set_vertex_data( - &mut self, - vertex: &DelaunayVertexHandle, - data: Option, - ) -> Result<(), DelaunayError> { - self.geometry - .set_vertex_data_by_key(vertex.vertex_key(), data)?; - self.bump_modification_count(); - Ok(()) - } + #[test] + fn test_creation_history() { + let triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - // ------------------------------------------------------------------------- - // Factory constructors - // ------------------------------------------------------------------------- - - /// Re-reads current backend labels during validation so stale stored bookkeeping is detected. - fn live_slice_sizes_from_vertex_labels( - backend: &DelaunayBackend2D, - num_slices: u32, - ) -> CdtResult> { - if num_slices == 0 { - return Err(FoliationError::SliceSizeMismatch { - slice_sizes_len: 0, - num_slices, - } - .into()); - } + // Should have at least one creation event + assert!(!triangulation.metadata().simulation_history.is_empty()); - let mut slice_sizes = vec![0usize; num_slices as usize]; - - for (vertex, vh) in backend.vertices().enumerate() { - if let Some(t) = backend.vertex_data_by_key(vh.vertex_key()) { - let idx = t as usize; - if idx >= slice_sizes.len() { - return Err(FoliationError::OutOfRangeVertexLabel { - vertex, - label: t, - expected_range_end: slice_sizes.len(), - } - .into()); - } - slice_sizes[idx] += 1; - } else { - return Err(FoliationError::MissingVertexLabel { vertex }.into()); + match &triangulation.metadata().simulation_history[0] { + SimulationEvent::Created { + vertex_count, + time_slices, + } => { + assert_eq!(*vertex_count, 5); + assert_eq!(*time_slices, 2); } + _ => panic!("First event should be Creation"), } - - Ok(slice_sizes) } - /// Create a new CDT triangulation with Delaunay backend from random points. - /// - /// This is the recommended way to create triangulations for simulations. - /// - /// # Errors - /// Returns error if triangulation generation fails - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::CdtTriangulation; - /// - /// let tri = CdtTriangulation::from_random_points(5, 2, 2).unwrap(); - /// assert_eq!(tri.time_slices(), 2); - /// ``` - pub fn from_random_points(vertices: u32, time_slices: u32, dimension: u8) -> CdtResult { - // Validate dimension first - if dimension != 2 { - return Err(CdtError::UnsupportedDimension(dimension.into())); - } + #[test] + fn test_metadata_mutation_invalidates_cache() { + let mut triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - // Validate other parameters - if vertices < 3 { - return Err(CdtError::InvalidGenerationParameters { - issue: "Insufficient vertex count".to_string(), - provided_value: vertices.to_string(), - expected_range: "≥ 3".to_string(), - }); - } + // Get initial edge count + let initial_edge_count = triangulation.edge_count(); + assert!(initial_edge_count > 0); - let dt = generate_delaunay2(vertices, (0.0, 10.0), None)?; - let backend = DelaunayBackend2D::from_triangulation(dt); + let initial_mod_count = triangulation.metadata().modification_count; + + triangulation.bump_modification_count(); + + // Modification count should have increased + assert_eq!( + triangulation.metadata().modification_count, + initial_mod_count + 1 + ); - Self::try_new(backend, time_slices, dimension) - } - - /// Create a new CDT triangulation with Delaunay backend from random points using a fixed seed. - /// - /// This function provides deterministic triangulation generation for testing purposes. - /// - /// # Errors - /// Returns error if triangulation generation fails - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::CdtTriangulation; - /// - /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53).unwrap(); - /// assert_eq!(tri.vertex_count(), 5); - /// ``` - pub fn from_seeded_points( - vertices: u32, - time_slices: u32, - dimension: u8, - seed: u64, - ) -> CdtResult { - // Validate dimension first - if dimension != 2 { - return Err(CdtError::UnsupportedDimension(dimension.into())); - } - - // Validate other parameters - if vertices < 3 { - return Err(CdtError::InvalidGenerationParameters { - issue: "Insufficient vertex count".to_string(), - provided_value: vertices.to_string(), - expected_range: "≥ 3".to_string(), - }); - } - - let dt = generate_delaunay2(vertices, (0.0, 10.0), Some(seed))?; - let backend = DelaunayBackend2D::from_triangulation(dt); - - Self::try_new(backend, time_slices, dimension) - } - - /// Wrap a labeled 2D Delaunay backend and derive foliation from vertex data. - /// - /// Preserves per-vertex time labels already embedded in the backend. - /// - /// # Errors - /// - /// 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. - /// - /// # Examples - /// - /// ``` - /// 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 tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - /// .expect("wrap labeled backend"); - /// - /// assert!(tri.has_foliation()); - /// assert_eq!(tri.slice_sizes(), &[2, 1]); - /// ``` - pub fn from_labeled_delaunay( - backend: DelaunayBackend2D, - time_slices: u32, - dimension: u8, - ) -> CdtResult { - if dimension != 2 { - return Err(CdtError::UnsupportedDimension(dimension.into())); - } - - Self::check_time_slices(CdtTopology::OpenBoundary, time_slices)?; - let slice_sizes = Self::live_slice_sizes_from_vertex_labels(&backend, time_slices)?; - for (slice, &size) in slice_sizes.iter().enumerate() { - if size == 0 { - return Err(FoliationError::EmptySlice { slice }.into()); - } - } - let foliation = - Foliation::from_slice_sizes(slice_sizes, time_slices).map_err(CdtError::from)?; - - let mut tri = Self::try_new(backend, time_slices, dimension)?; - tri.foliation = Some(foliation); - tri.mark_foliation_synchronized(); - Ok(tri) - } - - /// Construct a true 1+1 CDT strip by explicit layered connectivity. - /// - /// 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. - /// - /// # 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`], - /// [`CdtError::CausalityViolation`], or [`CdtError::ValidationFailed`] if the - /// constructed strip fails CDT validation. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::triangulation::*; - /// - /// let tri = CdtTriangulation::from_cdt_strip(4, 2) - /// .expect("build explicit CDT strip"); - /// assert_eq!(tri.vertex_count(), 8); - /// assert_eq!(tri.face_count(), 6); - /// assert!(tri.validate_cell_classification().is_ok()); - /// ``` - #[expect( - clippy::too_many_lines, - reason = "explicit 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 { - return Err(CdtError::InvalidGenerationParameters { - issue: "Insufficient vertices per slice".to_string(), - provided_value: vertices_per_slice.to_string(), - expected_range: "≥ 4".to_string(), - }); - } - if num_slices < 2 { - return Err(CdtError::InvalidGenerationParameters { - issue: "Insufficient number of time slices".to_string(), - provided_value: num_slices.to_string(), - expected_range: "≥ 2".to_string(), - }); - } - - let total_vertices = vertices_per_slice.checked_mul(num_slices).ok_or_else(|| { - CdtError::InvalidGenerationParameters { - issue: "Vertex count overflow".to_string(), - provided_value: format!("{vertices_per_slice} × {num_slices}"), - expected_range: "product ≤ u32::MAX".to_string(), - } - })?; - - let spatial_quads = vertices_per_slice - 1; - let temporal_quads = num_slices - 1; - let total_quads = spatial_quads.checked_mul(temporal_quads).ok_or_else(|| { - CdtError::InvalidGenerationParameters { - issue: "Cell count overflow".to_string(), - provided_value: format!("{spatial_quads} × {temporal_quads}"), - expected_range: "product ≤ u32::MAX".to_string(), - } - })?; - let total_cells = - total_quads - .checked_mul(2) - .ok_or_else(|| CdtError::InvalidGenerationParameters { - issue: "Cell count overflow".to_string(), - provided_value: format!("2 × {total_quads}"), - expected_range: "product ≤ u32::MAX".to_string(), - })?; - - let coordinate_max = f64::from(num_slices - 1).max(1.0); - let generation_failed = |underlying_error: String| { - strip_generation_error(total_vertices, coordinate_max, underlying_error) - }; - - let expected_vertices = - usize::try_from(total_vertices).map_err(|err| generation_failed(err.to_string()))?; - let expected_faces = - usize::try_from(total_cells).map_err(|err| generation_failed(err.to_string()))?; - - let n = usize::try_from(vertices_per_slice) - .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 mut vertex_specs: Vec<([f64; 2], u32)> = Vec::new(); - vertex_specs - .try_reserve_exact(expected_vertices) - .map_err(|err| { - generation_failed(format!( - "from_cdt_strip() failed to reserve {expected_vertices} vertex specs for vertices_per_slice={vertices_per_slice}, num_slices={num_slices}: {err}" - )) - })?; - 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)]); - } - } - - // 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) - .map_err(|err| remap_strip_generation_error(err, total_vertices, coordinate_max))?; - - let backend = DelaunayBackend2D::from_triangulation(dt); - validate_strip_counts( - &backend, - total_vertices, - total_cells, - expected_vertices, - expected_faces, - vertices_per_slice, - 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)?; - - 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()?; - - 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. - /// - /// 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. - /// - /// # Mesh structure - /// - /// With `N = vertices_per_slice` and `T = num_slices` the resulting mesh - /// 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. - /// - /// # Arguments - /// - /// * `vertices_per_slice` — Number of vertices in each spatial slice (≥ 3). - /// * `num_slices` — Number of time slices (≥ 3 to keep `t-1` and `t+1` - /// distinct after wrap-around). - /// - /// # Errors - /// - /// 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`], - /// [`CdtError::CausalityViolation`], or [`CdtError::ValidationFailed`] if the - /// constructed triangulation fails CDT validation. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::triangulation::*; - /// - /// let tri = CdtTriangulation::from_toroidal_cdt(4, 3) - /// .expect("build toroidal CDT"); - /// assert_eq!(tri.vertex_count(), 12); - /// assert_eq!(tri.face_count(), 24); - /// assert!(tri.has_foliation()); - /// ``` - #[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 { - issue: "Insufficient vertices per slice".to_string(), - provided_value: vertices_per_slice.to_string(), - expected_range: "≥ 3".to_string(), - }); - } - if num_slices < 3 { - // With T=2 the wrap-around makes every pair of adjacent slices - // identify (t-1, t) with (t, t+1), so each spatial edge would be - // shared by 4 triangles instead of 2 — a non-manifold mesh. - return Err(CdtError::InvalidGenerationParameters { - issue: "Insufficient number of time slices".to_string(), - provided_value: num_slices.to_string(), - expected_range: "≥ 3".to_string(), - }); - } - - let total_vertices = vertices_per_slice.checked_mul(num_slices).ok_or_else(|| { - CdtError::InvalidGenerationParameters { - issue: "Vertex count overflow".to_string(), - provided_value: format!("{vertices_per_slice} × {num_slices}"), - expected_range: "product ≤ u32::MAX".to_string(), - } - })?; - let total_cells = - total_vertices - .checked_mul(2) - .ok_or_else(|| CdtError::InvalidGenerationParameters { - issue: "Cell count overflow".to_string(), - provided_value: format!("2 × {total_vertices}"), - expected_range: "product ≤ u32::MAX".to_string(), - })?; - - let generation_failed = - |underlying_error: String| toroidal_generation_error(total_vertices, underlying_error); - - let expected_vertices = - usize::try_from(total_vertices).map_err(|err| generation_failed(err.to_string()))?; - let expected_faces = - usize::try_from(total_cells).map_err(|err| generation_failed(err.to_string()))?; - - let n = usize::try_from(vertices_per_slice) - .map_err(|err| generation_failed(err.to_string()))?; - 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. - 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(); - vertex_specs - .try_reserve_exact(expected_vertices) - .map_err(|err| { - generation_failed(format!( - "from_toroidal_cdt() failed to reserve {expected_vertices} vertex specs for vertices_per_slice={vertices_per_slice}, num_slices={num_slices}: {err}" - )) - })?; - 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; - 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) - .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)?; - - let slice_sizes = vec![n; t_count]; - let foliation = - Foliation::from_slice_sizes(slice_sizes, num_slices).map_err(CdtError::from)?; - - 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()?; - - Ok(tri) - } - - // ------------------------------------------------------------------------- - // Foliation assignment - // ------------------------------------------------------------------------- - - /// Assign a foliation to an existing triangulation by binning vertices - /// by their y-coordinate into `num_slices` equal bands. - /// - /// The y-coordinate range is determined from the actual vertex coordinates. - /// Band `t` covers `[y_min + t * band_height, y_min + (t+1) * band_height)`. - /// Time labels are written directly to vertex data. - /// - /// This is approximate — useful for testing but not guaranteed to produce - /// a valid causal structure. - /// - /// # Errors - /// - /// Returns error if `num_slices` is zero, if vertex coordinates cannot be - /// read, if y-bucket assignment would leave any time slice empty, if the - /// requested slice count violates the triangulation topology, or if writing - /// vertex labels or clearing stale cell labels in the backend fails. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::triangulation::*; - /// - /// let mut tri = CdtTriangulation::from_seeded_points(12, 3, 2, 42) - /// .expect("create seeded triangulation"); - /// tri.assign_foliation_by_y(3) - /// .expect("assign foliation from y-coordinates"); - /// - /// assert!(tri.has_foliation()); - /// assert_eq!(tri.slice_sizes().iter().sum::(), tri.vertex_count()); - /// ``` - #[expect( - clippy::too_many_lines, - reason = "foliation assignment stages labels, writes backend payloads, and rolls back on failure to preserve atomic metadata/foliation invariants" - )] - pub fn assign_foliation_by_y(&mut self, num_slices: u32) -> CdtResult<()> { - if num_slices == 0 { - return Err(CdtError::InvalidGenerationParameters { - issue: "Number of slices must be positive".to_string(), - provided_value: "0".to_string(), - expected_range: "≥ 1".to_string(), - }); - } - - // Collect all vertex y-coordinates, failing fast if any vertex is unreadable. - let y_coords: Vec<(DelaunayVertexHandle, f64)> = self - .geometry - .vertices() - .map(|vh| { - let coords = self.geometry.vertex_coordinates(&vh).map_err(|e| { - CdtError::ValidationFailed { - check: "foliation_assignment".to_string(), - detail: format!( - "failed to read coordinates for vertex {:?}: {e}", - vh.vertex_key() - ), - } - })?; - if coords.len() < 2 { - return Err(CdtError::ValidationFailed { - check: "foliation_assignment".to_string(), - detail: format!( - "vertex {:?} has {} coordinates, expected ≥ 2", - vh.vertex_key(), - coords.len() - ), - }); - } - Ok((vh, coords[1])) - }) - .collect::>>()?; - - let y_min = y_coords - .iter() - .map(|(_, y)| *y) - .fold(f64::INFINITY, f64::min); - let y_max = y_coords - .iter() - .map(|(_, y)| *y) - .fold(f64::NEG_INFINITY, f64::max); - - let range = y_max - y_min; - let band_height = if range.abs() < f64::EPSILON { - 1.0 - } else { - range / f64::from(num_slices) - }; - - // Plan per-vertex labels in memory first so foliation validation errors - // do not partially mutate backend state. - let mut assignments = Vec::with_capacity(y_coords.len()); - let mut slice_sizes = vec![0usize; num_slices as usize]; - for (vh, y) in &y_coords { - let t = if range.abs() < f64::EPSILON { - 0 - } else { - let band_index = ((y - y_min) / band_height).floor(); - f64_band_to_u32(band_index, num_slices - 1) - }; - assignments.push((vh.vertex_key(), t)); - slice_sizes[t as usize] += 1; - } - - Self::check_time_slices(self.metadata.topology, num_slices)?; - - let foliation = - Foliation::from_slice_sizes(slice_sizes, num_slices).map_err(CdtError::from)?; - - // Clear stale cell classifications from any previous classify_all_cells() call, - // since vertex time labels are about to change. - let face_keys: Vec<_> = self.geometry.faces().map(|f| f.cell_key()).collect(); - let previous_cell_data: Vec<_> = face_keys - .iter() - .map(|&key| (key, self.geometry.cell_data_by_key(key))) - .collect(); - let previous_vertex_data: Vec<_> = assignments - .iter() - .map(|&(key, _)| (key, self.geometry.vertex_data_by_key(key))) - .collect(); - - let rollback_payloads = |geometry: &mut DelaunayBackend2D| -> Vec { - let mut rollback_errors = Vec::new(); - - for &(key, data) in &previous_cell_data { - if let Err(err) = geometry.set_cell_data_by_key(key, data) { - rollback_errors.push(format!("face {key:?}: {err}")); - } - } - - for &(key, data) in &previous_vertex_data { - if let Err(err) = geometry.set_vertex_data_by_key(key, data) { - rollback_errors.push(format!("vertex {key:?}: {err}")); - } - } - - rollback_errors - }; - - for &key in &face_keys { - if let Err(err) = self.geometry.set_cell_data_by_key(key, None) { - let operation = "set_cell_data_by_key".to_string(); - let target = format!("face {key:?}"); - let detail = err.to_string(); - let rollback_errors = rollback_payloads(&mut self.geometry); - return if rollback_errors.is_empty() { - Err(CdtError::BackendMutationFailed { - operation, - target, - detail, - }) - } else { - Err(CdtError::BackendRollbackFailed { - operation, - target, - detail, - rollback_errors: rollback_errors.join("; "), - }) - }; - } - } - - // Write time labels directly to vertex data via set_vertex_data_by_key (O(1) per vertex). - for (vertex_key, t) in assignments { - if let Err(err) = self.geometry.set_vertex_data_by_key(vertex_key, Some(t)) { - let operation = "set_vertex_data_by_key".to_string(); - let target = format!("vertex {vertex_key:?}"); - let detail = format!("failed while assigning time label {t}: {err}"); - let rollback_errors = rollback_payloads(&mut self.geometry); - return if rollback_errors.is_empty() { - Err(CdtError::BackendMutationFailed { - operation, - target, - detail, - }) - } else { - Err(CdtError::BackendRollbackFailed { - operation, - target, - detail, - rollback_errors: rollback_errors.join("; "), - }) - }; - } - } - - self.metadata.time_slices = num_slices; - self.bump_modification_count(); - self.foliation = Some(foliation); - self.mark_foliation_synchronized(); - Ok(()) - } - - // ------------------------------------------------------------------------- - // Foliation queries - // ------------------------------------------------------------------------- - - /// Returns `true` if this triangulation has an assigned foliation. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::CdtTriangulation; - /// - /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53).unwrap(); - /// assert!(!tri.has_foliation()); - /// ``` - #[must_use] - pub fn has_foliation(&self) -> bool { - self.has_current_foliation() - } - - /// Returns a reference to the foliation, if present. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::CdtTriangulation; - /// - /// let mut tri = CdtTriangulation::from_seeded_points(8, 2, 2, 53).unwrap(); - /// tri.assign_foliation_by_y(2).unwrap(); - /// assert!(tri.foliation().is_some()); - /// ``` - #[must_use] - pub fn foliation(&self) -> Option<&Foliation> { - if self.has_current_foliation() { - self.foliation.as_ref() - } else { - None - } - } - - /// Returns the time slice label for a vertex, or `None` if no foliation - /// is present or the vertex is unlabeled. - /// - /// Reads the time label directly from the vertex data stored in the - /// Delaunay triangulation (like CDT++ `vertex->info()`). - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::geometry::*; - /// use causal_triangulations::CdtTriangulation; - /// - /// let mut tri = CdtTriangulation::from_seeded_points(8, 2, 2, 53).unwrap(); - /// tri.assign_foliation_by_y(2).unwrap(); - /// let vertex = tri.geometry().vertices().next().unwrap(); - /// assert!(tri.time_label(&vertex).is_some()); - /// ``` - #[must_use] - pub fn time_label(&self, vertex: &DelaunayVertexHandle) -> Option { - self.foliation.as_ref()?; - self.geometry.vertex_data_by_key(vertex.vertex_key()) - } - - /// Returns all vertex handles that belong to time slice `t`. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::CdtTriangulation; - /// - /// let mut tri = CdtTriangulation::from_seeded_points(8, 2, 2, 53).unwrap(); - /// tri.assign_foliation_by_y(2).unwrap(); - /// assert!(!tri.vertices_at_time(0).is_empty()); - /// ``` - #[must_use] - pub fn vertices_at_time(&self, t: u32) -> Vec { - if self.foliation.is_none() { - return vec![]; - } - self.geometry - .vertices() - .filter(|vh| self.geometry.vertex_data_by_key(vh.vertex_key()) == Some(t)) - .collect() - } - - /// Returns per-slice vertex counts, or an empty slice if no foliation. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::CdtTriangulation; - /// - /// let mut tri = CdtTriangulation::from_seeded_points(8, 2, 2, 53).unwrap(); - /// tri.assign_foliation_by_y(2).unwrap(); - /// assert_eq!(tri.slice_sizes().len(), 2); - /// ``` - #[must_use] - pub fn slice_sizes(&self) -> &[usize] { - self.foliation().map_or(&[], Foliation::slice_sizes) - } - - // ------------------------------------------------------------------------- - // Cell (triangle) classification - // ------------------------------------------------------------------------- - - /// Computes the temporal step distance between two time labels. - /// - /// For `OpenBoundary` topology this is just `t0.abs_diff(t1)`; for - /// `Toroidal` topology it is the circular distance - /// `min(d, T − d)` where `T = time_slices`, so the wrap-around edge - /// between slice `T − 1` and slice `0` reads as distance `1`. - /// - /// Out-of-range labels (`t ≥ time_slices`) bypass the toroidal wrap and - /// fall through to the raw difference so callers (e.g. validators) can - /// still detect them as causality violations rather than silently folding - /// `t == time_slices` into distance 0 via `min(T, 0)`. - #[must_use] - fn time_step_distance(&self, t0: u32, t1: u32) -> u32 { - let raw = t0.abs_diff(t1); - if matches!(self.metadata.topology, CdtTopology::Toroidal) { - let total = self.metadata.time_slices; - if total > 0 && t0 < total && t1 < total { - return raw.min(total - raw); - } - } - raw - } - - /// Topology-aware variant of [`crate::cdt::foliation::classify_cell`]. - /// - /// Uses [`Self::time_step_distance`] so the spacelike/timelike split - /// honours toroidal wrap. Distinguishes Up vs Down by checking whether - /// the apex sits at `base + 1` or `base - 1` modulo `time_slices`. - fn classify_cell_with_topology(&self, t0: u32, t1: u32, t2: u32) -> Option { - let mut dists = [ - self.time_step_distance(t0, t1), - self.time_step_distance(t1, t2), - self.time_step_distance(t0, t2), - ]; - dists.sort_unstable(); - if dists != [0, 1, 1] { - return None; - } - - let (base_slice, apex_slice) = if t0 == t1 { - (t0, t2) - } else if t1 == t2 { - (t1, t0) - } else if t0 == t2 { - (t0, t1) - } else { - return None; - }; - - let total = self.metadata.time_slices; - let toroidal = matches!(self.metadata.topology, CdtTopology::Toroidal) && total > 0; - let up_apex = if toroidal { - (base_slice + 1) % total - } else { - base_slice.checked_add(1)? - }; - let down_apex = if toroidal { - if base_slice == 0 { - total - 1 - } else { - base_slice - 1 - } - } else { - base_slice.checked_sub(1)? - }; - if apex_slice == up_apex { - Some(CellType::Up) - } else if apex_slice == down_apex { - Some(CellType::Down) - } else { - None - } - } - - /// Returns the causal classification of an edge from endpoint time labels. - /// - /// Returns `None` if no foliation is present, the edge endpoints cannot be - /// resolved, or either endpoint is missing a time label. Distance is - /// circular when the topology is `Toroidal`. - /// - /// # Examples - /// - /// ``` - /// 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 tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - /// .expect("create foliated triangulation"); - /// - /// let edge = tri.geometry().edges().next().expect("triangle has edges"); - /// let edge_type = tri.edge_type(&edge).expect("edge should be classifiable"); - /// assert!(!matches!(edge_type, EdgeType::Acausal)); - /// ``` - #[must_use] - pub fn edge_type(&self, edge: &DelaunayEdgeHandle) -> Option { - self.foliation.as_ref()?; - - let (v0, v1) = self.geometry.edge_endpoints(edge)?; - let t0 = self.geometry.vertex_data_by_key(v0.vertex_key())?; - let t1 = self.geometry.vertex_data_by_key(v1.vertex_key())?; - - Some(match self.time_step_distance(t0, t1) { - 0 => EdgeType::Spacelike, - 1 => EdgeType::Timelike, - _ => EdgeType::Acausal, - }) - } - - /// Classifies a triangle as Up (2,1) or Down (1,2) from vertex time labels. - /// - /// Returns `None` if no foliation is present, the face vertices cannot - /// be resolved, any vertex lacks a time label, or the triangle does not - /// span exactly one time slice (e.g. a boundary same-slice triangle). - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::triangulation::*; - /// - /// let mut tri = CdtTriangulation::from_seeded_points(12, 3, 2, 42) - /// .expect("create seeded triangulation"); - /// tri.assign_foliation_by_y(3) - /// .expect("assign foliation from y-coordinates"); - /// // Most cells should be classifiable; boundary cells may not be. - /// let classified: usize = tri.geometry().faces() - /// .filter(|f| tri.cell_type(f).is_some()) - /// .count(); - /// assert!(classified > 0); - /// ``` - #[must_use] - pub fn cell_type(&self, face: &DelaunayFaceHandle) -> Option { - self.foliation.as_ref()?; - let verts = self.geometry.face_vertices(face).ok()?; - if verts.len() != 3 { - return None; - } - let t0 = self.geometry.vertex_data_by_key(verts[0].vertex_key())?; - let t1 = self.geometry.vertex_data_by_key(verts[1].vertex_key())?; - let t2 = self.geometry.vertex_data_by_key(verts[2].vertex_key())?; - match self.metadata.topology { - CdtTopology::Toroidal => self.classify_cell_with_topology(t0, t1, t2), - CdtTopology::OpenBoundary => classify_cell(Some(t0), Some(t1), Some(t2)), - } - } - - /// Reads the stored cell type from cell data, if previously classified. - /// - /// Returns `None` if the face has no cell data or the data does not - /// encode a valid [`CellType`]. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::geometry::*; - /// use causal_triangulations::CdtTriangulation; - /// - /// let mut tri = CdtTriangulation::from_cdt_strip(4, 2).unwrap(); - /// tri.classify_all_cells().unwrap(); - /// let face = tri.geometry().faces().next().unwrap(); - /// let _stored = tri.cell_type_from_data(&face); - /// ``` - #[must_use] - pub fn cell_type_from_data(&self, face: &DelaunayFaceHandle) -> Option { - self.foliation()?; - let raw = self.geometry.cell_data_by_key(face.cell_key())?; - CellType::from_i32(raw) - } - - /// Returns the edge classification for a triangular face. - /// - /// Edge ordering matches the face vertex cycle `(v0-v1, v1-v2, v2-v0)`. - /// Returns `None` if foliation is absent, face vertices cannot be resolved, - /// the face is not triangular, or any vertex is unlabeled. - /// - /// # Examples - /// - /// ``` - /// 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 tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - /// .expect("create foliated triangulation"); - /// - /// let face = tri.geometry().faces().next().expect("triangle has a face"); - /// let edge_types = tri - /// .face_edge_types(&face) - /// .expect("face edge types should be available"); - /// - /// let spacelike = edge_types - /// .iter() - /// .filter(|e| matches!(e, EdgeType::Spacelike)) - /// .count(); - /// let timelike = edge_types - /// .iter() - /// .filter(|e| matches!(e, EdgeType::Timelike)) - /// .count(); - /// assert_eq!(spacelike, 1); - /// assert_eq!(timelike, 2); - /// ``` - #[must_use] - pub fn face_edge_types(&self, face: &DelaunayFaceHandle) -> Option<[EdgeType; 3]> { - self.foliation.as_ref()?; - - let verts = self.geometry.face_vertices(face).ok()?; - if verts.len() != 3 { - return None; - } - - let t = [ - self.geometry.vertex_data_by_key(verts[0].vertex_key())?, - self.geometry.vertex_data_by_key(verts[1].vertex_key())?, - self.geometry.vertex_data_by_key(verts[2].vertex_key())?, - ]; - - let edge_classify = |a: u32, b: u32| -> Option { - Some(match self.time_step_distance(a, b) { - 0 => EdgeType::Spacelike, - 1 => EdgeType::Timelike, - _ => EdgeType::Acausal, - }) - }; - - Some([ - edge_classify(t[0], t[1])?, - edge_classify(t[1], t[2])?, - edge_classify(t[2], t[0])?, - ]) - } - - /// Validates that every finite face has a strict CDT cell classification. - /// - /// If no foliation is present, succeeds vacuously. Otherwise every face - /// must classify as [`CellType::Up`] or [`CellType::Down`]. - /// - /// # Errors - /// - /// Returns [`CdtError::ValidationFailed`] if any face is unclassifiable. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::triangulation::*; - /// - /// let tri = CdtTriangulation::from_cdt_strip(4, 2) - /// .expect("build explicit CDT strip"); - /// tri.validate_cell_classification() - /// .expect("all strip cells classify"); - /// ``` - pub fn validate_cell_classification(&self) -> CdtResult<()> { - if self.foliation.is_none() { - return Ok(()); - } - - for face in self.geometry.faces() { - if self.cell_type(&face).is_none() { - return Err(CdtError::ValidationFailed { - check: "cell_classification".to_string(), - detail: format!( - "face {:?} is not a strict CDT cell (expected Up or Down)", - face.cell_key() - ), - }); - } - } - - Ok(()) - } - - /// Classifies every triangle and stores the result as cell data. - /// - /// Each classifiable cell receives `Some(CellType::to_i32())` via - /// `set_cell_data`. On a foliated triangulation, all finite faces must be - /// classifiable; unclassifiable same-slice or multi-slice triangles are - /// reported as validation failures. - /// - /// Requires a foliation to be present; returns `Ok(None)` if there is none. - /// - /// # Errors - /// - /// Returns [`CdtError::ValidationFailed`] if any foliated face is not an - /// Up or Down CDT triangle. Returns [`CdtError::BackendMutationFailed`] if - /// writing cell payloads to the backend fails, or - /// [`CdtError::BackendRollbackFailed`] if restoring previous payloads also fails. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::prelude::triangulation::*; - /// - /// let mut tri = CdtTriangulation::from_cdt_strip(4, 2) - /// .expect("create explicit strip"); - /// let classified = tri - /// .classify_all_cells() - /// .expect("classify cells") - /// .expect("foliation is present"); - /// assert!(classified > 0); - /// ``` - pub fn classify_all_cells(&mut self) -> CdtResult> { - if self.foliation.is_none() { - return Ok(None); - } - - let faces: Vec<_> = self.geometry.faces().collect(); - let mut classifications = Vec::with_capacity(faces.len()); - for face in &faces { - let Some(ct) = self.cell_type(face) else { - return Err(CdtError::ValidationFailed { - check: "cell_classification".to_string(), - detail: format!( - "face {:?} is not a strict CDT cell (expected Up or Down)", - face.cell_key() - ), - }); - }; - classifications.push((face.cell_key(), ct)); - } - - let count = classifications.len(); - let previous_cell_data: Vec<_> = faces - .iter() - .map(|face| { - let key = face.cell_key(); - (key, self.geometry.cell_data_by_key(key)) - }) - .collect(); - let rollback_cell_payloads = |geometry: &mut DelaunayBackend2D| -> Vec { - let mut rollback_errors = Vec::new(); - - for &(key, data) in &previous_cell_data { - if let Err(err) = geometry.set_cell_data_by_key(key, data) { - rollback_errors.push(format!("face {key:?}: {err}")); - } - } - - rollback_errors - }; - - // Clear all cell data first, then write fresh classifications. - for face in &faces { - let key = face.cell_key(); - if let Err(err) = self.geometry.set_cell_data_by_key(key, None) { - let operation = "set_cell_data_by_key".to_string(); - let target = format!("face {key:?}"); - let detail = - format!("failed to clear existing cell payload before classification: {err}"); - let rollback_errors = rollback_cell_payloads(&mut self.geometry); - return if rollback_errors.is_empty() { - Err(CdtError::BackendMutationFailed { - operation, - target, - detail, - }) - } else { - Err(CdtError::BackendRollbackFailed { - operation, - target, - detail, - rollback_errors: rollback_errors.join("; "), - }) - }; - } - } - for (key, ct) in classifications { - if let Err(err) = self.geometry.set_cell_data_by_key(key, Some(ct.to_i32())) { - let operation = "set_cell_data_by_key".to_string(); - let target = format!("face {key:?}"); - let detail = format!( - "failed to store classified cell payload {}: {err}", - ct.to_i32() - ); - let rollback_errors = rollback_cell_payloads(&mut self.geometry); - return if rollback_errors.is_empty() { - Err(CdtError::BackendMutationFailed { - operation, - target, - detail, - }) - } else { - Err(CdtError::BackendRollbackFailed { - operation, - target, - detail, - rollback_errors: rollback_errors.join("; "), - }) - }; - } - } - Ok(Some(count)) - } - - /// Rebuilds foliation bookkeeping from live backend vertex labels after a topology edit. - /// - /// Ergodic moves preserve labels on existing vertices and assign labels to - /// inserted vertices before calling this helper. The backend topology has - /// already been mutated, so this method only refreshes CDT-side aggregate - /// bookkeeping and cell payload classifications. - pub(crate) fn synchronize_foliation_from_live_labels(&mut self) -> CdtResult<()> { - if self.foliation.is_none() { - return Ok(()); - } - - let slice_sizes = - Self::live_slice_sizes_from_vertex_labels(&self.geometry, self.metadata.time_slices)?; - let foliation = Foliation::from_slice_sizes(slice_sizes, self.metadata.time_slices) - .map_err(CdtError::from)?; - - self.foliation = Some(foliation); - match self.classify_all_cells() { - Ok(_) => { - self.mark_foliation_synchronized(); - Ok(()) - } - Err(err) => { - self.foliation = None; - self.foliation_synced_at_modification = None; - Err(err) - } - } - } - - /// Validate CDT properties (geometry, Delaunay, topology, causality, foliation). - /// - /// # Errors - /// Returns error if any validation check fails. - /// - /// # Examples - /// - /// ``` - /// use causal_triangulations::CdtTriangulation; - /// - /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53).unwrap(); - /// assert!(tri.validate().is_ok()); - /// ``` - 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(), - ), - }); - } - - if !self.geometry.is_delaunay() { - return Err(CdtError::ValidationFailed { - check: "Delaunay".to_string(), - detail: format!( - "triangulation does not satisfy Delaunay property (V={}, E={}, F={})", - self.geometry.vertex_count(), - self.geometry.edge_count(), - self.geometry.face_count(), - ), - }); - } - - self.validate_topology()?; - self.validate_foliation()?; - self.validate_causality()?; - self.validate_cell_classification()?; - - Ok(()) - } - - /// Validate causality constraints. - /// - /// If no foliation is present, succeeds vacuously (no causal structure - /// to check). Otherwise delegates to [`validate_causality_delaunay`](Self::validate_causality_delaunay). - /// - /// # Errors - /// - /// Returns error if any edge spans more than one time slice (`|Δt| > 1`). - /// - /// # Examples - /// - /// ``` - /// 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 tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - /// .expect("create foliated triangulation"); - /// assert!(tri.validate_causality().is_ok()); - /// ``` - pub fn validate_causality(&self) -> CdtResult<()> { - self.validate_causality_delaunay() - } - - /// Validates the causal structure of this foliated triangulation. - /// - /// Reads time labels directly from face vertex data and checks that every - /// triangle lies within a single slice pair. In a 2D triangulation, this - /// implies that each edge of each finite face connects vertices within the - /// same slice or adjacent slices, while avoiding backend-specific edge - /// endpoint resolution. - /// - /// # Errors - /// - /// Returns error if any triangle spans more than one time slice, if a face - /// cannot be resolved to three vertices, or if any face vertex is unlabeled. - /// - /// # Examples - /// - /// ``` - /// 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 tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - /// .expect("create foliated triangulation"); - /// assert!(tri.validate_causality_delaunay().is_ok()); - /// ``` - #[expect( - clippy::too_many_lines, - reason = "causality validation includes detailed diagnostics for multiple face-resolution and label error paths" - )] - pub fn validate_causality_delaunay(&self) -> CdtResult<()> { - if self.foliation.is_none() { - return Ok(()); - } - - for face in self.geometry.faces() { - let verts = self.geometry.face_vertices(&face).map_err(|err| { - log::debug!( - "Causality validation failed to resolve vertices for face {:?}: {err}; vertex_count={}, edge_count={}, face_count={}", - face, - self.geometry.vertex_count(), - self.geometry.edge_count(), - self.geometry.face_count(), - ); - CdtError::ValidationFailed { - check: "causality".to_string(), - detail: "failed to resolve face vertices".to_string(), - } - })?; - - if verts.len() != 3 { - return Err(CdtError::ValidationFailed { - check: "causality".to_string(), - detail: format!( - "face {:?} has {} vertices, expected 3", - face.cell_key(), - verts.len(), - ), - }); - } - - let t0 = self - .geometry - .vertex_data_by_key(verts[0].vertex_key()) - .ok_or_else(|| { - log::debug!( - "Causality validation found unlabeled vertex {:?} while checking face {:?}", - verts[0].vertex_key(), - face, - ); - CdtError::ValidationFailed { - check: "causality".to_string(), - detail: format!( - "vertex {:?} has no time label in a foliated triangulation", - verts[0].vertex_key(), - ), - } - })?; - let t1 = self - .geometry - .vertex_data_by_key(verts[1].vertex_key()) - .ok_or_else(|| { - log::debug!( - "Causality validation found unlabeled vertex {:?} while checking face {:?}", - verts[1].vertex_key(), - face, - ); - CdtError::ValidationFailed { - check: "causality".to_string(), - detail: format!( - "vertex {:?} has no time label in a foliated triangulation", - verts[1].vertex_key(), - ), - } - })?; - let t2 = self - .geometry - .vertex_data_by_key(verts[2].vertex_key()) - .ok_or_else(|| { - log::debug!( - "Causality validation found unlabeled vertex {:?} while checking face {:?}", - verts[2].vertex_key(), - face, - ); - CdtError::ValidationFailed { - check: "causality".to_string(), - detail: format!( - "vertex {:?} has no time label in a foliated triangulation", - verts[2].vertex_key(), - ), - } - })?; - - // CDT triangle invariant: exactly 1 spacelike edge, 2 timelike edges. - // On Toroidal topology the wrap-around edge between slice T−1 and 0 - // is timelike, so we use the topology-aware step distance. - let mut spacelike = 0; - let mut timelike = 0; - - for (a, b) in [(t0, t1), (t1, t2), (t2, t0)] { - let step_distance = self.time_step_distance(a, b); - match step_distance { - 0 => spacelike += 1, - 1 => timelike += 1, - _ => { - return Err(CdtError::CausalityViolation { - time_0: a.min(b), - time_1: a.max(b), - step_distance, - }); - } - } - } - - if !(spacelike == 1 && timelike == 2) { - return Err(CdtError::ValidationFailed { - check: "causality".to_string(), - detail: format!( - "invalid CDT triangle at face {:?}: spacelike={}, timelike={}", - face.cell_key(), - spacelike, - timelike - ), - }); - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::thread; - use std::time::{Duration, Instant}; - - /// Builds a minimal labeled Delaunay backend for foliation and causality tests. - fn labeled_triangle_backend(labels: [u32; 3]) -> DelaunayBackend2D { - let dt = build_delaunay2_with_data(&[ - ([0.0, 0.0], labels[0]), - ([1.0, 0.0], labels[1]), - ([0.5, 1.0], labels[2]), - ]) - .expect("Should build labeled triangle"); - DelaunayBackend2D::from_triangulation(dt) - } - - /// Builds intentionally unchecked metadata for legacy validation tests. - fn unchecked_open_boundary( - backend: DelaunayBackend2D, - time_slices: u32, - dimension: u8, - ) -> CdtTriangulation { - CdtTriangulation::wrap_unchecked(backend, time_slices, dimension, CdtTopology::OpenBoundary) - } - - #[test] - fn test_remap_toroidal_generation_error_updates_context() { - let remapped = remap_toroidal_generation_error( - CdtError::DelaunayGenerationFailed { - vertex_count: 3, - coordinate_range: (-1.0, 1.0), - attempt: 7, - underlying_error: "builder failed".to_string(), - }, - 12, - ); - - assert!(matches!( - remapped, - CdtError::DelaunayGenerationFailed { - vertex_count: 12, - coordinate_range: (0.0, 1.0), - attempt: 1, - ref underlying_error, - } if underlying_error == "builder failed" - )); - } - - #[test] - fn test_remap_toroidal_generation_error_preserves_other_errors() { - let original = CdtError::InvalidGenerationParameters { - issue: "bad".to_string(), - provided_value: "x".to_string(), - expected_range: "y".to_string(), - }; - assert_eq!( - remap_toroidal_generation_error(original.clone(), 12), - original - ); - } - - #[test] - fn test_from_random_points() { - let triangulation = - CdtTriangulation::from_random_points(10, 3, 2).expect("Failed to create triangulation"); - - assert_eq!(triangulation.dimension(), 2); - assert_eq!(triangulation.time_slices(), 3); - assert!(triangulation.vertex_count() > 0); - assert!(triangulation.edge_count() > 0); - assert!(triangulation.face_count() > 0); - } - - #[test] - fn test_from_random_points_various_sizes() { - let test_cases = [ - (3, 1, "minimal"), - (5, 2, "small"), - (10, 3, "medium"), - (20, 5, "large"), - ]; - - for (vertices, time_slices, description) in test_cases { - let triangulation = CdtTriangulation::from_random_points(vertices, time_slices, 2) - .unwrap_or_else(|e| panic!("Failed to create {description} triangulation: {e}")); - - assert_eq!( - triangulation.dimension(), - 2, - "Dimension should be 2 for {description}" - ); - assert_eq!( - triangulation.time_slices(), - time_slices, - "Time slices should match for {description}" - ); - assert!( - triangulation.vertex_count() >= 3, - "Should have at least 3 vertices for {description}" - ); - assert!( - triangulation.edge_count() > 0, - "Should have edges for {description}" - ); - assert!( - triangulation.face_count() > 0, - "Should have faces for {description}" - ); - } - } - - #[test] - fn test_from_seeded_points() { - let seed = 42; - let triangulation = CdtTriangulation::from_seeded_points(8, 2, 2, seed) - .expect("Failed to create seeded triangulation"); - - assert_eq!(triangulation.dimension(), 2); - assert_eq!(triangulation.time_slices(), 2); - assert!(triangulation.vertex_count() > 0); - assert!(triangulation.edge_count() > 0); - assert!(triangulation.face_count() > 0); - } - - #[test] - fn test_seeded_determinism() { - let seed = 123; - let params = (6, 3, 2); - - let triangulation1 = - CdtTriangulation::from_seeded_points(params.0, params.1, params.2, seed) - .expect("Failed to create first triangulation"); - let triangulation2 = - CdtTriangulation::from_seeded_points(params.0, params.1, params.2, seed) - .expect("Failed to create second triangulation"); - - // Should produce identical results - assert_eq!(triangulation1.vertex_count(), triangulation2.vertex_count()); - assert_eq!(triangulation1.edge_count(), triangulation2.edge_count()); - assert_eq!(triangulation1.face_count(), triangulation2.face_count()); - assert_eq!(triangulation1.dimension(), triangulation2.dimension()); - assert_eq!(triangulation1.time_slices(), triangulation2.time_slices()); - } - - #[test] - fn test_seeded_different_seeds() { - let params = (7, 2, 2); - let tri1 = CdtTriangulation::from_seeded_points(params.0, params.1, params.2, 456) - .expect("Failed to create triangulation with seed 456"); - let tri2 = CdtTriangulation::from_seeded_points(params.0, params.1, params.2, 789) - .expect("Failed to create triangulation with seed 789"); - - // Both should succeed but may differ in structure - assert_eq!(tri1.dimension(), tri2.dimension()); - assert_eq!(tri1.time_slices(), tri2.time_slices()); - // Vertex counts should be same as input - assert_eq!(tri1.vertex_count(), 7); - assert_eq!(tri2.vertex_count(), 7); - } - - #[test] - fn test_invalid_dimension() { - let invalid_dimensions = [0, 1, 3, 4, 5]; - for dim in invalid_dimensions { - let result = CdtTriangulation::from_random_points(10, 3, dim); - assert!(result.is_err(), "Should fail with dimension {dim}"); - - if let Err(CdtError::UnsupportedDimension(d)) = result { - assert_eq!(d, u32::from(dim), "Error should report correct dimension"); - } else { - panic!("Expected UnsupportedDimension error for dimension {dim}"); - } - } - } - - #[test] - fn test_from_seeded_points_rejects_invalid_dimension() { - let result = CdtTriangulation::from_seeded_points(10, 3, 3, 42); - - assert!(matches!(result, Err(CdtError::UnsupportedDimension(3)))); - } - - #[test] - fn test_try_new_rejects_zero_time_slices() { - let backend = labeled_triangle_backend([0, 0, 1]); - let result = CdtTriangulation::try_new(backend, 0, 2); - - assert!(matches!( - result, - Err(CdtError::InvalidTriangulationMetadata { - ref field, - ref topology, - ref provided_value, - ref expected, - }) if field == "timeslices" - && topology == "open boundary" - && provided_value == "0" - && expected == "≥ 1" - )); - } - - #[test] - fn test_try_new_rejects_dimension_mismatch() { - let backend = labeled_triangle_backend([0, 0, 1]); - let result = CdtTriangulation::try_new(backend, 2, 3); - - assert!(matches!( - result, - Err(CdtError::InvalidTriangulationMetadata { - ref field, - ref topology, - ref provided_value, - ref expected, - }) if field == "dimension" - && topology == "open boundary" - && provided_value == "3" - && expected == "backend dimension (2)" - )); - } - - #[test] - fn test_validate_topology_rejects_legacy_dimension_mismatch() { - let backend = labeled_triangle_backend([0, 0, 1]); - let tri = unchecked_open_boundary(backend, 2, 3); - let result = tri.validate_topology(); - - assert!(matches!( - result, - Err(CdtError::InvalidTriangulationMetadata { - ref field, - ref provided_value, - ref expected, - .. - }) if field == "dimension" - && provided_value == "3" - && expected == "backend dimension (2)" - )); - } - - #[test] - fn test_from_seeded_points_rejects_zero_time_slices() { - let result = CdtTriangulation::from_seeded_points(5, 0, 2, 53); - - assert!(matches!( - result, - Err(CdtError::InvalidTriangulationMetadata { - ref field, - ref provided_value, - ref expected, - .. - }) if field == "timeslices" && provided_value == "0" && expected == "≥ 1" - )); - } - - #[test] - fn test_invalid_vertex_count() { - let invalid_counts = [0, 1, 2]; - for count in invalid_counts { - let result = CdtTriangulation::from_random_points(count, 2, 2); - assert!(result.is_err(), "Should fail with {count} vertices"); - - match result { - Err(CdtError::InvalidGenerationParameters { - issue, - provided_value, - .. - }) => { - assert_eq!(issue, "Insufficient vertex count"); - assert_eq!(provided_value, count.to_string()); - } - other => panic!( - "Expected InvalidGenerationParameters for {count} vertices, got {other:?}" - ), - } - } - } - - #[test] - fn test_invalid_vertex_count_seeded() { - let result = CdtTriangulation::from_seeded_points(2, 2, 2, 123); - assert!(result.is_err(), "Should fail with 2 vertices"); - - match result { - Err(CdtError::InvalidGenerationParameters { - issue, - provided_value, - .. - }) => { - assert_eq!(issue, "Insufficient vertex count"); - assert_eq!(provided_value, "2"); - } - other => panic!("Expected InvalidGenerationParameters, got {other:?}"), - } - } - - #[test] - fn test_geometry_access() { - let triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - // Test immutable access - let geometry = triangulation.geometry(); - assert!(geometry.vertex_count() > 0); - assert!(geometry.is_valid()); - assert_eq!(geometry.dimension(), 2); - } - - #[test] - fn test_basic_properties() { - let triangulation = - CdtTriangulation::from_random_points(8, 4, 2).expect("Failed to create triangulation"); - - // Test basic property getters - assert_eq!(triangulation.dimension(), 2); - assert_eq!(triangulation.time_slices(), 4); - assert_eq!(triangulation.vertex_count(), 8); - - let edge_count = triangulation.edge_count(); - let face_count = triangulation.face_count(); - - assert!(edge_count > 0, "Should have edges"); - assert!(face_count > 0, "Should have faces"); - - // For a triangulation, we expect certain relationships - assert!( - edge_count >= triangulation.vertex_count(), - "Usually E >= V for connected triangulation" - ); - assert!(face_count >= 1, "Should have at least one face"); - } - - #[test] - fn test_metadata_initialization() { - let triangulation = - CdtTriangulation::from_random_points(6, 3, 2).expect("Failed to create triangulation"); - - // Check that metadata is properly initialized - assert_eq!(triangulation.dimension(), 2); - assert_eq!(triangulation.time_slices(), 3); - - // Metadata should be accessible through debug formatting - let debug_output = format!("{triangulation:?}"); - assert!(debug_output.contains("CdtTriangulation")); - assert!(debug_output.contains("CdtMetadata")); - } - - #[test] - fn test_creation_history() { - let triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - // Should have at least one creation event - assert!(!triangulation.metadata().simulation_history.is_empty()); - - match &triangulation.metadata().simulation_history[0] { - SimulationEvent::Created { - vertex_count, - time_slices, - } => { - assert_eq!(*vertex_count, 5); - assert_eq!(*time_slices, 2); - } - _ => panic!("First event should be Creation"), - } - } - - #[test] - fn test_metadata_mutation_invalidates_cache() { - let mut triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - // Get initial edge count - let initial_edge_count = triangulation.edge_count(); - assert!(initial_edge_count > 0); - - let initial_mod_count = triangulation.metadata().modification_count; - - triangulation.bump_modification_count(); - - // Modification count should have increased - assert_eq!( - triangulation.metadata().modification_count, - initial_mod_count + 1 - ); - - // Cache should have been invalidated but recalculated value should be same - let recalculated_edge_count = triangulation.edge_count(); - assert_eq!(initial_edge_count, recalculated_edge_count); - } - - #[test] - fn test_cache_refresh_functionality() { - let mut triangulation = - CdtTriangulation::from_random_points(6, 2, 2).expect("Failed to create triangulation"); - - // Get initial counts without cache - let edge_count_1 = triangulation.edge_count(); - - // Refresh cache - triangulation.refresh_cache(); - - // Should return same values from cache - let edge_count_2 = triangulation.edge_count(); - assert_eq!( - edge_count_1, edge_count_2, - "Cache should return consistent values" - ); - - // Multiple cache hits should be consistent - let edge_count_3 = triangulation.edge_count(); - assert_eq!( - edge_count_1, edge_count_3, - "Multiple cache hits should be consistent" - ); - } - - #[test] - fn test_cache_invalidation_on_mutation() { - let mut triangulation = - CdtTriangulation::from_random_points(6, 2, 2).expect("Failed to create triangulation"); - - // Populate cache - triangulation.refresh_cache(); - let cached_count = triangulation.edge_count(); - - triangulation.bump_modification_count(); - - // Edge count should still be correct (recalculated, not cached) - let new_count = triangulation.edge_count(); - assert_eq!( - cached_count, new_count, - "Edge count should remain consistent after cache invalidation" - ); - } - - #[test] - fn test_euler_characteristic() { - // Use fixed seed to ensure deterministic closed triangulation with Euler=2 - // Seed 53 produces V=5, E=9, F=6, Euler=2 for this configuration - const TRIANGULATION_SEED: u64 = 53; - - let triangulation = CdtTriangulation::from_seeded_points(5, 2, 2, TRIANGULATION_SEED) - .expect("Failed to create triangulation with fixed seed"); - - let result = triangulation.geometry().is_valid(); - assert!(result, "Validation should succeed for closed triangulation"); - } - - #[test] - fn test_validation_success() { - // Use a known good seed that produces valid triangulation - const GOOD_SEED: u64 = 53; // Known to produce Euler=2 - - let triangulation = CdtTriangulation::from_seeded_points(5, 2, 2, GOOD_SEED) - .expect("Failed to create triangulation"); - - let result = triangulation.validate(); - assert!( - result.is_ok(), - "Validation should succeed for good triangulation: {result:?}" - ); - } - - #[test] - fn test_validate_topology() { - // Test with various configurations to check topology validation - let seeds = [53, 87, 203]; // Known good seeds that produce Euler=2 - - for seed in seeds { - let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, seed) - .expect("Failed to create triangulation"); - - let result = triangulation.validate_topology(); - assert!( - result.is_ok(), - "Topology validation should succeed for seed {seed}: {result:?}" - ); - } - } - - #[test] - fn test_validate_causality_no_foliation() { - let triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - // Without foliation, causality validation succeeds vacuously - let result = triangulation.validate_causality(); - assert!(result.is_ok(), "Causality should pass without foliation"); - } - - #[test] - fn test_validate_foliation_no_foliation() { - let triangulation = - CdtTriangulation::from_random_points(5, 3, 2).expect("Failed to create triangulation"); - - // Without foliation, foliation validation succeeds vacuously - let result = triangulation.validate_foliation(); - assert!(result.is_ok(), "Foliation should pass without foliation"); - } - - #[test] - fn test_simulation_event_recording() { - let mut triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - let initial_history_len = triangulation.metadata().simulation_history.len(); - let initial_last_modified = triangulation.metadata().last_modified; - thread::sleep(Duration::from_millis(5)); - - triangulation.record_event(SimulationEvent::MoveAttempted { - move_type: "test_move".to_string(), - step: 1, - }); - - triangulation.record_event(SimulationEvent::MoveAccepted { - move_type: "test_move".to_string(), - step: 1, - action_change: -0.5, - }); - - triangulation.record_event(SimulationEvent::MeasurementTaken { - step: 2, - action: 10.5, - }); - - // Should have 3 more events - assert_eq!( - triangulation.metadata().simulation_history.len(), - initial_history_len + 3 - ); - assert!(triangulation.metadata().last_modified > initial_last_modified); - - // Check the recorded events - let history = &triangulation.metadata().simulation_history; - match &history[initial_history_len] { - SimulationEvent::MoveAttempted { move_type, step } => { - assert_eq!(move_type, "test_move"); - assert_eq!(*step, 1); - } - _ => panic!("Expected MoveAttempted event"), - } - - match &history[initial_history_len + 1] { - SimulationEvent::MoveAccepted { - move_type, - step, - action_change, - } => { - assert_eq!(move_type, "test_move"); - assert_eq!(*step, 1); - approx::assert_relative_eq!(*action_change, -0.5); - } - _ => panic!("Expected MoveAccepted event"), - } - - match &history[initial_history_len + 2] { - SimulationEvent::MeasurementTaken { step, action } => { - assert_eq!(*step, 2); - approx::assert_relative_eq!(*action, 10.5); - } - _ => panic!("Expected MeasurementTaken event"), - } - } - - #[test] - fn test_record_event_keeps_foliation_synchronized() { - 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 mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - - let vertex = tri - .geometry() - .vertices() - .next() - .expect("Triangle should contain a vertex"); - let edge = tri - .geometry() - .edges() - .next() - .expect("Triangle should contain an edge"); - let face = tri - .geometry() - .faces() - .next() - .expect("Triangle should contain a face"); - - let initial_modification_count = tri.metadata().modification_count; - let initial_slice_sizes = tri.slice_sizes().to_vec(); - - tri.record_event(SimulationEvent::MoveAttempted { - move_type: "history_only".to_string(), - step: 7, - }); - - assert_eq!( - tri.metadata().modification_count, - initial_modification_count - ); - assert!(tri.has_foliation()); - assert_eq!(tri.slice_sizes(), initial_slice_sizes.as_slice()); - assert!(tri.time_label(&vertex).is_some()); - assert!(tri.edge_type(&edge).is_some()); - assert!(tri.cell_type(&face).is_some()); - } - - #[test] - fn test_metadata_timestamps() { - let start_time = Instant::now(); - - let mut triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - let creation_time = triangulation.metadata().creation_time; - let initial_last_modified = triangulation.metadata().last_modified; - - // Creation time should be after our start time - assert!(creation_time >= start_time); - - // Initially, creation_time and last_modified should be very close - let time_diff = initial_last_modified.duration_since(creation_time); - assert!(time_diff < Duration::from_millis(10)); - - // Make a small delay then modify - thread::sleep(Duration::from_millis(5)); - - triangulation.bump_modification_count(); - - let new_last_modified = triangulation.metadata().last_modified; - - // last_modified should have been updated - assert!(new_last_modified > initial_last_modified); - - // creation_time should remain unchanged - assert_eq!(triangulation.metadata().creation_time, creation_time); - } - - #[test] - fn test_modification_count() { - let mut triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - // Initial modification count should be 0 - assert_eq!(triangulation.metadata().modification_count, 0); - - // Each explicit mutation marker should increment once. - triangulation.bump_modification_count(); - assert_eq!(triangulation.metadata().modification_count, 1); - - triangulation.bump_modification_count(); - assert_eq!(triangulation.metadata().modification_count, 2); - - // Immutable access shouldn't change count - let _geometry = triangulation.geometry(); - let _edge_count = triangulation.edge_count(); - assert_eq!(triangulation.metadata().modification_count, 2); - } - - #[test] - fn test_zero_time_slices_rejected() { - let result = CdtTriangulation::from_random_points(5, 0, 2); - assert!(matches!( - result, - Err(CdtError::InvalidTriangulationMetadata { - ref field, - ref provided_value, - ref expected, - .. - }) if field == "timeslices" && provided_value == "0" && expected == "≥ 1" - )); - } - - #[test] - fn test_set_time_slices_noop_preserves_metadata() { - let mut triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - let initial_modification_count = triangulation.metadata().modification_count; - let initial_last_modified = triangulation.metadata().last_modified; - - triangulation - .set_time_slices(2) - .expect("unchanged time-slice count should be accepted"); - - assert_eq!(triangulation.time_slices(), 2); - assert_eq!( - triangulation.metadata().modification_count, - initial_modification_count - ); - assert_eq!( - triangulation.metadata().last_modified, - initial_last_modified - ); - } - - #[test] - fn test_set_time_slices_updates_and_clears_mismatched_foliation() { - let backend = labeled_triangle_backend([0, 0, 1]); - let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - let initial_modification_count = tri.metadata().modification_count; - - tri.set_time_slices(3) - .expect("open-boundary time-slice metadata can be widened"); - - assert_eq!(tri.time_slices(), 3); - assert_eq!( - tri.metadata().modification_count, - initial_modification_count + 1 - ); - assert!(!tri.has_foliation()); - assert!(tri.slice_sizes().is_empty()); - } - - #[test] - fn test_large_time_slices() { - let result = CdtTriangulation::from_random_points(5, 100, 2); - assert!(result.is_ok(), "Should allow large time slice count"); - - let triangulation = result.unwrap(); - assert_eq!(triangulation.time_slices(), 100); - } - - #[test] - fn test_consistency_across_methods() { - let triangulation = - CdtTriangulation::from_random_points(8, 3, 2).expect("Failed to create triangulation"); - - // Test consistency between different access methods - let direct_vertex_count = triangulation.vertex_count(); - let geometry_vertex_count = triangulation.geometry().vertex_count(); - assert_eq!( - direct_vertex_count, geometry_vertex_count, - "Vertex count should be consistent" - ); - - let direct_face_count = triangulation.face_count(); - let geometry_face_count = triangulation.geometry().face_count(); - assert_eq!( - direct_face_count, geometry_face_count, - "Face count should be consistent" - ); - - let direct_edge_count = triangulation.edge_count(); - let geometry_edge_count = triangulation.geometry().edge_count(); - assert_eq!( - direct_edge_count, geometry_edge_count, - "Edge count should be consistent" - ); - } - - #[test] - fn test_debug_formatting() { - let triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - let debug_str = format!("{triangulation:?}"); - - // Should contain key components - assert!(debug_str.contains("CdtTriangulation")); - assert!(debug_str.contains("geometry")); - assert!(debug_str.contains("metadata")); - assert!(debug_str.contains("cache")); - } - - #[test] - fn test_simulation_event_debug() { - let events = vec![ - SimulationEvent::Created { - vertex_count: 5, - time_slices: 2, - }, - SimulationEvent::MoveAttempted { - move_type: "flip".to_string(), - step: 1, - }, - SimulationEvent::MoveAccepted { - move_type: "flip".to_string(), - step: 1, - action_change: 0.5, - }, - SimulationEvent::MeasurementTaken { - step: 2, - action: 15.5, - }, - ]; - - for event in events { - let debug_str = format!("{event:?}"); - // Should not panic and should contain meaningful content - assert!(!debug_str.is_empty()); - } - } - - #[test] - fn test_cdt_metadata_clone() { - let triangulation = - CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - - let metadata1 = triangulation.metadata().clone(); - let metadata2 = metadata1.clone(); - - assert_eq!(metadata1.time_slices, metadata2.time_slices); - assert_eq!(metadata1.dimension, metadata2.dimension); - assert_eq!(metadata1.modification_count, metadata2.modification_count); - assert_eq!( - metadata1.simulation_history.len(), - metadata2.simulation_history.len() - ); - } - - #[test] - fn test_extreme_vertex_counts() { - // Test minimum valid count - let min_tri = CdtTriangulation::from_random_points(3, 1, 2) - .expect("Should create triangulation with 3 vertices"); - assert_eq!(min_tri.vertex_count(), 3); - - // Test larger count (within reasonable bounds for testing) - let large_tri = CdtTriangulation::from_random_points(50, 1, 2) - .expect("Should create triangulation with 50 vertices"); - assert_eq!(large_tri.vertex_count(), 50); - assert!( - large_tri.edge_count() > 50, - "Large triangulation should have many edges" - ); - assert!( - large_tri.face_count() > 10, - "Large triangulation should have many faces" - ); - } - - // ========================================================================= - // Foliation tests - // ========================================================================= - - /// Builds an explicit 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"); - assert_eq!( - tri.vertex_count(), - vertices_per_slice as usize * num_slices as usize - ); - assert_eq!( - tri.face_count(), - 2 * (vertices_per_slice as usize - 1) * (num_slices as usize - 1) - ); - assert_eq!( - tri.slice_sizes(), - vec![vertices_per_slice as usize; num_slices as usize].as_slice() - ); - tri.validate_foliation() - .expect("explicit strip foliation should validate"); - tri.validate_causality_delaunay() - .expect("explicit strip causality should validate"); - tri.validate_topology() - .expect("explicit strip topology should validate"); - tri.validate_cell_classification() - .expect("all explicit 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()); - } - tri - } - - #[test] - fn test_from_cdt_strip_all_vertices_labeled() { - let tri = strict_strip(5, 3); - for vertex in tri.geometry().vertices() { - assert!(tri.time_label(&vertex).is_some()); - } - } - - #[test] - fn test_from_labeled_delaunay_preserves_foliation() { - let backend = labeled_triangle_backend([0, 0, 1]); - - let tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - - assert!(tri.has_foliation()); - assert_eq!(tri.slice_sizes(), &[2, 1]); - assert!(tri.validate_foliation().is_ok()); - - for vh in tri.geometry().vertices() { - assert!(tri.time_label(&vh).is_some()); - } - } - - #[test] - fn test_from_labeled_delaunay_rejects_invalid_dimension() { - let backend = labeled_triangle_backend([0, 0, 1]); - - let result = CdtTriangulation::from_labeled_delaunay(backend, 2, 3); - - assert!(matches!(result, Err(CdtError::UnsupportedDimension(3)))); - } - - #[test] - fn test_from_labeled_delaunay_rejects_zero_slices() { - let backend = labeled_triangle_backend([0, 0, 1]); - - let result = CdtTriangulation::from_labeled_delaunay(backend, 0, 2); - - assert!(matches!( - result, - Err(CdtError::InvalidTriangulationMetadata { - ref field, - ref provided_value, - ref expected, - .. - }) if field == "timeslices" && provided_value == "0" && expected == "≥ 1" - )); - } - - #[test] - fn test_validate_foliation_missing_label() { - let backend = labeled_triangle_backend([0, 0, 1]); - - let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - - let vertex_to_clear = tri - .geometry() - .vertices() - .next() - .expect("Triangle should contain a vertex"); - - tri.set_vertex_data(&vertex_to_clear, None) - .expect("Expected valid vertex handle while clearing label"); - - assert!( - !tri.has_foliation(), - "CDT label mutation should invalidate cached foliation bookkeeping" - ); - assert!( - tri.slice_sizes().is_empty(), - "stale foliation bookkeeping should not be exposed via slice_sizes()" - ); - - let result = tri.validate_foliation(); - assert!(matches!( - result, - Err(CdtError::Foliation(FoliationError::MissingVertexLabel { - vertex: 0 - })) - )); - } - - #[test] - fn test_validate_foliation_out_of_range_label() { - 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 mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - - let vertex_to_mutate = tri - .geometry() - .vertices() - .next() - .expect("Triangle should contain a vertex"); - - tri.set_vertex_data(&vertex_to_mutate, Some(7)) - .expect("Expected valid vertex handle while mutating label"); - - let result = tri.validate_foliation(); - assert!(matches!( - result, - Err(CdtError::Foliation(FoliationError::OutOfRangeVertexLabel { - vertex: 0, - label: 7, - expected_range_end: 2, - })) - )); - } - - #[test] - fn test_validate_foliation_slice_mismatch() { - 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 mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - - let vertex_to_move = tri - .geometry() - .vertices() - .find(|vh| tri.geometry().vertex_data_by_key(vh.vertex_key()) == Some(0)) - .expect("Triangle should contain a vertex in slice 0"); - - tri.set_vertex_data(&vertex_to_move, Some(1)) - .expect("Expected valid vertex handle while mutating label"); - - let result = tri.validate_foliation(); - assert!(matches!( - result, - Err(CdtError::Foliation(FoliationError::LabelMismatch { .. })) - )); - } - - #[test] - fn test_validate_foliation_rejects_stored_label_count_mismatch() { - let backend = labeled_triangle_backend([0, 0, 1]); - let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - tri.foliation = Some( - Foliation::from_slice_sizes(vec![1, 1], 2) - .expect("non-empty mismatched bookkeeping is constructible"), - ); - - let result = tri.validate_foliation(); - - assert!(matches!( - result, - Err(CdtError::Foliation(FoliationError::LabelCountMismatch { - labeled: 2, - expected: 3, - })) - )); - } - - #[test] - fn test_from_labeled_delaunay_rejects_out_of_range_labels() { - let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 5)]) - .expect("Should build labeled triangle"); - let backend = DelaunayBackend2D::from_triangulation(dt); - - let result = CdtTriangulation::from_labeled_delaunay(backend, 2, 2); - assert!(matches!( - result, - Err(CdtError::Foliation(FoliationError::OutOfRangeVertexLabel { - label: 5, - expected_range_end: 2, - .. - })) - )); - } - - #[test] - fn test_from_labeled_delaunay_rejects_empty_intermediate_slice() { - let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 2), ([0.5, 1.0], 2)]) - .expect("Should build labeled triangle"); - let backend = DelaunayBackend2D::from_triangulation(dt); - - let result = CdtTriangulation::from_labeled_delaunay(backend, 3, 2); - assert!(matches!( - result, - Err(CdtError::Foliation(FoliationError::EmptySlice { slice: 1 })) - )); - } - - #[test] - fn test_from_cdt_strip_edge_classification() { - let tri = strict_strip(5, 3); - for edge in tri.geometry().edges() { - assert!(matches!( - tri.edge_type(&edge), - Some(EdgeType::Spacelike | EdgeType::Timelike) - )); - } - } - - #[test] - fn test_from_cdt_strip_rejects_invalid_params() { - let few_vertices = CdtTriangulation::from_cdt_strip(3, 3); - assert!(matches!( - few_vertices, - Err(CdtError::InvalidGenerationParameters { - ref issue, - ref provided_value, - ref expected_range, - }) if issue == "Insufficient vertices per slice" - && provided_value == "3" - && expected_range == "≥ 4" - )); - - let few_slices = CdtTriangulation::from_cdt_strip(4, 1); - assert!(matches!( - few_slices, - Err(CdtError::InvalidGenerationParameters { - ref issue, - ref provided_value, - ref expected_range, - }) if issue == "Insufficient number of time slices" - && provided_value == "1" - && expected_range == "≥ 2" - )); - } - - #[test] - fn test_from_cdt_strip_rejects_cell_count_overflow() { - let result = CdtTriangulation::from_cdt_strip(65_535, 65_537); - - assert!(matches!( - result, - Err(CdtError::InvalidGenerationParameters { - ref issue, - ref provided_value, - ref expected_range, - }) if issue == "Cell count overflow" - && provided_value == "2 × 4294836224" - && expected_range == "product ≤ u32::MAX" - )); - } - - #[test] - fn test_from_cdt_strip_builds_valid_mesh() { - let tri = CdtTriangulation::from_cdt_strip(4, 2).expect("explicit strip should build"); - assert_eq!(tri.vertex_count(), 8); - assert_eq!(tri.face_count(), 6); - assert!(tri.validate_topology().is_ok()); - assert!(tri.validate_foliation().is_ok()); - assert!(tri.validate_causality_delaunay().is_ok()); - assert!(tri.validate_cell_classification().is_ok()); - } - - #[test] - fn test_explicit_strip_count_validation_rejects_face_mismatch() { - let tri = CdtTriangulation::from_cdt_strip(4, 2).expect("explicit strip should build"); - let result = validate_strip_counts(tri.geometry(), 8, 7, 8, 7, 4, 2, 1.0); - - assert!(matches!( - result, - Err(CdtError::DelaunayGenerationFailed { - vertex_count: 8, - coordinate_range: (0.0, 1.0), - attempt: 1, - ref underlying_error, - }) if underlying_error.contains("build_delaunay2_from_cells()/from_cdt_strip()") - && underlying_error.contains("produced 6 faces, expected 7") - && underlying_error.contains("vertices_per_slice=4") - && underlying_error.contains("num_slices=2") - )); - } - - #[test] - fn test_vertices_at_time() { - let tri = strict_strip(4, 3); - for t in 0..3 { - assert_eq!(tri.vertices_at_time(t).len(), 4); - } - } - - #[test] - fn test_assign_foliation_by_y() { - let mut tri = CdtTriangulation::from_seeded_points(15, 3, 2, 42) - .expect("Failed to create deterministic triangulation"); - - assert!(!tri.has_foliation()); - - tri.assign_foliation_by_y(3) - .expect("Should assign foliation"); - - assert!(tri.has_foliation()); - assert_eq!(tri.time_slices(), 3); - assert_eq!(tri.slice_sizes().iter().sum::(), tri.vertex_count()); - - // All vertices should now be labeled - for vh in tri.geometry().vertices() { - assert!(tri.time_label(&vh).is_some()); - } - - // Foliation validation should pass - let result = tri.validate_foliation(); - assert!( - result.is_ok(), - "Foliation validation should pass: {result:?}" - ); + // Cache should have been invalidated but recalculated value should be same + let recalculated_edge_count = triangulation.edge_count(); + assert_eq!(initial_edge_count, recalculated_edge_count); } #[test] - fn test_assign_foliation_by_y_updates_metadata() { - let mut tri = CdtTriangulation::from_seeded_points(15, 3, 2, 42) - .expect("Failed to create deterministic triangulation"); - let initial_last_modified = tri.metadata().last_modified; - let initial_modification_count = tri.metadata().modification_count; - let initial_edge_count = tri.edge_count(); - let initial_euler_characteristic = tri.geometry().euler_characteristic(); + fn test_cache_refresh_functionality() { + let mut triangulation = + CdtTriangulation::from_random_points(6, 2, 2).expect("Failed to create triangulation"); - thread::sleep(Duration::from_millis(5)); + // Get initial counts without cache + let edge_count_1 = triangulation.edge_count(); - tri.assign_foliation_by_y(3) - .expect("Should update foliation metadata"); + // Refresh cache + triangulation.refresh_cache(); - assert!(tri.metadata().last_modified > initial_last_modified); + // Should return same values from cache + let edge_count_2 = triangulation.edge_count(); assert_eq!( - tri.metadata().modification_count, - initial_modification_count + 1, - "Foliation assignment should count as a modification" + edge_count_1, edge_count_2, + "Cache should return consistent values" ); - assert_eq!(tri.edge_count(), initial_edge_count); + + // Multiple cache hits should be consistent + let edge_count_3 = triangulation.edge_count(); assert_eq!( - tri.geometry().euler_characteristic(), - initial_euler_characteristic + edge_count_1, edge_count_3, + "Multiple cache hits should be consistent" ); } #[test] - fn test_assign_foliation_by_y_invalidates_cache() { - let mut tri = CdtTriangulation::from_seeded_points(15, 3, 2, 42) - .expect("Failed to create deterministic triangulation"); + fn test_cache_invalidation_on_mutation() { + let mut triangulation = + CdtTriangulation::from_random_points(6, 2, 2).expect("Failed to create triangulation"); - tri.refresh_cache(); - assert!(tri.cache.edge_count.is_some()); - assert!(tri.cache.euler_char.is_some()); + // Populate cache + triangulation.refresh_cache(); + let cached_count = triangulation.edge_count(); - tri.assign_foliation_by_y(3) - .expect("Should invalidate cache when assigning foliation"); + triangulation.bump_modification_count(); - assert!( - tri.cache.edge_count.is_none(), - "assign_foliation_by_y should clear cached edge count via invalidate_cache()" - ); - assert!( - tri.cache.euler_char.is_none(), - "assign_foliation_by_y should clear cached Euler characteristic via invalidate_cache()" + // Edge count should still be correct (recalculated, not cached) + let new_count = triangulation.edge_count(); + assert_eq!( + cached_count, new_count, + "Edge count should remain consistent after cache invalidation" ); } #[test] - fn test_assign_foliation_zero_slices() { - let mut tri = CdtTriangulation::from_random_points(5, 2, 2).unwrap(); - assert!(tri.assign_foliation_by_y(0).is_err()); - } - - #[test] - fn test_assign_foliation_empty_slice_error_preserves_metadata() { - let mut tri = - CdtTriangulation::from_random_points(6, 2, 2).expect("Failed to create triangulation"); - let initial_time_slices = tri.time_slices(); - let initial_modification_count = tri.metadata().modification_count; - let vertex_keys: Vec<_> = tri - .geometry() - .vertices() - .map(|vh| vh.vertex_key()) - .collect(); - - let requested_slices = u32::try_from(tri.vertex_count()) - .expect("vertex count should fit into u32 for this test") - .saturating_add(1); - let result = tri.assign_foliation_by_y(requested_slices); - - assert!(matches!( - result, - Err(CdtError::Foliation(FoliationError::EmptySlice { .. })) - )); - assert_eq!(tri.time_slices(), initial_time_slices); - assert_eq!( - tri.metadata().modification_count, - initial_modification_count - ); - assert!(!tri.has_foliation()); + fn test_euler_characteristic() { + // Use fixed seed to ensure deterministic closed triangulation with Euler=2 + // Seed 53 produces V=5, E=9, F=6, Euler=2 for this configuration + const TRIANGULATION_SEED: u64 = 53; - for key in vertex_keys { - assert_eq!( - tri.geometry().vertex_data_by_key(key), - None, - "failed assignment should not write vertex labels" - ); - } - } + let triangulation = CdtTriangulation::from_seeded_points(5, 2, 2, TRIANGULATION_SEED) + .expect("Failed to create triangulation with fixed seed"); - #[test] - fn test_all_faces_are_valid_cdt_triangles() { - let tri = strict_strip(5, 3); - for face in tri.geometry().faces() { - let edge_types = tri - .face_edge_types(&face) - .expect("explicit strip face should expose edge types"); - let spacelike = edge_types - .iter() - .filter(|edge_type| matches!(edge_type, EdgeType::Spacelike)) - .count(); - let timelike = edge_types - .iter() - .filter(|edge_type| matches!(edge_type, EdgeType::Timelike)) - .count(); - assert_eq!(spacelike, 1); - assert_eq!(timelike, 2); - } + let result = triangulation.geometry().is_valid(); + assert!(result, "Validation should succeed for closed triangulation"); } #[test] - fn test_assign_foliation_single_slice() { - let mut tri = - CdtTriangulation::from_random_points(6, 1, 2).expect("Failed to create triangulation"); + fn test_validate_topology() { + // Test with various configurations to check topology validation + let seeds = [53, 87, 203]; // Known good seeds that produce Euler=2 - tri.assign_foliation_by_y(1) - .expect("Should assign single-slice foliation"); + for seed in seeds { + let triangulation = CdtTriangulation::from_seeded_points(5, 1, 2, seed) + .expect("Failed to create triangulation"); - assert!(tri.has_foliation()); - // All vertices should be in slice 0 - for vh in tri.geometry().vertices() { - assert_eq!(tri.time_label(&vh), Some(0)); + let result = triangulation.validate_topology(); + assert!( + result.is_ok(), + "Topology validation should succeed for seed {seed}: {result:?}" + ); } - assert_eq!(tri.slice_sizes(), &[tri.vertex_count()]); } #[test] - fn test_no_foliation_queries_return_none() { - let tri = CdtTriangulation::from_random_points(5, 2, 2).unwrap(); - assert!(!tri.has_foliation()); - assert!(tri.foliation().is_none()); - assert!(tri.slice_sizes().is_empty()); - assert!(tri.vertices_at_time(0).is_empty()); - - // time_label and edge_type return None - for vh in tri.geometry().vertices() { - assert_eq!(tri.time_label(&vh), None); - } - for face in tri.geometry().faces() { - assert!(tri.face_edge_types(&face).is_none()); - } - } - - // ========================================================================= - // Cell classification tests - // ========================================================================= + fn test_simulation_event_recording() { + let mut triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - #[test] - fn test_cell_type_returns_up_or_down() { - let tri = strict_strip(5, 3); - for face in tri.geometry().faces() { - assert!(matches!( - tri.cell_type(&face), - Some(CellType::Up | CellType::Down) - )); - } - } + let initial_history_len = triangulation.metadata().simulation_history.len(); + let initial_last_modified = triangulation.metadata().last_modified; + thread::sleep(Duration::from_millis(5)); - #[test] - fn test_cell_type_no_foliation_returns_none() { - let tri = CdtTriangulation::from_random_points(5, 2, 2).unwrap(); - assert!(!tri.has_foliation()); + triangulation.record_event(SimulationEvent::MoveAttempted { + move_type: "test_move".to_string(), + step: 1, + }); - for face in tri.geometry().faces() { - assert_eq!(tri.cell_type(&face), None); - } - } + triangulation.record_event(SimulationEvent::MoveAccepted { + move_type: "test_move".to_string(), + step: 1, + action_change: -0.5, + }); - #[test] - fn test_classify_all_cells_stores_data() { - let mut tri = strict_strip(5, 3); - let classified = tri - .classify_all_cells() - .expect("strict strip cells should classify") - .expect("foliation is present"); - assert_eq!(classified, tri.face_count()); - } + triangulation.record_event(SimulationEvent::MeasurementTaken { + step: 2, + action: 10.5, + }); - #[test] - fn test_classify_all_cells_no_foliation_returns_none() { - let mut tri = CdtTriangulation::from_random_points(5, 2, 2).unwrap(); + // Should have 3 more events assert_eq!( - tri.classify_all_cells() - .expect("No foliation should classify as a no-op"), - None + triangulation.metadata().simulation_history.len(), + initial_history_len + 3 ); - } - - #[test] - fn test_validate_cell_classification_no_foliation_succeeds() { - let tri = CdtTriangulation::from_random_points(5, 2, 2).unwrap(); - - tri.validate_cell_classification() - .expect("missing foliation should validate vacuously"); - } - - #[test] - fn test_validate_and_classify_use_stored_foliation_after_label_mutation() { - let mut tri = CdtTriangulation::from_cdt_strip(4, 2).expect("Should build CDT strip"); - let vertex = tri - .geometry() - .vertices() - .next() - .expect("CDT strip should contain vertices"); - let label = tri - .geometry() - .vertex_data_by_key(vertex.vertex_key()) - .expect("CDT strip vertices should be labeled"); - - tri.set_vertex_data(&vertex, Some(label)) - .expect("Expected valid vertex handle while preserving label"); + assert!(triangulation.metadata().last_modified > initial_last_modified); - assert!( - !tri.has_foliation(), - "CDT label mutation should invalidate synchronized foliation bookkeeping" - ); - tri.validate_cell_classification() - .expect("stored foliation should still drive live cell validation"); - - let classified = tri - .classify_all_cells() - .expect("stored foliation should still drive live cell classification") - .expect("foliation is still present"); - assert_eq!(classified, tri.face_count()); - } + // Check the recorded events + let history = &triangulation.metadata().simulation_history; + match &history[initial_history_len] { + SimulationEvent::MoveAttempted { move_type, step } => { + assert_eq!(move_type, "test_move"); + assert_eq!(*step, 1); + } + _ => panic!("Expected MoveAttempted event"), + } - #[test] - fn test_cell_type_from_data_before_classify_returns_none() { - 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::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); + match &history[initial_history_len + 1] { + SimulationEvent::MoveAccepted { + move_type, + step, + action_change, + } => { + assert_eq!(move_type, "test_move"); + assert_eq!(*step, 1); + approx::assert_relative_eq!(*action_change, -0.5); + } + _ => panic!("Expected MoveAccepted event"), + } - let face = tri - .geometry() - .faces() - .next() - .expect("Triangle should contain a face"); - assert_eq!(tri.cell_type_from_data(&face), None); + match &history[initial_history_len + 2] { + SimulationEvent::MeasurementTaken { step, action } => { + assert_eq!(*step, 2); + approx::assert_relative_eq!(*action, 10.5); + } + _ => panic!("Expected MeasurementTaken event"), + } } #[test] - fn test_cell_type_from_data_returns_none_when_foliation_stale() { + fn test_record_event_keeps_foliation_synchronized() { 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 mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) .expect("Should preserve labels as foliation"); - let face = tri - .geometry() - .faces() - .next() - .expect("Triangle should contain a face"); - let vertex_to_mutate = tri + let vertex = tri .geometry() .vertices() .next() .expect("Triangle should contain a vertex"); - - let classified = tri - .classify_all_cells() - .expect("Should classify cells with foliation") - .expect("Foliation is present"); - assert!(classified > 0); - assert!(tri.cell_type_from_data(&face).is_some()); - - tri.set_vertex_data(&vertex_to_mutate, Some(7)) - .expect("Expected valid vertex handle while mutating label"); - - assert_eq!(tri.cell_type_from_data(&face), None); - } - - #[test] - fn test_classify_all_cells_on_labeled_triangle() { - 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 mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - - // cell_type should classify before bulk classification + let edge = tri + .geometry() + .edges() + .next() + .expect("Triangle should contain an edge"); let face = tri .geometry() .faces() .next() .expect("Triangle should contain a face"); - let live_ct = tri - .cell_type(&face) - .expect("Single face should be classifiable"); - assert!( - live_ct == CellType::Up || live_ct == CellType::Down, - "Single face spanning two slices must be Up or Down" - ); - - // Bulk classify and verify stored data matches live classification - let count = tri - .classify_all_cells() - .expect("classify_all_cells should succeed") - .expect("foliation is present"); - assert_eq!(count, 1, "Single-face triangulation should classify 1 cell"); - - let stored_ct = tri - .cell_type_from_data(&face) - .expect("Stored cell type should be present after classification"); - assert_eq!( - live_ct, stored_ct, - "Stored classification should match live classification" - ); - } - - #[test] - fn test_validate_cell_classification_rejects_same_slice_triangle() { - let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 0)]) - .expect("Should build same-slice triangle"); - let backend = DelaunayBackend2D::from_triangulation(dt); - let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 1, 2) - .expect("single-slice labels should build foliation"); - - let validation = tri.validate_cell_classification(); - assert!(matches!( - validation, - Err(CdtError::ValidationFailed { ref check, .. }) - if check == "cell_classification" - )); - let classification = tri.classify_all_cells(); - assert!(matches!( - classification, - Err(CdtError::ValidationFailed { ref check, .. }) - if check == "cell_classification" - )); - } - - #[test] - fn test_vertices_at_time_with_foliation() { - let mut tri = CdtTriangulation::from_seeded_points(15, 3, 2, 42) - .expect("Failed to create deterministic triangulation"); - - tri.assign_foliation_by_y(3) - .expect("Should assign foliation"); - - // Every slice should have at least one vertex - for t in 0..3 { - let verts = tri.vertices_at_time(t); - assert!( - !verts.is_empty(), - "Slice {t} should have at least one vertex" - ); - // Each returned vertex should actually carry that time label - for vh in &verts { - assert_eq!( - tri.time_label(vh), - Some(t), - "Vertex returned by vertices_at_time({t}) should have label {t}" - ); - } - } - // Sum across all slices should equal total vertex count - let total: usize = (0..3).map(|t| tri.vertices_at_time(t).len()).sum(); - assert_eq!( - total, - tri.vertex_count(), - "Sum of vertices_at_time across all slices should equal vertex_count" - ); - - // Non-existent slice should return empty - assert!(tri.vertices_at_time(999).is_empty()); - } + let initial_modification_count = tri.metadata().modification_count; + let initial_slice_sizes = tri.slice_sizes().to_vec(); - #[test] - fn test_assign_foliation_by_y_reassignment() { - let mut tri = - CdtTriangulation::from_cdt_strip(5, 3).expect("Failed to create deterministic strip"); + tri.record_event(SimulationEvent::MoveAttempted { + move_type: "history_only".to_string(), + step: 7, + }); - // First assignment: 3 slices - tri.assign_foliation_by_y(3) - .expect("First foliation assignment should succeed"); - assert!(tri.has_foliation()); - assert_eq!(tri.time_slices(), 3); - let sizes_3 = tri.slice_sizes().to_vec(); - assert_eq!(sizes_3.len(), 3); - - // Classify cells so we can verify stale data is cleared - let classified_before = tri - .classify_all_cells() - .expect("classify_all_cells should succeed") - .expect("foliation is present"); - assert!(classified_before > 0); - - // Re-assign with 2 slices - tri.assign_foliation_by_y(2) - .expect("Re-assignment with different slice count should succeed"); - assert!(tri.has_foliation()); - assert_eq!(tri.time_slices(), 2); - let sizes_2 = tri.slice_sizes().to_vec(); - assert_eq!(sizes_2.len(), 2); assert_eq!( - sizes_2.iter().sum::(), - tri.vertex_count(), - "Slice sizes should sum to vertex count after re-assignment" - ); - - // Stale cell data from the 3-slice classification should be cleared; - // cell_type_from_data should return None until classify_all_cells is - // called again with the new foliation. - for face in tri.geometry().faces() { - assert_eq!( - tri.cell_type_from_data(&face), - None, - "Stale cell data should be cleared after foliation re-assignment" - ); - } - - // Foliation and causality validation should still pass - assert!( - tri.validate_foliation().is_ok(), - "Foliation should be valid after re-assignment" - ); - } - - /// Builds stable diagnostic text for seeded-triangulation comparisons. - fn deterministic_triangle_debug_summary(backend: &DelaunayBackend2D) -> String { - let mut vertices: Vec<_> = backend - .vertices() - .map(|vh| { - let coords = backend.vertex_coordinates(&vh).map_or_else( - |err| format!("coord_error:{err}"), - |coords| format!("{coords:?}"), - ); - format!( - "{:?}@{}:{:?}", - vh.vertex_key(), - coords, - backend.vertex_data_by_key(vh.vertex_key()) - ) - }) - .collect(); - vertices.sort_unstable(); - - let mut edges: Vec<_> = backend - .edges() - .map(|edge| match backend.edge_endpoints(&edge) { - Some((v0, v1)) => format!( - "{:?}<->{:?}:{:?}->{:?}", - v0.vertex_key(), - v1.vertex_key(), - backend.vertex_data_by_key(v0.vertex_key()), - backend.vertex_data_by_key(v1.vertex_key()) - ), - None => "endpoint_error:unavailable".to_string(), - }) - .collect(); - edges.sort_unstable(); - - format!( - "vertex_count={}, edge_count={}, face_count={}, is_valid={}, is_delaunay={}, vertices=[{}], edges=[{}]", - backend.vertex_count(), - backend.edge_count(), - backend.face_count(), - backend.is_valid(), - backend.is_delaunay(), - vertices.join(", "), - edges.join(", "), - ) + tri.metadata().modification_count, + initial_modification_count + ); + assert!(tri.has_foliation()); + assert_eq!(tri.slice_sizes(), initial_slice_sizes.as_slice()); + assert!(tri.time_label(&vertex).is_some()); + assert!(tri.edge_type(&edge).is_some()); + assert!(tri.cell_type(&face).is_some()); } - // ========================================================================= - // Causality violation detection - // ========================================================================= - #[test] - fn test_causality_violation_detected() { - // Use a hand-built triangle so this test does not depend on Delaunay - // tie-breaking for a larger strip mesh. - let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]) - .expect("Should build deterministic causal triangle"); - let backend = DelaunayBackend2D::from_triangulation(dt); - let mut tri = unchecked_open_boundary(backend, 2, 2); + fn test_metadata_timestamps() { + let start_time = Instant::now(); - // Derive the foliation from coordinates instead of relying on - // build_delaunay2_with_data() to preserve vertex data across platforms. - // This test targets causality validation, not builder label retention. - tri.assign_foliation_by_y(2) - .expect("Should derive foliation from triangle coordinates"); + let mut triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - assert_eq!( - tri.slice_sizes(), - &[2, 1], - "Deterministic triangle should assign slice sizes [2, 1], got {:?}; {}", - tri.slice_sizes(), - deterministic_triangle_debug_summary(tri.geometry()) - ); + let creation_time = triangulation.metadata().creation_time; + let initial_last_modified = triangulation.metadata().last_modified; - let initial_validation = tri.validate_causality_delaunay(); - assert!( - initial_validation.is_ok(), - "Deterministic causal triangle should start causally valid: {initial_validation:?}; {}", - deterministic_triangle_debug_summary(tri.geometry()) - ); + // Creation time should be after our start time + assert!(creation_time >= start_time); - assert!( - tri.geometry().faces().any(|face| { - tri.face_edge_types(&face) - .is_some_and(|ets| ets.iter().any(|e| matches!(e, EdgeType::Timelike))) - }), - "Deterministic causal triangle should contain a timelike edge; {}", - deterministic_triangle_debug_summary(tri.geometry()) - ); + // Initially, creation_time and last_modified should be very close + let time_diff = initial_last_modified.duration_since(creation_time); + assert!(time_diff < Duration::from_millis(10)); - let vertex_to_mutate = tri - .geometry() - .vertices() - .next() - .expect("Deterministic causal triangle should contain a vertex"); + // Make a small delay then modify + thread::sleep(Duration::from_millis(5)); + + triangulation.bump_modification_count(); - tri.set_vertex_data(&vertex_to_mutate, Some(3)) - .expect("Expected valid vertex handle while mutating deterministic triangle"); + let new_last_modified = triangulation.metadata().last_modified; - let result = tri.validate_causality_delaunay(); - assert!( - result.is_err(), - "Explicitly acausal edge should fail causality validation" - ); + // last_modified should have been updated + assert!(new_last_modified > initial_last_modified); - // Verify the error is a CausalityViolation reporting step_distance > 1. - // OpenBoundary topology means step_distance == |Δt|. - if let Err(CdtError::CausalityViolation { - time_0, - time_1, - step_distance, - }) = result - { - assert!( - step_distance > 1, - "CausalityViolation should report step_distance > 1, got step_distance={step_distance} (t0={time_0}, t1={time_1})" - ); - assert_eq!( - step_distance, - time_0.abs_diff(time_1), - "OpenBoundary step_distance must equal |Δt|; got step_distance={step_distance}, |Δt|={}", - time_0.abs_diff(time_1) - ); - } else { - panic!( - "Expected CausalityViolation error, got {result:?}; {}", - deterministic_triangle_debug_summary(tri.geometry()) - ); - } + // creation_time should remain unchanged + assert_eq!(triangulation.metadata().creation_time, creation_time); } #[test] - fn test_validate_causality_rejects_missing_live_label() { - let backend = labeled_triangle_backend([0, 0, 1]); - let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) - .expect("Should preserve labels as foliation"); - let vertex_to_clear = tri - .geometry() - .vertices() - .next() - .expect("Triangle should contain a vertex"); + fn test_modification_count() { + let mut triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - tri.set_vertex_data(&vertex_to_clear, None) - .expect("Expected valid vertex handle while clearing label"); + // Initial modification count should be 0 + assert_eq!(triangulation.metadata().modification_count, 0); - let result = tri.validate_causality_delaunay(); + // Each explicit mutation marker should increment once. + triangulation.bump_modification_count(); + assert_eq!(triangulation.metadata().modification_count, 1); - assert!(matches!( - result, - Err(CdtError::ValidationFailed { ref check, ref detail }) - if check == "causality" - && detail.contains("has no time label in a foliated triangulation") - )); + triangulation.bump_modification_count(); + assert_eq!(triangulation.metadata().modification_count, 2); + + // Immutable access shouldn't change count + let _geometry = triangulation.geometry(); + let _edge_count = triangulation.edge_count(); + assert_eq!(triangulation.metadata().modification_count, 2); } #[test] - fn test_validate_causality_rejects_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"); - - let result = tri.validate_causality_delaunay(); - + fn test_zero_time_slices_rejected() { + let result = CdtTriangulation::from_random_points(5, 0, 2); 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") + Err(CdtError::InvalidTriangulationMetadata { + ref field, + ref provided_value, + ref expected, + .. + }) if field == "timeslices" && provided_value == "0" && expected == "≥ 1" )); } #[test] - fn test_toroidal_causality_violation_reports_circular_step_distance() { - // Build a real toroidal CDT with T=10 so wrap-around is non-trivial: - // mutating a slice-0 vertex to slice 8 makes its same-slice spacelike - // edges read as (raw |Δt|=8, circular distance 2), which exceeds the - // adjacency threshold of 1. The reported step_distance must be the - // circular distance, not the raw difference. - let mut tri = - CdtTriangulation::from_toroidal_cdt(3, 10).expect("build toroidal CDT (3, 10)"); - - let slice0_vertex = tri - .geometry() - .vertices() - .find(|vh| tri.geometry().vertex_data_by_key(vh.vertex_key()) == Some(0)) - .expect("Toroidal CDT should contain slice-0 vertices"); - - tri.set_vertex_data(&slice0_vertex, Some(8)) - .expect("Expected valid vertex handle while mutating label"); - - let result = tri.validate_causality_delaunay(); - match result { - Err(CdtError::CausalityViolation { - time_0, - time_1, - step_distance, - }) => { - let raw = time_0.abs_diff(time_1); - let circular = raw.min(10 - raw); - assert_eq!( - step_distance, circular, - "Toroidal step_distance must equal circular distance min(|Δt|, T−|Δt|); \ - got step_distance={step_distance}, raw |Δt|={raw}, T=10" - ); - assert!( - step_distance > 1, - "Violation must exceed adjacency threshold; got step_distance={step_distance}" - ); - assert!( - step_distance < raw, - "With T=10 and labels {{0,1,8}}, every violation crosses the wrap; \ - step_distance={step_distance} should be < raw |Δt|={raw}" - ); - } - other => panic!("Expected CausalityViolation on toroidal triangle, got {other:?}"), - } - } + fn test_set_time_slices_noop_preserves_metadata() { + let mut triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); + let initial_modification_count = triangulation.metadata().modification_count; + let initial_last_modified = triangulation.metadata().last_modified; - #[test] - fn test_foliation_error_converts_to_cdt_error() { - let fol_err = FoliationError::EmptySlice { slice: 3 }; - let cdt_err: CdtError = fol_err.into(); + triangulation + .set_time_slices(2) + .expect("unchanged time-slice count should be accepted"); - match cdt_err { - CdtError::Foliation(FoliationError::EmptySlice { slice }) => assert_eq!(slice, 3), - other => panic!("Expected Foliation error, got {other:?}"), - } + assert_eq!(triangulation.time_slices(), 2); + assert_eq!( + triangulation.metadata().modification_count, + initial_modification_count + ); + assert_eq!( + triangulation.metadata().last_modified, + initial_last_modified + ); } #[test] - fn test_out_of_range_error_conversion() { - let fol_err = FoliationError::OutOfRangeVertexLabel { - vertex: 2, - label: 7, - expected_range_end: 2, - }; - let cdt_err: CdtError = fol_err.into(); - - match cdt_err { - CdtError::Foliation(FoliationError::OutOfRangeVertexLabel { - vertex, - label, - expected_range_end, - }) => { - assert_eq!(vertex, 2); - assert_eq!(label, 7); - assert_eq!(expected_range_end, 2); - } - other => panic!("Expected Foliation error, got {other:?}"), - } - } + fn test_set_time_slices_updates_and_clears_mismatched_foliation() { + let backend = labeled_triangle_backend([0, 0, 1]); + let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) + .expect("Should preserve labels as foliation"); + let initial_modification_count = tri.metadata().modification_count; - // ========================================================================= - // Toroidal CDT tests - // ========================================================================= + tri.set_time_slices(3) + .expect("open-boundary time-slice metadata can be widened"); - #[test] - fn test_from_toroidal_cdt_basic() { - let tri = CdtTriangulation::from_toroidal_cdt(4, 3) - .expect("toroidal CDT should build with delaunay v0.7.6"); - - // V = N*T = 12, F = 2*N*T = 24, E = 3*N*T = 36, χ = 0. - assert_eq!(tri.vertex_count(), 12); - assert_eq!(tri.face_count(), 24); - assert_eq!(tri.edge_count(), 36); - assert_eq!(tri.geometry().euler_characteristic(), 0); - assert_eq!(tri.dimension(), 2); assert_eq!(tri.time_slices(), 3); - assert!(matches!(tri.metadata().topology, CdtTopology::Toroidal)); + assert_eq!( + tri.metadata().modification_count, + initial_modification_count + 1 + ); + assert!(!tri.has_foliation()); + assert!(tri.slice_sizes().is_empty()); } #[test] - fn test_explicit_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); + fn test_large_time_slices() { + let result = CdtTriangulation::from_random_points(5, 100, 2); + assert!(result.is_ok(), "Should allow large time slice count"); - assert!(matches!( - result, - Err(CdtError::DelaunayGenerationFailed { - vertex_count: 12, - coordinate_range: (0.0, 1.0), - attempt: 1, - ref underlying_error, - }) if underlying_error.contains("explicit toroidal builder") - && underlying_error.contains("produced 12 vertices and 24 faces") - && underlying_error.contains("expected 12 vertices and 23 faces") - )); + let triangulation = result.unwrap(); + assert_eq!(triangulation.time_slices(), 100); } #[test] - fn test_from_toroidal_cdt_various_sizes() { - for (n, t) in [(3_u32, 3_u32), (4, 3), (5, 4), (6, 5), (8, 4)] { - let tri = CdtTriangulation::from_toroidal_cdt(n, t) - .unwrap_or_else(|err| panic!("toroidal CDT N={n} T={t} should build: {err}")); - let nt = (n as usize) * (t as usize); - assert_eq!(tri.vertex_count(), nt); - assert_eq!(tri.face_count(), 2 * nt); - assert_eq!(tri.edge_count(), 3 * nt); - assert_eq!(tri.geometry().euler_characteristic(), 0); - } - } + fn test_consistency_across_methods() { + let triangulation = + CdtTriangulation::from_random_points(8, 3, 2).expect("Failed to create triangulation"); - #[test] - fn test_from_toroidal_cdt_foliation_per_slice() { - let tri = CdtTriangulation::from_toroidal_cdt(5, 4).expect("build toroidal CDT"); - assert!(tri.has_foliation()); - assert_eq!(tri.slice_sizes(), &[5, 5, 5, 5]); - for t in 0..4 { - assert_eq!( - tri.vertices_at_time(t).len(), - 5, - "slice {t} should contain N=5 vertices" - ); - } - } + // Test consistency between different access methods + let direct_vertex_count = triangulation.vertex_count(); + let geometry_vertex_count = triangulation.geometry().vertex_count(); + assert_eq!( + direct_vertex_count, geometry_vertex_count, + "Vertex count should be consistent" + ); - #[test] - fn test_from_toroidal_cdt_validate_passes() { - 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()); + let direct_face_count = triangulation.face_count(); + let geometry_face_count = triangulation.geometry().face_count(); + assert_eq!( + direct_face_count, geometry_face_count, + "Face count should be consistent" + ); + + let direct_edge_count = triangulation.edge_count(); + let geometry_edge_count = triangulation.geometry().edge_count(); + assert_eq!( + direct_edge_count, geometry_edge_count, + "Edge count should be consistent" + ); } #[test] - fn test_from_toroidal_cdt_each_slice_is_closed_s1() { - // The toroidal validate_foliation extension already enforces that - // each slice is a closed S¹; building a torus and validating again - // exercises that path explicitly. - 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"); + fn test_debug_formatting() { + let triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); + + let debug_str = format!("{triangulation:?}"); + + // Should contain key components + assert!(debug_str.contains("CdtTriangulation")); + assert!(debug_str.contains("geometry")); + assert!(debug_str.contains("metadata")); + assert!(debug_str.contains("cache")); } #[test] - fn test_from_toroidal_cdt_invalid_params() { - // Too few vertices per slice (N < 3 — no proper cycle). - assert!(CdtTriangulation::from_toroidal_cdt(2, 3).is_err()); - // Too few slices (T < 3 — wrap-around makes the mesh non-manifold). - assert!(CdtTriangulation::from_toroidal_cdt(4, 1).is_err()); - assert!(CdtTriangulation::from_toroidal_cdt(4, 2).is_err()); + fn test_simulation_event_debug() { + let events = vec![ + SimulationEvent::Created { + vertex_count: 5, + time_slices: 2, + }, + SimulationEvent::MoveAttempted { + move_type: "flip".to_string(), + step: 1, + }, + SimulationEvent::MoveAccepted { + move_type: "flip".to_string(), + step: 1, + action_change: 0.5, + }, + SimulationEvent::MeasurementTaken { + step: 2, + action: 15.5, + }, + ]; + + for event in events { + let debug_str = format!("{event:?}"); + // Should not panic and should contain meaningful content + assert!(!debug_str.is_empty()); + } } #[test] - fn test_from_toroidal_cdt_rejects_vertex_count_overflow() { - let result = CdtTriangulation::from_toroidal_cdt(u32::MAX, 3); + fn test_cdt_metadata_clone() { + let triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); - assert!(matches!( - result, - Err(CdtError::InvalidGenerationParameters { - ref issue, - ref provided_value, - ref expected_range, - }) if issue == "Vertex count overflow" - && provided_value == "4294967295 × 3" - && expected_range == "product ≤ u32::MAX" - )); + let metadata1 = triangulation.metadata().clone(); + let metadata2 = metadata1.clone(); + + assert_eq!(metadata1.time_slices, metadata2.time_slices); + assert_eq!(metadata1.dimension, metadata2.dimension); + assert_eq!(metadata1.modification_count, metadata2.modification_count); + assert_eq!( + metadata1.simulation_history.len(), + metadata2.simulation_history.len() + ); } #[test] - fn test_toroidal_cell_classification_uses_temporal_wrap() { - let tri = CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); - let mut saw_wrap_up = false; - let mut saw_wrap_down = false; - let mut saw_wrap_timelike_edge = false; - - for face in tri.geometry().faces() { - let vertices = tri - .geometry() - .face_vertices(&face) - .expect("toroidal face vertices should resolve"); - let labels: Vec<_> = vertices - .iter() - .map(|vh| { - tri.geometry() - .vertex_data_by_key(vh.vertex_key()) - .expect("toroidal vertices are labeled") - }) - .collect(); - - if labels.contains(&0) && labels.contains(&2) { - let cell_type = tri - .cell_type(&face) - .expect("wrap-around toroidal face should classify"); - let edge_types = tri - .face_edge_types(&face) - .expect("wrap-around toroidal face should expose edge types"); - saw_wrap_timelike_edge |= edge_types - .iter() - .any(|edge_type| matches!(edge_type, EdgeType::Timelike)); - - let zero_count = labels.iter().filter(|&&label| label == 0).count(); - let two_count = labels.iter().filter(|&&label| label == 2).count(); - let is_wrap_up = zero_count == 1 && two_count == 2; - let is_wrap_down = zero_count == 2 && two_count == 1; - - if is_wrap_up { - assert_eq!(cell_type, CellType::Up); - } - if is_wrap_down { - assert_eq!(cell_type, CellType::Down); - } - - saw_wrap_up |= is_wrap_up; - saw_wrap_down |= is_wrap_down; - } - } + fn test_extreme_vertex_counts() { + // Test minimum valid count + let min_tri = CdtTriangulation::from_random_points(3, 1, 2) + .expect("Should create triangulation with 3 vertices"); + assert_eq!(min_tri.vertex_count(), 3); - assert!(saw_wrap_up, "expected an Up cell across the temporal wrap"); + // Test larger count (within reasonable bounds for testing) + let large_tri = CdtTriangulation::from_random_points(50, 1, 2) + .expect("Should create triangulation with 50 vertices"); + assert_eq!(large_tri.vertex_count(), 50); assert!( - saw_wrap_down, - "expected a Down cell across the temporal wrap" + large_tri.edge_count() > 50, + "Large triangulation should have many edges" ); assert!( - saw_wrap_timelike_edge, - "expected a timelike edge across the temporal wrap" + large_tri.face_count() > 10, + "Large triangulation should have many faces" ); } @@ -4621,29 +1239,6 @@ mod tests { assert!(tri.validate_topology().is_ok()); } - #[test] - fn test_assign_foliation_by_y_rejects_invalid_toroidal_slice_count() { - let mut tri = CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); - let initial_slice_sizes = tri.slice_sizes().to_vec(); - - let result = tri.assign_foliation_by_y(2); - assert!(matches!( - result, - Err(CdtError::InvalidTriangulationMetadata { - ref field, - ref topology, - ref provided_value, - ref expected, - }) if field == "timeslices" - && topology == "toroidal" - && provided_value == "2" - && expected == "≥ 3" - )); - assert_eq!(tri.time_slices(), 3); - assert_eq!(tri.slice_sizes(), initial_slice_sizes.as_slice()); - assert!(tri.has_foliation()); - } - #[test] fn test_with_topology_rejects_few_toroidal_slices() { let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]) diff --git a/src/cdt/triangulation/builders.rs b/src/cdt/triangulation/builders.rs new file mode 100644 index 0000000..cc4f57f --- /dev/null +++ b/src/cdt/triangulation/builders.rs @@ -0,0 +1,1287 @@ +#![forbid(unsafe_code)] + +//! Builders for CDT triangulations backed by Delaunay geometry. + +use super::CdtTriangulation; +use crate::cdt::foliation::{Foliation, FoliationError}; +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, +}; +use crate::geometry::traits::TriangulationQuery; + +/// Rewrites explicit 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. +pub(super) fn remap_toroidal_generation_error(error: CdtError, total_vertices: u32) -> CdtError { + match error { + CdtError::DelaunayGenerationFailed { + underlying_error, .. + } => CdtError::DelaunayGenerationFailed { + vertex_count: total_vertices, + coordinate_range: (0.0, 1.0), + attempt: 1, + underlying_error, + }, + other => other, + } +} + +/// Rewrites explicit strip builder failures with CDT-level generation context. +fn remap_strip_generation_error( + error: CdtError, + total_vertices: u32, + coordinate_max: f64, +) -> CdtError { + match error { + CdtError::DelaunayGenerationFailed { + underlying_error, .. + } => CdtError::DelaunayGenerationFailed { + vertex_count: total_vertices, + coordinate_range: (0.0, coordinate_max), + attempt: 1, + underlying_error, + }, + other => other, + } +} + +/// Builds a CDT-level generation error for explicit strip construction failures. +const fn strip_generation_error( + total_vertices: u32, + coordinate_max: f64, + underlying_error: String, +) -> CdtError { + CdtError::DelaunayGenerationFailed { + vertex_count: total_vertices, + coordinate_range: (0.0, coordinate_max), + attempt: 1, + underlying_error, + } +} + +/// Verifies that the explicit 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" +)] +pub(super) fn validate_strip_counts( + backend: &DelaunayBackend2D, + total_vertices: u32, + total_cells: u32, + expected_vertices: usize, + expected_faces: usize, + vertices_per_slice: u32, + num_slices: u32, + coordinate_max: f64, +) -> CdtResult<()> { + if backend.vertex_count() != expected_vertices { + return Err(strip_generation_error( + total_vertices, + coordinate_max, + format!( + "build_delaunay2_from_cells()/from_cdt_strip() produced {} vertices, expected {} for vertices_per_slice={} and num_slices={}", + backend.vertex_count(), + total_vertices, + vertices_per_slice, + num_slices, + ), + )); + } + if backend.face_count() != expected_faces { + return Err(strip_generation_error( + total_vertices, + coordinate_max, + format!( + "build_delaunay2_from_cells()/from_cdt_strip() produced {} faces, expected {} for vertices_per_slice={} and num_slices={}", + backend.face_count(), + total_cells, + vertices_per_slice, + num_slices, + ), + )); + } + + Ok(()) +} + +/// Builds a CDT-level generation error for explicit toroidal construction failures. +const fn toroidal_generation_error(total_vertices: u32, underlying_error: String) -> CdtError { + CdtError::DelaunayGenerationFailed { + vertex_count: total_vertices, + coordinate_range: (0.0, 1.0), + attempt: 1, + underlying_error, + } +} + +/// Verifies that the explicit toroidal builder returned the requested mesh size. +pub(super) fn validate_toroidal_counts( + backend: &DelaunayBackend2D, + total_vertices: u32, + expected_vertices: usize, + expected_faces: usize, +) -> CdtResult<()> { + if backend.vertex_count() != expected_vertices || backend.face_count() != expected_faces { + return Err(toroidal_generation_error( + total_vertices, + format!( + "explicit toroidal builder produced {} vertices and {} faces, expected {} vertices and {} faces", + backend.vertex_count(), + backend.face_count(), + total_vertices, + expected_faces, + ), + )); + } + + Ok(()) +} + +impl CdtTriangulation { + /// Re-reads current backend labels during validation so stale stored bookkeeping is detected. + pub(super) fn live_slice_sizes_from_vertex_labels( + backend: &DelaunayBackend2D, + num_slices: u32, + ) -> CdtResult> { + if num_slices == 0 { + return Err(FoliationError::SliceSizeMismatch { + slice_sizes_len: 0, + num_slices, + } + .into()); + } + + let mut slice_sizes = vec![0usize; num_slices as usize]; + + for (vertex, vh) in backend.vertices().enumerate() { + if let Some(t) = backend.vertex_data_by_key(vh.vertex_key()) { + let idx = t as usize; + if idx >= slice_sizes.len() { + return Err(FoliationError::OutOfRangeVertexLabel { + vertex, + label: t, + expected_range_end: slice_sizes.len(), + } + .into()); + } + slice_sizes[idx] += 1; + } else { + return Err(FoliationError::MissingVertexLabel { vertex }.into()); + } + } + + Ok(slice_sizes) + } + + /// Creates a CDT triangulation with a Delaunay backend from random points. + /// + /// This is the recommended way to create triangulations for simulations. + /// + /// # 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`]. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_random_points(5, 2, 2)?; + /// assert_eq!(tri.time_slices(), 2); + /// Ok(()) + /// } + /// ``` + pub fn from_random_points(vertices: u32, time_slices: u32, dimension: u8) -> CdtResult { + if dimension != 2 { + return Err(CdtError::UnsupportedDimension(dimension.into())); + } + + if vertices < 3 { + return Err(CdtError::InvalidGenerationParameters { + issue: "Insufficient vertex count".to_string(), + provided_value: vertices.to_string(), + expected_range: "≥ 3".to_string(), + }); + } + + let dt = generate_delaunay2(vertices, (0.0, 10.0), None)?; + let backend = DelaunayBackend2D::from_triangulation(dt); + + Self::try_new(backend, time_slices, dimension) + } + + /// Creates a CDT 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. + /// + /// # 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`]. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; + /// assert_eq!(tri.vertex_count(), 5); + /// Ok(()) + /// } + /// ``` + pub fn from_seeded_points( + vertices: u32, + time_slices: u32, + dimension: u8, + seed: u64, + ) -> CdtResult { + if dimension != 2 { + return Err(CdtError::UnsupportedDimension(dimension.into())); + } + + if vertices < 3 { + return Err(CdtError::InvalidGenerationParameters { + issue: "Insufficient vertex count".to_string(), + provided_value: vertices.to_string(), + expected_range: "≥ 3".to_string(), + }); + } + + let dt = generate_delaunay2(vertices, (0.0, 10.0), Some(seed))?; + let backend = DelaunayBackend2D::from_triangulation(dt); + + Self::try_new(backend, time_slices, dimension) + } + + /// Wrap a labeled 2D Delaunay backend and derive foliation from vertex data. + /// + /// Preserves per-vertex time labels already embedded in the backend. + /// + /// # Errors + /// + /// 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. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::geometry::*; + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let dt = build_delaunay2_with_data(&[ + /// ([0.0, 0.0], 0), + /// ([1.0, 0.0], 0), + /// ([0.5, 1.0], 1), + /// ])?; + /// let backend = DelaunayBackend2D::from_triangulation(dt); + /// let tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2)?; + /// + /// assert!(tri.has_foliation()); + /// assert_eq!(tri.slice_sizes(), &[2, 1]); + /// Ok(()) + /// } + /// ``` + pub fn from_labeled_delaunay( + backend: DelaunayBackend2D, + time_slices: u32, + dimension: u8, + ) -> CdtResult { + if dimension != 2 { + return Err(CdtError::UnsupportedDimension(dimension.into())); + } + + Self::check_time_slices(CdtTopology::OpenBoundary, time_slices)?; + let slice_sizes = Self::live_slice_sizes_from_vertex_labels(&backend, time_slices)?; + for (slice, &size) in slice_sizes.iter().enumerate() { + if size == 0 { + return Err(FoliationError::EmptySlice { slice }.into()); + } + } + let foliation = + Foliation::from_slice_sizes(slice_sizes, time_slices).map_err(CdtError::from)?; + + let mut tri = Self::try_new(backend, time_slices, dimension)?; + tri.foliation = Some(foliation); + tri.mark_foliation_synchronized(); + Ok(tri) + } + + /// Construct a true 1+1 CDT strip by explicit layered connectivity. + /// + /// 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. + /// + /// # 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`], + /// [`CdtError::CausalityViolation`], or [`CdtError::ValidationFailed`] if the + /// constructed strip fails CDT validation. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert_eq!(tri.vertex_count(), 8); + /// assert_eq!(tri.face_count(), 6); + /// assert!(tri.validate_cell_classification().is_ok()); + /// Ok(()) + /// } + /// ``` + #[expect( + clippy::too_many_lines, + reason = "explicit 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 { + return Err(CdtError::InvalidGenerationParameters { + issue: "Insufficient vertices per slice".to_string(), + provided_value: vertices_per_slice.to_string(), + expected_range: "≥ 4".to_string(), + }); + } + if num_slices < 2 { + return Err(CdtError::InvalidGenerationParameters { + issue: "Insufficient number of time slices".to_string(), + provided_value: num_slices.to_string(), + expected_range: "≥ 2".to_string(), + }); + } + + let total_vertices = vertices_per_slice.checked_mul(num_slices).ok_or_else(|| { + CdtError::InvalidGenerationParameters { + issue: "Vertex count overflow".to_string(), + provided_value: format!("{vertices_per_slice} × {num_slices}"), + expected_range: "product ≤ u32::MAX".to_string(), + } + })?; + + let spatial_quads = vertices_per_slice - 1; + let temporal_quads = num_slices - 1; + let total_quads = spatial_quads.checked_mul(temporal_quads).ok_or_else(|| { + CdtError::InvalidGenerationParameters { + issue: "Cell count overflow".to_string(), + provided_value: format!("{spatial_quads} × {temporal_quads}"), + expected_range: "product ≤ u32::MAX".to_string(), + } + })?; + let total_cells = + total_quads + .checked_mul(2) + .ok_or_else(|| CdtError::InvalidGenerationParameters { + issue: "Cell count overflow".to_string(), + provided_value: format!("2 × {total_quads}"), + expected_range: "product ≤ u32::MAX".to_string(), + })?; + + let coordinate_max = f64::from(num_slices - 1).max(1.0); + let generation_failed = |underlying_error: String| { + strip_generation_error(total_vertices, coordinate_max, underlying_error) + }; + + let expected_vertices = + usize::try_from(total_vertices).map_err(|err| generation_failed(err.to_string()))?; + let expected_faces = + usize::try_from(total_cells).map_err(|err| generation_failed(err.to_string()))?; + + let n = usize::try_from(vertices_per_slice) + .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 mut vertex_specs: Vec<([f64; 2], u32)> = Vec::new(); + vertex_specs + .try_reserve_exact(expected_vertices) + .map_err(|err| { + generation_failed(format!( + "from_cdt_strip() failed to reserve {expected_vertices} vertex specs for vertices_per_slice={vertices_per_slice}, num_slices={num_slices}: {err}" + )) + })?; + 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)]); + } + } + + // 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) + .map_err(|err| remap_strip_generation_error(err, total_vertices, coordinate_max))?; + + let backend = DelaunayBackend2D::from_triangulation(dt); + validate_strip_counts( + &backend, + total_vertices, + total_cells, + expected_vertices, + expected_faces, + vertices_per_slice, + 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)?; + + 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()?; + + 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. + /// + /// 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. + /// + /// # Mesh structure + /// + /// With `N = vertices_per_slice` and `T = num_slices` the resulting mesh + /// 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. + /// + /// # Arguments + /// + /// * `vertices_per_slice` — Number of vertices in each spatial slice (≥ 3). + /// * `num_slices` — Number of time slices (≥ 3 to keep `t-1` and `t+1` + /// distinct after wrap-around). + /// + /// # Errors + /// + /// 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`], + /// [`CdtError::CausalityViolation`], or [`CdtError::ValidationFailed`] if the + /// constructed triangulation fails CDT validation. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_toroidal_cdt(4, 3)?; + /// assert_eq!(tri.vertex_count(), 12); + /// assert_eq!(tri.face_count(), 24); + /// assert!(tri.has_foliation()); + /// 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 { + issue: "Insufficient vertices per slice".to_string(), + provided_value: vertices_per_slice.to_string(), + expected_range: "≥ 3".to_string(), + }); + } + if num_slices < 3 { + // With T=2 the wrap-around makes every pair of adjacent slices + // identify (t-1, t) with (t, t+1), so each spatial edge would be + // shared by 4 triangles instead of 2 — a non-manifold mesh. + return Err(CdtError::InvalidGenerationParameters { + issue: "Insufficient number of time slices".to_string(), + provided_value: num_slices.to_string(), + expected_range: "≥ 3".to_string(), + }); + } + + let total_vertices = vertices_per_slice.checked_mul(num_slices).ok_or_else(|| { + CdtError::InvalidGenerationParameters { + issue: "Vertex count overflow".to_string(), + provided_value: format!("{vertices_per_slice} × {num_slices}"), + expected_range: "product ≤ u32::MAX".to_string(), + } + })?; + let total_cells = + total_vertices + .checked_mul(2) + .ok_or_else(|| CdtError::InvalidGenerationParameters { + issue: "Cell count overflow".to_string(), + provided_value: format!("2 × {total_vertices}"), + expected_range: "product ≤ u32::MAX".to_string(), + })?; + + let generation_failed = + |underlying_error: String| toroidal_generation_error(total_vertices, underlying_error); + + let expected_vertices = + usize::try_from(total_vertices).map_err(|err| generation_failed(err.to_string()))?; + let expected_faces = + usize::try_from(total_cells).map_err(|err| generation_failed(err.to_string()))?; + + let n = usize::try_from(vertices_per_slice) + .map_err(|err| generation_failed(err.to_string()))?; + 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. + 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(); + vertex_specs + .try_reserve_exact(expected_vertices) + .map_err(|err| { + generation_failed(format!( + "from_toroidal_cdt() failed to reserve {expected_vertices} vertex specs for vertices_per_slice={vertices_per_slice}, num_slices={num_slices}: {err}" + )) + })?; + 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; + 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) + .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)?; + + let slice_sizes = vec![n; t_count]; + let foliation = + Foliation::from_slice_sizes(slice_sizes, num_slices).map_err(CdtError::from)?; + + 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()?; + + Ok(tri) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cdt::foliation::{CellType, EdgeType, FoliationError}; + use crate::geometry::generators::build_delaunay2_with_data; + + /// Builds a minimal labeled Delaunay backend for constructor tests. + fn labeled_triangle_backend(labels: [u32; 3]) -> DelaunayBackend2D { + let dt = build_delaunay2_with_data(&[ + ([0.0, 0.0], labels[0]), + ([1.0, 0.0], labels[1]), + ([0.5, 1.0], labels[2]), + ]) + .expect("Should build labeled triangle"); + DelaunayBackend2D::from_triangulation(dt) + } + + /// Builds an explicit 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"); + assert_eq!( + tri.vertex_count(), + vertices_per_slice as usize * num_slices as usize + ); + assert_eq!( + tri.face_count(), + 2 * (vertices_per_slice as usize - 1) * (num_slices as usize - 1) + ); + assert_eq!( + tri.slice_sizes(), + vec![vertices_per_slice as usize; num_slices as usize].as_slice() + ); + tri.validate_foliation() + .expect("explicit strip foliation should validate"); + tri.validate_causality_delaunay() + .expect("explicit strip causality should validate"); + tri.validate_topology() + .expect("explicit strip topology should validate"); + tri.validate_cell_classification() + .expect("all explicit 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()); + } + tri + } + + #[test] + fn test_remap_toroidal_generation_error_updates_context() { + let remapped = remap_toroidal_generation_error( + CdtError::DelaunayGenerationFailed { + vertex_count: 3, + coordinate_range: (-1.0, 1.0), + attempt: 7, + underlying_error: "builder failed".to_string(), + }, + 12, + ); + + assert!(matches!( + remapped, + CdtError::DelaunayGenerationFailed { + vertex_count: 12, + coordinate_range: (0.0, 1.0), + attempt: 1, + ref underlying_error, + } if underlying_error == "builder failed" + )); + } + + #[test] + fn test_remap_toroidal_generation_error_preserves_other_errors() { + let original = CdtError::InvalidGenerationParameters { + issue: "bad".to_string(), + provided_value: "x".to_string(), + expected_range: "y".to_string(), + }; + assert_eq!( + remap_toroidal_generation_error(original.clone(), 12), + original + ); + } + + #[test] + fn test_from_random_points() { + let triangulation = + CdtTriangulation::from_random_points(10, 3, 2).expect("Failed to create triangulation"); + + assert_eq!(triangulation.dimension(), 2); + assert_eq!(triangulation.time_slices(), 3); + assert!(triangulation.vertex_count() > 0); + assert!(triangulation.edge_count() > 0); + assert!(triangulation.face_count() > 0); + } + + #[test] + fn test_from_random_points_various_sizes() { + let test_cases = [ + (3, 1, "minimal"), + (5, 2, "small"), + (10, 3, "medium"), + (20, 5, "large"), + ]; + + for (vertices, time_slices, description) in test_cases { + let triangulation = CdtTriangulation::from_random_points(vertices, time_slices, 2) + .unwrap_or_else(|e| panic!("Failed to create {description} triangulation: {e}")); + + assert_eq!( + triangulation.dimension(), + 2, + "Dimension should be 2 for {description}" + ); + assert_eq!( + triangulation.time_slices(), + time_slices, + "Time slices should match for {description}" + ); + assert!( + triangulation.vertex_count() >= 3, + "Should have at least 3 vertices for {description}" + ); + assert!( + triangulation.edge_count() > 0, + "Should have edges for {description}" + ); + assert!( + triangulation.face_count() > 0, + "Should have faces for {description}" + ); + } + } + + #[test] + fn test_from_seeded_points() { + let seed = 42; + let triangulation = CdtTriangulation::from_seeded_points(8, 2, 2, seed) + .expect("Failed to create seeded triangulation"); + + assert_eq!(triangulation.dimension(), 2); + assert_eq!(triangulation.time_slices(), 2); + assert!(triangulation.vertex_count() > 0); + assert!(triangulation.edge_count() > 0); + assert!(triangulation.face_count() > 0); + } + + #[test] + fn test_seeded_determinism() { + let seed = 123; + let params = (6, 3, 2); + + let triangulation1 = + CdtTriangulation::from_seeded_points(params.0, params.1, params.2, seed) + .expect("Failed to create first triangulation"); + let triangulation2 = + CdtTriangulation::from_seeded_points(params.0, params.1, params.2, seed) + .expect("Failed to create second triangulation"); + + assert_eq!(triangulation1.vertex_count(), triangulation2.vertex_count()); + assert_eq!(triangulation1.edge_count(), triangulation2.edge_count()); + assert_eq!(triangulation1.face_count(), triangulation2.face_count()); + assert_eq!(triangulation1.dimension(), triangulation2.dimension()); + assert_eq!(triangulation1.time_slices(), triangulation2.time_slices()); + } + + #[test] + fn test_seeded_different_seeds() { + let params = (7, 2, 2); + let tri1 = CdtTriangulation::from_seeded_points(params.0, params.1, params.2, 456) + .expect("Failed to create triangulation with seed 456"); + let tri2 = CdtTriangulation::from_seeded_points(params.0, params.1, params.2, 789) + .expect("Failed to create triangulation with seed 789"); + + assert_eq!(tri1.dimension(), tri2.dimension()); + assert_eq!(tri1.time_slices(), tri2.time_slices()); + assert_eq!(tri1.vertex_count(), 7); + assert_eq!(tri2.vertex_count(), 7); + } + + #[test] + fn test_invalid_dimension() { + let invalid_dimensions = [0, 1, 3, 4, 5]; + for dim in invalid_dimensions { + let result = CdtTriangulation::from_random_points(10, 3, dim); + assert!(result.is_err(), "Should fail with dimension {dim}"); + + if let Err(CdtError::UnsupportedDimension(d)) = result { + assert_eq!(d, u32::from(dim), "Error should report correct dimension"); + } else { + panic!("Expected UnsupportedDimension error for dimension {dim}"); + } + } + } + + #[test] + fn test_from_seeded_points_rejects_invalid_dimension() { + let result = CdtTriangulation::from_seeded_points(10, 3, 3, 42); + + assert!(matches!(result, Err(CdtError::UnsupportedDimension(3)))); + } + + #[test] + fn test_from_seeded_points_rejects_zero_time_slices() { + let result = CdtTriangulation::from_seeded_points(5, 0, 2, 53); + + assert!(matches!( + result, + Err(CdtError::InvalidTriangulationMetadata { + ref field, + ref provided_value, + ref expected, + .. + }) if field == "timeslices" && provided_value == "0" && expected == "≥ 1" + )); + } + + #[test] + fn test_invalid_vertex_count() { + let invalid_counts = [0, 1, 2]; + for count in invalid_counts { + let result = CdtTriangulation::from_random_points(count, 2, 2); + assert!(result.is_err(), "Should fail with {count} vertices"); + + match result { + Err(CdtError::InvalidGenerationParameters { + issue, + provided_value, + .. + }) => { + assert_eq!(issue, "Insufficient vertex count"); + assert_eq!(provided_value, count.to_string()); + } + other => panic!( + "Expected InvalidGenerationParameters for {count} vertices, got {other:?}" + ), + } + } + } + + #[test] + fn test_invalid_vertex_count_seeded() { + let result = CdtTriangulation::from_seeded_points(2, 2, 2, 123); + assert!(result.is_err(), "Should fail with 2 vertices"); + + match result { + Err(CdtError::InvalidGenerationParameters { + issue, + provided_value, + .. + }) => { + assert_eq!(issue, "Insufficient vertex count"); + assert_eq!(provided_value, "2"); + } + other => panic!("Expected InvalidGenerationParameters, got {other:?}"), + } + } + + #[test] + fn test_from_labeled_delaunay_preserves_foliation() { + let backend = labeled_triangle_backend([0, 0, 1]); + + let tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) + .expect("Should preserve labels as foliation"); + + assert!(tri.has_foliation()); + assert_eq!(tri.slice_sizes(), &[2, 1]); + assert!(tri.validate_foliation().is_ok()); + + for vh in tri.geometry().vertices() { + assert!(tri.time_label(&vh).is_some()); + } + } + + #[test] + fn test_from_labeled_delaunay_rejects_invalid_dimension() { + let backend = labeled_triangle_backend([0, 0, 1]); + + let result = CdtTriangulation::from_labeled_delaunay(backend, 2, 3); + + assert!(matches!(result, Err(CdtError::UnsupportedDimension(3)))); + } + + #[test] + fn test_from_labeled_delaunay_rejects_zero_slices() { + let backend = labeled_triangle_backend([0, 0, 1]); + + let result = CdtTriangulation::from_labeled_delaunay(backend, 0, 2); + + assert!(matches!( + result, + Err(CdtError::InvalidTriangulationMetadata { + ref field, + ref provided_value, + ref expected, + .. + }) if field == "timeslices" && provided_value == "0" && expected == "≥ 1" + )); + } + + #[test] + fn test_from_labeled_delaunay_rejects_out_of_range_labels() { + let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 5)]) + .expect("Should build labeled triangle"); + let backend = DelaunayBackend2D::from_triangulation(dt); + + let result = CdtTriangulation::from_labeled_delaunay(backend, 2, 2); + assert!(matches!( + result, + Err(CdtError::Foliation(FoliationError::OutOfRangeVertexLabel { + label: 5, + expected_range_end: 2, + .. + })) + )); + } + + #[test] + fn test_from_labeled_delaunay_rejects_empty_intermediate_slice() { + let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 2), ([0.5, 1.0], 2)]) + .expect("Should build labeled triangle"); + let backend = DelaunayBackend2D::from_triangulation(dt); + + let result = CdtTriangulation::from_labeled_delaunay(backend, 3, 2); + assert!(matches!( + result, + Err(CdtError::Foliation(FoliationError::EmptySlice { slice: 1 })) + )); + } + + #[test] + fn test_from_cdt_strip_all_vertices_labeled() { + let tri = strict_strip(5, 3); + for vertex in tri.geometry().vertices() { + assert!(tri.time_label(&vertex).is_some()); + } + } + + #[test] + fn test_from_cdt_strip_edge_classification() { + let tri = strict_strip(5, 3); + for edge in tri.geometry().edges() { + assert!(matches!( + tri.edge_type(&edge), + Some(EdgeType::Spacelike | EdgeType::Timelike) + )); + } + } + + #[test] + fn test_from_cdt_strip_rejects_invalid_params() { + let few_vertices = CdtTriangulation::from_cdt_strip(3, 3); + assert!(matches!( + few_vertices, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Insufficient vertices per slice" + && provided_value == "3" + && expected_range == "≥ 4" + )); + + let few_slices = CdtTriangulation::from_cdt_strip(4, 1); + assert!(matches!( + few_slices, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Insufficient number of time slices" + && provided_value == "1" + && expected_range == "≥ 2" + )); + } + + #[test] + fn test_from_cdt_strip_rejects_cell_count_overflow() { + let result = CdtTriangulation::from_cdt_strip(65_535, 65_537); + + assert!(matches!( + result, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Cell count overflow" + && provided_value == "2 × 4294836224" + && expected_range == "product ≤ u32::MAX" + )); + } + + #[test] + fn test_from_cdt_strip_builds_valid_mesh() { + let tri = CdtTriangulation::from_cdt_strip(4, 2).expect("explicit strip should build"); + assert_eq!(tri.vertex_count(), 8); + assert_eq!(tri.face_count(), 6); + assert!(tri.validate_topology().is_ok()); + assert!(tri.validate_foliation().is_ok()); + assert!(tri.validate_causality_delaunay().is_ok()); + assert!(tri.validate_cell_classification().is_ok()); + } + + #[test] + fn test_explicit_strip_count_validation_rejects_face_mismatch() { + let tri = CdtTriangulation::from_cdt_strip(4, 2).expect("explicit strip should build"); + let result = validate_strip_counts(tri.geometry(), 8, 7, 8, 7, 4, 2, 1.0); + + assert!(matches!( + result, + Err(CdtError::DelaunayGenerationFailed { + vertex_count: 8, + coordinate_range: (0.0, 1.0), + attempt: 1, + ref underlying_error, + }) if underlying_error.contains("build_delaunay2_from_cells()/from_cdt_strip()") + && underlying_error.contains("produced 6 faces, expected 7") + && underlying_error.contains("vertices_per_slice=4") + && underlying_error.contains("num_slices=2") + )); + } + + #[test] + fn test_cell_type_returns_up_or_down() { + let tri = strict_strip(5, 3); + for face in tri.geometry().faces() { + assert!(matches!( + tri.cell_type(&face), + Some(CellType::Up | CellType::Down) + )); + } + } + + #[test] + fn test_from_toroidal_cdt_basic() { + let tri = CdtTriangulation::from_toroidal_cdt(4, 3) + .expect("toroidal CDT should build with delaunay v0.7.6"); + + // V = N*T = 12, F = 2*N*T = 24, E = 3*N*T = 36, χ = 0. + assert_eq!(tri.vertex_count(), 12); + assert_eq!(tri.face_count(), 24); + assert_eq!(tri.edge_count(), 36); + assert_eq!(tri.geometry().euler_characteristic(), 0); + assert_eq!(tri.dimension(), 2); + assert_eq!(tri.time_slices(), 3); + assert!(matches!(tri.metadata().topology, CdtTopology::Toroidal)); + } + + #[test] + fn test_from_toroidal_cdt_various_sizes() { + for (n, t) in [(3_u32, 3_u32), (4, 3), (5, 4), (6, 5), (8, 4)] { + let tri = CdtTriangulation::from_toroidal_cdt(n, t) + .unwrap_or_else(|err| panic!("toroidal CDT N={n} T={t} should build: {err}")); + let nt = (n as usize) * (t as usize); + assert_eq!(tri.vertex_count(), nt); + assert_eq!(tri.face_count(), 2 * nt); + assert_eq!(tri.edge_count(), 3 * nt); + assert_eq!(tri.geometry().euler_characteristic(), 0); + } + } + + #[test] + fn test_from_toroidal_cdt_foliation_per_slice() { + let tri = CdtTriangulation::from_toroidal_cdt(5, 4).expect("build toroidal CDT"); + assert!(tri.has_foliation()); + assert_eq!(tri.slice_sizes(), &[5, 5, 5, 5]); + for t in 0..4 { + assert_eq!( + tri.vertices_at_time(t).len(), + 5, + "slice {t} should contain N=5 vertices" + ); + } + } + + #[test] + fn test_from_toroidal_cdt_validate_passes() { + 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()); + } + + #[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"); + } + + #[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()); + } + + #[test] + fn test_from_toroidal_cdt_rejects_vertex_count_overflow() { + let result = CdtTriangulation::from_toroidal_cdt(u32::MAX, 3); + + assert!(matches!( + result, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Vertex count overflow" + && provided_value == "4294967295 × 3" + && expected_range == "product ≤ u32::MAX" + )); + } + + #[test] + fn test_explicit_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); + + assert!(matches!( + result, + Err(CdtError::DelaunayGenerationFailed { + vertex_count: 12, + coordinate_range: (0.0, 1.0), + attempt: 1, + ref underlying_error, + }) if underlying_error.contains("explicit toroidal builder") + && underlying_error.contains("produced 12 vertices and 24 faces") + && underlying_error.contains("expected 12 vertices and 23 faces") + )); + } + + #[test] + fn test_toroidal_cell_classification_uses_temporal_wrap() { + let tri = CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); + let mut saw_wrap_up = false; + let mut saw_wrap_down = false; + let mut saw_wrap_timelike_edge = false; + + for face in tri.geometry().faces() { + let vertices = tri + .geometry() + .face_vertices(&face) + .expect("toroidal face vertices should resolve"); + let labels: Vec<_> = vertices + .iter() + .map(|vh| { + tri.geometry() + .vertex_data_by_key(vh.vertex_key()) + .expect("toroidal vertices are labeled") + }) + .collect(); + + if labels.contains(&0) && labels.contains(&2) { + let cell_type = tri + .cell_type(&face) + .expect("wrap-around toroidal face should classify"); + let edge_types = tri + .face_edge_types(&face) + .expect("wrap-around toroidal face should expose edge types"); + saw_wrap_timelike_edge |= edge_types + .iter() + .any(|edge_type| matches!(edge_type, EdgeType::Timelike)); + + let zero_count = labels.iter().filter(|&&label| label == 0).count(); + let two_count = labels.iter().filter(|&&label| label == 2).count(); + let is_wrap_up = zero_count == 1 && two_count == 2; + let is_wrap_down = zero_count == 2 && two_count == 1; + + if is_wrap_up { + assert_eq!(cell_type, CellType::Up); + } + if is_wrap_down { + assert_eq!(cell_type, CellType::Down); + } + + saw_wrap_up |= is_wrap_up; + saw_wrap_down |= is_wrap_down; + } + } + + assert!(saw_wrap_up, "expected an Up cell across the temporal wrap"); + assert!( + saw_wrap_down, + "expected a Down cell across the temporal wrap" + ); + assert!( + saw_wrap_timelike_edge, + "expected a timelike edge across the temporal wrap" + ); + } +} diff --git a/src/cdt/triangulation/foliation.rs b/src/cdt/triangulation/foliation.rs new file mode 100644 index 0000000..fcef708 --- /dev/null +++ b/src/cdt/triangulation/foliation.rs @@ -0,0 +1,1366 @@ +#![forbid(unsafe_code)] + +//! Foliation assignment, queries, and CDT cell classification. + +use super::CdtTriangulation; +use crate::cdt::foliation::{CellType, EdgeType, Foliation, FoliationError, classify_cell}; +use crate::config::CdtTopology; +use crate::errors::{CdtError, CdtResult}; +use crate::geometry::DelaunayBackend2D; +use crate::geometry::backends::delaunay::{ + DelaunayEdgeHandle, DelaunayFaceHandle, DelaunayVertexHandle, +}; +use crate::geometry::traits::TriangulationQuery; +use crate::util::f64_band_to_u32; +use std::collections::{HashMap, HashSet}; + +impl CdtTriangulation { + /// Validate foliation consistency. + /// + /// If no foliation is present, succeeds vacuously. + /// Otherwise checks: + /// 1. The stored labeled-vertex count matches the geometry vertex count + /// 2. Every stored time slice is non-empty + /// 3. Live backend labels match stored per-slice bookkeeping + /// + /// # Errors + /// + /// Returns error if foliation structure is invalid. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let mut tri = CdtTriangulation::from_seeded_points(12, 3, 2, 42)?; + /// tri.assign_foliation_by_y(3)?; + /// tri.validate_foliation()?; + /// Ok(()) + /// } + /// ``` + pub fn validate_foliation(&self) -> CdtResult<()> { + let Some(foliation) = &self.foliation else { + return Ok(()); + }; + + let vertex_count = self.geometry.vertex_count(); + if foliation.labeled_vertex_count() != vertex_count { + return Err(FoliationError::LabelCountMismatch { + labeled: foliation.labeled_vertex_count(), + expected: vertex_count, + } + .into()); + } + + for (t, &size) in foliation.slice_sizes().iter().enumerate() { + if size == 0 { + return Err(FoliationError::EmptySlice { slice: t }.into()); + } + } + + let mut live_slice_sizes = vec![0usize; foliation.slice_sizes().len()]; + + for (vertex, vh) in self.geometry.vertices().enumerate() { + let Some(label) = self.geometry.vertex_data_by_key(vh.vertex_key()) else { + return Err(FoliationError::MissingVertexLabel { vertex }.into()); + }; + + let slice = label as usize; + if slice >= live_slice_sizes.len() { + return Err(FoliationError::OutOfRangeVertexLabel { + vertex, + label, + expected_range_end: live_slice_sizes.len(), + } + .into()); + } + + live_slice_sizes[slice] += 1; + } + + for (slice, (&expected, &actual)) in foliation + .slice_sizes() + .iter() + .zip(live_slice_sizes.iter()) + .enumerate() + { + if expected != actual { + return Err(FoliationError::LabelMismatch { + slice, + expected, + actual, + } + .into()); + } + } + + if matches!(self.metadata.topology, CdtTopology::Toroidal) { + self.validate_toroidal_spatial_rings()?; + self.validate_toroidal_temporal_wraparound()?; + } + + Ok(()) + } + + /// Validates that the time direction wraps for toroidal topology. + fn validate_toroidal_temporal_wraparound(&self) -> CdtResult<()> { + let total = self.metadata.time_slices; + if total < 2 { + return Ok(()); + } + let num_slices = total as usize; + + let mut neighbor_slices: Vec> = vec![HashSet::new(); num_slices]; + for edge in self.geometry.edges() { + let Some((v0, v1)) = self.geometry.edge_endpoints(&edge) else { + continue; + }; + let Some(t0) = self.geometry.vertex_data_by_key(v0.vertex_key()) else { + continue; + }; + let Some(t1) = self.geometry.vertex_data_by_key(v1.vertex_key()) else { + continue; + }; + if t0 >= total || t1 >= total { + continue; + } + if self.time_step_distance(t0, t1) != 1 { + continue; + } + let s0 = t0 as usize; + let s1 = t1 as usize; + neighbor_slices[s0].insert(s1); + neighbor_slices[s1].insert(s0); + } + + for (slice, neighbors) in neighbor_slices.iter().enumerate() { + let prev = if slice == 0 { + num_slices - 1 + } else { + slice - 1 + }; + let next = (slice + 1) % num_slices; + if !neighbors.contains(&prev) { + return Err(FoliationError::MissingTemporalWrapAround { + slice, + missing_neighbor: prev, + } + .into()); + } + if !neighbors.contains(&next) { + return Err(FoliationError::MissingTemporalWrapAround { + slice, + missing_neighbor: next, + } + .into()); + } + } + + Ok(()) + } + + /// Validates that every spatial slice forms a closed S¹. + fn validate_toroidal_spatial_rings(&self) -> CdtResult<()> { + let Some(foliation) = &self.foliation else { + return Ok(()); + }; + + let num_slices = foliation.slice_sizes().len(); + let mut spacelike_neighbors: Vec>> = + vec![HashMap::new(); num_slices]; + + for edge in self.geometry.edges() { + let Some((v0, v1)) = self.geometry.edge_endpoints(&edge) else { + continue; + }; + let Some(t0) = self.geometry.vertex_data_by_key(v0.vertex_key()) else { + continue; + }; + let Some(t1) = self.geometry.vertex_data_by_key(v1.vertex_key()) else { + continue; + }; + if t0 != t1 { + continue; + } + let slice = t0 as usize; + if slice >= num_slices { + continue; + } + spacelike_neighbors[slice] + .entry(v0.clone()) + .or_default() + .push(v1.clone()); + spacelike_neighbors[slice].entry(v1).or_default().push(v0); + } + + for (slice, adjacency) in spacelike_neighbors.iter().enumerate() { + let expected_size = foliation.slice_sizes()[slice]; + if adjacency.len() != expected_size { + return Err(FoliationError::SpacelikeSubgraphSizeMismatch { + slice, + observed: adjacency.len(), + expected: expected_size, + } + .into()); + } + for (vertex, neighbors) in adjacency { + if neighbors.len() != 2 { + return Err(FoliationError::SpacelikeDegreeViolation { + slice, + vertex: format!("{:?}", vertex.vertex_key()), + observed_degree: neighbors.len(), + } + .into()); + } + } + + let Some(start) = adjacency.keys().next() else { + continue; + }; + let mut visited: HashSet = HashSet::new(); + visited.insert(start.clone()); + let mut prev = start.clone(); + let mut current = adjacency[start][0].clone(); + while current != *start { + if !visited.insert(current.clone()) { + return Err(FoliationError::SpacelikeNonClosedRing { + slice, + walked: visited.len(), + expected: expected_size, + } + .into()); + } + let neighbors = &adjacency[¤t]; + let next = if neighbors[0] == prev { + neighbors[1].clone() + } else { + neighbors[0].clone() + }; + prev = current; + current = next; + } + if visited.len() != expected_size { + return Err(FoliationError::SpacelikeNonClosedRing { + slice, + walked: visited.len(), + expected: expected_size, + } + .into()); + } + } + + Ok(()) + } + + /// Assign a foliation to an existing triangulation by binning vertices + /// by their y-coordinate into `num_slices` equal bands. + /// + /// The y-coordinate range is determined from the actual vertex coordinates. + /// Band `t` covers `[y_min + t * band_height, y_min + (t+1) * band_height)`. + /// Time labels are written directly to vertex data. + /// + /// This is approximate — useful for testing but not guaranteed to produce + /// a valid causal structure. + /// + /// # Errors + /// + /// Returns error if `num_slices` is zero, if vertex coordinates cannot be + /// read, if y-bucket assignment would leave any time slice empty, if the + /// requested slice count violates the triangulation topology, or if writing + /// vertex labels or clearing stale cell labels in the backend fails. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let mut tri = CdtTriangulation::from_seeded_points(12, 3, 2, 42)?; + /// tri.assign_foliation_by_y(3)?; + /// + /// assert!(tri.has_foliation()); + /// assert_eq!(tri.slice_sizes().iter().sum::(), tri.vertex_count()); + /// Ok(()) + /// } + /// ``` + #[expect( + clippy::too_many_lines, + reason = "foliation assignment stages labels, writes backend payloads, and rolls back on failure to preserve atomic metadata/foliation invariants" + )] + pub fn assign_foliation_by_y(&mut self, num_slices: u32) -> CdtResult<()> { + if num_slices == 0 { + return Err(CdtError::InvalidGenerationParameters { + issue: "Number of slices must be positive".to_string(), + provided_value: "0".to_string(), + expected_range: "≥ 1".to_string(), + }); + } + + let y_coords: Vec<(DelaunayVertexHandle, f64)> = self + .geometry + .vertices() + .map(|vh| { + let coords = self.geometry.vertex_coordinates(&vh).map_err(|e| { + CdtError::ValidationFailed { + check: "foliation_assignment".to_string(), + detail: format!( + "failed to read coordinates for vertex {:?}: {e}", + vh.vertex_key() + ), + } + })?; + if coords.len() < 2 { + return Err(CdtError::ValidationFailed { + check: "foliation_assignment".to_string(), + detail: format!( + "vertex {:?} has {} coordinates, expected ≥ 2", + vh.vertex_key(), + coords.len() + ), + }); + } + Ok((vh, coords[1])) + }) + .collect::>>()?; + + let y_min = y_coords + .iter() + .map(|(_, y)| *y) + .fold(f64::INFINITY, f64::min); + let y_max = y_coords + .iter() + .map(|(_, y)| *y) + .fold(f64::NEG_INFINITY, f64::max); + + let range = y_max - y_min; + let band_height = if range.abs() < f64::EPSILON { + 1.0 + } else { + range / f64::from(num_slices) + }; + + let mut assignments = Vec::with_capacity(y_coords.len()); + let mut slice_sizes = vec![0usize; num_slices as usize]; + for (vh, y) in &y_coords { + let t = if range.abs() < f64::EPSILON { + 0 + } else { + let band_index = ((y - y_min) / band_height).floor(); + f64_band_to_u32(band_index, num_slices - 1) + }; + assignments.push((vh.vertex_key(), t)); + slice_sizes[t as usize] += 1; + } + + Self::check_time_slices(self.metadata.topology, num_slices)?; + + let foliation = + Foliation::from_slice_sizes(slice_sizes, num_slices).map_err(CdtError::from)?; + + let face_keys: Vec<_> = self.geometry.faces().map(|f| f.cell_key()).collect(); + let previous_cell_data: Vec<_> = face_keys + .iter() + .map(|&key| (key, self.geometry.cell_data_by_key(key))) + .collect(); + let previous_vertex_data: Vec<_> = assignments + .iter() + .map(|&(key, _)| (key, self.geometry.vertex_data_by_key(key))) + .collect(); + + let rollback_payloads = |geometry: &mut DelaunayBackend2D| -> Vec { + let mut rollback_errors = Vec::new(); + + for &(key, data) in &previous_cell_data { + if let Err(err) = geometry.set_cell_data_by_key(key, data) { + rollback_errors.push(format!("face {key:?}: {err}")); + } + } + + for &(key, data) in &previous_vertex_data { + if let Err(err) = geometry.set_vertex_data_by_key(key, data) { + rollback_errors.push(format!("vertex {key:?}: {err}")); + } + } + + rollback_errors + }; + + for &key in &face_keys { + if let Err(err) = self.geometry.set_cell_data_by_key(key, None) { + let operation = "set_cell_data_by_key".to_string(); + let target = format!("face {key:?}"); + let detail = err.to_string(); + let rollback_errors = rollback_payloads(&mut self.geometry); + return if rollback_errors.is_empty() { + Err(CdtError::BackendMutationFailed { + operation, + target, + detail, + }) + } else { + Err(CdtError::BackendRollbackFailed { + operation, + target, + detail, + rollback_errors: rollback_errors.join("; "), + }) + }; + } + } + + for (vertex_key, t) in assignments { + if let Err(err) = self.geometry.set_vertex_data_by_key(vertex_key, Some(t)) { + let operation = "set_vertex_data_by_key".to_string(); + let target = format!("vertex {vertex_key:?}"); + let detail = format!("failed while assigning time label {t}: {err}"); + let rollback_errors = rollback_payloads(&mut self.geometry); + return if rollback_errors.is_empty() { + Err(CdtError::BackendMutationFailed { + operation, + target, + detail, + }) + } else { + Err(CdtError::BackendRollbackFailed { + operation, + target, + detail, + rollback_errors: rollback_errors.join("; "), + }) + }; + } + } + + self.metadata.time_slices = num_slices; + self.bump_modification_count(); + self.foliation = Some(foliation); + self.mark_foliation_synchronized(); + Ok(()) + } + + /// Returns `true` if this triangulation has current foliation bookkeeping. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert!(tri.has_foliation()); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn has_foliation(&self) -> bool { + self.has_current_foliation() + } + + /// Returns a reference to the foliation, if present. + /// + /// A triangulation can still contain backend vertex labels while stored + /// foliation bookkeeping is stale; this method returns `None` in that case. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert_eq!(tri.foliation().map(Foliation::num_slices), Some(2)); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn foliation(&self) -> Option<&Foliation> { + if self.has_current_foliation() { + self.foliation.as_ref() + } else { + None + } + } + + /// Returns the time slice label for a vertex, or `None` if no foliation + /// is present or the vertex is unlabeled. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert!(tri.geometry().vertices().all(|vertex| tri.time_label(&vertex).is_some())); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn time_label(&self, vertex: &DelaunayVertexHandle) -> Option { + self.foliation.as_ref()?; + self.geometry.vertex_data_by_key(vertex.vertex_key()) + } + + /// Returns all vertex handles that belong to time slice `t`. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// assert_eq!(tri.vertices_at_time(0).len(), 4); + /// assert!(tri.vertices_at_time(99).is_empty()); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn vertices_at_time(&self, t: u32) -> Vec { + if self.foliation.is_none() { + return vec![]; + } + self.geometry + .vertices() + .filter(|vh| self.geometry.vertex_data_by_key(vh.vertex_key()) == Some(t)) + .collect() + } + + /// Returns per-slice vertex counts, or an empty slice if no foliation. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// assert_eq!(tri.slice_sizes(), &[4, 4, 4]); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn slice_sizes(&self) -> &[usize] { + self.foliation().map_or(&[], Foliation::slice_sizes) + } + + /// Counts strict CDT triangles by the time slab of their lower slice. + /// + /// The returned vector has one entry per time slice. For open-boundary + /// strips, the final slice usually has zero lower-slab triangles because no + /// future slice exists; toroidal triangulations wrap the last slab back to + /// slice zero. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// assert_eq!(tri.volume_profile(), vec![6, 6, 0]); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn volume_profile(&self) -> Vec { + if !self.has_current_foliation() { + return Vec::new(); + } + + let Ok(slice_count) = usize::try_from(self.metadata.time_slices) else { + return Vec::new(); + }; + let mut profile = vec![0_u32; slice_count]; + + for face in self.geometry.faces() { + let Some(slice) = self.face_time_slice(&face) else { + continue; + }; + let Ok(index) = usize::try_from(slice) else { + continue; + }; + if let Some(count) = profile.get_mut(index) { + *count = count.saturating_add(1); + } + } + + profile + } + + /// Computes the temporal step distance between two time labels. + #[must_use] + pub(super) fn time_step_distance(&self, t0: u32, t1: u32) -> u32 { + let raw = t0.abs_diff(t1); + if matches!(self.metadata.topology, CdtTopology::Toroidal) { + let total = self.metadata.time_slices; + if total > 0 && t0 < total && t1 < total { + return raw.min(total - raw); + } + } + raw + } + + /// Topology-aware variant of [`crate::cdt::foliation::classify_cell`]. + fn classify_cell_with_topology(&self, t0: u32, t1: u32, t2: u32) -> Option { + let mut dists = [ + self.time_step_distance(t0, t1), + self.time_step_distance(t1, t2), + self.time_step_distance(t0, t2), + ]; + dists.sort_unstable(); + if dists != [0, 1, 1] { + return None; + } + + let (base_slice, apex_slice) = if t0 == t1 { + (t0, t2) + } else if t1 == t2 { + (t1, t0) + } else if t0 == t2 { + (t0, t1) + } else { + return None; + }; + + let total = self.metadata.time_slices; + let toroidal = matches!(self.metadata.topology, CdtTopology::Toroidal) && total > 0; + let up_apex = if toroidal { + (base_slice + 1) % total + } else { + base_slice.checked_add(1)? + }; + let down_apex = if toroidal { + if base_slice == 0 { + total - 1 + } else { + base_slice - 1 + } + } else { + base_slice.checked_sub(1)? + }; + if apex_slice == up_apex { + Some(CellType::Up) + } else if apex_slice == down_apex { + Some(CellType::Down) + } else { + None + } + } + + /// Returns the lower time-slab index assigned to a classifiable CDT face. + fn face_time_slice(&self, face: &DelaunayFaceHandle) -> Option { + self.cell_type(face)?; + + let vertices = self.geometry.face_vertices(face).ok()?; + let [v0, v1, v2] = vertices.as_slice() else { + return None; + }; + + let labels = [ + self.geometry.vertex_data_by_key(v0.vertex_key())?, + self.geometry.vertex_data_by_key(v1.vertex_key())?, + self.geometry.vertex_data_by_key(v2.vertex_key())?, + ]; + + match self.metadata.topology { + CdtTopology::OpenBoundary => Some(labels[0].min(labels[1]).min(labels[2])), + CdtTopology::Toroidal => { + let total = self.metadata.time_slices; + if total == 0 { + return None; + } + + let first = labels[0]; + let mut second = None; + for &label in &labels[1..] { + if label != first { + if second.is_some_and(|distinct| distinct != label) { + return None; + } + second = Some(label); + } + } + let second = second?; + + let next_slice = |slice: u32| slice.checked_add(1).map(|next| next % total); + if next_slice(first) == Some(second) { + Some(first) + } else if next_slice(second) == Some(first) { + Some(second) + } else { + None + } + } + } + } + + /// Returns the causal classification of an edge from endpoint time labels. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert!(tri.geometry().edges().any(|edge| tri.edge_type(&edge).is_some())); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn edge_type(&self, edge: &DelaunayEdgeHandle) -> Option { + self.foliation.as_ref()?; + + let (v0, v1) = self.geometry.edge_endpoints(edge)?; + let t0 = self.geometry.vertex_data_by_key(v0.vertex_key())?; + let t1 = self.geometry.vertex_data_by_key(v1.vertex_key())?; + + Some(match self.time_step_distance(t0, t1) { + 0 => EdgeType::Spacelike, + 1 => EdgeType::Timelike, + _ => EdgeType::Acausal, + }) + } + + /// Classifies a triangle as Up (2,1) or Down (1,2) from vertex time labels. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert!(tri.geometry().faces().all(|face| tri.cell_type(&face).is_some())); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn cell_type(&self, face: &DelaunayFaceHandle) -> Option { + self.foliation.as_ref()?; + let verts = self.geometry.face_vertices(face).ok()?; + if verts.len() != 3 { + return None; + } + let t0 = self.geometry.vertex_data_by_key(verts[0].vertex_key())?; + let t1 = self.geometry.vertex_data_by_key(verts[1].vertex_key())?; + let t2 = self.geometry.vertex_data_by_key(verts[2].vertex_key())?; + match self.metadata.topology { + CdtTopology::Toroidal => self.classify_cell_with_topology(t0, t1, t2), + CdtTopology::OpenBoundary => classify_cell(Some(t0), Some(t1), Some(t2)), + } + } + + /// Reads the stored cell type from cell data, if previously classified. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert!(tri.geometry().faces().all(|face| tri.cell_type_from_data(&face).is_some())); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn cell_type_from_data(&self, face: &DelaunayFaceHandle) -> Option { + self.foliation()?; + let raw = self.geometry.cell_data_by_key(face.cell_key())?; + CellType::from_i32(raw) + } + + /// Returns the edge classification for a triangular face. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert!(tri.geometry().faces().all(|face| { + /// tri.face_edge_types(&face).is_some_and(|edges| { + /// edges.iter().filter(|&&edge| edge == EdgeType::Spacelike).count() == 1 + /// && edges.iter().filter(|&&edge| edge == EdgeType::Timelike).count() == 2 + /// }) + /// })); + /// Ok(()) + /// } + /// ``` + #[must_use] + pub fn face_edge_types(&self, face: &DelaunayFaceHandle) -> Option<[EdgeType; 3]> { + self.foliation.as_ref()?; + + let verts = self.geometry.face_vertices(face).ok()?; + if verts.len() != 3 { + return None; + } + + let t = [ + self.geometry.vertex_data_by_key(verts[0].vertex_key())?, + self.geometry.vertex_data_by_key(verts[1].vertex_key())?, + self.geometry.vertex_data_by_key(verts[2].vertex_key())?, + ]; + + let edge_classify = |a: u32, b: u32| -> Option { + Some(match self.time_step_distance(a, b) { + 0 => EdgeType::Spacelike, + 1 => EdgeType::Timelike, + _ => EdgeType::Acausal, + }) + }; + + Some([ + edge_classify(t[0], t[1])?, + edge_classify(t[1], t[2])?, + edge_classify(t[2], t[0])?, + ]) + } + + /// Validates that every finite face has a strict CDT cell classification. + /// + /// # Errors + /// + /// Returns [`CdtError::ValidationFailed`] if any face in a foliated + /// triangulation cannot be classified as a strict Up or Down CDT cell. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// tri.validate_cell_classification()?; + /// Ok(()) + /// } + /// ``` + pub fn validate_cell_classification(&self) -> CdtResult<()> { + if self.foliation.is_none() { + return Ok(()); + } + + for face in self.geometry.faces() { + if self.cell_type(&face).is_none() { + return Err(CdtError::ValidationFailed { + check: "cell_classification".to_string(), + detail: format!( + "face {:?} is not a strict CDT cell (expected Up or Down)", + face.cell_key() + ), + }); + } + } + + Ok(()) + } + + /// Classifies every triangle and stores the result as cell data. + /// + /// # Errors + /// + /// Returns [`CdtError::ValidationFailed`] if a foliated face is not an Up + /// or Down CDT cell. Returns [`CdtError::BackendMutationFailed`] if writing + /// cell payloads fails, or [`CdtError::BackendRollbackFailed`] if restoring + /// previous payloads also fails. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let mut tri = CdtTriangulation::from_cdt_strip(4, 2)?; + /// assert_eq!(tri.classify_all_cells()?, Some(tri.face_count())); + /// Ok(()) + /// } + /// ``` + pub fn classify_all_cells(&mut self) -> CdtResult> { + if self.foliation.is_none() { + return Ok(None); + } + + let faces: Vec<_> = self.geometry.faces().collect(); + let mut classifications = Vec::with_capacity(faces.len()); + for face in &faces { + let Some(ct) = self.cell_type(face) else { + return Err(CdtError::ValidationFailed { + check: "cell_classification".to_string(), + detail: format!( + "face {:?} is not a strict CDT cell (expected Up or Down)", + face.cell_key() + ), + }); + }; + classifications.push((face.cell_key(), ct)); + } + + let count = classifications.len(); + let previous_cell_data: Vec<_> = faces + .iter() + .map(|face| { + let key = face.cell_key(); + (key, self.geometry.cell_data_by_key(key)) + }) + .collect(); + let rollback_cell_payloads = |geometry: &mut DelaunayBackend2D| -> Vec { + let mut rollback_errors = Vec::new(); + + for &(key, data) in &previous_cell_data { + if let Err(err) = geometry.set_cell_data_by_key(key, data) { + rollback_errors.push(format!("face {key:?}: {err}")); + } + } + + rollback_errors + }; + + for face in &faces { + let key = face.cell_key(); + if let Err(err) = self.geometry.set_cell_data_by_key(key, None) { + let operation = "set_cell_data_by_key".to_string(); + let target = format!("face {key:?}"); + let detail = + format!("failed to clear existing cell payload before classification: {err}"); + let rollback_errors = rollback_cell_payloads(&mut self.geometry); + return if rollback_errors.is_empty() { + Err(CdtError::BackendMutationFailed { + operation, + target, + detail, + }) + } else { + Err(CdtError::BackendRollbackFailed { + operation, + target, + detail, + rollback_errors: rollback_errors.join("; "), + }) + }; + } + } + for (key, ct) in classifications { + if let Err(err) = self.geometry.set_cell_data_by_key(key, Some(ct.to_i32())) { + let operation = "set_cell_data_by_key".to_string(); + let target = format!("face {key:?}"); + let detail = format!( + "failed to store classified cell payload {}: {err}", + ct.to_i32() + ); + let rollback_errors = rollback_cell_payloads(&mut self.geometry); + return if rollback_errors.is_empty() { + Err(CdtError::BackendMutationFailed { + operation, + target, + detail, + }) + } else { + Err(CdtError::BackendRollbackFailed { + operation, + target, + detail, + rollback_errors: rollback_errors.join("; "), + }) + }; + } + } + Ok(Some(count)) + } + + /// Rebuilds foliation bookkeeping from live backend vertex labels after a topology edit. + pub(crate) fn synchronize_foliation_from_live_labels(&mut self) -> CdtResult<()> { + if self.foliation.is_none() { + return Ok(()); + } + + let slice_sizes = + Self::live_slice_sizes_from_vertex_labels(&self.geometry, self.metadata.time_slices)?; + let foliation = Foliation::from_slice_sizes(slice_sizes, self.metadata.time_slices) + .map_err(CdtError::from)?; + + self.foliation = Some(foliation); + match self.classify_all_cells() { + Ok(_) => { + self.mark_foliation_synchronized(); + Ok(()) + } + Err(err) => { + self.foliation = None; + self.foliation_synced_at_modification = None; + Err(err) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::generators::build_delaunay2_with_data; + use std::thread; + use std::time::Duration; + + /// Builds a minimal labeled Delaunay backend for foliation tests. + fn labeled_triangle_backend(labels: [u32; 3]) -> DelaunayBackend2D { + let dt = build_delaunay2_with_data(&[ + ([0.0, 0.0], labels[0]), + ([1.0, 0.0], labels[1]), + ([0.5, 1.0], labels[2]), + ]) + .expect("Should build labeled triangle"); + DelaunayBackend2D::from_triangulation(dt) + } + + /// Builds an explicit 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"); + assert_eq!( + tri.vertex_count(), + vertices_per_slice as usize * num_slices as usize + ); + assert_eq!( + tri.face_count(), + 2 * (vertices_per_slice as usize - 1) * (num_slices as usize - 1) + ); + assert_eq!( + tri.slice_sizes(), + vec![vertices_per_slice as usize; num_slices as usize].as_slice() + ); + tri.validate_foliation() + .expect("explicit strip foliation should validate"); + tri.validate_causality_delaunay() + .expect("explicit strip causality should validate"); + tri.validate_cell_classification() + .expect("all explicit strip cells should classify"); + tri + } + + #[test] + fn validate_foliation_is_vacuous_without_foliation() { + let triangulation = + CdtTriangulation::from_random_points(5, 3, 2).expect("Failed to create triangulation"); + + triangulation + .validate_foliation() + .expect("missing foliation should validate vacuously"); + } + + #[test] + fn validate_foliation_detects_missing_out_of_range_and_mismatched_labels() { + let backend = labeled_triangle_backend([0, 0, 1]); + let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) + .expect("Should preserve labels as foliation"); + let first_vertex = tri + .geometry() + .vertices() + .next() + .expect("Triangle should contain a vertex"); + + tri.set_vertex_data(&first_vertex, None) + .expect("Expected valid vertex handle while clearing label"); + assert!(matches!( + tri.validate_foliation(), + Err(CdtError::Foliation(FoliationError::MissingVertexLabel { + vertex: 0 + })) + )); + + let backend = labeled_triangle_backend([0, 0, 1]); + let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) + .expect("Should preserve labels as foliation"); + let first_vertex = tri + .geometry() + .vertices() + .next() + .expect("Triangle should contain a vertex"); + + tri.set_vertex_data(&first_vertex, Some(7)) + .expect("Expected valid vertex handle while mutating label"); + assert!(matches!( + tri.validate_foliation(), + Err(CdtError::Foliation(FoliationError::OutOfRangeVertexLabel { + vertex: 0, + label: 7, + expected_range_end: 2, + })) + )); + + let backend = labeled_triangle_backend([0, 0, 1]); + let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) + .expect("Should preserve labels as foliation"); + let slice_zero_vertex = tri + .geometry() + .vertices() + .find(|vh| tri.geometry().vertex_data_by_key(vh.vertex_key()) == Some(0)) + .expect("Triangle should contain a vertex in slice 0"); + + tri.set_vertex_data(&slice_zero_vertex, Some(1)) + .expect("Expected valid vertex handle while mutating label"); + assert!(matches!( + tri.validate_foliation(), + Err(CdtError::Foliation(FoliationError::LabelMismatch { .. })) + )); + } + + #[test] + fn validate_foliation_rejects_stored_label_count_mismatch() { + let backend = labeled_triangle_backend([0, 0, 1]); + let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) + .expect("Should preserve labels as foliation"); + tri.foliation = Some( + Foliation::from_slice_sizes(vec![1, 1], 2) + .expect("non-empty mismatched bookkeeping is constructible"), + ); + + assert!(matches!( + tri.validate_foliation(), + Err(CdtError::Foliation(FoliationError::LabelCountMismatch { + labeled: 2, + expected: 3, + })) + )); + } + + #[test] + fn assign_foliation_by_y_updates_metadata_invalidates_cache_and_writes_labels() { + let mut tri = CdtTriangulation::from_seeded_points(15, 3, 2, 42) + .expect("Failed to create deterministic triangulation"); + let initial_last_modified = tri.metadata().last_modified; + let initial_modification_count = tri.metadata().modification_count; + let initial_edge_count = tri.edge_count(); + tri.refresh_cache(); + assert!(tri.cache.edge_count.is_some()); + + thread::sleep(Duration::from_millis(5)); + tri.assign_foliation_by_y(3) + .expect("Should assign foliation"); + + assert!(tri.has_foliation()); + assert_eq!(tri.time_slices(), 3); + assert_eq!(tri.slice_sizes().iter().sum::(), tri.vertex_count()); + assert!(tri.metadata().last_modified > initial_last_modified); + assert_eq!( + tri.metadata().modification_count, + initial_modification_count + 1 + ); + assert!(tri.cache.edge_count.is_none()); + assert_eq!(tri.edge_count(), initial_edge_count); + for vh in tri.geometry().vertices() { + assert!(tri.time_label(&vh).is_some()); + } + } + + #[test] + fn assign_foliation_by_y_error_paths_preserve_state() { + let mut tri = + CdtTriangulation::from_random_points(6, 2, 2).expect("Failed to create triangulation"); + let initial_time_slices = tri.time_slices(); + let initial_modification_count = tri.metadata().modification_count; + let vertex_keys: Vec<_> = tri + .geometry() + .vertices() + .map(|vh| vh.vertex_key()) + .collect(); + + assert!(tri.assign_foliation_by_y(0).is_err()); + assert_eq!(tri.time_slices(), initial_time_slices); + assert_eq!( + tri.metadata().modification_count, + initial_modification_count + ); + + let requested_slices = u32::try_from(tri.vertex_count()) + .expect("vertex count should fit into u32 for this test") + .saturating_add(1); + let result = tri.assign_foliation_by_y(requested_slices); + + assert!(matches!( + result, + Err(CdtError::Foliation(FoliationError::EmptySlice { .. })) + )); + assert_eq!(tri.time_slices(), initial_time_slices); + assert_eq!( + tri.metadata().modification_count, + initial_modification_count + ); + assert!(!tri.has_foliation()); + for key in vertex_keys { + assert_eq!(tri.geometry().vertex_data_by_key(key), None); + } + } + + #[test] + fn assign_foliation_by_y_rejects_invalid_toroidal_slice_count() { + let mut tri = CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); + let initial_slice_sizes = tri.slice_sizes().to_vec(); + + let result = tri.assign_foliation_by_y(2); + + assert!(matches!( + result, + Err(CdtError::InvalidTriangulationMetadata { + ref field, + ref topology, + ref provided_value, + ref expected, + }) if field == "timeslices" + && topology == "toroidal" + && provided_value == "2" + && expected == "≥ 3" + )); + assert_eq!(tri.time_slices(), 3); + assert_eq!(tri.slice_sizes(), initial_slice_sizes.as_slice()); + assert!(tri.has_foliation()); + } + + #[test] + fn foliation_queries_report_current_labels_only() { + let mut tri = + CdtTriangulation::from_random_points(6, 1, 2).expect("Failed to create triangulation"); + assert!(!tri.has_foliation()); + assert!(tri.foliation().is_none()); + assert!(tri.slice_sizes().is_empty()); + assert!(tri.vertices_at_time(0).is_empty()); + + tri.assign_foliation_by_y(1) + .expect("Should assign single-slice foliation"); + + assert!(tri.has_foliation()); + assert_eq!(tri.slice_sizes(), &[tri.vertex_count()]); + assert_eq!(tri.vertices_at_time(0).len(), tri.vertex_count()); + assert!(tri.vertices_at_time(999).is_empty()); + for vh in tri.geometry().vertices() { + assert_eq!(tri.time_label(&vh), Some(0)); + } + } + + #[test] + fn face_and_cell_classification_cover_foliated_and_unfoliated_states() { + let tri = CdtTriangulation::from_random_points(5, 2, 2) + .expect("create triangulation without foliation"); + for face in tri.geometry().faces() { + assert!(tri.face_edge_types(&face).is_none()); + assert_eq!(tri.cell_type(&face), None); + } + tri.validate_cell_classification() + .expect("missing foliation should validate vacuously"); + + let mut tri = strict_strip(5, 3); + for face in tri.geometry().faces() { + let edge_types = tri + .face_edge_types(&face) + .expect("explicit strip face should expose edge types"); + assert_eq!( + edge_types + .iter() + .filter(|edge_type| matches!(edge_type, EdgeType::Spacelike)) + .count(), + 1 + ); + assert_eq!( + edge_types + .iter() + .filter(|edge_type| matches!(edge_type, EdgeType::Timelike)) + .count(), + 2 + ); + } + + let classified = tri + .classify_all_cells() + .expect("strict strip cells should classify") + .expect("foliation is present"); + assert_eq!(classified, tri.face_count()); + } + + #[test] + fn classification_payloads_are_cleared_when_foliation_becomes_stale() { + let backend = labeled_triangle_backend([0, 0, 1]); + let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) + .expect("Should preserve labels as foliation"); + let face = tri + .geometry() + .faces() + .next() + .expect("Triangle should contain a face"); + assert_eq!(tri.cell_type_from_data(&face), None); + let live_ct = tri + .cell_type(&face) + .expect("Single face should be classifiable"); + assert!(matches!(live_ct, CellType::Up | CellType::Down)); + + tri.classify_all_cells() + .expect("Should classify cells with foliation") + .expect("Foliation is present"); + assert_eq!(tri.cell_type_from_data(&face), Some(live_ct)); + + let vertex_to_mutate = tri + .geometry() + .vertices() + .next() + .expect("Triangle should contain a vertex"); + tri.set_vertex_data(&vertex_to_mutate, Some(7)) + .expect("Expected valid vertex handle while mutating label"); + + assert_eq!(tri.cell_type_from_data(&face), None); + } + + #[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" + )); + } + + #[test] + fn reassigning_foliation_clears_stale_cell_payloads() { + let mut tri = + CdtTriangulation::from_cdt_strip(5, 3).expect("Failed to create deterministic strip"); + + tri.assign_foliation_by_y(3) + .expect("First foliation assignment should succeed"); + tri.classify_all_cells() + .expect("classify_all_cells should succeed") + .expect("foliation is present"); + + tri.assign_foliation_by_y(2) + .expect("Re-assignment with different slice count should succeed"); + + assert_eq!(tri.time_slices(), 2); + assert_eq!(tri.slice_sizes().len(), 2); + assert_eq!(tri.slice_sizes().iter().sum::(), tri.vertex_count()); + for face in tri.geometry().faces() { + assert_eq!(tri.cell_type_from_data(&face), None); + } + assert!(tri.validate_foliation().is_ok()); + } + + #[test] + fn volume_profile_counts_temporal_wrap_slab() { + let tri = CdtTriangulation::from_toroidal_cdt(4, 3).expect("build toroidal CDT"); + + let profile = tri.volume_profile(); + + assert_eq!(profile, vec![8, 8, 8]); + assert_eq!(profile.iter().sum::(), 24); + } +} diff --git a/src/cdt/triangulation/moves.rs b/src/cdt/triangulation/moves.rs new file mode 100644 index 0000000..f70d71f --- /dev/null +++ b/src/cdt/triangulation/moves.rs @@ -0,0 +1,184 @@ +#![forbid(unsafe_code)] + +//! Narrow CDT-owned move-support hooks over the Delaunay backend. + +use super::CdtTriangulation; +use crate::geometry::DelaunayBackend2D; +use crate::geometry::backends::delaunay::{ + DelaunayEdgeHandle, DelaunayError, DelaunayFaceHandle, DelaunayVertexHandle, +}; +use crate::geometry::traits::{FlipResult, SubdivisionResult, TriangulationMut}; + +impl CdtTriangulation { + /// Flips an edge and marks CDT-derived state stale when the backend mutation succeeds. + pub(crate) fn flip_edge( + &mut self, + edge: DelaunayEdgeHandle, + ) -> Result, DelaunayError> { + let result = self.geometry.flip_edge(edge)?; + self.bump_modification_count(); + Ok(result) + } + + /// Subdivides a face and marks CDT-derived state stale when the backend mutation succeeds. + pub(crate) fn subdivide_face( + &mut self, + face: DelaunayFaceHandle, + point: &[f64], + ) -> Result, DelaunayError> { + let result = self.geometry.subdivide_face(face, point)?; + self.bump_modification_count(); + Ok(result) + } + + /// Removes a vertex and marks CDT-derived state stale when the backend mutation succeeds. + pub(crate) fn remove_vertex( + &mut self, + vertex: DelaunayVertexHandle, + ) -> Result, DelaunayError> { + let result = self.geometry.remove_vertex(vertex)?; + self.bump_modification_count(); + Ok(result) + } + + /// Updates a vertex time label and marks CDT-derived state stale on success. + pub(crate) fn set_vertex_data( + &mut self, + vertex: &DelaunayVertexHandle, + data: Option, + ) -> Result<(), DelaunayError> { + self.geometry + .set_vertex_data_by_key(vertex.vertex_key(), data)?; + self.bump_modification_count(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geometry::traits::{TriangulationMut, TriangulationQuery}; + + /// Computes the centroid of a live triangular face. + fn face_centroid(triangulation: &CdtTriangulation) -> Vec { + let face = triangulation + .geometry() + .faces() + .next() + .expect("triangulation should contain a face"); + let vertices = triangulation + .geometry() + .face_vertices(&face) + .expect("face vertices should resolve"); + + let mut centroid = vec![0.0; 2]; + for vertex in vertices { + let coords = triangulation + .geometry() + .vertex_coordinates(&vertex) + .expect("vertex coordinates should resolve"); + centroid[0] += coords[0] / 3.0; + centroid[1] += coords[1] / 3.0; + } + centroid + } + + #[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 vertex = tri + .geometry() + .vertices() + .next() + .expect("strip should contain vertices"); + let label = tri + .geometry() + .vertex_data_by_key(vertex.vertex_key()) + .expect("strip vertices should be labeled"); + + tri.refresh_cache(); + assert!(tri.cache.edge_count.is_some()); + let initial_modification_count = tri.metadata().modification_count; + + tri.set_vertex_data(&vertex, Some(label)) + .expect("label update should succeed"); + + assert_eq!( + tri.metadata().modification_count, + initial_modification_count + 1 + ); + assert!(tri.cache.edge_count.is_none()); + assert!(!tri.has_foliation()); + assert!(tri.slice_sizes().is_empty()); + } + + #[test] + fn subdivide_and_remove_vertex_each_invalidate_cached_counts() { + let mut tri = + CdtTriangulation::from_seeded_points(8, 1, 2, 53).expect("build triangulation"); + let face = tri + .geometry() + .faces() + .next() + .expect("triangulation should contain a face"); + let point = face_centroid(&tri); + + tri.refresh_cache(); + let initial_vertices = tri.vertex_count(); + let initial_modification_count = tri.metadata().modification_count; + + let subdivision = tri + .subdivide_face(face, &point) + .expect("centroid subdivision should succeed"); + + assert_eq!(tri.vertex_count(), initial_vertices + 1); + assert_eq!( + tri.metadata().modification_count, + initial_modification_count + 1 + ); + assert!(tri.cache.edge_count.is_none()); + + tri.refresh_cache(); + assert!(tri.cache.edge_count.is_some()); + let after_subdivision_count = tri.metadata().modification_count; + + tri.remove_vertex(subdivision.new_vertex.clone()) + .expect("new subdivision vertex should be removable"); + + assert_eq!(tri.vertex_count(), initial_vertices); + assert_eq!( + tri.metadata().modification_count, + after_subdivision_count + 1 + ); + assert!(tri.cache.edge_count.is_none()); + + let after_removal_count = tri.metadata().modification_count; + assert!( + tri.set_vertex_data(&subdivision.new_vertex, Some(0)) + .is_err() + ); + assert_eq!(tri.metadata().modification_count, after_removal_count); + } + + #[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 edge = tri + .geometry() + .edges() + .find(|edge| tri.geometry().can_flip_edge(edge)) + .expect("seeded triangulation should contain a flippable edge"); + + tri.refresh_cache(); + let initial_modification_count = tri.metadata().modification_count; + + tri.flip_edge(edge).expect("flippable edge should flip"); + + assert_eq!( + tri.metadata().modification_count, + initial_modification_count + 1 + ); + assert!(tri.cache.edge_count.is_none()); + } +} diff --git a/src/cdt/triangulation/validation.rs b/src/cdt/triangulation/validation.rs new file mode 100644 index 0000000..2aa1451 --- /dev/null +++ b/src/cdt/triangulation/validation.rs @@ -0,0 +1,462 @@ +#![forbid(unsafe_code)] + +//! Whole-triangulation validation and causality checks. + +use super::CdtTriangulation; +use crate::errors::{CdtError, CdtResult}; +use crate::geometry::DelaunayBackend2D; +use crate::geometry::traits::TriangulationQuery; + +impl CdtTriangulation { + /// Validate CDT properties (geometry, Delaunay, topology, causality, foliation). + /// + /// # 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. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; + /// tri.validate()?; + /// Ok(()) + /// } + /// ``` + 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(), + ), + }); + } + + if !self.geometry.is_delaunay() { + return Err(CdtError::ValidationFailed { + check: "Delaunay".to_string(), + detail: format!( + "triangulation does not satisfy Delaunay property (V={}, E={}, F={})", + self.geometry.vertex_count(), + self.geometry.edge_count(), + self.geometry.face_count(), + ), + }); + } + + self.validate_topology()?; + self.validate_foliation()?; + self.validate_causality()?; + self.validate_cell_classification()?; + + Ok(()) + } + + /// Validate causality constraints. + /// + /// If no foliation is present, succeeds vacuously (no causal structure + /// to check). Otherwise delegates to [`validate_causality_delaunay`](Self::validate_causality_delaunay). + /// + /// # Errors + /// + /// Returns error if any edge spans more than one time slice (`|Δt| > 1`). + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::geometry::*; + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let dt = build_delaunay2_with_data(&[ + /// ([0.0, 0.0], 0), + /// ([1.0, 0.0], 0), + /// ([0.5, 1.0], 1), + /// ])?; + /// let backend = DelaunayBackend2D::from_triangulation(dt); + /// let tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2)?; + /// tri.validate_causality()?; + /// Ok(()) + /// } + /// ``` + pub fn validate_causality(&self) -> CdtResult<()> { + self.validate_causality_delaunay() + } + + /// Validates the causal structure of this foliated triangulation. + /// + /// Reads time labels directly from face vertex data and checks that every + /// triangle lies within a single slice pair. In a 2D triangulation, this + /// implies that each edge of each finite face connects vertices within the + /// same slice or adjacent slices, while avoiding backend-specific edge + /// endpoint resolution. + /// + /// # Errors + /// + /// Returns error if any triangle spans more than one time slice, if a face + /// cannot be resolved to three vertices, or if any face vertex is unlabeled. + /// + /// # Examples + /// + /// ``` + /// use causal_triangulations::prelude::geometry::*; + /// use causal_triangulations::prelude::triangulation::*; + /// + /// fn main() -> CdtResult<()> { + /// let dt = build_delaunay2_with_data(&[ + /// ([0.0, 0.0], 0), + /// ([1.0, 0.0], 0), + /// ([0.5, 1.0], 1), + /// ])?; + /// let backend = DelaunayBackend2D::from_triangulation(dt); + /// let tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2)?; + /// tri.validate_causality_delaunay()?; + /// Ok(()) + /// } + /// ``` + #[expect( + clippy::too_many_lines, + reason = "causality validation includes detailed diagnostics for multiple face-resolution and label error paths" + )] + pub fn validate_causality_delaunay(&self) -> CdtResult<()> { + if self.foliation.is_none() { + return Ok(()); + } + + for face in self.geometry.faces() { + let verts = self.geometry.face_vertices(&face).map_err(|err| { + log::debug!( + "Causality validation failed to resolve vertices for face {:?}: {err}; vertex_count={}, edge_count={}, face_count={}", + face, + self.geometry.vertex_count(), + self.geometry.edge_count(), + self.geometry.face_count(), + ); + CdtError::ValidationFailed { + check: "causality".to_string(), + detail: "failed to resolve face vertices".to_string(), + } + })?; + + if verts.len() != 3 { + return Err(CdtError::ValidationFailed { + check: "causality".to_string(), + detail: format!( + "face {:?} has {} vertices, expected 3", + face.cell_key(), + verts.len(), + ), + }); + } + + let t0 = self + .geometry + .vertex_data_by_key(verts[0].vertex_key()) + .ok_or_else(|| { + log::debug!( + "Causality validation found unlabeled vertex {:?} while checking face {:?}", + verts[0].vertex_key(), + face, + ); + CdtError::ValidationFailed { + check: "causality".to_string(), + detail: format!( + "vertex {:?} has no time label in a foliated triangulation", + verts[0].vertex_key(), + ), + } + })?; + let t1 = self + .geometry + .vertex_data_by_key(verts[1].vertex_key()) + .ok_or_else(|| { + log::debug!( + "Causality validation found unlabeled vertex {:?} while checking face {:?}", + verts[1].vertex_key(), + face, + ); + CdtError::ValidationFailed { + check: "causality".to_string(), + detail: format!( + "vertex {:?} has no time label in a foliated triangulation", + verts[1].vertex_key(), + ), + } + })?; + let t2 = self + .geometry + .vertex_data_by_key(verts[2].vertex_key()) + .ok_or_else(|| { + log::debug!( + "Causality validation found unlabeled vertex {:?} while checking face {:?}", + verts[2].vertex_key(), + face, + ); + CdtError::ValidationFailed { + check: "causality".to_string(), + detail: format!( + "vertex {:?} has no time label in a foliated triangulation", + verts[2].vertex_key(), + ), + } + })?; + + let mut spacelike = 0; + let mut timelike = 0; + + for (a, b) in [(t0, t1), (t1, t2), (t2, t0)] { + let step_distance = self.time_step_distance(a, b); + match step_distance { + 0 => spacelike += 1, + 1 => timelike += 1, + _ => { + return Err(CdtError::CausalityViolation { + time_0: a.min(b), + time_1: a.max(b), + step_distance, + }); + } + } + } + + if !(spacelike == 1 && timelike == 2) { + return Err(CdtError::ValidationFailed { + check: "causality".to_string(), + detail: format!( + "invalid CDT triangle at face {:?}: spacelike={}, timelike={}", + face.cell_key(), + spacelike, + timelike + ), + }); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cdt::foliation::EdgeType; + use crate::config::CdtTopology; + use crate::geometry::generators::build_delaunay2_with_data; + + /// Builds a minimal labeled Delaunay backend for validation tests. + fn labeled_triangle_backend(labels: [u32; 3]) -> DelaunayBackend2D { + let dt = build_delaunay2_with_data(&[ + ([0.0, 0.0], labels[0]), + ([1.0, 0.0], labels[1]), + ([0.5, 1.0], labels[2]), + ]) + .expect("Should build labeled triangle"); + DelaunayBackend2D::from_triangulation(dt) + } + + /// Builds intentionally unchecked metadata for causality validation tests. + fn unchecked_open_boundary( + backend: DelaunayBackend2D, + time_slices: u32, + dimension: u8, + ) -> CdtTriangulation { + CdtTriangulation::wrap_unchecked(backend, time_slices, dimension, CdtTopology::OpenBoundary) + } + + /// Builds stable diagnostic text for seeded-triangulation comparisons. + fn deterministic_triangle_debug_summary(backend: &DelaunayBackend2D) -> String { + let mut vertices: Vec<_> = backend + .vertices() + .map(|vh| { + let coords = backend.vertex_coordinates(&vh).map_or_else( + |err| format!("coord_error:{err}"), + |coords| format!("{coords:?}"), + ); + format!( + "{:?}@{}:{:?}", + vh.vertex_key(), + coords, + backend.vertex_data_by_key(vh.vertex_key()) + ) + }) + .collect(); + vertices.sort_unstable(); + + let mut edges: Vec<_> = backend + .edges() + .map(|edge| match backend.edge_endpoints(&edge) { + Some((v0, v1)) => format!( + "{:?}<->{:?}:{:?}->{:?}", + v0.vertex_key(), + v1.vertex_key(), + backend.vertex_data_by_key(v0.vertex_key()), + backend.vertex_data_by_key(v1.vertex_key()) + ), + None => "endpoint_error:unavailable".to_string(), + }) + .collect(); + edges.sort_unstable(); + + format!( + "vertex_count={}, edge_count={}, face_count={}, is_valid={}, is_delaunay={}, vertices=[{}], edges=[{}]", + backend.vertex_count(), + backend.edge_count(), + backend.face_count(), + backend.is_valid(), + backend.is_delaunay(), + vertices.join(", "), + edges.join(", "), + ) + } + + #[test] + fn validate_succeeds_for_known_good_seed() { + let triangulation = CdtTriangulation::from_seeded_points(5, 2, 2, 53) + .expect("Failed to create triangulation"); + + triangulation + .validate() + .expect("known good triangulation should validate"); + } + + #[test] + fn validate_causality_is_vacuous_without_foliation() { + let triangulation = + CdtTriangulation::from_random_points(5, 2, 2).expect("Failed to create triangulation"); + + triangulation + .validate_causality() + .expect("causality should pass without foliation"); + } + + #[test] + fn causality_violation_detected() { + let dt = build_delaunay2_with_data(&[([0.0, 0.0], 0), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]) + .expect("Should build deterministic causal triangle"); + let backend = DelaunayBackend2D::from_triangulation(dt); + let mut tri = unchecked_open_boundary(backend, 2, 2); + + tri.assign_foliation_by_y(2) + .expect("Should derive foliation from triangle coordinates"); + + assert_eq!( + tri.slice_sizes(), + &[2, 1], + "Deterministic triangle should assign slice sizes [2, 1], got {:?}; {}", + tri.slice_sizes(), + deterministic_triangle_debug_summary(tri.geometry()) + ); + tri.validate_causality_delaunay() + .expect("deterministic causal triangle should start causally valid"); + assert!( + tri.geometry().faces().any(|face| { + tri.face_edge_types(&face) + .is_some_and(|ets| ets.iter().any(|e| matches!(e, EdgeType::Timelike))) + }), + "Deterministic causal triangle should contain a timelike edge; {}", + deterministic_triangle_debug_summary(tri.geometry()) + ); + + let vertex_to_mutate = tri + .geometry() + .vertices() + .next() + .expect("Deterministic causal triangle should contain a vertex"); + + tri.set_vertex_data(&vertex_to_mutate, Some(3)) + .expect("Expected valid vertex handle while mutating deterministic triangle"); + + match tri.validate_causality_delaunay() { + Err(CdtError::CausalityViolation { + time_0, + time_1, + step_distance, + }) => { + assert!(step_distance > 1); + assert_eq!(step_distance, time_0.abs_diff(time_1)); + } + other => panic!( + "Expected CausalityViolation error, got {other:?}; {}", + deterministic_triangle_debug_summary(tri.geometry()) + ), + } + } + + #[test] + fn validate_causality_rejects_missing_live_label() { + let backend = labeled_triangle_backend([0, 0, 1]); + let mut tri = CdtTriangulation::from_labeled_delaunay(backend, 2, 2) + .expect("Should preserve labels as foliation"); + let vertex_to_clear = tri + .geometry() + .vertices() + .next() + .expect("Triangle should contain a vertex"); + + tri.set_vertex_data(&vertex_to_clear, None) + .expect("Expected valid vertex handle while clearing label"); + + assert!(matches!( + tri.validate_causality_delaunay(), + Err(CdtError::ValidationFailed { ref check, ref detail }) + if check == "causality" + && detail.contains("has no time label in a foliated triangulation") + )); + } + + #[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") + )); + } + } + + #[test] + fn toroidal_causality_violation_reports_circular_step_distance() { + let mut tri = + CdtTriangulation::from_toroidal_cdt(3, 10).expect("build toroidal CDT (3, 10)"); + let slice0_vertex = tri + .geometry() + .vertices() + .find(|vh| tri.geometry().vertex_data_by_key(vh.vertex_key()) == Some(0)) + .expect("Toroidal CDT should contain slice-0 vertices"); + + tri.set_vertex_data(&slice0_vertex, Some(8)) + .expect("Expected valid vertex handle while mutating label"); + + match tri.validate_causality_delaunay() { + Err(CdtError::CausalityViolation { + time_0, + time_1, + step_distance, + }) => { + let raw = time_0.abs_diff(time_1); + let circular = raw.min(10 - raw); + assert_eq!(step_distance, circular); + assert!(step_distance > 1); + assert!(step_distance < raw); + } + other => panic!("Expected CausalityViolation on toroidal triangle, got {other:?}"), + } + } +} diff --git a/src/config.rs b/src/config.rs index 9acde6f..133d0f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Configuration management for CDT simulations. //! //! This module provides structured configuration for various aspects of diff --git a/src/geometry/backends/delaunay.rs b/src/geometry/backends/delaunay.rs index c311ed9..3384f75 100644 --- a/src/geometry/backends/delaunay.rs +++ b/src/geometry/backends/delaunay.rs @@ -545,20 +545,18 @@ impl TriangulationQuer D } - fn vertices(&self) -> Box + '_> { - Box::new( - self.dt - .vertices() - .map(|(key, _)| DelaunayVertexHandle { key }), - ) + fn vertices(&self) -> impl Iterator + '_ { + self.dt + .vertices() + .map(|(key, _)| DelaunayVertexHandle { key }) } - fn edges(&self) -> Box + '_> { - Box::new(self.dt.edges().map(|key| DelaunayEdgeHandle { key })) + fn edges(&self) -> impl Iterator + '_ { + self.dt.edges().map(|key| DelaunayEdgeHandle { key }) } - fn faces(&self) -> Box + '_> { - Box::new(self.dt.cells().map(|(key, _)| DelaunayFaceHandle { key })) + fn faces(&self) -> impl Iterator + '_ { + self.dt.cells().map(|(key, _)| DelaunayFaceHandle { key }) } fn vertex_coordinates( @@ -775,14 +773,20 @@ impl TriangulationMut coords: &[Self::Coordinate], ) -> Result { let vertex = Self::build_vertex(coords, None, "insert_vertex")?; - let key = self - .dt - .insert(vertex) - .map_err(|err| DelaunayError::InsertionFailed { - operation: "insert_vertex", - coordinates: coords.to_vec(), - detail: err.to_string(), - })?; + let dt_before = self.dt.clone(); + let facets_before = self.interior_facets_by_edge.clone(); + let key = match self.dt.insert(vertex) { + Ok(key) => key, + Err(err) => { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; + return Err(DelaunayError::InsertionFailed { + operation: "insert_vertex", + coordinates: coords.to_vec(), + detail: err.to_string(), + }); + } + }; self.rebuild_interior_facet_index(); Ok(DelaunayVertexHandle { key }) } @@ -795,14 +799,20 @@ impl TriangulationMut return Err(DelaunayError::InvalidVertex { key: vertex.key }); } - let info = self - .dt - .flip_k1_remove(vertex.key) - .map_err(|err| DelaunayError::FlipFailed { - operation: "flip_k1_remove", - target: format!("vertex {:?}", vertex.key), - detail: err.to_string(), - })?; + let dt_before = self.dt.clone(); + let facets_before = self.interior_facets_by_edge.clone(); + let info = match self.dt.flip_k1_remove(vertex.key) { + Ok(info) => info, + Err(err) => { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; + return Err(DelaunayError::FlipFailed { + operation: "flip_k1_remove", + target: format!("vertex {:?}", vertex.key), + detail: err.to_string(), + }); + } + }; self.rebuild_interior_facet_index(); Ok(info .new_cells @@ -841,29 +851,58 @@ impl TriangulationMut v1: edge.key.v1(), }); }; - let info = self - .dt - .flip_k2(facet) - .map_err(|err| DelaunayError::FlipFailed { + let dt_before = self.dt.clone(); + let facets_before = self.interior_facets_by_edge.clone(); + let info = match self.dt.flip_k2(facet) { + Ok(info) => info, + Err(err) => { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; + return Err(DelaunayError::FlipFailed { + operation: "flip_k2", + target: format!( + "edge {:?} -- {:?} via facet {:?}", + edge.key.v0(), + edge.key.v1(), + facet + ), + detail: err.to_string(), + }); + } + }; + let mut inserted = info.inserted_face_vertices.iter().copied(); + let Some(v0) = inserted.next() else { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; + return Err(DelaunayError::UnexpectedFlipOutput { operation: "flip_k2", - target: format!( - "edge {:?} -- {:?} via facet {:?}", - edge.key.v0(), - edge.key.v1(), - facet - ), - detail: err.to_string(), - })?; - self.rebuild_interior_facet_index(); - let inserted: Vec<_> = info.inserted_face_vertices.iter().copied().collect(); - let [v0, v1] = inserted.as_slice() else { + target: format!("edge {:?} -- {:?}", edge.key.v0(), edge.key.v1()), + expected: "exactly two inserted-face vertices for the replacement edge", + actual: "0 inserted-face vertices".to_string(), + }); + }; + let Some(v1) = inserted.next() else { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; return Err(DelaunayError::UnexpectedFlipOutput { operation: "flip_k2", target: format!("edge {:?} -- {:?}", edge.key.v0(), edge.key.v1()), expected: "exactly two inserted-face vertices for the replacement edge", - actual: format!("{} inserted-face vertices", inserted.len()), + actual: "1 inserted-face vertices".to_string(), }); }; + if let Some(extra) = inserted.next() { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; + let actual = 3 + inserted.count(); + return Err(DelaunayError::UnexpectedFlipOutput { + operation: "flip_k2", + target: format!("edge {:?} -- {:?}", edge.key.v0(), edge.key.v1()), + expected: "exactly two inserted-face vertices for the replacement edge", + actual: format!("{actual} inserted-face vertices including unexpected {extra:?}"), + }); + } + self.rebuild_interior_facet_index(); let affected_faces = info .new_cells .iter() @@ -872,7 +911,7 @@ impl TriangulationMut .collect(); Ok(FlipResult::new( DelaunayEdgeHandle { - key: EdgeKey::new(*v0, *v1), + key: EdgeKey::new(v0, v1), }, affected_faces, )) @@ -892,23 +931,43 @@ impl TriangulationMut } let vertex = Self::build_vertex(point, None, "subdivide_face")?; - let info = - self.dt - .flip_k1_insert(face.key, vertex) - .map_err(|err| DelaunayError::FlipFailed { + let dt_before = self.dt.clone(); + let facets_before = self.interior_facets_by_edge.clone(); + let info = match self.dt.flip_k1_insert(face.key, vertex) { + Ok(info) => info, + Err(err) => { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; + return Err(DelaunayError::FlipFailed { operation: "flip_k1_insert", target: format!("face {:?} at point {:?}", face.key, point), detail: err.to_string(), - })?; - self.rebuild_interior_facet_index(); - let Some(&new_vertex) = info.inserted_face_vertices.first() else { + }); + } + }; + let mut inserted = info.inserted_face_vertices.iter().copied(); + let Some(new_vertex) = inserted.next() else { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; return Err(DelaunayError::UnexpectedFlipOutput { operation: "flip_k1_insert", target: format!("face {:?} at point {:?}", face.key, point), - expected: "at least one inserted-face vertex for the inserted point", - actual: "no inserted-face vertices".to_string(), + expected: "exactly one inserted-face vertex for the inserted point", + actual: "0 inserted-face vertices".to_string(), }); }; + if let Some(extra) = inserted.next() { + self.dt = dt_before; + self.interior_facets_by_edge = facets_before; + let actual = 2 + inserted.count(); + return Err(DelaunayError::UnexpectedFlipOutput { + operation: "flip_k1_insert", + target: format!("face {:?} at point {:?}", face.key, point), + expected: "exactly one inserted-face vertex for the inserted point", + actual: format!("{actual} inserted-face vertices including unexpected {extra:?}"), + }); + } + self.rebuild_interior_facet_index(); Ok(SubdivisionResult::new( DelaunayVertexHandle { key: new_vertex }, info.new_cells @@ -946,6 +1005,51 @@ mod tests { use super::*; + #[test] + fn test_delaunay_mutation_error_messages_preserve_context() { + let insertion = DelaunayError::InsertionFailed { + operation: "insert_vertex", + coordinates: vec![0.25, 0.75], + detail: "duplicate point".to_string(), + }; + assert_eq!( + insertion.to_string(), + "insert_vertex insertion failed at [0.25, 0.75]: duplicate point" + ); + + let flip = DelaunayError::FlipFailed { + operation: "flip_k2", + target: "edge VertexKey(1v1) -- VertexKey(2v1)".to_string(), + detail: "non-convex cavity".to_string(), + }; + assert_eq!( + flip.to_string(), + "flip_k2 failed on edge VertexKey(1v1) -- VertexKey(2v1): non-convex cavity" + ); + + let malformed = DelaunayError::UnexpectedFlipOutput { + operation: "flip_k2", + target: "edge VertexKey(1v1) -- VertexKey(2v1)".to_string(), + expected: "exactly two inserted-face vertices for the replacement edge", + actual: "1 inserted-face vertices".to_string(), + }; + assert_eq!( + malformed.to_string(), + "flip_k2 returned unexpected output for edge VertexKey(1v1) -- VertexKey(2v1): expected exactly two inserted-face vertices for the replacement edge, got 1 inserted-face vertices" + ); + + let malformed_insert = DelaunayError::UnexpectedFlipOutput { + operation: "flip_k1_insert", + target: "face CellKey(3v1) at point [0.5, 0.5]".to_string(), + expected: "exactly one inserted-face vertex for the inserted point", + actual: "2 inserted-face vertices including unexpected VertexKey(4v1)".to_string(), + }; + assert_eq!( + 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)" + ); + } + #[test] fn test_is_delaunay_various_sizes() { // is_delaunay() should pass for valid triangulations of all sizes @@ -1462,12 +1566,14 @@ mod tests { let backend = DelaunayBackend::from_triangulation(dt); // Test all vertices are accessible - let vertices: Vec<_> = backend.vertices().collect(); - assert_eq!(vertices.len(), 3, "Should have exactly 3 vertices"); + assert_eq!( + backend.vertices().count(), + 3, + "Should have exactly 3 vertices" + ); // Test all edges are accessible - let edges: Vec<_> = backend.edges().collect(); - assert_eq!(edges.len(), 3, "Should have exactly 3 edges"); + assert_eq!(backend.edges().count(), 3, "Should have exactly 3 edges"); // Test face is accessible let faces: Vec<_> = backend.faces().collect(); diff --git a/src/geometry/backends/mock.rs b/src/geometry/backends/mock.rs index 57dc2bf..07f604d 100644 --- a/src/geometry/backends/mock.rs +++ b/src/geometry/backends/mock.rs @@ -294,16 +294,16 @@ impl TriangulationQuery for MockBackend { self.dimension } - fn vertices(&self) -> Box + '_> { - Box::new(self.vertices.keys().map(|&id| MockVertexHandle(id))) + fn vertices(&self) -> impl Iterator + '_ { + self.vertices.keys().map(|&id| MockVertexHandle(id)) } - fn edges(&self) -> Box + '_> { - Box::new(self.edges.keys().map(|&id| MockEdgeHandle(id))) + fn edges(&self) -> impl Iterator + '_ { + self.edges.keys().map(|&id| MockEdgeHandle(id)) } - fn faces(&self) -> Box + '_> { - Box::new(self.faces.keys().map(|&id| MockFaceHandle(id))) + fn faces(&self) -> impl Iterator + '_ { + self.faces.keys().map(|&id| MockFaceHandle(id)) } fn vertex_coordinates( diff --git a/src/geometry/generators.rs b/src/geometry/generators.rs index f5cb992..772f062 100644 --- a/src/geometry/generators.rs +++ b/src/geometry/generators.rs @@ -34,6 +34,63 @@ fn generate_delaunay2_vertex_build_error( } } +/// Builds a consistent typed validation error for generator argument checks. +fn invalid_generation_parameters( + issue: &str, + provided_value: String, + expected_range: &str, +) -> CdtError { + CdtError::InvalidGenerationParameters { + issue: issue.to_string(), + provided_value, + expected_range: expected_range.to_string(), + } +} + +/// Rejects coordinate ranges before they reach random point generation. +fn validate_coordinate_range(coordinate_range: (f64, f64)) -> CdtResult<()> { + let (min, max) = coordinate_range; + if min.is_finite() && max.is_finite() && min < max { + Ok(()) + } else { + Err(invalid_generation_parameters( + "Invalid coordinate range", + format!("[{min}, {max}]"), + "finite min < max", + )) + } +} + +/// Rejects explicit vertex coordinates that geometric predicates cannot order. +fn validate_explicit_coordinates(coords_with_data: &[([f64; 2], u32)]) -> CdtResult<()> { + for (vertex_index, (coord, _)) in coords_with_data.iter().enumerate() { + for (axis, value) in coord.iter().copied().enumerate() { + if !value.is_finite() { + return Err(invalid_generation_parameters( + "Non-finite vertex coordinate", + format!("vertex {vertex_index} axis {axis} = {value}"), + "finite coordinate values", + )); + } + } + } + Ok(()) +} + +/// Rejects toroidal periods that cannot define a finite positive quotient domain. +fn validate_toroidal_domain(domain: [f64; 2]) -> CdtResult<()> { + for (axis, period) in domain.into_iter().enumerate() { + if !period.is_finite() || period <= 0.0 { + return Err(invalid_generation_parameters( + "Invalid toroidal domain", + format!("axis {axis} period {period}"), + "finite and positive periods", + )); + } + } + Ok(()) +} + /// Generates a Delaunay triangulation with optional seed for deterministic testing. /// /// Uses [`DelaunayTriangulationBuilder`] (introduced in delaunay v0.7.2) for @@ -42,7 +99,11 @@ fn generate_delaunay2_vertex_build_error( /// /// # Errors /// -/// Returns enhanced error information including vertex count, coordinate range, and underlying error. +/// Returns [`crate::CdtError::InvalidGenerationParameters`] if +/// `number_of_vertices < 3` or `coordinate_range` is not finite with `min < max`. +/// Returns [`crate::CdtError::DelaunayGenerationFailed`] if random point +/// generation or Delaunay construction fails, and +/// [`crate::CdtError::VertexBuildFailed`] if an upstream vertex cannot be built. /// /// # Examples /// @@ -60,20 +121,14 @@ pub fn generate_delaunay2( ) -> CdtResult { // Validate parameters before attempting generation if number_of_vertices < 3 { - return Err(CdtError::InvalidGenerationParameters { - issue: "Insufficient vertex count".to_string(), - provided_value: number_of_vertices.to_string(), - expected_range: "≥ 3".to_string(), - }); + return Err(invalid_generation_parameters( + "Insufficient vertex count", + number_of_vertices.to_string(), + "≥ 3", + )); } - if coordinate_range.0 >= coordinate_range.1 { - return Err(CdtError::InvalidGenerationParameters { - issue: "Invalid coordinate range".to_string(), - provided_value: format!("[{}, {}]", coordinate_range.0, coordinate_range.1), - expected_range: "min < max".to_string(), - }); - } + validate_coordinate_range(coordinate_range)?; // Generate random points, then build triangulation via the builder API let n = number_of_vertices as usize; @@ -117,7 +172,10 @@ pub fn generate_delaunay2( /// /// # Errors /// -/// Returns error if vertex construction or Delaunay triangulation building fails. +/// Returns [`crate::CdtError::InvalidGenerationParameters`] if any coordinate is +/// NaN or infinite. Returns [`crate::CdtError::VertexBuildFailed`] if a vertex +/// cannot be constructed, or [`crate::CdtError::DelaunayGenerationFailed`] if +/// the Delaunay builder rejects the finite vertex set. /// /// # Examples /// @@ -135,6 +193,8 @@ pub fn generate_delaunay2( pub fn build_delaunay2_with_data( coords_with_data: &[([f64; 2], u32)], ) -> CdtResult { + validate_explicit_coordinates(coords_with_data)?; + let vertices: Vec<_> = coords_with_data .iter() .enumerate() @@ -185,9 +245,11 @@ pub fn build_delaunay2_with_data( /// /// # Errors /// -/// Returns error if vertex construction fails or the explicit cell builder -/// rejects the input (e.g., invalid cell arity, out-of-bounds indices, or -/// topological validation failure). +/// Returns [`crate::CdtError::InvalidGenerationParameters`] if any coordinate is +/// NaN or infinite. Returns [`crate::CdtError::VertexBuildFailed`] if a vertex +/// cannot be constructed, or [`crate::CdtError::DelaunayGenerationFailed`] if +/// the explicit cell builder rejects the input (for example invalid cell arity, +/// out-of-bounds indices, or topological validation failure). /// /// # Examples /// @@ -226,7 +288,9 @@ pub fn build_delaunay2_from_cells( /// /// # Errors /// -/// Same as [`build_delaunay2_from_cells`]. +/// Same as [`build_delaunay2_from_cells`]: coordinates must be finite, vertices +/// must build successfully, and the explicit cells must satisfy the selected +/// topology guarantee and global topology. /// /// # Examples /// @@ -255,6 +319,8 @@ pub fn build_delaunay2_with_topology( topology_guarantee: TopologyGuarantee, global_topology: GlobalTopology<2>, ) -> CdtResult { + validate_explicit_coordinates(coords_with_data)?; + let vertices: Vec<_> = coords_with_data .iter() .enumerate() @@ -304,7 +370,9 @@ pub fn build_delaunay2_with_topology( /// /// # Errors /// -/// Same as [`build_delaunay2_with_topology`]. +/// Returns [`crate::CdtError::InvalidGenerationParameters`] if either toroidal +/// period in `domain` is NaN, infinite, or non-positive. Otherwise the error +/// behavior is the same as [`build_delaunay2_with_topology`]. /// /// # Examples /// @@ -348,6 +416,8 @@ pub fn build_toroidal_delaunay2( cells: &[Vec], domain: [f64; 2], ) -> CdtResult { + validate_toroidal_domain(domain)?; + build_delaunay2_with_topology( coords_with_data, cells, @@ -600,7 +670,7 @@ mod tests { } => { assert_eq!(issue, "Invalid coordinate range"); assert_eq!(provided_value, "[10, 5]"); - assert_eq!(expected_range, "min < max"); + assert_eq!(expected_range, "finite min < max"); } _ => panic!("Expected InvalidGenerationParameters error"), } @@ -619,6 +689,141 @@ mod tests { } } + #[test] + fn test_generate_delaunay2_rejects_non_finite_range() { + for range in [(f64::NAN, 1.0), (0.0, f64::INFINITY)] { + let result = generate_delaunay2(4, range, None); + assert!( + matches!( + result, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref expected_range, + .. + }) if issue == "Invalid coordinate range" + && expected_range == "finite min < max" + ), + "non-finite range {range:?} should be rejected, got {result:?}" + ); + } + } + + #[test] + fn test_build_delaunay2_with_data_rejects_non_finite_coordinate() { + let vertices = [([0.0, 0.0], 0u32), ([1.0, f64::NAN], 0), ([0.5, 1.0], 1)]; + + let result = build_delaunay2_with_data(&vertices); + 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 = NaN" + && expected_range == "finite coordinate values" + ), + "explicit non-finite coordinate should be rejected, got {result:?}" + ); + } + + #[test] + fn test_build_delaunay2_from_cells_rejects_non_finite_coordinate() { + let vertices = [ + ([0.0, 0.0], 0u32), + ([1.0, 0.0], 0), + ([0.5, f64::NEG_INFINITY], 1), + ]; + let cells = vec![vec![0, 1, 2]]; + + let result = build_delaunay2_from_cells(&vertices, &cells); + assert!( + matches!( + result, + Err(CdtError::InvalidGenerationParameters { + ref issue, + ref provided_value, + ref expected_range, + }) if issue == "Non-finite vertex coordinate" + && provided_value == "vertex 2 axis 1 = -inf" + && expected_range == "finite coordinate values" + ), + "delegating explicit-cell builder should reject non-finite coordinates, got {result:?}" + ); + } + + #[test] + fn test_build_delaunay2_with_topology_rejects_non_finite_coordinate() { + let vertices = [ + ([0.0, 0.0], 0u32), + ([f64::INFINITY, 0.0], 0), + ([0.5, 1.0], 1), + ]; + let cells = vec![vec![0, 1, 2]]; + + let result = build_delaunay2_with_topology( + &vertices, + &cells, + TopologyGuarantee::DEFAULT, + GlobalTopology::Euclidean, + ); + 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 0 = inf" + && expected_range == "finite coordinate values" + ), + "explicit non-finite topology coordinate should be rejected, got {result:?}" + ); + } + + #[test] + fn test_build_toroidal_delaunay2_rejects_invalid_domain() { + let vertices = [([0.0, 0.0], 0u32), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]; + let cells = vec![vec![0, 1, 2]]; + + for (domain, expected_value) in [ + ([0.0, 1.0], "axis 0 period 0"), + ([-1.0, 1.0], "axis 0 period -1"), + ([1.0, f64::NAN], "axis 1 period NaN"), + ([f64::INFINITY, 1.0], "axis 0 period inf"), + ] { + let result = build_toroidal_delaunay2(&vertices, &cells, 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 domain {domain:?} should be rejected, got {result:?}" + ); + } + } + + #[test] + fn test_invalid_toroidal_domain_display_is_actionable() { + let vertices = [([0.0, 0.0], 0u32), ([1.0, 0.0], 0), ([0.5, 1.0], 1)]; + let cells = vec![vec![0, 1, 2]]; + + let error = build_toroidal_delaunay2(&vertices, &cells, [-1.0, 1.0]) + .expect_err("negative toroidal period should be rejected"); + assert_eq!( + error.to_string(), + "Invalid triangulation parameters: Invalid toroidal domain (got: axis 0 period -1, expected: finite and positive periods)" + ); + } + #[test] fn test_generate_delaunay2_various_sizes() { let test_cases = [(3, "minimal"), (5, "small"), (10, "medium"), (20, "large")]; diff --git a/src/geometry/operations.rs b/src/geometry/operations.rs index 4b4d225..2fcdc35 100644 --- a/src/geometry/operations.rs +++ b/src/geometry/operations.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! High-level triangulation operations. //! //! This module provides common operations that work across different @@ -78,7 +80,7 @@ impl Hash for UnorderedSet { /// /// For simplices (Delaunay cells), a facet is the set of all cell vertices excluding one vertex. /// Any facet that appears in exactly one cell is on the boundary. -fn boundary_facets(tri: &B) -> Vec> { +fn boundary_facets(tri: &B) -> Vec> { // Map: facet key -> (occurrence count, representative vertex list) type FacetCounts = HashMap, (usize, Vec)>; let mut facet_counts: FacetCounts = HashMap::new(); @@ -129,7 +131,7 @@ fn boundary_facets(tri: &B) -> Vec Box + '_> { - Box::new(self.vertices.iter().copied()) + fn vertices(&self) -> impl Iterator + '_ { + self.vertices.iter().copied() } - fn edges(&self) -> Box + '_> { - Box::new(self.edges.iter().map(|(edge, _)| *edge)) + fn edges(&self) -> impl Iterator + '_ { + self.edges.iter().map(|(edge, _)| *edge) } - fn faces(&self) -> Box + '_> { - Box::new(self.faces.iter().map(|(face, _)| *face)) + fn faces(&self) -> impl Iterator + '_ { + self.faces.iter().map(|(face, _)| *face) } fn vertex_coordinates( diff --git a/src/geometry/traits.rs b/src/geometry/traits.rs index c3ec546..ca2ff12 100644 --- a/src/geometry/traits.rs +++ b/src/geometry/traits.rs @@ -55,14 +55,23 @@ pub trait TriangulationQuery: GeometryBackend { /// Get the dimensionality of the triangulation fn dimension(&self) -> usize; - /// Iterate over all vertices in the triangulation - fn vertices(&self) -> Box + '_>; + /// Iterate over all vertices in the triangulation. + /// + /// Implementations return a concrete iterator so repeated topology scans do + /// not require heap allocation or dynamic dispatch. + fn vertices(&self) -> impl Iterator + '_; - /// Iterate over all edges in the triangulation - fn edges(&self) -> Box + '_>; + /// Iterate over all edges in the triangulation. + /// + /// Implementations return a concrete iterator so repeated topology scans do + /// not require heap allocation or dynamic dispatch. + fn edges(&self) -> impl Iterator + '_; - /// Iterate over all faces in the triangulation - fn faces(&self) -> Box + '_>; + /// Iterate over all faces in the triangulation. + /// + /// Implementations return a concrete iterator so repeated topology scans do + /// not require heap allocation or dynamic dispatch. + fn faces(&self) -> impl Iterator + '_; /// Get the coordinates of a vertex /// diff --git a/src/lib.rs b/src/lib.rs index 5c470a0..a48907d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,21 +17,27 @@ //! - Foliated 2D triangulation construction and validation //! - Foliation-aware 2D ergodic moves backed by bistellar flips //! - Metropolis-Hastings sampling over foliation-aware 2D ergodic moves +//! - Volume-profile, Hausdorff-dimension, and spectral-dimension observables +//! for CDT analysis //! -//! The crate root re-exports the most common construction, simulation, and -//! error types. Focused preludes under [`prelude`] provide smaller import -//! surfaces for documentation, examples, integration tests, and benchmarks. +//! The crate root re-exports the most common construction, simulation, +//! observable, and error types. Focused preludes under [`prelude`] provide +//! smaller import surfaces for documentation, examples, integration tests, and +//! benchmarks. //! //! # Example //! //! ``` //! use causal_triangulations::prelude::triangulation::CdtTriangulation; +//! use causal_triangulations::prelude::errors::CdtResult; //! -//! let tri = CdtTriangulation::from_toroidal_cdt(4, 3) -//! .expect("build toroidal CDT"); -//! assert_eq!(tri.vertex_count(), 12); -//! assert!(tri.validate_topology().is_ok()); -//! assert!(tri.validate_foliation().is_ok()); +//! fn main() -> CdtResult<()> { +//! let tri = CdtTriangulation::from_toroidal_cdt(4, 3)?; +//! assert_eq!(tri.vertex_count(), 12); +//! assert!(tri.validate_topology().is_ok()); +//! assert!(tri.validate_foliation().is_ok()); +//! Ok(()) +//! } //! ``` // Module declarations (avoiding mod.rs files) @@ -91,6 +97,10 @@ pub mod cdt { pub mod foliation; /// Metropolis-Hastings algorithm implementation. pub mod metropolis; + /// User-facing CDT observable estimators. + pub mod observables; + /// Simulation result containers and measurement summaries. + pub mod results; /// CDT triangulation wrapper. pub mod triangulation; } @@ -100,12 +110,15 @@ pub use cdt::action::{ActionConfig, compute_regge_action}; pub use cdt::ergodic_moves::{ErgodicsSystem, MoveResult, MoveStatistics, MoveType}; pub use cdt::foliation::{CellType, EdgeType, Foliation}; pub use cdt::metropolis::{ - CdtProposal, CdtProposalError, CdtProposalInfo, CdtProposalPlan, CdtTarget, Measurement, - MetropolisAlgorithm, MetropolisConfig, MonteCarloStep, SimulationResultsBackend, + CdtProposal, CdtProposalError, CdtProposalInfo, CdtProposalPlan, CdtTarget, + MetropolisAlgorithm, MetropolisConfig, MonteCarloStep, }; +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}; +use crate::util::saturating_usize_to_u32; use std::time::Duration; // Trait-based triangulation (recommended) @@ -114,16 +127,21 @@ pub use geometry::traits::TriangulationQuery; /// Prelude module for convenient imports. /// -/// Provides commonly used types for CDT construction, simulation, and analysis. +/// Provides the small set of types most examples need for CDT construction, +/// configuration, simulation startup, and error handling. Use scoped preludes +/// such as [`prelude::simulation`], [`prelude::observables`], and +/// [`prelude::geometry`] for specialized workflows. /// /// # Quick start /// /// ``` /// use causal_triangulations::prelude::*; /// -/// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53) -/// .expect("create seeded triangulation"); -/// assert!(tri.validate().is_ok()); +/// fn main() -> CdtResult<()> { +/// let tri = CdtTriangulation::from_seeded_points(5, 2, 2, 53)?; +/// assert!(tri.validate().is_ok()); +/// Ok(()) +/// } /// ``` pub mod prelude { // Core CDT types @@ -131,20 +149,9 @@ pub mod prelude { pub use crate::geometry::CdtTriangulation2D; pub use crate::geometry::traits::TriangulationQuery; - // Foliation / classification - pub use crate::cdt::foliation::{CellType, EdgeType, Foliation, FoliationError}; - - // Action - pub use crate::cdt::action::{ActionConfig, compute_regge_action}; - - // Ergodic moves - pub use crate::cdt::ergodic_moves::{ErgodicsSystem, MoveResult, MoveStatistics, MoveType}; - - // Metropolis simulation - pub use crate::cdt::metropolis::{ - CdtProposal, CdtTarget, Measurement, MetropolisAlgorithm, MetropolisConfig, MonteCarloStep, - SimulationResultsBackend, - }; + // Action and simulation setup + pub use crate::cdt::action::ActionConfig; + pub use crate::cdt::metropolis::{MetropolisAlgorithm, MetropolisConfig}; // Configuration and errors pub use crate::config::{CdtConfig, CdtTopology}; @@ -194,9 +201,11 @@ pub mod prelude { /// ``` /// use causal_triangulations::prelude::triangulation::*; /// - /// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53) - /// .expect("create triangulation"); - /// assert!(tri.vertex_count() >= 3); + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_seeded_points(5, 1, 2, 53)?; + /// assert!(tri.vertex_count() >= 3); + /// Ok(()) + /// } /// ``` pub mod triangulation { pub use crate::CdtTriangulation; @@ -228,19 +237,51 @@ pub mod prelude { /// /// This prelude includes the Metropolis runner, delayed proposal adapter, /// telemetry structs, and typed proposal errors needed by MCMC workflows. + /// Observable estimators live in [`crate::prelude::observables`]. pub mod simulation { pub use crate::CdtTriangulation; pub use crate::cdt::action::{ActionConfig, compute_regge_action}; pub use crate::cdt::ergodic_moves::MoveType; pub use crate::cdt::metropolis::{ CdtProposal, CdtProposalError, CdtProposalInfo, CdtProposalPlan, CdtTarget, - Measurement, MetropolisAlgorithm, MetropolisConfig, MonteCarloStep, - SimulationResultsBackend, + MetropolisAlgorithm, MetropolisConfig, MonteCarloStep, }; + pub use crate::cdt::results::{Measurement, SimulationResultsBackend}; pub use crate::config::{CdtConfig, CdtTopology}; pub use crate::errors::{CdtError, CdtResult}; } + /// Focused exports for CDT observables and post-simulation analysis. + /// + /// This prelude is intended for measuring triangulations without importing + /// simulation runner, telemetry, proposal, or move APIs. + /// It intentionally re-exports [`CdtTriangulation`] and + /// [`CdtTriangulation2D`] so observable doctests can build inputs with + /// constructors such as [`CdtTriangulation::from_cdt_strip`] without + /// importing the triangulation or geometry preludes separately. + /// + /// ``` + /// use causal_triangulations::prelude::errors::CdtResult; + /// use causal_triangulations::prelude::observables::*; + /// + /// fn main() -> CdtResult<()> { + /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?; + /// let profile = tri.volume_profile(); + /// + /// assert_eq!(profile.len(), 3); + /// assert!(estimate_hausdorff_dimension(&tri).is_some_and(f64::is_finite)); + /// assert!(estimate_spectral_dimension(&tri).is_some_and(f64::is_finite)); + /// Ok(()) + /// } + /// ``` + pub mod observables { + pub use crate::CdtTriangulation; + pub use crate::cdt::observables::{ + estimate_hausdorff_dimension, estimate_spectral_dimension, + }; + pub use crate::geometry::CdtTriangulation2D; + } + /// Focused exports for geometry backend construction and querying. /// /// This prelude is intended for backend-level workflows (e.g. building @@ -248,28 +289,35 @@ pub mod prelude { /// queries), without pulling in simulation-specific symbols. /// /// ``` + /// use causal_triangulations::CdtResult; /// use causal_triangulations::prelude::geometry::*; /// - /// 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"); + /// fn main() -> CdtResult<()> { + /// let dt = build_delaunay2_with_data(&[ + /// ([0.0, 0.0], 0), + /// ([1.0, 0.0], 0), + /// ([0.5, 1.0], 1), + /// ])?; /// - /// let backend = DelaunayBackend2D::from_triangulation(dt); - /// assert!(backend.is_valid()); + /// let mut backend = DelaunayBackend2D::from_triangulation(dt); + /// assert!(backend.is_valid()); /// - /// let topology: GlobalTopology<2> = GlobalTopology::Toroidal { - /// domain: [1.0, 1.0], - /// mode: ToroidalConstructionMode::Explicit, - /// }; - /// assert!(matches!(topology, GlobalTopology::Toroidal { .. })); + /// let topology: GlobalTopology<2> = GlobalTopology::Toroidal { + /// domain: [1.0, 1.0], + /// mode: ToroidalConstructionMode::Explicit, + /// }; + /// assert!(matches!(topology, GlobalTopology::Toroidal { .. })); + /// + /// let error = backend.insert_vertex(&[0.0]).expect_err("coordinate dimension is invalid"); + /// assert!(matches!(error, DelaunayError::CoordinateDimensionMismatch { .. })); + /// Ok(()) + /// } /// ``` pub mod geometry { pub use crate::geometry::DelaunayBackend2D; pub use crate::geometry::backends::delaunay::{ - DelaunayBackend, DelaunayEdgeHandle, DelaunayFaceHandle, DelaunayVertexHandle, + DelaunayBackend, DelaunayEdgeHandle, DelaunayError, DelaunayFaceHandle, + DelaunayVertexHandle, }; pub use crate::geometry::backends::mock::MockBackend; pub use crate::geometry::generators::{ @@ -306,18 +354,21 @@ pub mod prelude { /// # Examples /// /// ``` -/// use causal_triangulations::{CdtConfig, run_simulation}; +/// use causal_triangulations::{CdtConfig, CdtResult, run_simulation}; /// -/// let config = CdtConfig { -/// steps: 1, -/// thermalization_steps: 0, -/// measurement_frequency: 1, -/// seed: Some(7), -/// simulate: false, -/// ..CdtConfig::new(5, 2) -/// }; -/// let results = run_simulation(&config).unwrap(); -/// assert_eq!(results.measurements.len(), 1); +/// fn main() -> CdtResult<()> { +/// let config = CdtConfig { +/// steps: 1, +/// thermalization_steps: 0, +/// measurement_frequency: 1, +/// seed: Some(7), +/// simulate: false, +/// ..CdtConfig::new(5, 2) +/// }; +/// let results = run_simulation(&config)?; +/// assert_eq!(results.measurements.len(), 1); +/// Ok(()) +/// } /// ``` pub fn run_simulation(config: &CdtConfig) -> CdtResult { // Validate configuration early to fail fast with clear error messages @@ -385,11 +436,12 @@ pub fn run_simulation(config: &CdtConfig) -> CdtResult Ok(results) } else { // Just return basic simulation results with the triangulation - let initial_action = config.to_action_config().calculate_action( - u32::try_from(triangulation.vertex_count()).unwrap_or_default(), - u32::try_from(triangulation.edge_count()).unwrap_or_default(), - u32::try_from(triangulation.face_count()).unwrap_or_default(), - ); + let vertices = saturating_usize_to_u32(triangulation.vertex_count()); + let edges = saturating_usize_to_u32(triangulation.edge_count()); + let triangles = saturating_usize_to_u32(triangulation.face_count()); + let initial_action = config + .to_action_config() + .calculate_action(vertices, edges, triangles); Ok(SimulationResultsBackend { config: config.to_metropolis_config(), @@ -399,9 +451,10 @@ pub fn run_simulation(config: &CdtConfig) -> CdtResult measurements: vec![Measurement { step: 0, action: initial_action, - vertices: u32::try_from(triangulation.vertex_count()).unwrap_or_default(), - edges: u32::try_from(triangulation.edge_count()).unwrap_or_default(), - triangles: u32::try_from(triangulation.face_count()).unwrap_or_default(), + vertices, + edges, + triangles, + volume_profile: triangulation.volume_profile(), }], elapsed_time: Duration::from_millis(0), triangulation, @@ -410,7 +463,7 @@ pub fn run_simulation(config: &CdtConfig) -> CdtResult } #[cfg(test)] -mod lib_tests { +mod tests { use super::*; use approx::assert_relative_eq; diff --git a/src/util.rs b/src/util.rs index fa8b02b..5ab1906 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Utility functions for random number generation and mathematical operations. use rand::random; @@ -6,24 +8,18 @@ use rand::random; // Safe numeric conversions // --------------------------------------------------------------------------- -/// Convert a `usize` to `i32`, saturating at `i32::MAX`. -/// -/// Useful for Euler characteristic calculations where simplex counts -/// are `usize` but arithmetic needs signed integers. -/// -/// # Examples -/// -/// ``` -/// use causal_triangulations::util::saturating_usize_to_i32; -/// -/// assert_eq!(saturating_usize_to_i32(42), 42); -/// assert_eq!(saturating_usize_to_i32(usize::MAX), i32::MAX); -/// ``` +/// Converts a `usize` to `i32`, saturating at `i32::MAX`. #[must_use] -pub fn saturating_usize_to_i32(n: usize) -> i32 { +pub(crate) fn saturating_usize_to_i32(n: usize) -> i32 { i32::try_from(n).unwrap_or(i32::MAX) } +/// Converts a `usize` to `u32`, saturating at `u32::MAX`. +#[must_use] +pub(crate) fn saturating_usize_to_u32(n: usize) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} + /// Convert a y-coordinate to a time-slice index via `round()`, clamped to `[0, max_t]`. /// /// Returns `None` if the rounded value is negative or exceeds `u32::MAX`. @@ -134,6 +130,25 @@ mod tests { assert_eq!(saturating_usize_to_i32(usize::MAX), i32::MAX); } + #[test] + fn test_saturating_usize_to_u32_normal() { + assert_eq!(saturating_usize_to_u32(0), 0); + assert_eq!(saturating_usize_to_u32(1), 1); + assert_eq!(saturating_usize_to_u32(42), 42); + } + + #[test] + fn test_saturating_usize_to_u32_boundary() { + assert_eq!(saturating_usize_to_u32(u32::MAX as usize), u32::MAX); + } + + #[test] + fn test_saturating_usize_to_u32_overflow() { + #[cfg(target_pointer_width = "64")] + assert_eq!(saturating_usize_to_u32(u32::MAX as usize + 1), u32::MAX); + assert_eq!(saturating_usize_to_u32(usize::MAX), u32::MAX); + } + #[test] fn test_y_to_time_bucket_exact_integers() { assert_eq!(y_to_time_bucket(0.0, 5), Some(0)); diff --git a/tests/proptest_metropolis.rs b/tests/proptest_metropolis.rs index a7a8ba8..d90f638 100644 --- a/tests/proptest_metropolis.rs +++ b/tests/proptest_metropolis.rs @@ -26,7 +26,8 @@ proptest! { let tri = test_triangulation(); let action_config = ActionConfig::new(coupling_0, coupling_2, cosmological_constant); - let target = CdtTarget::new(action_config.clone(), temperature); + let target = CdtTarget::new(action_config.clone(), temperature) + .expect("generated action config and temperature are valid"); let log_prob = target.log_prob(&tri); diff --git a/tests/semgrep/src/project_rules/rust_style.rs b/tests/semgrep/src/project_rules/rust_style.rs index adce4c6..2dd5bb1 100644 --- a/tests/semgrep/src/project_rules/rust_style.rs +++ b/tests/semgrep/src/project_rules/rust_style.rs @@ -58,6 +58,40 @@ fn nonfinite_conversion_default_fixture(value: Option) { let _ = value.unwrap_or(f64::MAX); } +fn production_unwrap_and_panic_fixture(result: Result, value: Option) { + // ruleid: causal-triangulations.rust.no-bare-unwrap-in-src + let _ = result.unwrap(); + + // ruleid: causal-triangulations.rust.no-bare-unwrap-in-src + let _ = value.unwrap(); + + // ok: causal-triangulations.rust.no-bare-unwrap-in-src + let _ = result.unwrap_or(0); + + // ruleid: causal-triangulations.rust.no-panic-in-src + panic!("production code should return a typed error"); +} + +#[cfg(test)] +fn test_only_unwrap_and_panic_fixture(result: Result) { + // ok: causal-triangulations.rust.no-bare-unwrap-in-src + let _ = result.unwrap(); + + // ok: causal-triangulations.rust.no-panic-in-src + panic!("tests may fail fast"); +} + +#[cfg(test)] +mod prop_tests { + fn helper(result: Result) { + // ok: causal-triangulations.rust.no-bare-unwrap-in-src + let _ = result.unwrap(); + + // ok: causal-triangulations.rust.no-panic-in-src + panic!("tests may fail fast"); + } +} + // ruleid: causal-triangulations.rust.expect-requires-reason #[expect(clippy::too_many_lines)] fn expect_without_reason_fixture() {}