From 20930d280415f9a07d8df0369e3b3c935e1c76fe Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Fri, 26 Jun 2026 21:54:32 -0700 Subject: [PATCH 1/3] feat(validation)!: add embedded triangulation validation layer (#449) Add a Level 4 "embedding" validation layer that certifies a triangulation is a faithful geometric embedding independently of the Delaunay predicate: maximal simplices are nondegenerate and meet only in shared faces within the active affine chart. This renumbers the Delaunay empty-circumsphere property to Level 5. - Add a public geometry::embedding module (backed by internal core::embedding) with the embedding checks and their typed errors. - Add Triangulation::is_valid_embedding, validate_embedding, and embedding_report for the new Level 4 layer. - Move Delaunay property validation to delaunay::property_validation (renamed from core::util::delaunay_validation) and expose the Level 5 check as DelaunayTriangulation::is_valid_delaunay. - Refresh the validation guide, invariants, API-design, and prelude docs for the five-level stack. BREAKING CHANGE: The validation stack is renumbered from four to five levels. Level 4 is now faithful embedding validation (Triangulation::is_valid_embedding / validate_embedding / embedding_report), and the Delaunay empty-circumsphere property moves to Level 5 (DelaunayTriangulation::is_valid_delaunay); migrate callers of the former Level-4 Delaunay entry points (e.g. is_valid -> is_valid_delaunay). The Delaunay property-validation items DelaunayValidationError, find_delaunay_violations, DelaunayViolationDetail, DelaunayViolationReport, delaunay_violation_report, and debug_print_first_delaunay_violation now live in delaunay::property_validation (renamed from core::util::delaunay_validation); import them from the crate root or prelude. Closes #449 --- AGENTS.md | 43 +- CITATION.cff | 5 +- README.md | 37 +- benches/ci_performance_suite.rs | 2 +- benches/common/flip_fixtures.rs | 23 +- benches/common/flip_workflows.rs | 44 +- benches/profiling_suite.rs | 8 +- docs/ORIENTATION_SPEC.md | 2 +- docs/README.md | 2 +- docs/api_design.md | 16 +- docs/architecture/module_map.md | 4 +- docs/architecture/prelude_reference.md | 2 +- docs/code_organization.md | 4 +- docs/dev/docs.md | 5 + docs/dev/testing.md | 11 +- docs/dev/tooling-alignment.md | 6 + docs/diagnostics.md | 31 +- docs/invariants.md | 51 +- docs/limitations.md | 8 +- docs/numerical_robustness_guide.md | 6 +- docs/topology.md | 10 +- docs/validation.md | 262 ++- docs/workflows.md | 15 +- examples/delaunayize_repair.rs | 11 +- examples/diagnostics.rs | 4 +- examples/topology_editing.rs | 6 +- scripts/tests/test_readme_citation_mirror.py | 46 + semgrep.yaml | 56 +- src/core/algorithms/flips.rs | 6 +- src/core/algorithms/incremental_insertion.rs | 8 +- src/core/construction.rs | 12 +- src/core/embedding.rs | 1676 +++++++++++++++++ src/core/insertion.rs | 12 +- src/core/orientation.rs | 6 +- src/core/repair.rs | 13 +- src/core/simplex.rs | 88 + src/core/tds/errors.rs | 16 +- src/core/tds/storage.rs | 24 +- src/core/tds/validation.rs | 146 +- src/core/validation.rs | 736 +++++--- src/core/vertex.rs | 57 + src/delaunay/builder.rs | 24 +- src/delaunay/construction.rs | 14 +- src/delaunay/deletion.rs | 87 +- src/delaunay/insertion.rs | 4 +- .../property_validation.rs} | 323 ++-- src/delaunay/query.rs | 3 +- src/delaunay/repair.rs | 8 +- src/delaunay/serialization.rs | 2 +- src/delaunay/triangulation.rs | 2 +- src/delaunay/validation.rs | 443 +++-- src/geometry/embedding.rs | 764 ++++++++ src/geometry/util/triangulation_generation.rs | 18 +- src/lib.rs | 161 +- src/topology/traits/global_topology_model.rs | 35 + src/topology/traits/topological_space.rs | 2 +- tests/benchmark_flip_fixtures.rs | 79 +- tests/dedup_batch_construction.rs | 4 +- tests/delaunay_edge_cases.rs | 38 +- tests/delaunay_repair_fallback.rs | 2 +- tests/euler_characteristic.rs | 6 +- tests/large_scale_debug.rs | 4 +- tests/pachner_roundtrip.rs | 38 +- tests/prelude_exports.rs | 94 +- tests/proptest_delaunay_triangulation.rs | 16 +- tests/proptest_flips.rs | 2 +- tests/proptest_serialization.rs | 11 +- tests/regressions.rs | 6 +- tests/semgrep/docs/validation_levels.md | 22 + tests/semgrep/src/project_rules/rust_style.rs | 39 + tests/triangulation_builder.rs | 48 +- 71 files changed, 4757 insertions(+), 1062 deletions(-) create mode 100644 scripts/tests/test_readme_citation_mirror.py create mode 100644 src/core/embedding.rs rename src/{core/util/delaunay_validation.rs => delaunay/property_validation.rs} (83%) create mode 100644 src/geometry/embedding.rs create mode 100644 tests/semgrep/docs/validation_levels.md diff --git a/AGENTS.md b/AGENTS.md index 1234c412..d07cfd99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,10 @@ workflow. Agents must load every file in `docs/dev/` before making changes. focused surfaces changed. - **Do not edit generated changelogs manually.** Changelog and documentation maintenance rules live in `docs/dev/docs.md`. +- **Keep README and citation prose mirrored.** The first paragraph under + `README.md`'s Introduction is mirrored by the `abstract` field in + `CITATION.cff`; update both together. The invariant is checked by + `scripts/tests/test_readme_citation_mirror.py`. ## Project Context @@ -99,10 +103,13 @@ When in doubt, favor the invariant over the convenient edit. ### Topological Correctness -- Every mutating operation preserves the invariants checked by `Tds::is_valid` - (Levels 1-3) and `DelaunayTriangulation::is_valid` (Level 4). An operation - that cannot preserve them must fail explicitly rather than leave inconsistent - state behind. +- Every mutating operation preserves the invariants checked by + `Tds::is_valid` / `validate` (Levels 1-2), + `Triangulation::is_valid_topology` / `validate` (Level 3), + `Triangulation::is_valid_embedding` / `validate_embedding` (Level 4), and + `DelaunayTriangulation::is_valid_delaunay` / `validate` (Level 5). An + operation that cannot preserve them must fail explicitly rather than leave + inconsistent state behind. - PL-manifold invariants: facets have multiplicity 1 (boundary) or 2 (interior), ridges are linked consistently, and Euler characteristic matches the triangulation's `TopologyGuarantee`. @@ -112,7 +119,7 @@ When in doubt, favor the invariant over the convenient edit. ### Validation Layers -The library exposes four validation levels, each a superset of the last: +The library exposes five validation levels, each a superset of the last: 1. **Level 1 - elements**: individual simplices, vertices, and facets are internally consistent. @@ -120,10 +127,23 @@ The library exposes four validation levels, each a superset of the last: incidence graph. 3. **Level 3 - topology**: PL-manifold-with-boundary, Euler characteristic, and ridge-link consistency. -4. **Level 4 - Delaunay property**: every facet is locally Delaunay. - -Only Level 4 requires predicate evaluation. Levels 1-3 are pure graph checks. -Validation code belongs at the lowest layer that owns the invariant. +4. **Level 4 - embedding**: maximal simplices are nondegenerate and intersect + only in shared faces in the active affine chart. +5. **Level 5 - Delaunay property**: every facet is locally Delaunay. + +Level 4 uses orientation and exact barycentric geometry. Level 5 uses +Delaunay predicates. Levels 1-3 are pure graph/topology checks. Validation code +belongs at the lowest layer that owns the invariant. +Each layer should expose the standard validation surface. Use plain +`is_valid()` when the owner already names the invariant scope (`Vertex`, +`Simplex`, and `Tds`); use `is_valid_*` when higher-level owners expose +multiple validation layers. Use `*_diagnostic` for the first actionable +repair/retry diagnostic, `*_report` for layer-local aggregate diagnostics, and +`validate()` / `validation_report()` for cumulative roll-up through the owning +layer. Report names should identify the layer being checked, e.g. +`structure_report`, `topology_report`, `embedding_report`, and +`delaunay_report`. Higher layers should roll lower diagnostics up without +stringifying them. ### Symbolic Perturbation @@ -175,6 +195,11 @@ enforce that contract. - Prefer small, focused patches. - Search `docs/`, `docs/dev/README.md`, and `docs/architecture/README.md` before inventing new conventions. +- Opportunistically fix nearby issues discovered while working in a touched + area, even when they predate the current patch, when the fix is small, + clearly related, and improves correctness, clarity, tests, or + maintainability. Avoid broad mechanical churn; split repo-wide cleanup into + separate work. - Keep code simple and maintainable when multiple correct solutions exist. - Preserve numerical and topological invariants first; optimize only inside that envelope. diff --git a/CITATION.cff b/CITATION.cff index 4064e1e6..88f11859 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -25,8 +25,9 @@ abstract: >- global topologies. Uses exact predicates and Simulation of Simplicity for robustness and degeneracy handling, and Hilbert curves for deterministic insertion ordering and efficient spatial indexing. Provides an explicit - 4-level validation hierarchy on individual elements, triangulation data - structure validity, manifold topology, and Delaunay property adherence. + 5-level validation hierarchy on individual elements, triangulation data + structure validity, manifold topology, faithful embedding in the active + affine chart, and Delaunay property adherence. Allows for the complete set of Pachner moves up to D=5 using bistellar flips, vertex insertion and deletion, and the conversion of non-Delaunay triangulations into Delaunay triangulations via bounded flip/rebuilds. diff --git a/README.md b/README.md index 41019eda..15e3b0e3 100644 --- a/README.md +++ b/README.md @@ -39,22 +39,24 @@ Rust crate providing D-dimensional [Delaunay triangulations] and [convex hulls][ [pseudomanifold][Pseudomanifold] guarantee on finite point sets with Euclidean and toroidal global topologies. Uses [exact predicates] and [Simulation of Simplicity] for robustness and degeneracy handling, and [Hilbert curve]s for deterministic insertion ordering and efficient spatial indexing. -Provides an explicit [4-level validation hierarchy][Validation Guide] on individual elements, -triangulation data structure validity, manifold topology, and Delaunay property adherence. Allows for -the complete set of [Pachner moves] up to D=5 using bistellar flips, vertex insertion and deletion, -and the conversion of non-Delaunay triangulations into Delaunay triangulations via bounded -flip/rebuilds. Auxiliary data may be stored directly in vertices and simplices with external -[secondary maps][Secondary maps] provided for vertex- and simplex-keyed algorithm use, and the entire -data structure is serializable/deserializable. Written in safe Rust with no unsafe code. +Provides an explicit [5-level validation hierarchy][Validation Guide] on individual elements, +triangulation data structure validity, manifold topology, faithful embedding in the active affine +chart, and Delaunay property adherence. Allows for the complete set of [Pachner moves] up to D=5 +using bistellar flips, vertex insertion and deletion, and the conversion of non-Delaunay +triangulations into Delaunay triangulations via bounded flip/rebuilds. Auxiliary data may be stored +directly in vertices and simplices with external [secondary maps][Secondary maps] provided for +vertex- and simplex-keyed algorithm use, and the entire data structure is +serializable/deserializable. Written in safe Rust with no unsafe code. Use this crate when you want: - Delaunay triangulations or convex hulls in 2D through 5D. - Exact predicates and deterministic SoS handling for degenerate inputs. +- Faithful Euclidean and toroidal affine embedding validation independent of Delaunay predicates. - PL-manifold checks and explicit topology guarantees. - PL-manifold-aware editing via bistellar flips and bounded Delaunay repair. - Typed construction, insertion, validation, topology, and repair diagnostics. -- Validation reports that separate element, structure, topology, and Delaunay failures. +- Validation reports that separate element, structure, topology, embedding, and Delaunay failures. This is not a replacement for full meshing packages such as [CGAL], TetGen, or Gmsh when you need constrained Delaunay triangulations, direct Voronoi extraction, out-of-core meshing, GPU/parallel @@ -147,14 +149,21 @@ and [`docs/numerical_robustness_guide.md`](docs/numerical_robustness_guide.md). | Level | Validates | Primary API | |---|---|---| | 1 | Vertex, simplex, and facet element invariants | `vertex.is_valid()` / `simplex.is_valid()` | -| 2 | TDS keys, incidences, and neighbor links | `dt.tds().is_valid()` | -| 3 | Manifold topology, ridge links, and Euler consistency | `dt.as_triangulation().is_valid()` | -| 4 | Delaunay property via local predicates | `dt.is_valid()` | -| 1-4 | Cumulative diagnostics | `dt.validate()` / `dt.validation_report()` | +| 2 | TDS keys, incidences, and neighbor links | `dt.tds().is_valid()` / `dt.tds().structure_report()` | +| 3 | Manifold topology, ridge links, and Euler consistency | `dt.as_triangulation().is_valid_topology()` / `dt.as_triangulation().topology_report()` | +| 4 | Faithful embedding | `dt.as_triangulation().is_valid_embedding()` / `dt.as_triangulation().embedding_report()` | +| 5 | Delaunay property via local predicates | `dt.is_valid_delaunay()` / `dt.delaunay_report()` | +| 1-5 | Cumulative diagnostics | `dt.validate()` / `dt.validation_report()` | `TopologyGuarantee` controls which Level 3 topology invariants are enforced. `ValidationPolicy` -controls when Level 3 checks run during incremental insertion. The default is PL-manifold topology with -explicit full-validation checkpoints. +controls when Level 3 checks run during incremental insertion. Level 4 embedding validation is +topology-aware for Euclidean and toroidal affine charts and runs before Level 5 Delaunay predicate +validation. Use `dt.as_triangulation().validate_embedding()` when you want cumulative Levels 1-4 +validation. `dt.as_triangulation().embedding_report()` returns simplex keys, simplex UUIDs, and +offending vertex keys/UUIDs for Level 4 repair planning. The default is PL-manifold topology with explicit full-validation +checkpoints. Layer-local APIs use `is_valid()` for unambiguous element/TDS owners, `is_valid_*` +for higher-level fast-fail checks, and `*_diagnostic` / `*_report` for diagnostics; cumulative +APIs use `validate()` / `validation_report()`. ## πŸ—ΊοΈ Documentation Map diff --git a/benches/ci_performance_suite.rs b/benches/ci_performance_suite.rs index 6e888635..6a0578d1 100644 --- a/benches/ci_performance_suite.rs +++ b/benches/ci_performance_suite.rs @@ -10,7 +10,7 @@ //! 2. Convex hull extraction from completed triangulations //! 3. Convex hull visibility/containment queries //! 4. Boundary facet traversal -//! 5. Full validation (Levels 1-4) +//! 5. Full validation (Levels 1-5) //! 6. Incremental vertex insertion //! 7. Explicit bistellar flip workflows on stable and adversarial 2D-5D //! PL-manifold cases diff --git a/benches/common/flip_fixtures.rs b/benches/common/flip_fixtures.rs index 3d285fe9..d7c92084 100644 --- a/benches/common/flip_fixtures.rs +++ b/benches/common/flip_fixtures.rs @@ -108,7 +108,7 @@ pub const ADVERSARIAL_POINTS_3D: &[[f64; 3]] = &[ [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], - [1.0, 1.0, 0.0], + [1.0, 1.0, 1.0], [1.0e-9, 0.25, 0.25], [0.25, 1.0e-9, 0.25], [0.25, 0.25, 1.0e-9], @@ -127,7 +127,7 @@ pub const ADVERSARIAL_POINTS_4D: &[[f64; 4]] = &[ [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], - [1.0, 1.0, 0.0, 0.0], + [1.0, 1.0, 1.0, 1.0], [1.0e-9, 0.20, 0.20, 0.20], [0.20, 1.0e-9, 0.20, 0.20], [0.20, 0.20, 1.0e-9, 0.20], @@ -149,7 +149,7 @@ pub const ADVERSARIAL_POINTS_5D: &[[f64; 5]] = &[ [0.0, 0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 0.0, 1.0], - [1.0, 1.0, 0.0, 0.0, 0.0], + [1.0, 1.0, 1.0, 1.0, 1.0], [1.0e-9, 0.16, 0.16, 0.16, 0.16], [0.16, 1.0e-9, 0.16, 0.16, 0.16], [0.16, 0.16, 1.0e-9, 0.16, 0.16], @@ -159,3 +159,20 @@ pub const ADVERSARIAL_POINTS_5D: &[[f64; 5]] = &[ [0.160_000_001, 0.16, 0.16, 0.16, 0.16], [1.0e6, -1.0e6, 1.0e6, -1.0e6, 1.0e6], ]; + +/// Intentionally invalid 3D fixture used to prove benchmark setup rejects +/// degenerate inputs instead of silently sanitizing them. +/// +/// All points are coplanar (`z = 0`), so no faithful 3D simplex embedding can +/// be formed. +#[allow( + dead_code, + reason = "negative fixture is imported by integration tests, not every benchmark target" +)] +pub const DEGENERATE_POINTS_3D: &[[f64; 3]] = &[ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [0.5, 0.25, 0.0], +]; diff --git a/benches/common/flip_workflows.rs b/benches/common/flip_workflows.rs index 30382717..42255b63 100644 --- a/benches/common/flip_workflows.rs +++ b/benches/common/flip_workflows.rs @@ -321,6 +321,16 @@ pub enum FlipWorkflowError { #[source] source: DelaunayTriangulationValidationError, }, + + /// A roundtrip produced a triangulation that failed topology validation. + #[error("{context} produced invalid topology after roundtrip: {source}")] + InvalidTopologyAfterRoundtrip { + /// Roundtrip context label. + context: String, + /// Underlying topology validation failure. + #[source] + source: Box, + }, } /// Jaccard report category used by @@ -1051,12 +1061,7 @@ pub fn verify_k1_roundtrip( let before = snapshot_topology(base_dt)?; let mut trial = base_dt.clone(); roundtrip_k1(&mut trial, simplex_key)?; - trial - .validate() - .map_err(|source| FlipWorkflowError::InvalidAfterRoundtrip { - context: context.to_string(), - source, - })?; + validate_topology_and_delaunay(&trial, context)?; assert_same_topology(&trial, &before, context) } @@ -1078,12 +1083,7 @@ pub fn verify_k2_roundtrip( let before = snapshot_topology(base_dt)?; let mut trial = base_dt.clone(); roundtrip_k2(&mut trial, facet)?; - trial - .validate() - .map_err(|source| FlipWorkflowError::InvalidAfterRoundtrip { - context: context.to_string(), - source, - })?; + validate_topology_and_delaunay(&trial, context)?; assert_same_topology(&trial, &before, context) } @@ -1105,13 +1105,25 @@ pub fn verify_k3_roundtrip( let before = snapshot_topology(base_dt)?; let mut trial = base_dt.clone(); roundtrip_k3(&mut trial, ridge)?; - trial - .validate() + validate_topology_and_delaunay(&trial, context)?; + assert_same_topology(&trial, &before, context) +} + +fn validate_topology_and_delaunay( + dt: &FlipTriangulation, + context: &str, +) -> FlipWorkflowResult<()> { + dt.as_triangulation().validate().map_err(|source| { + FlipWorkflowError::InvalidTopologyAfterRoundtrip { + context: context.to_string(), + source: Box::new(source), + } + })?; + dt.is_valid_delaunay() .map_err(|source| FlipWorkflowError::InvalidAfterRoundtrip { context: context.to_string(), source, - })?; - assert_same_topology(&trial, &before, context) + }) } /// Reports whether a k=2 facet support touches an adversarial fixture feature. diff --git a/benches/profiling_suite.rs b/benches/profiling_suite.rs index 4f011e7e..afcaad23 100644 --- a/benches/profiling_suite.rs +++ b/benches/profiling_suite.rs @@ -490,7 +490,7 @@ fn bench_validation(c: &mut Criterion, dimension_name: &str, n_p group.bench_function("validate_topology", |b| { b.iter(|| { - if let Err(error) = tri.is_valid() { + if let Err(error) = tri.is_valid_topology() { abort_benchmark(format_args!( "triangulation should be structurally valid during validation benchmark: {error}" )); @@ -1062,9 +1062,9 @@ macro_rules! benchmark_validation_components_dimension { }); }); - group.bench_function("tri_is_valid", |b| { + group.bench_function("is_valid_topology", |b| { b.iter(|| { - if let Err(error) = black_box(dt.as_triangulation().is_valid()) { + if let Err(error) = black_box(dt.as_triangulation().is_valid_topology()) { abort_benchmark(format_args!( "triangulation validation should pass for benchmark triangulation: {error}" )); @@ -1074,7 +1074,7 @@ macro_rules! benchmark_validation_components_dimension { group.bench_function("is_valid_delaunay", |b| { b.iter(|| { - if let Err(error) = black_box(dt.is_valid()) { + if let Err(error) = black_box(dt.is_valid_delaunay()) { abort_benchmark(format_args!( "Delaunay validation should pass for benchmark triangulation: {error}" )); diff --git a/docs/ORIENTATION_SPEC.md b/docs/ORIENTATION_SPEC.md index d53b0a86..2458e474 100644 --- a/docs/ORIENTATION_SPEC.md +++ b/docs/ORIENTATION_SPEC.md @@ -202,7 +202,7 @@ drive repair, but replacement-simplex orientation itself uses `robust_orientatio and then enforces the Delaunay property. - `.try_toroidal([..])` builds an image-point triangulation and then runs orientation normalization, lifted geometric orientation validation, final - Levels 1-3 topology validation, and final Level 4 Delaunay validation before + Levels 1-3 topology validation, and final Level 5 Delaunay validation before returning the quotient triangulation. ## Degenerate Simplices diff --git a/docs/README.md b/docs/README.md index 7dfa3c71..a6775fc4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ Historical design notes, investigations, and completed optimization roadmaps liv - [`api_design.md`](api_design.md): construction, vertex lifecycle, and Pachner move APIs. - [`topology.md`](topology.md): Level 3 topology invariants (manifold checks, Euler characteristic). -- [`validation.md`](validation.md): the four-level validation model (Levels 1–4) and how to configure it. +- [`validation.md`](validation.md): the five-level validation model (Levels 1–5) and how to configure it. - [`diagnostics.md`](diagnostics.md): opt-in diagnostic helpers, structured reports, and debug switches. - [`mesh_export.md`](mesh_export.md): stable simplicial-complex export schema for notebooks and downstream tools. - [`workflows.md`](workflows.md): practical recipes for construction, deletion, and local Pachner moves. diff --git a/docs/api_design.md b/docs/api_design.md index 280d02d3..7b505b01 100644 --- a/docs/api_design.md +++ b/docs/api_design.md @@ -291,7 +291,8 @@ After applying flips, you should: 1. Manually verify the Delaunay property if needed: ```rust - assert!(dt.is_valid().is_ok()); // Check Level 4 (Delaunay property) + assert!(dt.as_triangulation().validate_embedding().is_ok()); // Check Level 4 (faithful embedding) + assert!(dt.is_valid_delaunay().is_ok()); // Check Level 5 (Delaunay property) ``` 2. Consider running a repair pass if you need the Delaunay property again (requires `K: ExactPredicates`): @@ -340,7 +341,7 @@ fn main() -> Result<(), ExampleError> { dt.attempt_pachner(PachnerMove::K2 { facet })?; // 4. Verify Delaunay property if needed - if let Err(e) = dt.is_valid() { + if let Err(e) = dt.is_valid_delaunay() { eprintln!("Warning: Delaunay property violated after manual edit: {}", e); // Optionally restore using Builder API or custom repair } @@ -356,7 +357,7 @@ Both APIs work with the same validation framework but have different guarantees: - βœ… Maintains **structural invariants** (Level 1-2) - βœ… Maintains **manifold topology** (Level 3, controlled by `TopologyGuarantee`) -- βœ… Designed to maintain **Delaunay property** (Level 4) +- βœ… Designed to maintain **faithful embedding** (Level 4) and **Delaunay property** (Level 5) - βœ… Fails gracefully if invariants cannot be maintained ### Pachner Move API Guarantees @@ -375,10 +376,13 @@ Use the appropriate validation level for your needs: assert!(dt.tds().is_valid().is_ok()); // Level 3: + Manifold topology -assert!(dt.as_triangulation().is_valid().is_ok()); +assert!(dt.as_triangulation().is_valid_topology().is_ok()); -// Level 4: + Delaunay property (most comprehensive) -assert!(dt.is_valid().is_ok()); +// Level 4: + Faithful embedding +assert!(dt.as_triangulation().validate_embedding().is_ok()); + +// Level 5: + Delaunay property (most comprehensive) +assert!(dt.is_valid_delaunay().is_ok()); // Full diagnostic report let report = dt.validation_report(); diff --git a/docs/architecture/module_map.md b/docs/architecture/module_map.md index 518bd2d3..cb6f563e 100644 --- a/docs/architecture/module_map.md +++ b/docs/architecture/module_map.md @@ -109,8 +109,10 @@ coordinate model/API rather than loosening ordinary `f64` APIs. - `repair.rs` - Delaunay repair policies, rebuild config, and repair outcomes. - `serialization.rs` - conversion to/from `Tds` with topology metadata reset rules. -- `validation.rs` - Level 4 validation errors and construction validation +- `validation.rs` - Level 5 Delaunay validation errors and construction validation cadence helpers. +- `property_validation.rs` - TDS-level Delaunay empty-circumsphere scans and + repair-oriented violation reports used by Level 5 validation APIs. `src/lib.rs` wires public modules, root re-exports, focused preludes, and the crate-level documentation map. Delaunay-facing modules are exposed directly as diff --git a/docs/architecture/prelude_reference.md b/docs/architecture/prelude_reference.md index d1273403..f140163d 100644 --- a/docs/architecture/prelude_reference.md +++ b/docs/architecture/prelude_reference.md @@ -11,7 +11,7 @@ they exercise. | Construct/configure a Delaunay triangulation | `use delaunay::prelude::construction::*` | | Construction telemetry diagnostics | `use delaunay::prelude::diagnostics::*` | | Export stable simplicial-complex primitives | `use delaunay::prelude::export::*` | -| Construction validation cadence/policy | `use delaunay::prelude::validation::*` | +| Validation policies, errors, reports, and Level 5 diagnostics | `use delaunay::prelude::validation::*` | | Delaunay repair diagnostics and policies | `use delaunay::prelude::repair::*` | | Delaunayize workflow | `use delaunay::prelude::delaunayize::*` | | Hilbert ordering and quantization utilities | `use delaunay::prelude::ordering::*` | diff --git a/docs/code_organization.md b/docs/code_organization.md index d9e47c86..04480a0b 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -48,7 +48,9 @@ into architecture docs; link to the command guide instead. - `edge.rs` and `facet.rs` stay in `src/core/` because they are direct TDS traversal primitives. Ridge query/view types belong in `src/topology/` because ridge shape and link semantics depend on dimension and topology. -- Delaunay Level 4 validation belongs in `src/delaunay/validation.rs`; generic +- Generic Level 4 embedding validation belongs in `src/core/embedding.rs`; + Delaunay Level 5 public validation APIs belong in `src/delaunay/validation.rs`, + with TDS-level Delaunay-property scan helpers under `src/delaunay/`; generic Level 1-3 validation belongs in the core/topology layers. - Focused preludes should stay narrow and workflow-specific. Use `delaunay::prelude::pachner::*` for local move workflows, and import diff --git a/docs/dev/docs.md b/docs/dev/docs.md index 6b7f32fe..26b3b79b 100644 --- a/docs/dev/docs.md +++ b/docs/dev/docs.md @@ -23,6 +23,11 @@ repository. - `docs/architecture/README.md` indexes focused architecture references. - `REFERENCES.md` owns literature references. - `CITATION.cff` owns citation metadata. +- `README.md`'s first Introduction paragraph and `CITATION.cff`'s `abstract` + field intentionally mirror each other. When one changes, update the other in + the same patch. `scripts/tests/test_readme_citation_mirror.py` checks the + normalized text after Markdown links are stripped from the README prose. + Semgrep also rejects stale public validation-hierarchy wording. - `docs/archive/` stores historical plans, completed changelog series, and old design notes. Do not update archived docs as active guidance unless an explicit archive-maintenance task asks for it. diff --git a/docs/dev/testing.md b/docs/dev/testing.md index ed28084b..215978b2 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -120,7 +120,7 @@ proptest! { #[test] fn triangulation_is_valid(points in point_cloud_strategy()) { let tri = build_triangulation(points); - assert!(tri.is_valid()); + assert!(tri.validate().is_ok()); } } ``` @@ -300,7 +300,7 @@ Whenever possible, prefer validating triangulations using invariant checks. Example: ```rust -assert!(tri.is_valid()); +assert!(tri.validate().is_ok()); ``` Validation helpers are preferred over writing manual assertions about @@ -320,7 +320,8 @@ docs/invariants.md Tests should verify behavior consistent with that specification. -For details on validation helpers such as `tri.is_valid()`, see: +For details on validation helpers such as `validate()`, `is_valid()`, +`is_valid_topology()`, and `is_valid_delaunay()`, see: ```text docs/validation.md @@ -344,7 +345,7 @@ When writing tests that construct or modify a triangulation, agents should prefer validating the following checklist rather than writing ad‑hoc assertions: -- `tri.is_valid()` returns true +- `tri.validate()` returns `Ok(())` - every simplex references existing vertices - adjacency relationships are symmetric - vertex stars form closed topological neighborhoods @@ -352,7 +353,7 @@ assertions: - orientation predicates are consistent across neighbors Whenever possible, prefer a single invariant validation call (e.g. -`tri.is_valid()`) rather than duplicating these checks manually. +`tri.validate()`) rather than duplicating these checks manually. Invariant-based testing is the most reliable way to validate geometric algorithms. diff --git a/docs/dev/tooling-alignment.md b/docs/dev/tooling-alignment.md index e7c3ec67..719b6b1e 100644 --- a/docs/dev/tooling-alignment.md +++ b/docs/dev/tooling-alignment.md @@ -413,6 +413,12 @@ The following previously deferred checks are now repository-owned Semgrep rules: - `delaunay.rust.prefer-vertex-macro-for-workflow-fixtures` keeps incidental vertex setup in workflow tests, examples, and benchmarks on `vertex!`, while leaving constructor-focused tests free to exercise `Vertex::try_new` directly. +- `delaunay.rust.validation-api-naming-standard` keeps higher-layer validation + APIs on the v0.8 naming pattern: `is_valid_*` for layer-local fast-fail + checks, `*_diagnostic` for first actionable diagnostics, `*_report` for + layer-local aggregate reports, and `validate()` / `validation_report()` for + cumulative roll-ups. Plain `is_valid()` remains reserved for unambiguous + element and TDS owners. ## Retired Repository Rules diff --git a/docs/diagnostics.md b/docs/diagnostics.md index fc06838c..19c22dce 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -15,7 +15,7 @@ The crate exposes two kinds of diagnostics. Always available: -- `validate()` and `validation_report()` for cumulative Levels 1-4 validation. +- `validate()` and `validation_report()` for cumulative Levels 1-5 validation. - Typed construction, insertion, validation, topology, and repair errors. - Repair diagnostics attached to non-convergence and repair-neighbor failures. - Construction statistics and telemetry through @@ -87,8 +87,25 @@ fn main() -> DelaunayResult<()> { } ``` -Use `validate()` when a pass/fail result is enough. Use `validation_report()` -when you need all violated invariants instead of the first error. +Use `validate()` when a cumulative pass/fail result is enough. Use +`validation_report()` when you need all violated invariants across the stack +instead of the first error. + +Layer-local diagnostics follow a standard naming pattern: + +- `is_valid()` for unambiguous element/TDS owners, and `is_valid_*` for + higher-level owners with multiple validation layers. +- `*_diagnostic`: first actionable repair/retry diagnostic for that layer. +- `*_report`: all checkable layer-local failures. + +For Level 4 embedding failures specifically, use +`dt.as_triangulation().embedding_diagnostic()` for the first repair-oriented +failure and `dt.as_triangulation().embedding_report()` for all checkable +embedding failures. These report invalid simplices or simplex pairs with +simplex keys, simplex UUIDs, offending vertex keys, and offending vertex UUIDs. +That key-oriented payload is the intended starting point for explicit rollback, +vertex deletion, or future repair workflows; the report itself is pure and does +not mutate the triangulation. ## Construction Telemetry @@ -110,7 +127,7 @@ For large-scale reproducible diagnostics, prefer the documented debug recipes in ## Delaunay Violation Reports -Use `delaunay_violation_report` when you want key-based data about Level 4 +Use `delaunay_violation_report` when you want key-based data about Level 5 empty-circumsphere violations: ```rust @@ -155,8 +172,10 @@ Useful fields: - `number_of_vertices`, `number_of_simplices`: size of the inspected TDS. - `checked_simplices`: number of requested simplices considered by the scan. - `violating_simplices`: all simplices that violate the Delaunay property. -- `first_violation`: first violating simplex, its vertex keys, neighbor slots, and - one offending external vertex when identified. +- `violation_details`: per-violation repair seeds with the simplex vertices, + neighbor slots, and one offending external vertex when identified. +- `first_violation()`: borrowed view of the first `violation_details` entry, + avoiding a duplicate stored detail that could drift from the aggregate report. ## Tracing Output diff --git a/docs/invariants.md b/docs/invariants.md index 73110075..4a01aa5b 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -27,6 +27,7 @@ the guarantees stated in the public API documentation. - [Validation layering](#validation-layering) - [Coherent orientation](#coherent-orientation) - [Geometric invariants](#geometric-invariants) + - [Faithful embedding in affine charts](#faithful-embedding-in-affine-charts) - [Delaunay condition (empty circumsphere property)](#delaunay-condition-empty-circumsphere-property) - [Robust predicate envelope](#robust-predicate-envelope) - [PL-manifold conditions](#pl-manifold-conditions) @@ -103,7 +104,7 @@ This is why the TDS validation layer checks coherent orientation alongside neigh ## Validation layering -The implementation separates invariants into four validation levels. Keeping these layers distinct +The implementation separates invariants into five validation levels. Keeping these layers distinct prevents geometric checks from leaking into purely combinatorial validation and makes it clear which operation has certified which part of the structure: @@ -115,17 +116,21 @@ operation has certified which part of the structure: 3. **Level 3 β€” topology**: the triangulation satisfies the requested `TopologyGuarantee` (pseudomanifold, PL manifold, or strict PL manifold) through incidence, connectivity, Euler-characteristic, and link checks. -4. **Level 4 β€” Delaunay property**: the embedded triangulation satisfies the local Delaunay +4. **Level 4 β€” embedding**: each maximal simplex is nondegenerate in the active affine chart, and + maximal simplices intersect only along their shared faces. Euclidean topology validates in the + ambient chart; toroidal topology validates in periodic covering-space charts. +5. **Level 5 β€” Delaunay property**: the embedded triangulation satisfies the local Delaunay predicates for its facets. -`Triangulation::is_valid()` is a Level 3 topology check. `DelaunayTriangulation::is_valid()` is a -Level 4 Delaunay-property check for an already-formed Delaunay triangulation. Cumulative validation -is exposed through the `validate` / `validation_report` APIs described in +`Triangulation::is_valid_topology()` is a Level 3 topology check. `DelaunayTriangulation::is_valid_delaunay()` is a +Level 5 Delaunay-property check for an already-formed Delaunay triangulation. `Triangulation` also +exposes Level 4 embedding validation through `is_valid_embedding` / `validate_embedding`. +Cumulative validation is exposed through the `validate` / `validation_report` APIs described in [`docs/validation.md`](validation.md). Automatic validation during construction is intentionally topology-oriented: `ValidationPolicy` -controls Level 3 checks during insertion, while Level 4 Delaunay validation remains an explicit -certification step for workflows that need it. +controls Level 3 checks during insertion, while Level 4 embedding and Level 5 Delaunay validation +remain explicit certification steps for workflows that need them. --- @@ -154,6 +159,27 @@ map, and test expectations. ## Geometric invariants +### Faithful embedding in affine charts + +Levels 1-3 validate the abstract oriented simplicial complex: elements, incidence, neighbor +reciprocity, coherent orientation, manifoldness, links, connectedness, and Euler consistency. These +checks do not by themselves prove that the complex is faithfully realized by its coordinates. A +topologically valid complex can still fold over itself, contain a zero-volume maximal simplex, or +identify simplices in a way that overlaps in the chosen geometric chart. + +Level 4 is the embedded-realization check. It is independent of the Delaunay predicate and enforces: + +- every maximal simplex has nonzero `D`-volume under the robust orientation predicate; +- every pair of maximal simplices intersects only in the face spanned by their shared vertices; +- toroidal triangulations are checked in periodic covering-space charts, including translated + images that can overlap across the fundamental-domain boundary. + +This is intentionally separate from topology. Non-orientable spaces are valid objects in topology in +general, but this crate's TDS contract maintains coherent orientation for the oriented complexes its +construction, flip, and predicate machinery operate on. Level 4 then asks whether that oriented +complex is faithfully embedded in the active affine chart. Spherical and hyperbolic topologies need +model-specific chart validators before they can offer the same Level 4 guarantee. + ### Delaunay condition (empty circumsphere property) A Delaunay triangulation is characterized by the **empty circumsphere** condition:[^deberg2008][^edelsbrunner2001] @@ -178,10 +204,11 @@ In practice, floating-point degeneracy matters: - For near-degenerate configurations, robust predicates (and/or retry/repair strategies) may be required to construct or certify the Delaunay property. -- Validation can be performed explicitly via the Level 4 Delaunay-property check - (`DelaunayTriangulation::is_valid`) when a workflow requires certainty. +- Validation can be performed explicitly via the Level 5 Delaunay-property check + (`DelaunayTriangulation::is_valid_delaunay`) when a workflow requires + certainty. -Internally, the crate’s Level 4 verifier prefers fast, local flip-based checks over the naive +Internally, the crate’s Level 5 verifier prefers fast, local flip-based checks over the naive O(simplices Γ— vertices) brute-force test. This reflects the standard theoretical relationship between Delaunay optimality and local flip predicates.[^edelshah1996][^impl-flips][^impl-delaunay-validation] @@ -433,7 +460,7 @@ The crate therefore treats flip/repair as a best-effort procedure with explicit - Prefer to validate Level 3 topology (`Triangulation::validate` / `TopologyGuarantee`) when running flip-heavy workflows. -- Validate the Delaunay property (Level 4) explicitly when inputs are near-degenerate. +- Validate the Delaunay property (Level 5) explicitly when inputs are near-degenerate. See the public API docs () and [`docs/workflows.md`](workflows.md) for practical guidance. @@ -491,7 +518,7 @@ For the project-wide bibliography (including references not cited here), see [`R [^pachner1991]: Udo Pachner. β€œP.L. Homeomorphic Manifolds Are Equivalent by Elementary Shellings.” *European Journal of Combinatorics* 12(2), 1991. DOI: . [^impl-flips]: Implementation: [src/core/algorithms/flips.rs](../src/core/algorithms/flips.rs). -[^impl-delaunay-validation]: Implementation: [src/core/util/delaunay_validation.rs](../src/core/util/delaunay_validation.rs). +[^impl-delaunay-validation]: Implementation: [src/delaunay/property_validation.rs](../src/delaunay/property_validation.rs). [^hatcher2002]: Allen Hatcher. *Algebraic Topology*. Cambridge University Press, 2002. Free online version: . (See Appendix A: β€œPL Manifolds and Links”.) [^rourke-sanderson]: C. P. Rourke and B. J. Sanderson. *Introduction to Piecewise-Linear Topology*. Springer, 1972. diff --git a/docs/limitations.md b/docs/limitations.md index d59a4973..e5232e54 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -73,7 +73,7 @@ Toroidal support has two modes: - `.try_toroidal([..])` uses the 3^D image-point method to construct a true periodic quotient with rewired neighbor pointers. This path is release covered in 2D and compact 3D, where periodic triangulations validate as - closed tori through Levels 1-4. 4D/5D periodic construction fails fast until + closed tori through Levels 1-5. 4D/5D periodic construction fails fast until issue #416 makes quotient selection scalable and diagnosable enough for release validation. @@ -100,7 +100,7 @@ release characterization, not a portable performance promise: - `just debug-large-scale-{2,3,4,5}d [n] [repair_every]` runs the same release-mode `slow-tests` harness shape across dimensions: deterministic point generation, batch construction, final flip repair, and `validation_report` - for Levels 1–4. + for Levels 1–5. - The `just` helper defaults are dimension-aware rather than identical: 2D defaults to 36,000 vertices, 3D defaults to 7,500 vertices, 4D defaults to 800 vertices, and 5D defaults to 140 vertices. Pass `n` explicitly when a @@ -125,12 +125,12 @@ Current 3D scale envelope: - `just debug-large-scale-3d 7500 1` is the current release-mode acceptance harness for the 7,500-vertex 3D path. - This helper is the default near-one-minute acceptance/profiling target for - final flip repair and `validation_report` coverage across Levels 1–4. + final flip repair and `validation_report` coverage across Levels 1–5. - Wall time is hardware- and load-sensitive. Recent Apple M4 Max-class local runs complete in roughly 56 seconds; treat that as an envelope, not a portable guarantee. - `just debug-large-scale-3d 10000 1` is a heavier characterization probe that - has also passed final Levels 1–4 validation; use it when the 10,000-vertex + has also passed final Levels 1–5 validation; use it when the 10,000-vertex envelope matters more than one-minute feedback. Current 4D scale envelope: diff --git a/docs/numerical_robustness_guide.md b/docs/numerical_robustness_guide.md index b66ef71f..b501a792 100644 --- a/docs/numerical_robustness_guide.md +++ b/docs/numerical_robustness_guide.md @@ -137,7 +137,7 @@ let vertices = vec![ let dt: DelaunayTriangulation, (), (), 3> = DelaunayTriangulation::try_with_kernel(&kernel, &vertices)?; -assert!(dt.is_valid().is_ok()); +assert!(dt.is_valid_delaunay().is_ok()); ``` ### Identity-based SoS perturbation via canonical vertex ordering @@ -243,10 +243,10 @@ You can also run repair manually: - `dt.repair_delaunay_with_flips()` - `dt.repair_delaunay_with_flips_advanced(DelaunayRepairHeuristicConfig::default())` -After construction (or repair), verify the Delaunay property via `dt.is_valid()` +After construction (or repair), verify the Delaunay property via `dt.is_valid_delaunay()` (which uses local flip predicates). -For full-stack diagnostics (Levels 1-4), use `dt.validate()` or `dt.validation_report()`; +For full-stack diagnostics (Levels 1-5), use `dt.validate()` or `dt.validation_report()`; see `docs/validation.md`. ### Exact circumcenter computation (v0.7.3+) diff --git a/docs/topology.md b/docs/topology.md index fab71c22..5fd4c14d 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -5,7 +5,7 @@ Level 3 manifold validation, Euler characteristic checks, and support for different topological spaces (Euclidean and toroidal are fully integrated; spherical and hyperbolic are currently scaffolded for future integration). -If you want the user-facing guide to the full validation stack (Levels 1–4), start +If you want the user-facing guide to the full validation stack (Levels 1–5), start with `docs/validation.md`. For the theoretical background and rationale behind the invariants themselves, see @@ -52,13 +52,13 @@ Notes: validated `Point` coordinates; exact-coordinate input, if added in the future, should be an explicit documented API rather than incidental generic support. -## Level 3 topology validation (`Triangulation::is_valid()`) +## Level 3 topology validation (`Triangulation::is_valid_topology()`) -`Triangulation::is_valid()` validates *topology-only* invariants (Level 3). It +`Triangulation::is_valid_topology()` validates *topology-only* invariants (Level 3). It intentionally does **not** validate lower layers (elements or TDS structure). For cumulative validation, use `Triangulation::validate()` (Levels 1–3) or -`DelaunayTriangulation::validate()` (Levels 1–4). +`DelaunayTriangulation::validate()` (Levels 1–5). ### Always-checked invariants @@ -93,7 +93,7 @@ Level 3 always checks: Implementation pointers: - Level 3 entry points and validation vocabulary: `src/core/validation.rs` - (`Triangulation::is_valid`, `Triangulation::validate`) + (`Triangulation::is_valid_topology`, `Triangulation::validate`) - Public manifold validators: `src/topology/manifold.rs` (`validate_closed_boundary`, `validate_vertex_links`, `validate_ridge_links`) - Internal raw-map reuse helpers: `src/topology/manifold.rs` diff --git a/docs/validation.md b/docs/validation.md index 1b004ff6..7978740e 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -11,12 +11,13 @@ snippets into an application. ## Overview -The library provides **four levels of validation**, each building on the previous level to provide increasingly comprehensive correctness guarantees: +The library provides **five levels of validation**, each building on the previous level to provide increasingly comprehensive correctness guarantees: 1. **Element Validity** - Basic data integrity 2. **TDS Structural Validity** - Combinatorial correctness 3. **Manifold Topology** - Topological properties -4. **Delaunay Property** - Geometric optimality +4. **Faithful Embedding** - Nondegenerate embedded simplices and no overlap outside shared faces +5. **Delaunay Property** - Geometric optimality ## Validation Hierarchy @@ -27,26 +28,52 @@ Level 2: TDS Structural Validity ↓ (called by) Level 3: Manifold Topology ↓ (independent) -Level 4: Delaunay Property +Level 4: Faithful Embedding + ↓ (independent) +Level 5: Delaunay Property ``` +## Validation API Pattern + +Each validation layer exposes the same public API shape when the layer can +support it without hiding useful failures: + +- **Fast-fail check**: `is_valid()` is used when the owner already identifies + the invariant scope (`Vertex`, `Simplex`, and `Tds`); `is_valid_*` is used + for higher-level owners with multiple validation layers. +- **Repair/retry diagnostic**: `*_diagnostic` returns the first actionable + layer-local diagnostic with keys, UUIDs, or other repair context where the + layer can provide it. +- **Aggregate report**: `*_report` collects every checkable layer-local failure. + If an early failure makes later checks meaningless, the report includes the + blocking failure rather than guessing at downstream errors. +- **Cumulative validation**: `validate()` and `validation_report()` roll lower + layers up through the owning abstraction. + +Report names identify the layer being checked: `structure_report`, +`topology_report`, `embedding_report`, and `delaunay_report`. + +Higher-level reports roll up lower-level diagnostics as structured enum values, +not strings. This keeps `DelaunayTriangulation::validation_report()` useful both +as a full audit and as input to future repair workflows. + --- ## Automatic validation during incremental insertion (`ValidationPolicy`) -The library always provides **explicit** validation APIs (Levels 1–4) that you can call when you need them. +The library always provides **explicit** validation APIs (Levels 1–5) that you can call when you need them. Separately, incremental construction (`new()` / `insert*()`) can run an **automatic** *Level 3* topology validation pass after an insertion attempt, controlled by a `ValidationPolicy` on the triangulation. -This is a performance vs certainty knob: Level 3 (`Triangulation::is_valid()`) is +This is a performance vs certainty knob: Level 3 (`Triangulation::is_valid_topology()`) is relatively expensive, so the default behavior is to validate only when something looks β€œoff”. ### What is validated automatically? -Only **Level 3** (`Triangulation::is_valid()`), using the triangulation’s current +Only **Level 3** (`Triangulation::is_valid_topology()`), using the triangulation’s current `TopologyGuarantee` (default: `PLManifold`): - Codimension-1 manifoldness (facet degree: 1 or 2 incident simplices per facet) @@ -60,8 +87,9 @@ Only **Level 3** (`Triangulation::is_valid()`), using the triangulation’s curr Note: neighbor-pointer consistency is a **Level 2** structural invariant checked by `Tds::is_valid()` / `Tds::validate()`, and is intentionally not part of Level 3. -Automatic validation does **not** run Level 4 (the Delaunay empty-circumsphere property). -If you need geometric verification, call `dt.is_valid()` or `dt.validate()` explicitly. +Automatic validation does **not** run Level 4 embedding validation or Level 5 Delaunay +empty-circumsphere validation. If you need geometric verification, call +`dt.as_triangulation().validate_embedding()`, `dt.is_valid_delaunay()`, or `dt.validate()` explicitly. ### Default: derived from `TopologyGuarantee` @@ -169,7 +197,7 @@ fn main() -> DelaunayResult<()> { dt.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); // Now Level 3 skips vertex-link validation entirely. - assert!(dt.as_triangulation().is_valid().is_ok()); + assert!(dt.as_triangulation().is_valid_topology().is_ok()); Ok(()) } ``` @@ -192,7 +220,7 @@ The library separates **construction-time** failures from **validation-time** in - `TriangulationConstructionError` (Level 3 construction): wraps `TdsConstructionError` and adds triangulation-layer failures (e.g. `GeometricDegeneracy`, `DuplicateCoordinates`, `InsufficientVertices`). -- `DelaunayTriangulationConstructionError` (Level 4 construction): wraps +- `DelaunayTriangulationConstructionError` (Level 5 construction): wraps `TriangulationConstructionError`. ### Validation errors (checking invariants) @@ -201,16 +229,18 @@ The library separates **construction-time** failures from **validation-time** in - `TriangulationValidationError` (Level 3): wraps `TdsError` and adds codimension-1 manifoldness + codimension-2 boundary manifoldness (closed boundary) + (optional) vertex-link PL-manifold checks + connectedness + isolated-vertex + Euler characteristic checks. -- `DelaunayTriangulationValidationError` (Level 4): wraps `TriangulationValidationError` and adds - the empty-circumsphere (Delaunay) checks. +- `TriangulationEmbeddingValidationError` (Level 4): wraps `TriangulationValidationError` and adds + nondegenerate-simplex and overlap checks in the active affine chart. +- `DelaunayTriangulationValidationError` (Level 5): wraps `TriangulationEmbeddingValidationError` + and adds the empty-circumsphere (Delaunay) checks. ### Reporting (full diagnostics) `DelaunayTriangulation::validation_report()` returns `Result<(), TriangulationValidationReport>`. On failure, the `Err(TriangulationValidationReport)` contains a `Vec`; each `InvariantViolation` stores an `InvariantKind` plus an `InvariantError` **enum** that wraps the -structured error from the failing layer (`TdsError`, `TriangulationValidationError`, or -`DelaunayTriangulationValidationError`). +structured error from the failing layer (`TdsError`, `TriangulationValidationError`, +`TriangulationEmbeddingValidationError`, or `DelaunayTriangulationValidationError`). --- @@ -222,8 +252,12 @@ Validates basic data integrity of individual vertices and simplices. ### Methods -- `Simplex::is_valid()` - Check if a simplex has valid structure -- `Vertex::is_valid()` - Check if a vertex has valid coordinates +- `Simplex::is_valid()` - Fast-fail simplex structure check +- `Simplex::simplex_diagnostic()` - First simplex repair/retry diagnostic +- `Simplex::simplex_report()` - Aggregate simplex validation report +- `Vertex::is_valid()` - Fast-fail vertex coordinate and UUID check +- `Vertex::vertex_diagnostic()` - First vertex repair/retry diagnostic +- `Vertex::vertex_report()` - Aggregate vertex validation report ### What It Checks @@ -264,8 +298,10 @@ Validates the combinatorial structure of the Triangulation Data Structure. ### Methods - `Tds::is_valid()` - Level 2 (structural) checks only (fast-fail). +- `Tds::structure_diagnostic()` - First actionable Level 2 diagnostic. +- `Tds::structure_report()` - All checkable Level 2 structural failures. - `Tds::validate()` - Levels 1–2 (elements + structural). -- `DelaunayTriangulation::validation_report()` - Cumulative diagnostic report across Levels 1–4. +- `DelaunayTriangulation::validation_report()` - Cumulative diagnostic report across Levels 1–5. ### What It Checks @@ -319,7 +355,7 @@ fn main() -> DelaunayResult<()> { // Quick structural check (Level 2) assert!(dt.tds().is_valid().is_ok()); - // Detailed report showing all violations across Levels 1–4 (on failure) + // Detailed report showing all violations across Levels 1–5 (on failure) match dt.validation_report() { Ok(()) => println!("βœ“ All invariants satisfied"), Err(report) => { @@ -334,7 +370,7 @@ fn main() -> DelaunayResult<()> { ### Diagnostics -For most users, start with `dt.tds().is_valid()` (fast-fail) or `dt.validation_report()` (full diagnostics across Levels 1–4). +For most users, start with `dt.tds().is_valid()` (fast-fail) or `dt.validation_report()` (full diagnostics across Levels 1–5). --- @@ -346,12 +382,14 @@ Validates that the triangulation forms a valid topological manifold. ### Methods -- `Triangulation::is_valid()` - Level 3 topology validation only. +- `Triangulation::is_valid_topology()` - Level 3 topology fast-fail validation only. +- `Triangulation::topology_diagnostic()` - First actionable Level 3 diagnostic. +- `Triangulation::topology_report()` - All checkable Level 3 topology failures. - `Triangulation::validate()` - Levels 1–3 (elements + structure + topology). ### What It Checks -`Triangulation::is_valid()` (Level 3) checks: +`Triangulation::is_valid_topology()` (Level 3) checks: 1. **Codimension-1 manifoldness (facet degree)**: Each facet belongs to exactly 1 simplex (boundary) or exactly 2 simplices (interior) - Stronger than Level 2's "≀2 simplices per facet" @@ -412,16 +450,93 @@ fn main() -> DelaunayResult<()> { --- -## Level 4: Delaunay Property +## Level 4: Faithful Embedding ### Purpose -Validates the geometric optimality of the triangulation. +Validates that the abstract triangulation is faithfully realized in the active affine chart. ### Methods -- `DelaunayTriangulation::is_valid()` - Level 4 Delaunay property only (fast verification via local flip predicates). -- `DelaunayTriangulation::validate()` - Levels 1–4 (elements + structure + topology + Delaunay property). +- `Triangulation::is_valid_embedding()` - Level 4 embedding fast-fail validation only. +- `Triangulation::embedding_diagnostic()` - First actionable Level 4 diagnostic. +- `Triangulation::embedding_report()` - Level 4 diagnostic report with offending + simplex and vertex keys/UUIDs. +- `Triangulation::validate_embedding()` - Levels 1–4 (elements + structure + topology + embedding). + +### What It Checks + +- **Nondegenerate maximal simplices**: every maximal simplex has nonzero `D`-volume by the robust + orientation predicate. +- **No overlap outside shared faces**: any two maximal simplices may intersect only in the face + spanned by their shared vertices. +- **Toroidal periodic images**: toroidal topology is checked in covering-space charts, including + periodic translates that can overlap across the fundamental-domain boundary. +- **Independent of Delaunay predicates**: Level 4 does not evaluate the empty-circumsphere property. + It catches invalid embedded realizations before Level 5 asks whether the faithful embedding is + Delaunay. + +Spherical and hyperbolic topologies currently return an unsupported-topology error for Level 4 until +model-specific chart validators are implemented. + +### Complexity + +- **Time**: O(simplicesΒ² Γ— f(D)) for pairwise simplex-intersection checks in fixed dimension, with + bounding-box pruning before exact rational barycentric witness construction. +- **Space**: O(DΒ²) to O(simplices) temporary space depending on the number of candidate overlaps. + +### When to Use + +- **Tests**: After construction or manual edits when embedded correctness matters. +- **Debug**: Investigating folded, self-overlapping, or zero-volume triangulations. +- **Before Delaunay certification**: Level 5 validation runs Level 4 first so Delaunay predicates are + evaluated only on a faithful embedded complex. +- **Repair planning**: `Triangulation::embedding_report()` reports simplex keys, UUIDs, shared + vertices, and witness vertices that can guide explicit rollback or deletion-based repair. The + validator itself is pure and does not delete vertices. + +### Example + +```rust +use delaunay::prelude::construction::{ + DelaunayResult, DelaunayTriangulationBuilder, vertex, +}; + +fn main() -> DelaunayResult<()> { + let vertices = vec![ + vertex![0.0, 0.0, 0.0]?, + vertex![1.0, 0.0, 0.0]?, + vertex![0.0, 1.0, 0.0]?, + vertex![0.0, 0.0, 1.0]?, + ]; + let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + + // Faithful embedding validation (Levels 1-4) + match dt.as_triangulation().validate_embedding() { + Ok(()) => println!("valid embedded triangulation"), + Err(e) => eprintln!("embedding violation: {}", e), + } + Ok(()) +} +``` + +--- + +## Level 5: Delaunay Property + +### Purpose + +Validates the geometric optimality of a faithfully embedded triangulation. + +### Methods + +- `DelaunayTriangulation::is_valid_delaunay()` - Level 5 Delaunay property only after Level 4 embedding +- `DelaunayTriangulation::delaunay_diagnostic()` - First actionable Level 5 diagnostic, including the + violating simplex, its vertices, neighbor slots, and an offending vertex when available. +- `DelaunayTriangulation::delaunay_report()` - All checkable Level 5 Delaunay failures with the same + repair-oriented detail where it can be reconstructed. +- `DelaunayTriangulation::validate()` - Levels 1–5 (elements + structure + topology + embedding + + Delaunay property). ### What It Checks @@ -429,7 +544,8 @@ Validates the geometric optimality of the triangulation. inverses), equivalent to the empty-circumsphere condition for properly constructed triangulations - Uses geometric predicates from the kernel (`insphere` test) -- **Independent of Levels 1-3**: Checks geometric property, not structural/topological +- **Layered after Levels 1-4**: `validate()` checks elements, structure, topology, and embedding + before the Delaunay predicate layer. - **Flip-based repair**: Insertions run k=2/k=3 flip repairs with inverse edge/triangle queues in higher dimensions by default. Delaunay validation can still fail if repair is disabled, if repair fails to converge, or if inputs are highly degenerate/duplicate-heavy. See @@ -442,9 +558,9 @@ Validates the geometric optimality of the triangulation. ### Complexity - **Time**: - - `DelaunayTriangulation::is_valid()` (Level 4 only): O(simplices) (local flip-predicate verification, for fixed D) - - `DelaunayTriangulation::validate()` (Levels 1–4): O(simplices Γ— DΒ²) + O(simplices) (typically dominated by Levels 1–3) - - `DelaunayTriangulation::validation_report()` (Levels 1–4): O(simplices Γ— DΒ²) + O(simplices) + - `DelaunayTriangulation::is_valid_delaunay()` (Level 5 only): O(simplices) local flip-predicate verification. + - `DelaunayTriangulation::validate()` (Levels 1–5): Levels 1-4 plus O(simplices) local flip-predicate verification. + - `DelaunayTriangulation::validation_report()` (Levels 1–5): Levels 1-4 plus O(simplices) local flip-predicate verification. - **Space**: O(1) additional space (aside from temporary working sets) ### When to Use @@ -471,8 +587,8 @@ fn main() -> DelaunayResult<()> { ]; let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; - // Delaunay property validation (Level 4) - match dt.is_valid() { + // Delaunay property validation (Level 5) + match dt.is_valid_delaunay() { Ok(()) => println!("βœ“ All simplices satisfy empty circumsphere property"), Err(e) => eprintln!("βœ— Delaunay violation: {}", e), } @@ -487,20 +603,23 @@ fn main() -> DelaunayResult<()> { ```text Start: Do you need to validate? β”‚ - β”œβ”€ Writing tests / CI? β†’ Always validate (start with Level 2; add Level 3/4 as needed) + β”œβ”€ Writing tests / CI? β†’ Always validate (start with Level 2; add Levels 3/4/5 as needed) β”‚ β”œβ”€ Just built triangulation? β”‚ β”œβ”€ Production hot path? β†’ Usually skip (but validate during integration testing / when debugging) - β”‚ └─ Need certainty? β†’ Validate (Level 2 or 3; add Level 4 if geometry matters) + β”‚ └─ Need certainty? β†’ Validate (Level 2 or 3; add Level 4 if embedding matters, Level 5 if Delaunay matters) β”‚ β”œβ”€ After manual TDS mutation? β†’ Level 2 (`dt.tds().is_valid()`) β”‚ - β”œβ”€ Debugging geometric issues? β†’ Level 4 (`dt.is_valid()`) + β”œβ”€ Debugging embedded-geometry issues? β†’ Level 4 (`dt.as_triangulation().validate_embedding()`) + β”‚ + β”œβ”€ Debugging Delaunay issues? β†’ Level 5 (`dt.is_valid_delaunay()`) β”‚ β”œβ”€ Production validation? β”‚ β”œβ”€ Performance critical? β†’ Level 2 (`dt.tds().is_valid()`) - β”‚ β”œβ”€ Topological correctness critical? β†’ Level 3 (`dt.as_triangulation().is_valid()`) - β”‚ └─ Geometric correctness critical? β†’ Level 4 (`dt.is_valid()`) + β”‚ β”œβ”€ Topological correctness critical? β†’ Level 3 (`dt.as_triangulation().is_valid_topology()`) + β”‚ β”œβ”€ Embedded correctness critical? β†’ Level 4 (`dt.as_triangulation().validate_embedding()`) + β”‚ └─ Delaunay correctness critical? β†’ Level 5 (`dt.is_valid_delaunay()`) β”‚ └─ Paranoid mode? β†’ All levels (`dt.validate()`) ``` @@ -510,28 +629,31 @@ Start: Do you need to validate? ## Performance notes - Level 2 and Level 3 validation are dominated by combinatorial bookkeeping (roughly O(simplices Γ— DΒ²)). -- Level 4 `DelaunayTriangulation::is_valid()` verifies the Delaunay property via local flip predicates and is - roughly O(simplices) for fixed `D`. -- A brute-force empty-circumsphere check would be O(simplices Γ— vertices) and is not used by `is_valid()`. - -In practice, `DelaunayTriangulation::validate()` is usually dominated by Level 3 (topology) work. +- Level 4 embedding validation checks simplex degeneracy and pairwise embedded intersections, using + bounding boxes before exact rational witness construction. +- Level 5 `DelaunayTriangulation::is_valid_delaunay()` verifies the Delaunay property via local flip predicates after + Level 4 embedding validation. +- A brute-force empty-circumsphere check would be O(simplices Γ— vertices) and is not used by `is_valid_delaunay()`. + +In practice, `DelaunayTriangulation::validate()` is usually dominated by Level 3 topology work or +Level 4 pairwise embedding checks, depending on mesh size and overlap candidates. As a post-construction acceptance check, the current 7,500-vertex 3D large-scale debug harness is the default near-one-minute `validation_report` run for Levels -1–4; on maintainer Apple M4 Max hardware the final report itself is a +1–5; on maintainer Apple M4 Max hardware the final report itself is a low-single-digit-second step. The explicit 10,000-vertex 3D run is a heavier characterization probe that has -also passed Levels 1–4 validation, but it is not the default local acceptance +also passed Levels 1–5 validation, but it is not the default local acceptance helper. --- ## Common Patterns -`Triangulation::is_valid()` returns `InvariantError`, the public wrapper enum +`Triangulation::is_valid_topology()` returns `InvariantError`, the public wrapper enum used for validation failures across Levels 1–4. Its variants preserve the failing layer's typed error: `TdsError` for Levels 1–2, `TriangulationValidationError` for Level 3 topology failures, and -`DelaunayTriangulationValidationError` for Level 4 Delaunay failures. In normal +`TriangulationEmbeddingValidationError` for Level 4 embedding failures. In normal Level 3 code, handle the wrapper as shown in Patterns 2 and 3 rather than expecting `TriangulationValidationError` directly. @@ -546,10 +668,11 @@ fn test_my_triangulation_operation() { my_operation(&mut dt); // Validate at appropriate level - assert!(dt.tds().is_valid().is_ok()); // Level 2: Structural - assert!(dt.as_triangulation().is_valid().is_ok()); // Level 3: Topology - assert!(dt.is_valid().is_ok()); // Level 4: Delaunay property - assert!(dt.validate().is_ok()); // Levels 1–4: Full validation + assert!(dt.tds().is_valid().is_ok()); // Level 2: Structural + assert!(dt.as_triangulation().is_valid_topology().is_ok()); // Level 3: Topology + assert!(dt.as_triangulation().validate_embedding().is_ok()); // Level 4: Faithful embedding + assert!(dt.is_valid_delaunay().is_ok()); // Level 5: Delaunay property + assert!(dt.validate().is_ok()); // Levels 1–5: Full validation } ``` @@ -575,7 +698,7 @@ pub fn my_algorithm( #[cfg(debug_assertions)] { dt.tds().is_valid()?; - dt.as_triangulation().is_valid()?; + dt.as_triangulation().is_valid_topology()?; } Ok(()) @@ -587,7 +710,9 @@ pub fn my_algorithm( ```rust use delaunay::prelude::query::*; use delaunay::prelude::tds::{InvariantError, TdsError}; -use delaunay::DelaunayTriangulationValidationError; +use delaunay::{ + DelaunayTriangulationValidationError, TriangulationEmbeddingValidationError, +}; #[derive(Debug, thiserror::Error)] pub enum ValidationLevelError { @@ -596,8 +721,10 @@ pub enum ValidationLevelError { #[error(transparent)] Topology(#[from] InvariantError), #[error(transparent)] + Embedding(#[from] TriangulationEmbeddingValidationError), + #[error(transparent)] Delaunay(#[from] DelaunayTriangulationValidationError), - #[error("unsupported validation level {level}; expected 2, 3, or 4")] + #[error("unsupported validation level {level}; expected 2, 3, 4, or 5")] UnsupportedLevel { level: u8 }, } @@ -609,9 +736,13 @@ pub fn validate_with_level( 2 => dt.tds().is_valid().map_err(ValidationLevelError::from), 3 => dt .as_triangulation() - .is_valid() + .is_valid_topology() + .map_err(ValidationLevelError::from), + 4 => dt + .as_triangulation() + .validate_embedding() .map_err(ValidationLevelError::from), - 4 => dt.is_valid().map_err(ValidationLevelError::from), + 5 => dt.is_valid_delaunay().map_err(ValidationLevelError::from), _ => Err(ValidationLevelError::UnsupportedLevel { level }), } } @@ -638,6 +769,15 @@ ensure no isolated vertices, and verify the simplex neighbor graph is connected ### Validation Passes Level 3, Fails at Level 4 +**Problem**: Embedded simplex degeneracy or overlap outside shared faces +**Likely Cause**: Folded realization, duplicate/collinear/coplanar coordinates, +or a toroidal periodic image that overlaps across the fundamental-domain boundary +**Fix**: Inspect the embedding error's simplex keys, UUIDs, shared vertices, and witness vertices. +If this happened during insertion, rollback or explicit deletion-based repair can use those witnesses, +but the validator itself does not mutate the triangulation. + +### Validation Passes Level 4, Fails at Level 5 + **Problem**: Delaunay property violated (vertex inside circumsphere) **Likely Cause**: Repair disabled or non-convergent, geometric degeneracy, numerical precision, or missing higher-dimensional flip coverage @@ -653,15 +793,17 @@ converge, consider the opt-in heuristic rebuild fallback via | Level | Method | Module | Complexity | |-------|--------|--------|------------| -| 1 | `Simplex::is_valid()` | `tds` | O(1) | -| 1 | `Vertex::is_valid()` | `tds` | O(1) | +| 1 | `Simplex::is_valid()` / `simplex_report()` | `tds` | O(1) | +| 1 | `Vertex::is_valid()` / `vertex_report()` | `tds` | O(1) | | 2 | `Tds::is_valid()` | `tds` | O(NΓ—DΒ²) | | 2 | `Tds::validate()` | `tds` | O(NΓ—DΒ²) | -| 3 | `Triangulation::is_valid()` | `triangulation` | O(NΓ—DΒ²) | +| 3 | `Triangulation::is_valid_topology()` | `triangulation` | O(NΓ—DΒ²) | | 3 | `Triangulation::validate()` | `triangulation` | O(NΓ—DΒ²) | -| 4 | `DelaunayTriangulation::is_valid()` | `delaunay` | O(simplices) | -| 4 | `DelaunayTriangulation::validate()` | `delaunay` | O(simplices Γ— DΒ²) + O(simplices) | -| β€” | `DelaunayTriangulation::validation_report()` | `delaunay` | O(simplices Γ— DΒ²) + O(simplices) | +| 4 | `Triangulation::is_valid_embedding()` | `triangulation` | O(simplicesΒ² Γ— f(D)) | +| 4 | `Triangulation::validate_embedding()` | `triangulation` | O(simplices Γ— DΒ²) + O(simplicesΒ² Γ— f(D)) | +| 5 | `DelaunayTriangulation::is_valid_delaunay()` | `delaunay` | O(simplices) | +| 5 | `DelaunayTriangulation::validate()` | `delaunay` | Levels 1-4 + O(simplices) | +| β€” | `DelaunayTriangulation::validation_report()` | `delaunay` | Levels 1-4 + O(simplices) | --- diff --git a/docs/workflows.md b/docs/workflows.md index 95aae013..3a6bf145 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -31,7 +31,8 @@ fn main() -> DelaunayResult<()> { let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; // Optional verification (see docs/validation.md for when to use each): - assert!(dt.is_valid().is_ok()); // Level 4 only (Delaunay property) + assert!(dt.as_triangulation().validate_embedding().is_ok()); // Levels 1-4 (faithful embedding) + assert!(dt.is_valid_delaunay().is_ok()); // Level 5 only (Delaunay property) Ok(()) } ``` @@ -83,7 +84,7 @@ flip-based repair passes during construction. Batch construction uses `Construct default repair cadence is `DelaunayRepairPolicy::EveryInsertion` plus final repair/validation. That cadence reflects the current #341 3D scale acceptance path: the release-mode `just debug-large-scale-3d 7500 1` harness is the current roughly one-minute -maintainer-hardware envelope for final Levels 1–4 validation. The explicit +maintainer-hardware envelope for final Levels 1–5 validation. The explicit `just debug-large-scale-3d 10000 1` run is a heavier characterization probe that has also passed the same final validation checks. Direct incremental insertion keeps the lower-level `DelaunayRepairPolicy` default at `EveryInsertion`. @@ -403,7 +404,7 @@ For guidance on retry/skip behavior and choosing `RobustKernel`, see Vertex deletion is supported and preserves Levels 1–3. It uses an inverse k=1 fast path when possible and fan retriangulation otherwise, then runs flip-based Delaunay repair when the active `DelaunayRepairPolicy` allows it. If automatic repair is disabled, deletion still runs Level 4 -validation and rolls back on any Delaunay violation. If post-deletion repair, validation, or +embedding validation and Level 5 Delaunay validation, rolling back on any violation. If post-deletion repair, validation, or orientation canonicalization fails, the operation rolls back to the pre-deletion triangulation. ```rust @@ -444,15 +445,15 @@ fn main() -> Result<(), DeletionExampleError> { // If automatic repair is enabled, successful deletion has already attempted to // restore the Delaunay property. - assert!(dt.is_valid().is_ok()); + assert!(dt.is_valid_delaunay().is_ok()); Ok(()) } ``` When automatic repair fails after the mutation, `delete_vertex` reports `DeleteVertexError::InvariantViolation { source: -InvariantError::Delaunay(DelaunayTriangulationValidationError::RepairOperationFailed { operation: -DelaunayRepairOperation::VertexRemoval, source }) }`, preserving the underlying +Box::new(InvariantError::Delaunay(DelaunayTriangulationValidationError::RepairOperationFailed { +operation: DelaunayRepairOperation::VertexRemoval, source })) }`, preserving the underlying `DelaunayRepairError` for callers that need to inspect the exact repair failure. Successful deletions invalidate internal locate hints so stale simplex handles are not reused. The spatial index is retained, but the deleted vertex entry is @@ -518,7 +519,7 @@ fn main() -> Result<(), FlipExampleError> { // If you need Delaunay after edits (requires K: ExactPredicates): // dt.repair_delaunay_with_flips()?; - // assert!(dt.is_valid().is_ok()); + // assert!(dt.is_valid_delaunay().is_ok()); Ok(()) } ``` diff --git a/examples/delaunayize_repair.rs b/examples/delaunayize_repair.rs index 3e33212c..6cecd08c 100644 --- a/examples/delaunayize_repair.rs +++ b/examples/delaunayize_repair.rs @@ -96,7 +96,7 @@ fn already_delaunay_3d() -> Result<(), DelaunayizeRepairExampleError> { print_outcome(&outcome); dt.validate()?; - println!(" βœ“ Full validation (Levels 1–4) passed"); + println!(" βœ“ Full validation (Levels 1–5) passed"); Ok(()) } @@ -115,7 +115,6 @@ fn already_delaunay_4d() -> Result<(), DelaunayizeRepairExampleError> { vertex![0.0, 1.0, 0.0, 0.0]?, vertex![0.0, 0.0, 1.0, 0.0]?, vertex![0.0, 0.0, 0.0, 1.0]?, - vertex![0.25, 0.25, 0.25, 0.25]?, ]; let mut dt: DelaunayTriangulation<_, (), (), 4> = DelaunayTriangulation::try_new(&vertices)?; @@ -129,7 +128,7 @@ fn already_delaunay_4d() -> Result<(), DelaunayizeRepairExampleError> { print_outcome(&outcome); dt.validate()?; - println!(" βœ“ Full validation (Levels 1–4) passed"); + println!(" βœ“ Full validation (Levels 1–5) passed"); Ok(()) } @@ -178,7 +177,9 @@ fn flip_then_repair_2d() -> Result<(), DelaunayizeRepairExampleError> { let mut violating_facet = None; for facet in facets { let mut trial = dt.clone(); - if trial.attempt_pachner(PachnerMove::K2 { facet }).is_ok() && trial.is_valid().is_err() { + if trial.attempt_pachner(PachnerMove::K2 { facet }).is_ok() + && trial.is_valid_delaunay().is_err() + { violating_facet = Some(facet); break; } @@ -191,7 +192,7 @@ fn flip_then_repair_2d() -> Result<(), DelaunayizeRepairExampleError> { let selected_flip = dt.attempt_pachner(PachnerMove::K2 { facet })?; assert!(!selected_flip.new_simplices.is_empty()); - match dt.is_valid() { + match dt.is_valid_delaunay() { Ok(()) => { println!( " Applied selected k=2 flip, but Delaunay property remained satisfied (unexpected)" diff --git a/examples/diagnostics.rs b/examples/diagnostics.rs index 0d20213f..2e617577 100644 --- a/examples/diagnostics.rs +++ b/examples/diagnostics.rs @@ -105,7 +105,7 @@ fn report_non_delaunay_triangulation() -> Result<(), DiagnosticsExampleError> { ); assert!(!report.is_valid()); - if let Some(detail) = &report.first_violation { + if let Some(detail) = report.first_violation() { println!(" first violating simplex: {:?}", detail.simplex_key); println!(" simplex vertex count: {}", detail.simplex_vertices.len()); println!(" offending external vertex: {:?}", detail.offending_vertex); @@ -145,7 +145,7 @@ fn build_non_delaunay_triangulation_2d() if trial.attempt_pachner(PachnerMove::K2 { facet }).is_ok() && trial.as_triangulation().validate().is_ok() && matches!( - trial.is_valid(), + trial.is_valid_delaunay(), Err(DelaunayTriangulationValidationError::VerificationFailed { .. }) ) { diff --git a/examples/topology_editing.rs b/examples/topology_editing.rs index a4715943..36a04857 100644 --- a/examples/topology_editing.rs +++ b/examples/topology_editing.rs @@ -251,7 +251,7 @@ fn pachner_2d_k2() -> ExampleResult { println!("Initial square (2 triangles):"); print_stats_2d(&dt); - let initial_valid = dt.is_valid().is_ok(); + let initial_valid = dt.is_valid_delaunay().is_ok(); println!( " Initial Delaunay: {}", if initial_valid { "βœ“" } else { "⚠️" } @@ -271,7 +271,7 @@ fn pachner_2d_k2() -> ExampleResult { println!(" Inserted: {} simplices", flip_info.new_simplices.len()); // Check if Delaunay property changed - let after_valid = dt.is_valid().is_ok(); + let after_valid = dt.is_valid_delaunay().is_ok(); println!( " Delaunay after flip: {}", if after_valid { "βœ“" } else { "⚠️" } @@ -328,7 +328,7 @@ fn builder_api_3d() -> ExampleResult { // Insert vertices using Builder API println!("Inserting 2 vertices using Builder API:"); - let new_vertices = vec![vertex![1.0, 0.5, 0.5]?, vertex![0.8, 0.8, 0.8]?]; + let new_vertices = vec![vertex![1.0, 0.5, 0.5]?, vertex![1.0, 0.9, 0.8]?]; for (i, v) in new_vertices.into_iter().enumerate() { dt.insert_vertex(v)?; diff --git a/scripts/tests/test_readme_citation_mirror.py b/scripts/tests/test_readme_citation_mirror.py new file mode 100644 index 00000000..536cafe1 --- /dev/null +++ b/scripts/tests/test_readme_citation_mirror.py @@ -0,0 +1,46 @@ +"""Tests for README and CITATION.cff documentation coupling.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +README = ROOT / "README.md" +CITATION = ROOT / "CITATION.cff" + + +def normalize_prose(text: str) -> str: + """Normalize prose for README/CITATION mirror comparison.""" + text = re.sub(r"\[([^\]]+)\]\[[^\]]+\]", r"\1", text) + text = re.sub(r"\[([^\]]+)\]", r"\1", text) + return " ".join(text.split()) + + +def read_readme_introduction_first_paragraph() -> str: + """Read the first paragraph under the README introduction heading.""" + readme_text = README.read_text(encoding="utf-8") + match = re.search( + r"^## .*Introduction\n\n(?P.+?)(?:\n\n)", + readme_text, + flags=re.MULTILINE | re.DOTALL, + ) + assert match is not None, "README.md must contain an Introduction paragraph" + return normalize_prose(match.group("paragraph")) + + +def read_citation_abstract() -> str: + """Read the folded block scalar used for CITATION.cff abstract text.""" + citation_text = CITATION.read_text(encoding="utf-8") + match = re.search( + r"^abstract: >-\n(?P(?: .+\n)+)", + citation_text, + flags=re.MULTILINE, + ) + assert match is not None, "CITATION.cff must contain an abstract block scalar" + return normalize_prose("\n".join(line.strip() for line in match.group("body").splitlines())) + + +def test_citation_abstract_mirrors_readme_introduction_first_paragraph() -> None: + """CITATION.cff abstract should mirror README.md's Introduction paragraph.""" + assert read_citation_abstract() == read_readme_introduction_first_paragraph() diff --git a/semgrep.yaml b/semgrep.yaml index 1d3a64f6..1e185e75 100644 --- a/semgrep.yaml +++ b/semgrep.yaml @@ -169,7 +169,12 @@ rules: triangulation snapshots and public error payloads should remain Vec. paths: include: - - "/src/**/*.rs" + - "/src/core/vertex.rs" + - "/src/core/simplex.rs" + - "/src/core/tds/validation.rs" + - "/src/core/validation.rs" + - "/src/core/embedding.rs" + - "/src/delaunay/validation.rs" - "/tests/semgrep/src/project_rules/**/*.rs" pattern-regex: >- \b(?:(?:(?:pending|repair|seed|soft_fail|touched|affected|frontier|sample)_[A-Za-z0-9_]*|conflict_preview|(?:new|removed)_simplices)\s*:\s*(?:&\s*mut\s*)?Vec\s*<\s*SimplexKey\s*>|let\s+(?:mut\s+)?(?:(?:pending|repair|seed|soft_fail|touched|affected|frontier|sample)_[A-Za-z0-9_]*|conflict_preview|(?:new|removed)_simplices)\s*=\s*Vec\s*::\s*(?:(?:<\s*SimplexKey\s*>\s*::\s*)?)(?:new|with_capacity)\s*\() @@ -416,6 +421,31 @@ rules: ... } + - id: delaunay.rust.validation-api-naming-standard + languages: + - generic + severity: WARNING + message: >- + Public validation APIs above the unambiguous element/TDS layers must + follow the v0.8 naming standard: is_valid_* for layer-local fast-fail + checks, *_diagnostic for first repair/retry diagnostics, *_report for + layer-local aggregate reports, and validate()/validation_report() for + cumulative roll-up. + metadata: + category: correctness + rationale: >- + Higher validation layers should advertise whether a method is + fast-fail, first-diagnostic, layer-local aggregate, or cumulative. + Bare is_valid() is reserved for unambiguous element and TDS owners. + paths: + include: + - "/src/core/validation.rs" + - "/src/core/embedding.rs" + - "/src/delaunay/validation.rs" + - "/tests/semgrep/src/project_rules/**/*.rs" + pattern-regex: >- + (?m)^\s*pub\s+fn\s+(?:is_valid\s*\(|[A-Za-z0-9_]+_validation_report\s*\() + - id: delaunay.rust.no-public-surface-unwrap-panic languages: - rust @@ -1588,6 +1618,30 @@ rules: - pattern-regex: '(?ms)^\s*(?:@echo\s+"?\s*)?just\s+toml-fix\b[^\n]*(?:\n\s*(?:#[^\n]*)?)*?\n\s*(?:@echo\s+"?\s*)?just\s+toml-check\b' # yamllint disable-line rule:line-length - pattern-regex: '(?ms)^\s*(?:@echo\s+"?\s*)?just\s+shell-fix\b[^\n]*(?:\n\s*(?:#[^\n]*)?)*?\n\s*(?:@echo\s+"?\s*)?just\s+shell-check\b' # yamllint disable-line rule:line-length + - id: delaunay.docs.no-stale-four-level-validation-hierarchy + languages: + - regex + severity: WARNING + message: "Validation documentation should describe the current five-level hierarchy." + metadata: + category: maintainability + rationale: >- + The validation stack now separates Level 4 embedding checks from Level 5 + Delaunay predicates. Public docs and citation prose should not keep the + old four-level hierarchy wording. + paths: + include: + - "/README.md" + - "/CITATION.cff" + - "/docs/**/*.md" + - "/src/**/*.rs" + - "/tests/semgrep/docs/**/*.md" + exclude: + - "/docs/archive/**" + pattern-regex: >- + (?i)(?:\b4-level validation hierarchy\b|\bfour levels of validation\b|\blevel 4:\s*delaunay + property\b|\bdelaunay property\s*\(level 4\)|\bdelaunay level 4 validation\b) + - id: delaunay.github-actions.external-action-sha-pinned languages: - regex diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index e327ba61..ca95df54 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -3246,6 +3246,9 @@ pub enum FlipNeighborDelaunayValidationFailureKind { /// Lower-layer topology validation failed. #[error("triangulation")] Triangulation, + /// Embedded-geometry validation failed. + #[error("embedding")] + Embedding, /// Delaunay verification failed. #[error("verification failed")] VerificationFailed, @@ -3259,6 +3262,7 @@ impl From<&DelaunayTriangulationValidationError> for FlipNeighborDelaunayValidat match source { DelaunayTriangulationValidationError::Tds(_) => Self::Tds, DelaunayTriangulationValidationError::Triangulation(_) => Self::Triangulation, + DelaunayTriangulationValidationError::Embedding(_) => Self::Embedding, DelaunayTriangulationValidationError::VerificationFailed { .. } => { Self::VerificationFailed } @@ -7089,7 +7093,7 @@ where /// Verify the Delaunay property via local flip predicates for a full triangulation. /// -/// This is the preferred Level 4 validation entry point because it carries the +/// This is the preferred Level 5 validation entry point because it carries the /// triangulation's global topology alongside the TDS. For periodic topologies /// (e.g. toroidal), insphere predicates are evaluated in lifted coordinates so /// that facets spanning periodic boundaries are not reported as false violations. diff --git a/src/core/algorithms/incremental_insertion.rs b/src/core/algorithms/incremental_insertion.rs index 57391106..69840282 100644 --- a/src/core/algorithms/incremental_insertion.rs +++ b/src/core/algorithms/incremental_insertion.rs @@ -1064,7 +1064,7 @@ pub enum InsertionErrorSourceKind { Tds(TdsErrorKind), /// Triangulation-layer topology validation failed. Triangulation(TriangulationValidationErrorKind), - /// Level 4 Delaunay validation failed. + /// Level 5 Delaunay validation failed. Delaunay(DelaunayValidationErrorKind), /// Flip repair failed. DelaunayRepair(DelaunayRepairErrorKind), @@ -1616,10 +1616,10 @@ pub enum InsertionError { /// Global Delaunay validation failed after insertion. /// /// This indicates the triangulation is structurally valid but violates the - /// empty-circumsphere property (Level 4). + /// empty-circumsphere property (Level 5). #[error("Delaunay validation failed: {source}")] DelaunayValidationFailed { - /// The structured Level 4 validation error. + /// The structured Level 5 validation error. #[source] source: DelaunayTriangulationValidationError, }, @@ -2007,7 +2007,7 @@ impl InsertionError { | TriangulationValidationErrorKind::OrientationPromotionNonConvergence | TriangulationValidationErrorKind::IsolatedVertex ), - InvariantError::Delaunay(_) => false, + InvariantError::Embedding(_) | InvariantError::Delaunay(_) => false, } } diff --git a/src/core/construction.rs b/src/core/construction.rs index 96e45fcd..ef8d88f2 100644 --- a/src/core/construction.rs +++ b/src/core/construction.rs @@ -64,7 +64,7 @@ impl std::fmt::Display for FinalTopologyValidationContext { } } -/// Classifies the construction phase that failed final Level 4 validation. +/// Classifies the construction phase that failed final Level 5 Delaunay validation. /// /// This context is carried by /// [`TriangulationConstructionError::FinalDelaunayValidation`] so callers can @@ -73,9 +73,9 @@ impl std::fmt::Display for FinalTopologyValidationContext { #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum FinalDelaunayValidationContext { - /// Standard final Level 4 Delaunay validation after construction. + /// Standard final Level 5 Delaunay validation after construction. ConstructionFinalize, - /// Final Level 4 Delaunay validation for a periodic quotient. + /// Final Level 5 Delaunay validation for a periodic quotient. PeriodicQuotientDelaunay, } @@ -86,7 +86,7 @@ impl std::fmt::Display for FinalDelaunayValidationContext { f.write_str("Delaunay validation failed after construction") } Self::PeriodicQuotientDelaunay => { - f.write_str("periodic quotient failed final Level 4 Delaunay validation") + f.write_str("periodic quotient failed final Level 5 Delaunay validation") } } } @@ -500,7 +500,7 @@ pub enum TriangulationConstructionError { reason: HullExtensionReason, }, - /// Level 4 Delaunay validation failed during incremental construction. + /// Level 5 Delaunay validation failed during incremental construction. #[error("Delaunay validation failed during insertion: {source}")] InsertionDelaunayValidation { /// Underlying Delaunay validation error. @@ -782,7 +782,7 @@ mod tests { ); assert_eq!( FinalDelaunayValidationContext::PeriodicQuotientDelaunay.to_string(), - "periodic quotient failed final Level 4 Delaunay validation" + "periodic quotient failed final Level 5 Delaunay validation" ); } diff --git a/src/core/embedding.rs b/src/core/embedding.rs new file mode 100644 index 00000000..5afe6093 --- /dev/null +++ b/src/core/embedding.rs @@ -0,0 +1,1676 @@ +//! Embedded-geometry validation for generic triangulations. +//! +//! This module owns Level 4 validation for generic [`Triangulation`](crate::Triangulation): +//! after the TDS and topology layers have certified a valid oriented simplicial +//! complex, the embedding layer verifies that maximal simplices are nondegenerate +//! and intersect only in their shared faces in the topology's active affine chart. + +#![forbid(unsafe_code)] + +use crate::core::collections::{ + FastHashSet, MAX_PRACTICAL_DIMENSION_SIZE, SimplexVertexKeyBuffer, SimplexVertexUuidBuffer, + SmallBuffer, +}; +use crate::core::simplex::Simplex; +use crate::core::tds::{InvariantError, InvariantKind, SimplexKey, Tds, TdsError, VertexKey}; +use crate::core::traits::data_type::DataType; +use crate::core::triangulation::Triangulation; +use crate::core::validation::TriangulationValidationError; +use crate::geometry::embedding::{ + LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, PeriodicSimplexSpanError, + SimplexIntersectionFailure, axis_aligned_bounding_boxes_overlap, coordinate_range_for_axis, + try_periodic_simplex_span, validate_simplex_embeddings_intersect_only_in_shared_faces, +}; +use crate::geometry::kernel::Kernel; +use crate::geometry::point::Point; +use crate::geometry::predicates::Orientation; +use crate::geometry::robust_predicates::robust_orientation; +use crate::geometry::traits::coordinate::{ + CoordinateConversionError, CoordinateValidationError, InvalidCoordinateValue, +}; +use crate::topology::traits::global_topology_model::{ + GlobalTopologyModel, GlobalTopologyModelError, +}; +use crate::topology::traits::topological_space::TopologyKind; +use num_traits::ToPrimitive; +use thiserror::Error; +use uuid::Uuid; + +/// Key- and UUID-based snapshot of one embedded simplex. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TriangulationEmbeddingSimplexDetail { + /// Simplex key at validation time. + pub key: SimplexKey, + /// Simplex UUID at validation time. + pub uuid: Uuid, + /// Vertex keys stored by the simplex at validation time. + pub vertices: SimplexVertexKeyBuffer, + /// Vertex UUIDs stored by the simplex at validation time. + pub vertex_uuids: SimplexVertexUuidBuffer, +} + +/// Key- and UUID-based snapshot of one embedded simplex pair. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TriangulationEmbeddingSimplexPairDetail { + /// First simplex in the pair. + pub first_simplex: TriangulationEmbeddingSimplexDetail, + /// Second simplex in the pair. + pub second_simplex: TriangulationEmbeddingSimplexDetail, +} + +/// Detailed witness for an illegal embedded-simplex intersection. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TriangulationEmbeddingIntersectionDetail { + /// First simplex in the violating pair. + pub first_simplex: TriangulationEmbeddingSimplexDetail, + /// Second simplex in the violating pair. + pub second_simplex: TriangulationEmbeddingSimplexDetail, + /// Vertices shared by both simplices. + pub shared_vertices: SimplexVertexKeyBuffer, + /// UUIDs of vertices shared by both simplices. + pub shared_vertex_uuids: SimplexVertexUuidBuffer, + /// First-simplex vertices with positive barycentric weight at the witness. + pub first_only_witness_vertices: SimplexVertexKeyBuffer, + /// UUIDs of first-simplex vertices with positive barycentric weight at the witness. + pub first_only_witness_vertex_uuids: SimplexVertexUuidBuffer, + /// Second-simplex vertices with positive barycentric weight at the witness. + pub second_only_witness_vertices: SimplexVertexKeyBuffer, + /// UUIDs of second-simplex vertices with positive barycentric weight at the witness. + pub second_only_witness_vertex_uuids: SimplexVertexUuidBuffer, +} + +/// Invalid periodic-domain period observed during Level 4 embedding validation. +#[derive(Clone, Debug, Error, PartialEq)] +#[non_exhaustive] +pub enum PeriodicDomainPeriodError { + /// A period was NaN or infinite. + #[error("non-finite periodic domain period at axis {axis}: {period}")] + NonFinitePeriod { + /// Periodic axis with the invalid period. + axis: usize, + /// Classified invalid period value. + period: InvalidCoordinateValue, + }, + /// A finite period was zero or negative. + #[error("non-positive periodic domain period at axis {axis}: {period}")] + NonPositivePeriod { + /// Periodic axis with the invalid period. + axis: usize, + /// Raw finite non-positive period. + period: f64, + }, +} + +impl From for PeriodicDomainPeriodError { + fn from(source: PeriodicSimplexSpanError) -> Self { + match source { + PeriodicSimplexSpanError::NonFinitePeriod { axis, period } => { + Self::NonFinitePeriod { axis, period } + } + PeriodicSimplexSpanError::NonPositivePeriod { axis, period } => { + Self::NonPositivePeriod { axis, period } + } + } + } +} + +/// Errors returned by embedded-geometry validation (Level 4). +/// +/// This error type is independent of the Delaunay empty-circumsphere predicate: +/// it certifies that the generic triangulation is faithfully embedded in the +/// topology's supported affine chart before any Delaunay-specific predicate is +/// evaluated. +#[derive(Clone, Debug, Error, PartialEq)] +#[non_exhaustive] +pub enum TriangulationEmbeddingValidationError { + /// Lower-layer element or TDS structural validation failed (Levels 1-2). + #[error(transparent)] + Tds(Box), + + /// Lower-layer topology validation failed (Level 3). + #[error(transparent)] + Triangulation(Box), + + /// Embedded-overlap validation is not yet defined for this topology model. + #[error( + "embedded validation is unsupported for {topology:?} topology in dimension {dimension}" + )] + UnsupportedTopology { + /// Topology kind configured on the triangulation. + topology: TopologyKind, + /// Const-generic coordinate dimension. + dimension: usize, + }, + + /// Topology-specific coordinate lifting failed while preparing an embedded simplex. + #[error( + "topology-specific lifting failed for simplex {simplex_uuid} (key {simplex_key:?}), vertex {vertex_key:?}: {source}" + )] + TopologyLifting { + /// Simplex whose coordinates were being lifted. + simplex_key: SimplexKey, + /// UUID of the simplex whose coordinates were being lifted. + simplex_uuid: Uuid, + /// Vertex whose point triggered the lifting failure. + vertex_key: VertexKey, + /// UUID of the vertex whose point triggered the lifting failure. + vertex_uuid: Uuid, + /// Underlying topology model failure. + #[source] + source: GlobalTopologyModelError, + }, + + /// A simplex has exactly zero orientation and therefore zero D-volume. + #[error("simplex {simplex_uuid} (key {simplex_key:?}) is degenerate in dimension {dimension}")] + DegenerateSimplex { + /// Key of the degenerate simplex. + simplex_key: SimplexKey, + /// UUID of the degenerate simplex. + simplex_uuid: Uuid, + /// Vertex-level diagnostic details for the degenerate simplex. + detail: Box, + /// Const-generic coordinate dimension. + dimension: usize, + }, + + /// Coordinate validation failed while preparing an exact predicate input. + #[error( + "coordinate validation failed for simplex {simplex_uuid} (key {simplex_key:?}), vertex {vertex_key:?}: {source}" + )] + CoordinateValidation { + /// Simplex whose coordinates were being validated. + simplex_key: SimplexKey, + /// UUID of the simplex whose coordinates were being validated. + simplex_uuid: Uuid, + /// Vertex whose point triggered the validation failure. + vertex_key: VertexKey, + /// UUID of the vertex whose point triggered the validation failure. + vertex_uuid: Uuid, + /// Underlying coordinate validation failure. + #[source] + source: CoordinateValidationError, + }, + + /// The exact orientation predicate failed for a simplex. + #[error( + "orientation predicate failed for simplex {simplex_uuid} (key {simplex_key:?}): {source}" + )] + PredicateFailed { + /// Simplex whose orientation predicate failed. + simplex_key: SimplexKey, + /// UUID of the simplex whose orientation predicate failed. + simplex_uuid: Uuid, + /// Vertex-level diagnostic details for the simplex. + detail: Box, + /// Underlying coordinate conversion failure from the predicate boundary. + #[source] + source: CoordinateConversionError, + }, + + /// Exact rational barycentric construction found a singular simplex basis. + #[error( + "simplex {simplex_uuid} (key {simplex_key:?}) has a singular barycentric basis in dimension {dimension}" + )] + SingularBarycentricBasis { + /// Simplex whose basis was singular. + simplex_key: SimplexKey, + /// UUID of the simplex whose basis was singular. + simplex_uuid: Uuid, + /// Vertex-level diagnostic details for the singular simplex. + detail: Box, + /// Const-generic coordinate dimension. + dimension: usize, + }, + + /// Two maximal simplices intersect beyond the face spanned by their shared vertices. + #[error( + "simplices {first_simplex_uuid} (key {first_simplex_key:?}) and {second_simplex_uuid} (key {second_simplex_key:?}) intersect outside their shared face" + )] + SimplexIntersectionOutsideSharedFace { + /// Key of the first offending simplex. + first_simplex_key: SimplexKey, + /// UUID of the first offending simplex. + first_simplex_uuid: Uuid, + /// Key of the second offending simplex. + second_simplex_key: SimplexKey, + /// UUID of the second offending simplex. + second_simplex_uuid: Uuid, + /// Vertex-level diagnostic details for the illegal intersection. + detail: Box, + }, + + /// A lifted periodic simplex spans at least one full period along an axis. + /// + /// Such a simplex cannot be certified as injective in one affine covering + /// chart, so the quotient embedding is invalid before pairwise overlap + /// checks run. + #[error( + "simplex {simplex_uuid} (key {simplex_key:?}) spans {span} along periodic axis {axis}, but the period is {period}" + )] + PeriodicSimplexSpansDomain { + /// Key of the offending simplex. + simplex_key: SimplexKey, + /// UUID of the offending simplex. + simplex_uuid: Uuid, + /// Vertex-level diagnostic details for the offending simplex. + detail: Box, + /// Periodic axis whose lifted span is too wide. + axis: usize, + /// Lifted coordinate span along `axis`. + span: f64, + /// Fundamental-domain period along `axis`. + period: f64, + }, + + /// A periodic domain period was invalid while checking embedded geometry. + #[error( + "invalid periodic domain period while validating simplex {simplex_uuid} (key {simplex_key:?}): {source}" + )] + InvalidPeriodicDomainPeriod { + /// Key of the simplex being checked. + simplex_key: SimplexKey, + /// UUID of the simplex being checked. + simplex_uuid: Uuid, + /// Vertex-level diagnostic details for the simplex being checked. + detail: Box, + /// Underlying invalid-period error. + #[source] + source: PeriodicDomainPeriodError, + }, + + /// Periodic translate enumeration would require shifts outside the supported range. + #[error( + "periodic translate range for simplices {first_simplex_uuid} (key {first_simplex_key:?}) and {second_simplex_uuid} (key {second_simplex_key:?}) on axis {axis} exceeds i32 shift bounds: lower {lower_bound}, upper {upper_bound}" + )] + PeriodicTranslateRangeOverflow { + /// Key of the first simplex in the pair. + first_simplex_key: SimplexKey, + /// UUID of the first simplex in the pair. + first_simplex_uuid: Uuid, + /// Key of the second simplex in the pair. + second_simplex_key: SimplexKey, + /// UUID of the second simplex in the pair. + second_simplex_uuid: Uuid, + /// Vertex-level diagnostic details for the pair. + detail: Box, + /// Periodic axis whose shift range overflowed. + axis: usize, + /// Lower floating-point shift bound before integer conversion. + lower_bound: f64, + /// Upper floating-point shift bound before integer conversion. + upper_bound: f64, + }, + + /// A higher validation layer unexpectedly surfaced while running Level 4 validation. + #[error("unexpected {kind:?} validation error while validating Level 4 embedding: {source}")] + UnexpectedValidationLayer { + /// Validation layer that leaked into the embedding boundary. + kind: InvariantKind, + /// Original typed validation error. + #[source] + source: Box, + }, +} + +impl From for TriangulationEmbeddingValidationError { + fn from(source: TdsError) -> Self { + Self::Tds(Box::new(source)) + } +} + +impl From for TriangulationEmbeddingValidationError { + fn from(source: TriangulationValidationError) -> Self { + Self::Triangulation(Box::new(source)) + } +} + +/// Discriminant for compact Level 4 embedded-geometry validation summaries. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TriangulationEmbeddingValidationErrorKind { + /// Lower-layer TDS validation failed. + Tds, + /// Lower-layer topology validation failed. + Triangulation, + /// The topology is not currently supported by embedded validation. + UnsupportedTopology, + /// Topology-specific coordinate lifting failed. + TopologyLifting, + /// A simplex has zero D-volume. + DegenerateSimplex, + /// Coordinate validation failed at the predicate boundary. + CoordinateValidation, + /// The robust orientation predicate failed. + PredicateFailed, + /// Exact barycentric coordinates could not be computed. + SingularBarycentricBasis, + /// Two simplices overlap outside their shared face. + SimplexIntersectionOutsideSharedFace, + /// A periodic simplex spans at least one full domain period. + PeriodicSimplexSpansDomain, + /// A periodic domain period was invalid. + InvalidPeriodicDomainPeriod, + /// Periodic translate enumeration exceeded supported shift bounds. + PeriodicTranslateRangeOverflow, + /// A higher validation layer unexpectedly surfaced during embedding validation. + UnexpectedValidationLayer, +} + +impl From<&TriangulationEmbeddingValidationError> for TriangulationEmbeddingValidationErrorKind { + fn from(source: &TriangulationEmbeddingValidationError) -> Self { + match source { + TriangulationEmbeddingValidationError::Tds(_) => Self::Tds, + TriangulationEmbeddingValidationError::Triangulation(_) => Self::Triangulation, + TriangulationEmbeddingValidationError::UnsupportedTopology { .. } => { + Self::UnsupportedTopology + } + TriangulationEmbeddingValidationError::TopologyLifting { .. } => Self::TopologyLifting, + TriangulationEmbeddingValidationError::DegenerateSimplex { .. } => { + Self::DegenerateSimplex + } + TriangulationEmbeddingValidationError::CoordinateValidation { .. } => { + Self::CoordinateValidation + } + TriangulationEmbeddingValidationError::PredicateFailed { .. } => Self::PredicateFailed, + TriangulationEmbeddingValidationError::SingularBarycentricBasis { .. } => { + Self::SingularBarycentricBasis + } + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { + .. + } => Self::SimplexIntersectionOutsideSharedFace, + TriangulationEmbeddingValidationError::PeriodicSimplexSpansDomain { .. } => { + Self::PeriodicSimplexSpansDomain + } + TriangulationEmbeddingValidationError::InvalidPeriodicDomainPeriod { .. } => { + Self::InvalidPeriodicDomainPeriod + } + TriangulationEmbeddingValidationError::PeriodicTranslateRangeOverflow { .. } => { + Self::PeriodicTranslateRangeOverflow + } + TriangulationEmbeddingValidationError::UnexpectedValidationLayer { .. } => { + Self::UnexpectedValidationLayer + } + } + } +} + +/// Structured Level 4 embedding validation report. +/// +/// This report is the diagnostic counterpart to +/// [`Triangulation::is_valid_embedding`]. The fast-fail method returns the +/// first invalid embedding condition, while this report records every +/// simplex-level failure and every pairwise overlap failure that can be checked +/// after invalid simplices are excluded from pairwise intersection work. +#[derive(Clone, Debug, PartialEq)] +#[must_use] +pub struct TriangulationEmbeddingValidationReport { + /// Number of vertices in the triangulation when the report was generated. + pub number_of_vertices: usize, + /// Number of simplices in the triangulation when the report was generated. + pub number_of_simplices: usize, + /// Number of simplex embeddings prepared for Level 4 validation. + pub checked_simplices: usize, + /// Number of simplex pairs considered for overlap validation. + pub checked_simplex_pairs: usize, + /// Ordered list of Level 4 embedding violations. + pub violations: Vec, +} + +impl TriangulationEmbeddingValidationReport { + /// Returns `true` when no Level 4 embedding violations were found. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// std::assert_matches!( + /// dt.as_triangulation().embedding_report(), + /// Ok(report) if report.is_valid() + /// ); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub const fn is_valid(&self) -> bool { + self.violations.is_empty() + } +} + +#[derive(Debug)] +struct EmbeddedSimplex { + key: SimplexKey, + uuid: Uuid, + vertex_keys: SimplexVertexKeyBuffer, + vertex_uuids: SimplexVertexUuidBuffer, + embedding: LabeledSimplexEmbedding, +} + +type PeriodicShiftRangeBuffer = SmallBuffer<(i32, i32), MAX_PRACTICAL_DIMENSION_SIZE>; + +impl EmbeddedSimplex { + /// Builds the lifted, labeled embedding for one TDS simplex while preserving + /// simplex and vertex identities for later diagnostics. + fn try_from_simplex( + tds: &Tds, + topology_model: &impl GlobalTopologyModel, + simplex_key: SimplexKey, + simplex: &Simplex, + ) -> Result { + let mut vertices = SimplexVertexKeyBuffer::with_capacity(simplex.number_of_vertices()); + let mut vertex_uuids = SimplexVertexUuidBuffer::with_capacity(simplex.number_of_vertices()); + let mut coords = SmallBuffer::<[f64; D], MAX_PRACTICAL_DIMENSION_SIZE>::with_capacity( + simplex.number_of_vertices(), + ); + + let periodic_offsets = simplex.periodic_vertex_offsets(); + if let Some(offsets) = periodic_offsets + && offsets.len() != simplex.number_of_vertices() + { + return Err(TdsError::DimensionMismatch { + expected: simplex.number_of_vertices(), + actual: offsets.len(), + context: format!( + "simplex {:?} (key {simplex_key:?}) periodic offset count vs vertex count during embedding validation", + simplex.uuid(), + ), + } + .into()); + } + + for (vertex_index, &vertex_key) in simplex.vertices().iter().enumerate() { + let vertex = tds + .vertex(vertex_key) + .ok_or_else(|| TdsError::VertexNotFound { + vertex_key, + context: format!( + "embedded validation for simplex {:?} (key {simplex_key:?})", + simplex.uuid() + ), + })?; + vertices.push(vertex_key); + vertex_uuids.push(vertex.uuid()); + let periodic_offset = periodic_offsets.map(|offsets| offsets[vertex_index]); + let lifted_coords = topology_model + .lift_for_orientation(*vertex.point().coords(), periodic_offset) + .map_err( + |source| TriangulationEmbeddingValidationError::TopologyLifting { + simplex_key, + simplex_uuid: simplex.uuid(), + vertex_key, + vertex_uuid: vertex.uuid(), + source, + }, + )?; + coords.push(lifted_coords); + } + + let embedding = + LabeledSimplexEmbedding::try_new(vertices.iter().copied(), coords.iter().copied()) + .map_err(|source| { + labeled_simplex_error_to_embedding_error( + source, + simplex_key, + simplex, + &vertices, + &vertex_uuids, + ) + })?; + + Ok(Self { + key: simplex_key, + uuid: simplex.uuid(), + vertex_keys: vertices, + vertex_uuids, + embedding, + }) + } + + /// Rehydrates one embedded vertex coordinate as a validated point for exact predicates. + fn point_at( + &self, + vertex_index: usize, + ) -> Result, TriangulationEmbeddingValidationError> { + let vertex_key = self.embedding.labels()[vertex_index]; + let vertex_uuid = self.vertex_uuids[vertex_index]; + Point::try_new(self.embedding.coordinates()[vertex_index]).map_err(|source| { + TriangulationEmbeddingValidationError::CoordinateValidation { + simplex_key: self.key, + simplex_uuid: self.uuid, + vertex_key, + vertex_uuid, + source, + } + }) + } + + /// Maps witness vertex keys back to UUIDs from this simplex snapshot. + fn vertex_uuids_for_keys(&self, vertex_keys: &[VertexKey]) -> SimplexVertexUuidBuffer { + let mut uuids = SimplexVertexUuidBuffer::with_capacity(vertex_keys.len()); + uuids.extend(vertex_keys.iter().filter_map(|vertex_key| { + self.vertex_keys + .iter() + .position(|candidate| candidate == vertex_key) + .map(|index| self.vertex_uuids[index]) + })); + uuids + } + + /// Builds the public simplex detail payload reused by Level 4 error variants. + fn detail(&self) -> TriangulationEmbeddingSimplexDetail { + TriangulationEmbeddingSimplexDetail { + key: self.key, + uuid: self.uuid, + vertices: self.vertex_keys.clone(), + vertex_uuids: self.vertex_uuids.clone(), + } + } +} + +/// Converts labeled simplex construction failures into Level 4 diagnostics +/// that preserve the owning simplex and vertex identities callers need for +/// repair planning. +fn labeled_simplex_error_to_embedding_error( + source: LabeledSimplexEmbeddingError, + simplex_key: SimplexKey, + simplex: &Simplex, + vertex_keys: &SimplexVertexKeyBuffer, + vertex_uuids: &SimplexVertexUuidBuffer, +) -> TriangulationEmbeddingValidationError { + let (expected, actual) = match source { + LabeledSimplexEmbeddingError::LabelCoordinateLengthMismatch { + label_count, + coordinate_count, + } => (label_count, coordinate_count), + LabeledSimplexEmbeddingError::InvalidArity { expected, actual } => (expected, actual), + LabeledSimplexEmbeddingError::NonFiniteCoordinate { + vertex_index, + coordinate_index, + coordinate_value, + } => { + let Some(&vertex_key) = vertex_keys.get(vertex_index) else { + return TdsError::DimensionMismatch { + expected: vertex_keys.len(), + actual: vertex_index.saturating_add(1), + context: format!( + "simplex {:?} (key {simplex_key:?}) finite-coordinate diagnostic vertex index during embedding validation", + simplex.uuid(), + ), + } + .into(); + }; + let Some(&vertex_uuid) = vertex_uuids.get(vertex_index) else { + return TdsError::DimensionMismatch { + expected: vertex_uuids.len(), + actual: vertex_index.saturating_add(1), + context: format!( + "simplex {:?} (key {simplex_key:?}) finite-coordinate diagnostic vertex UUID index during embedding validation", + simplex.uuid(), + ), + } + .into(); + }; + return TriangulationEmbeddingValidationError::CoordinateValidation { + simplex_key, + simplex_uuid: simplex.uuid(), + vertex_key, + vertex_uuid, + source: CoordinateValidationError::InvalidCoordinate { + coordinate_index, + coordinate_value, + dimension: D, + }, + }; + } + LabeledSimplexEmbeddingError::InvalidPeriodicDomainPeriod { source } => { + return TriangulationEmbeddingValidationError::InvalidPeriodicDomainPeriod { + simplex_key, + simplex_uuid: simplex.uuid(), + detail: Box::new(TriangulationEmbeddingSimplexDetail { + key: simplex_key, + uuid: simplex.uuid(), + vertices: vertex_keys.clone(), + vertex_uuids: vertex_uuids.clone(), + }), + source: source.into(), + }; + } + }; + + TdsError::DimensionMismatch { + expected, + actual, + context: format!( + "simplex {:?} (key {simplex_key:?}) arity during embedding validation", + simplex.uuid(), + ), + } + .into() +} + +/// Converts translated embedded-simplex construction failures into the same +/// key- and UUID-rich public diagnostics as the primary embedding path. +fn labeled_simplex_error_to_embedded_simplex_error( + source: LabeledSimplexEmbeddingError, + simplex: &EmbeddedSimplex, +) -> TriangulationEmbeddingValidationError { + let (expected, actual) = match source { + LabeledSimplexEmbeddingError::LabelCoordinateLengthMismatch { + label_count, + coordinate_count, + } => (label_count, coordinate_count), + LabeledSimplexEmbeddingError::InvalidArity { expected, actual } => (expected, actual), + LabeledSimplexEmbeddingError::NonFiniteCoordinate { + vertex_index, + coordinate_index, + coordinate_value, + } => { + let Some(&vertex_key) = simplex.vertex_keys.get(vertex_index) else { + return TdsError::DimensionMismatch { + expected: simplex.vertex_keys.len(), + actual: vertex_index.saturating_add(1), + context: format!( + "simplex {:?} (key {:?}) finite-coordinate translated diagnostic vertex index during embedding validation", + simplex.uuid, simplex.key, + ), + } + .into(); + }; + let Some(&vertex_uuid) = simplex.vertex_uuids.get(vertex_index) else { + return TdsError::DimensionMismatch { + expected: simplex.vertex_uuids.len(), + actual: vertex_index.saturating_add(1), + context: format!( + "simplex {:?} (key {:?}) finite-coordinate translated diagnostic vertex UUID index during embedding validation", + simplex.uuid, simplex.key, + ), + } + .into(); + }; + return TriangulationEmbeddingValidationError::CoordinateValidation { + simplex_key: simplex.key, + simplex_uuid: simplex.uuid, + vertex_key, + vertex_uuid, + source: CoordinateValidationError::InvalidCoordinate { + coordinate_index, + coordinate_value, + dimension: D, + }, + }; + } + LabeledSimplexEmbeddingError::InvalidPeriodicDomainPeriod { source } => { + return TriangulationEmbeddingValidationError::InvalidPeriodicDomainPeriod { + simplex_key: simplex.key, + simplex_uuid: simplex.uuid, + detail: Box::new(simplex.detail()), + source: source.into(), + }; + } + }; + + TdsError::DimensionMismatch { + expected, + actual, + context: format!( + "simplex {:?} (key {:?}) arity during translated embedding validation", + simplex.uuid, simplex.key, + ), + } + .into() +} + +impl Triangulation { + /// Validates embedded geometry only (Level 4). + /// + /// This method assumes lower layers have already passed validation. Use + /// [`validate_embedding`](Self::validate_embedding) for cumulative Levels + /// 1-4 validation. + /// + /// Euclidean topology is validated in its ordinary affine chart. Toroidal + /// topology is validated in the stored periodic covering-space charts and + /// across periodic translates. Spherical and hyperbolic topology currently + /// return [`TriangulationEmbeddingValidationError::UnsupportedTopology`] + /// until their model-specific affine/projective chart validators are added. + /// + /// # Errors + /// + /// Returns [`TriangulationEmbeddingValidationError`] if the topology model is + /// unsupported, a simplex is geometrically degenerate, a periodic simplex is + /// not contained in a single covering chart, or two maximal simplices + /// intersect outside their shared face. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// assert!(dt.as_triangulation().is_valid_embedding().is_ok()); + /// # Ok(()) + /// # } + /// ``` + pub fn is_valid_embedding(&self) -> Result<(), TriangulationEmbeddingValidationError> { + if let Some(first_violation) = self.embedding_diagnostic()? { + return Err(first_violation); + } + Ok(()) + } + + /// Returns the first actionable Level 4 embedding diagnostic, if any. + /// + /// This is the repair/retry-oriented counterpart to + /// [`is_valid_embedding`](Self::is_valid_embedding). It returns at most one + /// Level 4 violation with simplex keys, simplex UUIDs, and offending vertex + /// keys/UUIDs where applicable. + /// + /// # Errors + /// + /// Returns [`TriangulationEmbeddingValidationError`] when simplex embedding + /// preparation cannot continue because lower-layer TDS data are missing or + /// malformed. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// std::assert_matches!(dt.as_triangulation().embedding_diagnostic(), Ok(None)); + /// # Ok(()) + /// # } + /// ``` + pub fn embedding_diagnostic( + &self, + ) -> Result, TriangulationEmbeddingValidationError> + { + self.first_embedding_violation() + } + + /// Builds a Level 4 embedding report with key- and UUID-based violation details. + /// + /// This method checks embedded geometry only. It does not run lower-layer + /// TDS/topology validation and does not evaluate the Level 5 Delaunay + /// property. Use [`validate_embedding`](Self::validate_embedding) for + /// cumulative Levels 1-4 validation when pass/fail behavior is enough. + /// + /// # Errors + /// + /// Returns [`TriangulationEmbeddingValidationError`] when simplex embedding + /// preparation cannot continue because lower-layer TDS data are missing or + /// malformed. Ordinary Level 4 violations are returned inside the report. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// std::assert_matches!( + /// dt.as_triangulation().embedding_report(), + /// Ok(report) if report.is_valid() + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn embedding_report( + &self, + ) -> Result { + let topology_model = self.global_topology.model(); + let mut report = TriangulationEmbeddingValidationReport { + number_of_vertices: self.tds.number_of_vertices(), + number_of_simplices: self.tds.number_of_simplices(), + checked_simplices: 0, + checked_simplex_pairs: 0, + violations: Vec::new(), + }; + + if !topology_model.supports_affine_embedding_validation() { + report + .violations + .push(TriangulationEmbeddingValidationError::UnsupportedTopology { + topology: self.global_topology.kind(), + dimension: D, + }); + return Ok(report); + } + + let simplices = self.collect_embedded_simplices()?; + report.checked_simplices = simplices.len(); + let periodic_domain = topology_model.periodic_domain(); + let periodic_periods = periodic_domain.map(|domain| *domain.periods()); + let mut invalid_simplex_keys = FastHashSet::default(); + + for simplex in &simplices { + if let Err(error) = validate_simplex_nondegenerate(simplex) { + invalid_simplex_keys.insert(simplex.key); + report.violations.push(error); + } + if let Some(domain) = periodic_domain + && let Err(error) = validate_periodic_simplex_chart(simplex, domain.periods()) + { + invalid_simplex_keys.insert(simplex.key); + report.violations.push(error); + } + } + + for (first_index, first) in simplices.iter().enumerate() { + for second in &simplices[first_index + 1..] { + if invalid_simplex_keys.contains(&first.key) + || invalid_simplex_keys.contains(&second.key) + { + continue; + } + report.checked_simplex_pairs += 1; + if let Err(error) = + validate_topology_aware_simplex_pair(first, second, periodic_periods) + { + report.violations.push(error); + } + } + } + + Ok(report) + } + + /// Performs cumulative validation for Levels 1-4. + /// + /// This validates: + /// - **Levels 1-3** via [`Triangulation::validate`](Self::validate) + /// - **Level 4** via [`Triangulation::is_valid_embedding`](Self::is_valid_embedding) + /// + /// # Errors + /// + /// Returns [`TriangulationEmbeddingValidationError`] if lower-layer + /// validation fails, the topology cannot currently be embedded-validated, + /// or embedded Euclidean geometry is invalid. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// assert!(dt.as_triangulation().validate_embedding().is_ok()); + /// # Ok(()) + /// # } + /// ``` + pub fn validate_embedding(&self) -> Result<(), TriangulationEmbeddingValidationError> + where + K: Kernel, + U: DataType, + V: DataType, + { + self.validate().map_err(|error| match error { + InvariantError::Tds(source) => source.into(), + InvariantError::Triangulation(source) => source.into(), + InvariantError::Embedding(source) => source, + source @ InvariantError::Delaunay(_) => { + TriangulationEmbeddingValidationError::UnexpectedValidationLayer { + kind: InvariantKind::DelaunayProperty, + source: Box::new(source), + } + } + })?; + self.is_valid_embedding() + } + + /// Validates the Level 4 nondegeneracy invariant for a local simplex set. + /// + /// This intentionally does not perform pairwise overlap checks; insertion + /// uses it as a cheap mutation-time guard so zero-volume simplices fail + /// inside the existing rollback transaction. Full embedding validation + /// remains the responsibility of [`is_valid_embedding`](Self::is_valid_embedding). + pub(crate) fn validate_local_embedding_nondegeneracy( + &self, + simplices: &[SimplexKey], + ) -> Result<(), TriangulationEmbeddingValidationError> { + let topology_model = self.global_topology.model(); + if !topology_model.supports_affine_embedding_validation() { + return Ok(()); + } + + let periodic_domain = topology_model.periodic_domain(); + for &simplex_key in simplices { + let simplex = + self.tds + .simplex(simplex_key) + .ok_or_else(|| TdsError::SimplexNotFound { + simplex_key, + context: "local embedding nondegeneracy validation".to_string(), + })?; + let embedded = EmbeddedSimplex::try_from_simplex( + &self.tds, + &topology_model, + simplex_key, + simplex, + )?; + validate_simplex_nondegenerate(&embedded)?; + if let Some(domain) = periodic_domain { + validate_periodic_simplex_chart(&embedded, domain.periods())?; + } + } + + Ok(()) + } + + /// Collects all simplex embeddings after applying the topology model's active chart. + fn collect_embedded_simplices( + &self, + ) -> Result>, TriangulationEmbeddingValidationError> { + let topology_model = self.global_topology.model(); + self.tds + .simplices() + .map(|(simplex_key, simplex)| { + EmbeddedSimplex::try_from_simplex(&self.tds, &topology_model, simplex_key, simplex) + }) + .collect() + } + + fn first_embedding_violation( + &self, + ) -> Result, TriangulationEmbeddingValidationError> + { + let topology_model = self.global_topology.model(); + if !topology_model.supports_affine_embedding_validation() { + return Ok(Some( + TriangulationEmbeddingValidationError::UnsupportedTopology { + topology: self.global_topology.kind(), + dimension: D, + }, + )); + } + + let periodic_domain = topology_model.periodic_domain(); + let periodic_periods = periodic_domain.map(|domain| *domain.periods()); + let mut simplices = Vec::with_capacity(self.tds.number_of_simplices()); + for (simplex_key, simplex) in self.tds.simplices() { + let embedded = EmbeddedSimplex::try_from_simplex( + &self.tds, + &topology_model, + simplex_key, + simplex, + )?; + if let Err(error) = validate_simplex_nondegenerate(&embedded) { + return Ok(Some(error)); + } + if let Some(domain) = periodic_domain + && let Err(error) = validate_periodic_simplex_chart(&embedded, domain.periods()) + { + return Ok(Some(error)); + } + simplices.push(embedded); + } + + for (first_index, first) in simplices.iter().enumerate() { + for second in &simplices[first_index + 1..] { + if let Err(error) = + validate_topology_aware_simplex_pair(first, second, periodic_periods) + { + return Ok(Some(error)); + } + } + } + + Ok(None) + } +} + +/// Dispatches pairwise overlap validation through Euclidean or periodic chart logic. +fn validate_topology_aware_simplex_pair( + first: &EmbeddedSimplex, + second: &EmbeddedSimplex, + periodic_periods: Option<[f64; D]>, +) -> Result<(), TriangulationEmbeddingValidationError> { + let Some(periods) = periodic_periods else { + if bounding_boxes_overlap(first, second) { + validate_simplex_pair_intersection(first, second)?; + } + return Ok(()); + }; + + let shift_ranges = periodic_shift_ranges(first, second, &periods)?; + let mut shift = [0_i32; D]; + validate_periodic_translates(first, second, &periods, &shift_ranges, 0, &mut shift) +} + +/// Recursively checks every periodic translate that can overlap two simplex boxes. +fn validate_periodic_translates( + first: &EmbeddedSimplex, + second: &EmbeddedSimplex, + periods: &[f64; D], + shift_ranges: &[(i32, i32)], + axis: usize, + shift: &mut [i32; D], +) -> Result<(), TriangulationEmbeddingValidationError> { + if axis == D { + let translated = translated_simplex(second, periods, shift)?; + if bounding_boxes_overlap(first, &translated) { + validate_simplex_pair_intersection(first, &translated)?; + } + return Ok(()); + } + + let (start, end) = shift_ranges[axis]; + for value in start..=end { + shift[axis] = value; + validate_periodic_translates(first, second, periods, shift_ranges, axis + 1, shift)?; + } + Ok(()) +} + +/// Computes the finite integer shift range needed to test possible periodic overlaps. +fn periodic_shift_ranges( + first: &EmbeddedSimplex, + second: &EmbeddedSimplex, + periods: &[f64; D], +) -> Result { + (0..D) + .map(|axis| { + let (first_min, first_max) = coordinate_range_for_axis(&first.embedding, axis) + .expect("axis generated from 0..D must be valid"); + let (second_min, second_max) = coordinate_range_for_axis(&second.embedding, axis) + .expect("axis generated from 0..D must be valid"); + let period = periods[axis]; + let lower_bound = ((first_min - second_max) / period).floor(); + let upper_bound = ((first_max - second_min) / period).ceil(); + let Some(start) = lower_bound.to_i32() else { + return Err(periodic_translate_range_overflow( + first, + second, + axis, + lower_bound, + upper_bound, + )); + }; + let Some(end) = upper_bound.to_i32() else { + return Err(periodic_translate_range_overflow( + first, + second, + axis, + lower_bound, + upper_bound, + )); + }; + Ok((start, end)) + }) + .collect() +} + +/// Builds the shared diagnostic for periodic shift bounds that cannot fit in `i32`. +fn periodic_translate_range_overflow( + first: &EmbeddedSimplex, + second: &EmbeddedSimplex, + axis: usize, + lower_bound: f64, + upper_bound: f64, +) -> TriangulationEmbeddingValidationError { + TriangulationEmbeddingValidationError::PeriodicTranslateRangeOverflow { + first_simplex_key: first.key, + first_simplex_uuid: first.uuid, + second_simplex_key: second.key, + second_simplex_uuid: second.uuid, + detail: Box::new(TriangulationEmbeddingSimplexPairDetail { + first_simplex: first.detail(), + second_simplex: second.detail(), + }), + axis, + lower_bound, + upper_bound, + } +} + +/// Translates one embedded simplex into a neighboring periodic chart. +fn translated_simplex( + simplex: &EmbeddedSimplex, + periods: &[f64; D], + shift: &[i32; D], +) -> Result, TriangulationEmbeddingValidationError> { + let embedding = simplex + .embedding + .try_translated(periods, shift) + .map_err(|source| labeled_simplex_error_to_embedded_simplex_error(source, simplex))?; + Ok(EmbeddedSimplex { + key: simplex.key, + uuid: simplex.uuid, + vertex_keys: simplex.vertex_keys.clone(), + vertex_uuids: simplex.vertex_uuids.clone(), + embedding, + }) +} + +/// Rejects a periodic simplex whose lifted vertices cannot fit in one chart. +fn validate_periodic_simplex_chart( + simplex: &EmbeddedSimplex, + periods: &[f64; D], +) -> Result<(), TriangulationEmbeddingValidationError> { + let span = try_periodic_simplex_span(&simplex.embedding, periods).map_err(|source| { + TriangulationEmbeddingValidationError::InvalidPeriodicDomainPeriod { + simplex_key: simplex.key, + simplex_uuid: simplex.uuid, + detail: Box::new(simplex.detail()), + source: source.into(), + } + })?; + if let Some(span) = span { + return Err( + TriangulationEmbeddingValidationError::PeriodicSimplexSpansDomain { + simplex_key: simplex.key, + simplex_uuid: simplex.uuid, + detail: Box::new(simplex.detail()), + axis: span.axis, + span: span.span, + period: span.period, + }, + ); + } + Ok(()) +} + +/// Rejects zero-volume simplices before pairwise overlap validation runs. +fn validate_simplex_nondegenerate( + simplex: &EmbeddedSimplex, +) -> Result<(), TriangulationEmbeddingValidationError> { + let points: SmallBuffer, MAX_PRACTICAL_DIMENSION_SIZE> = + (0..simplex.embedding.labels().len()) + .map(|index| simplex.point_at(index)) + .collect::>()?; + + match robust_orientation(&points) { + Ok(Orientation::POSITIVE | Orientation::NEGATIVE) => Ok(()), + Ok(Orientation::DEGENERATE) => { + Err(TriangulationEmbeddingValidationError::DegenerateSimplex { + simplex_key: simplex.key, + simplex_uuid: simplex.uuid, + detail: Box::new(simplex.detail()), + dimension: D, + }) + } + Err(source) => Err(TriangulationEmbeddingValidationError::PredicateFailed { + simplex_key: simplex.key, + simplex_uuid: simplex.uuid, + detail: Box::new(simplex.detail()), + source, + }), + } +} + +/// Applies the cheap bounding-box prefilter before exact intersection work. +fn bounding_boxes_overlap( + first: &EmbeddedSimplex, + second: &EmbeddedSimplex, +) -> bool { + axis_aligned_bounding_boxes_overlap(&first.embedding, &second.embedding) +} + +/// Converts pure simplex-intersection failures into triangulation-level diagnostics. +fn validate_simplex_pair_intersection( + first: &EmbeddedSimplex, + second: &EmbeddedSimplex, +) -> Result<(), TriangulationEmbeddingValidationError> { + match validate_simplex_embeddings_intersect_only_in_shared_faces( + &first.embedding, + &second.embedding, + ) { + Ok(()) => Ok(()), + Err(SimplexIntersectionFailure::SingularBarycentricBasis) => Err( + TriangulationEmbeddingValidationError::SingularBarycentricBasis { + simplex_key: first.key, + simplex_uuid: first.uuid, + detail: Box::new(first.detail()), + dimension: D, + }, + ), + Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace(witness)) => { + let shared_vertex_uuids = first.vertex_uuids_for_keys(&witness.shared); + let first_only_witness_vertex_uuids = + first.vertex_uuids_for_keys(&witness.first_only_witness); + let second_only_witness_vertex_uuids = + second.vertex_uuids_for_keys(&witness.second_only_witness); + Err( + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { + first_simplex_key: first.key, + first_simplex_uuid: first.uuid, + second_simplex_key: second.key, + second_simplex_uuid: second.uuid, + detail: Box::new(TriangulationEmbeddingIntersectionDetail { + first_simplex: first.detail(), + second_simplex: second.detail(), + shared_vertices: witness.shared, + shared_vertex_uuids, + first_only_witness_vertices: witness.first_only_witness, + first_only_witness_vertex_uuids, + second_only_witness_vertices: witness.second_only_witness, + second_only_witness_vertex_uuids, + }), + }, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::builder::DelaunayTriangulationBuilder; + use crate::core::tds::{Tds, TriangulationConstructionState}; + use crate::core::triangulation::Triangulation; + use crate::core::vertex::Vertex; + use crate::geometry::kernel::FastKernel; + use crate::topology::traits::topological_space::{GlobalTopology, ToroidalConstructionMode}; + use crate::vertex; + use approx::assert_abs_diff_eq; + use std::assert_matches; + + fn test_vertex(coords: [f64; D]) -> Vertex<(), D> { + vertex!(coords).unwrap() + } + + fn tds_from_vertices_and_simplices( + coords: &[[f64; D]], + simplices: &[Vec], + ) -> Tds<(), (), D> { + tds_from_vertices_and_simplices_with_keys(coords, simplices).0 + } + + fn tds_from_vertices_and_simplices_with_keys( + coords: &[[f64; D]], + simplices: &[Vec], + ) -> (Tds<(), (), D>, Vec) { + let mut tds = Tds::empty(); + let vertex_keys: Vec<_> = coords + .iter() + .map(|coords| { + tds.insert_vertex_with_mapping(test_vertex(*coords)) + .unwrap() + }) + .collect(); + + let mut simplex_keys = Vec::with_capacity(simplices.len()); + for simplex_vertices in simplices { + let vertices: Vec<_> = simplex_vertices + .iter() + .map(|&index| vertex_keys[index]) + .collect(); + let simplex_key = tds + .insert_simplex_with_mapping(Simplex::try_new_with_data(vertices, None).unwrap()) + .unwrap(); + simplex_keys.push(simplex_key); + } + + tds.construction_state = TriangulationConstructionState::Constructed; + tds.assign_neighbors().unwrap(); + tds.assign_incident_simplices().unwrap(); + (tds, simplex_keys) + } + + fn tri_from_tds( + tds: Tds<(), (), D>, + ) -> Triangulation, (), (), D> { + Triangulation::new_with_tds(FastKernel::new(), tds) + } + + fn tri_from_tds_with_topology( + tds: Tds<(), (), D>, + global_topology: GlobalTopology, + ) -> Triangulation, (), (), D> { + let mut tri = tri_from_tds(tds); + tri.global_topology = global_topology; + tri + } + + fn assert_single_simplex_embeds() { + let mut coords = Vec::with_capacity(D + 1); + coords.push([0.0; D]); + for axis in 0..D { + let mut point = [0.0; D]; + point[axis] = 1.0; + coords.push(point); + } + let simplex = (0..=D).collect(); + let tri = tri_from_tds(tds_from_vertices_and_simplices(&coords, &[simplex])); + assert!(tri.is_valid_embedding().is_ok()); + } + + #[test] + fn is_valid_embedding_accepts_single_simplex_dimensions_two_through_five() { + assert_single_simplex_embeds::<2>(); + assert_single_simplex_embeds::<3>(); + assert_single_simplex_embeds::<4>(); + assert_single_simplex_embeds::<5>(); + } + + #[test] + fn validate_embedding_accepts_builder_constructed_triangulation() { + let vertices = vec![ + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), + test_vertex([0.25, 0.25, 0.25]), + ]; + let dt = DelaunayTriangulationBuilder::new(&vertices) + .build::<()>() + .unwrap(); + + assert!(dt.as_triangulation().validate_embedding().is_ok()); + } + + #[test] + fn is_valid_embedding_accepts_two_tetrahedra_sharing_a_facet() { + let coords = [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, -1.0], + ]; + let tds = tds_from_vertices_and_simplices(&coords, &[vec![0, 1, 2, 3], vec![0, 2, 1, 4]]); + let tri = tri_from_tds(tds); + + assert!(tri.is_valid_embedding().is_ok()); + } + + #[test] + fn validate_embedding_rejects_degenerate_simplex() { + let coords = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]; + let tds = tds_from_vertices_and_simplices(&coords, &[vec![0, 1, 2]]); + let tri = tri_from_tds(tds); + + let diagnostic = tri + .embedding_diagnostic() + .unwrap() + .expect("degenerate simplex should produce a diagnostic"); + let report_first = tri + .embedding_report() + .unwrap() + .violations + .into_iter() + .next() + .expect("degenerate simplex should be the first report violation"); + assert_eq!(diagnostic, report_first); + + let err = tri.is_valid_embedding().unwrap_err(); + assert_eq!(err, diagnostic); + assert_matches!( + err, + TriangulationEmbeddingValidationError::DegenerateSimplex { dimension: 2, .. } + ); + } + + #[test] + fn embedding_report_includes_degenerate_simplex_vertices() { + let coords = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]; + let tds = tds_from_vertices_and_simplices(&coords, &[vec![0, 1, 2]]); + let tri = tri_from_tds(tds); + + let report = tri + .embedding_report() + .expect("embedding report should be generated"); + assert!(!report.is_valid()); + assert_eq!(report.checked_simplices, 1); + assert_matches!( + &report.violations[..], + [TriangulationEmbeddingValidationError::DegenerateSimplex { + detail, + dimension: 2, + .. + }] if detail.vertices.len() == 3 && detail.vertex_uuids.len() == 3 + ); + } + + #[test] + fn is_valid_embedding_rejects_nonadjacent_edge_crossing() { + let coords = [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0], [1.0, -1.0]]; + let tds = tds_from_vertices_and_simplices( + &coords, + &[vec![0, 1, 2], vec![2, 1, 3], vec![3, 2, 4]], + ); + let tri = tri_from_tds(tds); + + let err = tri.is_valid_embedding().unwrap_err(); + assert_matches!( + err, + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { .. } + ); + } + + #[test] + fn embedding_report_includes_intersection_witness_vertices() { + let coords = [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0], [1.0, -1.0]]; + let tds = tds_from_vertices_and_simplices( + &coords, + &[vec![0, 1, 2], vec![2, 1, 3], vec![3, 2, 4]], + ); + let tri = tri_from_tds(tds); + + let report = tri + .embedding_report() + .expect("embedding report should be generated"); + let intersection = + report + .violations + .iter() + .find(|violation| { + matches!( + violation, + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { + .. + } + ) + }) + .expect("report should include an illegal simplex intersection"); + + assert_matches!( + intersection, + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { + detail, + .. + } if detail.first_simplex.vertices.len() == 3 + && detail.first_simplex.vertex_uuids.len() == 3 + && detail.second_simplex.vertices.len() == 3 + && detail.second_simplex.vertex_uuids.len() == 3 + && !detail.first_only_witness_vertices.is_empty() + && detail.first_only_witness_vertices.len() + == detail.first_only_witness_vertex_uuids.len() + && !detail.second_only_witness_vertices.is_empty() + && detail.second_only_witness_vertices.len() + == detail.second_only_witness_vertex_uuids.len() + ); + } + + #[test] + fn is_valid_embedding_accepts_lifted_toroidal_simplex_chart() { + let coords = [[0.9, 0.1], [0.1, 0.1], [0.9, 0.3]]; + let (mut tds, simplex_keys) = + tds_from_vertices_and_simplices_with_keys(&coords, &[vec![0, 1, 2]]); + tds.simplex_mut(simplex_keys[0]) + .unwrap() + .set_periodic_vertex_offsets(vec![[0, 0], [1, 0], [0, 0]]) + .unwrap(); + let tri = tri_from_tds_with_topology( + tds, + GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::PeriodicImagePoint) + .unwrap(), + ); + + assert!(tri.is_valid_embedding().is_ok()); + } + + #[test] + fn is_valid_embedding_rejects_unsupported_spherical_topology() { + let coords = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]; + let tds = tds_from_vertices_and_simplices(&coords, &[vec![0, 1, 2]]); + let tri = tri_from_tds_with_topology(tds, GlobalTopology::Spherical); + + let err = tri.is_valid_embedding().unwrap_err(); + assert_matches!( + err, + TriangulationEmbeddingValidationError::UnsupportedTopology { + topology: TopologyKind::Spherical, + dimension: 2, + } + ); + } + + #[test] + fn is_valid_embedding_rejects_periodic_simplex_spanning_domain() { + let coords = [[0.0, 0.0], [1.0, 0.0], [0.0, 0.25]]; + let tds = tds_from_vertices_and_simplices(&coords, &[vec![0, 1, 2]]); + let tri = tri_from_tds_with_topology( + tds, + GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::PeriodicImagePoint) + .unwrap(), + ); + + let err = tri.is_valid_embedding().unwrap_err(); + let (span, period) = match err { + TriangulationEmbeddingValidationError::PeriodicSimplexSpansDomain { + axis: 0, + span, + period, + .. + } => (span, period), + other => panic!("expected periodic simplex span violation, got {other:?}"), + }; + assert_abs_diff_eq!(span, 1.0, epsilon = f64::EPSILON); + assert_abs_diff_eq!(period, 1.0, epsilon = f64::EPSILON); + } + + #[test] + fn is_valid_embedding_rejects_periodic_translate_overlap() { + let coords = [ + [0.0, 0.0], + [0.2, 0.0], + [0.0, 0.8], + [0.95, 0.1], + [0.15, 0.1], + [0.95, 0.3], + ]; + let (mut tds, simplex_keys) = + tds_from_vertices_and_simplices_with_keys(&coords, &[vec![0, 1, 2], vec![3, 4, 5]]); + tds.simplex_mut(simplex_keys[1]) + .unwrap() + .set_periodic_vertex_offsets(vec![[0, 0], [1, 0], [0, 0]]) + .unwrap(); + let tri = tri_from_tds_with_topology( + tds, + GlobalTopology::try_toroidal([1.0, 1.0], ToroidalConstructionMode::PeriodicImagePoint) + .unwrap(), + ); + + let err = tri.is_valid_embedding().unwrap_err(); + assert_matches!( + err, + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { .. } + ); + } + + #[test] + fn embedding_error_kind_covers_variants() { + let source = TriangulationEmbeddingValidationError::DegenerateSimplex { + simplex_key: SimplexKey::default(), + simplex_uuid: Uuid::nil(), + detail: Box::new(TriangulationEmbeddingSimplexDetail { + key: SimplexKey::default(), + uuid: Uuid::nil(), + vertices: SimplexVertexKeyBuffer::new(), + vertex_uuids: SimplexVertexUuidBuffer::new(), + }), + dimension: 2, + }; + + assert_eq!( + TriangulationEmbeddingValidationErrorKind::from(&source), + TriangulationEmbeddingValidationErrorKind::DegenerateSimplex, + ); + + let invalid_period_source = + TriangulationEmbeddingValidationError::InvalidPeriodicDomainPeriod { + simplex_key: SimplexKey::default(), + simplex_uuid: Uuid::nil(), + detail: Box::new(TriangulationEmbeddingSimplexDetail { + key: SimplexKey::default(), + uuid: Uuid::nil(), + vertices: SimplexVertexKeyBuffer::new(), + vertex_uuids: SimplexVertexUuidBuffer::new(), + }), + source: PeriodicDomainPeriodError::NonPositivePeriod { + axis: 0, + period: 0.0, + }, + }; + + assert_eq!( + TriangulationEmbeddingValidationErrorKind::from(&invalid_period_source), + TriangulationEmbeddingValidationErrorKind::InvalidPeriodicDomainPeriod, + ); + + let unexpected_source = + TriangulationEmbeddingValidationError::UnexpectedValidationLayer { + kind: InvariantKind::DelaunayProperty, + source: Box::new(InvariantError::Delaunay( + crate::validation::DelaunayTriangulationValidationError::VerificationFailed { + source: Box::new(crate::validation::DelaunayVerificationError::from( + crate::delaunay_property_validation::DelaunayValidationError::TriangulationState { + source: TdsError::InconsistentDataStructure { + message: "synthetic higher-layer failure".to_string(), + }, + }, + )), + }, + )), + }; + + assert_eq!( + TriangulationEmbeddingValidationErrorKind::from(&unexpected_source), + TriangulationEmbeddingValidationErrorKind::UnexpectedValidationLayer, + ); + } +} diff --git a/src/core/insertion.rs b/src/core/insertion.rs index 55b4d775..be337ffe 100644 --- a/src/core/insertion.rs +++ b/src/core/insertion.rs @@ -3460,7 +3460,7 @@ mod tests { .all(|incident_simplices| incident_simplices.len() <= 2), "hull extension should leave every facet with at most two incident simplices" ); - assert!(tri.is_valid().is_ok()); + assert!(tri.is_valid_topology().is_ok()); } #[test] @@ -3513,7 +3513,7 @@ mod tests { .all(|incident_simplices| incident_simplices.len() <= 2), "hull extension should leave every facet with at most two incident simplices" ); - assert!(tri.is_valid().is_ok()); + assert!(tri.is_valid_topology().is_ok()); } #[test] @@ -3551,7 +3551,7 @@ mod tests { !detail.delaunay_repair_required, "caller-provided conflict simplices should preserve the cavity insertion repair flag" ); - assert!(tri.is_valid().is_ok()); + assert!(tri.is_valid_topology().is_ok()); } #[test] @@ -4354,7 +4354,11 @@ mod tests { assert!(hint.is_some(), "{}D: hint returned after D+2 insertion", $dim); assert!(tri.number_of_simplices() > 1, "{}D: simplex count increased", $dim); - assert!(tri.is_valid().is_ok(), "{}D: topology valid after insertion", $dim); + assert!( + tri.is_valid_topology().is_ok(), + "{}D: topology valid after insertion", + $dim + ); } } }; diff --git a/src/core/orientation.rs b/src/core/orientation.rs index 4b17e8ee..85af1169 100644 --- a/src/core/orientation.rs +++ b/src/core/orientation.rs @@ -534,7 +534,7 @@ mod tests { let mut tri = Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); - assert!(tri.is_valid().is_ok()); + assert!(tri.is_valid_topology().is_ok()); assert!(tri.is_valid_topology_only().is_ok()); let simplex_key = tri.tds.simplex_keys().next().unwrap(); @@ -544,7 +544,7 @@ mod tests { .swap_vertex_slots(0, 1); assert!(tri.is_valid_topology_only().is_ok()); - assert!(tri.is_valid().is_err()); + assert!(tri.is_valid_topology().is_err()); } #[test] @@ -587,7 +587,7 @@ mod tests { .swap_vertex_slots(0, 1); let tri = Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); - let err = tri.is_valid().unwrap_err(); + let err = tri.is_valid_topology().unwrap_err(); assert_matches!( err, InvariantError::Tds(TdsError::Geometric(GeometricError::NegativeOrientation { message })) diff --git a/src/core/repair.rs b/src/core/repair.rs index 4c088575..56174739 100644 --- a/src/core/repair.rs +++ b/src/core/repair.rs @@ -731,7 +731,7 @@ where .remove_vertex(vertex_key) .map_err(|e| InvariantError::Tds(e.into_inner()))?; tri.tds.is_valid().map_err(InvariantError::Tds)?; - tri.is_valid()?; + tri.is_valid_topology()?; Ok(simplices_removed) })() }; @@ -840,7 +840,7 @@ where || validation_scope.is_empty() { self.tds.is_valid().map_err(InvariantError::Tds)?; - self.is_valid()?; + self.is_valid_topology()?; return Ok(()); } @@ -857,7 +857,7 @@ where #[cfg(debug_assertions)] { self.tds.is_valid().map_err(InvariantError::Tds)?; - self.is_valid()?; + self.is_valid_topology()?; } Ok(()) @@ -2304,10 +2304,13 @@ mod tests { matches!( error, crate::DeleteVertexError::InvariantViolation { - source: InvariantError::Triangulation( + ref source + } if matches!( + source.as_ref(), + InvariantError::Triangulation( TriangulationValidationError::IsolatedVertex { .. } ) - } + ) ), "expected isolated-vertex invariant failure, got {error:?}" ); diff --git a/src/core/simplex.rs b/src/core/simplex.rs index bee6d58d..5f61cc29 100644 --- a/src/core/simplex.rs +++ b/src/core/simplex.rs @@ -183,6 +183,30 @@ pub enum SimplexValidationError { }, } +/// Aggregate report for standalone simplex validation failures. +/// +/// This is the Level 1 element-local report counterpart to +/// [`Simplex::is_valid`] and [`Simplex::simplex_diagnostic`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SimplexValidationReport { + /// The ordered list of simplex invariant violations that occurred. + pub violations: Vec, +} + +impl SimplexValidationReport { + /// Returns `true` if no violations were recorded. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.violations.is_empty() + } + + /// Returns the recorded simplex invariant violations. + #[must_use] + pub fn violations(&self) -> &[SimplexValidationError] { + &self.violations + } +} + impl From for SimplexValidationError { fn from(source: StackMatrixDispatchError) -> Self { CoordinateConversionError::from(source).into() @@ -1717,6 +1741,70 @@ impl Simplex { Ok(()) } + + /// Returns the first standalone simplex validation diagnostic, if any. + #[must_use] + pub fn simplex_diagnostic(&self) -> Option { + self.is_valid().err() + } + + /// Runs standalone simplex validation and returns all checkable failures. + /// + /// Unlike [`is_valid`](Self::is_valid), this method does + /// not stop after the first invalid field. + /// + /// # Errors + /// + /// Returns a [`SimplexValidationReport`] containing all checkable simplex + /// violations. + pub fn simplex_report(&self) -> Result<(), SimplexValidationReport> { + let mut violations = Vec::new(); + + if let Err(source) = validate_uuid(&self.uuid) { + violations.push(SimplexValidationError::InvalidUuid { source }); + } + + if self.vertices.len() != D + 1 { + violations.push(SimplexValidationError::InsufficientVertices { + actual: self.vertices.len(), + expected: D + 1, + dimension: D, + }); + } + + // D is intentionally small in this crate; a fixed-size scan avoids a hash allocation. + let mut duplicate_vertices = false; + for (index, &vkey) in self.vertices.iter().enumerate() { + if self.vertices[..index].contains(&vkey) { + duplicate_vertices = true; + break; + } + } + if duplicate_vertices { + violations.push(SimplexValidationError::DuplicateVertices); + } + + if let Some(ref neighbors) = self.neighbors { + if neighbors.len() != D + 1 { + violations.push(SimplexValidationError::InvalidNeighborsLength { + actual: neighbors.len(), + expected: D + 1, + dimension: D, + }); + } + for (facet_index, slot) in neighbors.iter().enumerate() { + if slot.is_unassigned() { + violations.push(SimplexValidationError::UnassignedNeighborSlot { facet_index }); + } + } + } + + if violations.is_empty() { + Ok(()) + } else { + Err(SimplexValidationReport { violations }) + } + } } // Advanced implementation block for Simplex methods diff --git a/src/core/tds/errors.rs b/src/core/tds/errors.rs index e54c92c8..082b2872 100644 --- a/src/core/tds/errors.rs +++ b/src/core/tds/errors.rs @@ -5,6 +5,7 @@ use super::{SimplexKey, VertexKey}; use crate::core::algorithms::flips::{FlipError, FlipNeighborWiringError}; +use crate::core::embedding::TriangulationEmbeddingValidationError; use crate::core::facet::FacetError; use crate::core::simplex::SimplexValidationError; use crate::core::validation::TriangulationValidationError; @@ -179,7 +180,7 @@ pub enum GeometricError { /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; /// assert_eq!(dt.number_of_vertices(), 4); -/// assert!(dt.is_valid().is_ok()); +/// assert!(dt.is_valid_delaunay().is_ok()); /// # Ok(()) /// # } /// ``` @@ -949,6 +950,8 @@ pub enum InvariantKind { Connectedness, /// Triangulation/topology invariants (manifold-with-boundary, Euler characteristic). Topology, + /// Embedded Euclidean geometry (nondegenerate simplices, no illegal overlap). + Embedding, /// Delaunay empty-circumsphere property. DelaunayProperty, } @@ -980,7 +983,11 @@ pub enum InvariantError { #[error(transparent)] Triangulation(#[from] TriangulationValidationError), - /// Level 4 (Delaunay property). + /// Level 4 (embedded Euclidean geometry). + #[error(transparent)] + Embedding(#[from] TriangulationEmbeddingValidationError), + + /// Level 5 (Delaunay property). #[error(transparent)] Delaunay(#[from] DelaunayTriangulationValidationError), } @@ -1045,7 +1052,7 @@ impl From<&TriangulationValidationError> for TriangulationValidationErrorKind { } } -/// Discriminant for compact Level 4 Delaunay-validation summaries. +/// Discriminant for compact Level 5 Delaunay-validation summaries. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum DelaunayValidationErrorKind { @@ -1053,6 +1060,8 @@ pub enum DelaunayValidationErrorKind { Tds, /// Lower-layer topology validation failed. Triangulation, + /// Lower-layer embedded-geometry validation failed. + Embedding, /// Delaunay verification failed. VerificationFailed, /// Typed repair validation failed. @@ -1064,6 +1073,7 @@ impl From<&DelaunayTriangulationValidationError> for DelaunayValidationErrorKind match source { DelaunayTriangulationValidationError::Tds(_) => Self::Tds, DelaunayTriangulationValidationError::Triangulation(_) => Self::Triangulation, + DelaunayTriangulationValidationError::Embedding(_) => Self::Embedding, DelaunayTriangulationValidationError::VerificationFailed { .. } => { Self::VerificationFailed } diff --git a/src/core/tds/storage.rs b/src/core/tds/storage.rs index fd604166..16d9d950 100644 --- a/src/core/tds/storage.rs +++ b/src/core/tds/storage.rs @@ -74,13 +74,15 @@ //! | **Coherent Orientation** | `Tds::is_valid()` / `Tds::validate()` | Adjacent simplices induce opposite facet orientations | //! | **Simplex Vertex Keys** | `Tds::is_valid()` / `Tds::validate()` | Simplices reference only valid vertex keys | //! | **Vertex Incidence** | `Tds::is_valid()` / `Tds::validate()` | `Vertex::incident_simplex` is non-dangling and consistent (when present) | -//! | **Simplex Validity** | `SimplexBuilder::validate()` (vertex count) + `simplex.is_valid()` (comprehensive) | Construction + runtime validation | -//! | **Vertex Validity** | [`Point::try_new`](crate::geometry::point::Point::try_new) / [`Point`](crate::geometry::point::Point) coordinate conversion (coordinates) + UUID auto-gen + `vertex.is_valid()` | Construction + runtime validation | +//! | **Simplex Validity** | `SimplexBuilder::validate()` (vertex count) + `simplex.is_valid()` / `simplex_report()` | Construction + runtime validation | +//! | **Vertex Validity** | [`Point::try_new`](crate::geometry::point::Point::try_new) / [`Point`](crate::geometry::point::Point) coordinate conversion (coordinates) + UUID auto-gen + `vertex.is_valid()` / `vertex_report()` | Construction + runtime validation | //! //! The incremental insertion algorithm attempts to maintain the Delaunay property during //! construction, but rare violations can remain. Structural invariants are enforced //! **reactively** through validation methods. For a definitive Delaunay check, run -//! Level 4 validation via `DelaunayTriangulation::is_valid()` / `DelaunayTriangulation::validate()`. +//! Level 4 embedding validation via `Triangulation::validate_embedding()` and +//! Level 5 Delaunay validation via `DelaunayTriangulation::is_valid_delaunay()` / +//! `DelaunayTriangulation::validate()`. //! //! # Validation //! @@ -99,10 +101,12 @@ //! - Coherent orientation (adjacent simplices induce opposite facet orientations) //! - Facet sharing invariant (≀2 simplices per facet) //! - Neighbor consistency -//! 3. **Level 3: Manifold Topology** - [`Triangulation::is_valid()`] +//! 3. **Level 3: Manifold Topology** - [`Triangulation::is_valid_topology()`] //! - Builds on Level 2, and rejects isolated vertices (every vertex must be incident to β‰₯ 1 simplex) //! - Adds manifold-with-boundary + Euler characteristic -//! 4. **Level 4: Delaunay Property** - [`DelaunayTriangulation::is_valid()`] +//! 4. **Level 4: Faithful Embedding** - [`Triangulation::validate_embedding()`](crate::Triangulation::validate_embedding) +//! - Nondegenerate embedded simplices and no intersections outside shared faces +//! 5. **Level 5: Delaunay Property** - [`DelaunayTriangulation::is_valid_delaunay()`] //! - Empty circumsphere property //! //! ## TDS Validation Methods @@ -110,7 +114,7 @@ //! - [`is_valid()`](Tds::is_valid) - Level 2 only (structural); returns first error, stops early //! - [`validate()`](Tds::validate) - Levels 1–2 (elements + structural); returns first error, stops early //! -//! For cumulative diagnostics across the full stack (Levels 1–4), use +//! For cumulative diagnostics across the full stack (Levels 1–5), use //! [`DelaunayTriangulation::validation_report()`]. //! //! ## Example: Using Validation @@ -172,8 +176,8 @@ //! //! [`Simplex::is_valid()`]: crate::prelude::tds::Simplex::is_valid //! [`Vertex::is_valid()`]: crate::prelude::Vertex::is_valid -//! [`Triangulation::is_valid()`]: crate::prelude::triangulation::Triangulation::is_valid -//! [`DelaunayTriangulation::is_valid()`]: crate::DelaunayTriangulation::is_valid +//! [`Triangulation::is_valid_topology()`]: crate::prelude::triangulation::Triangulation::is_valid_topology +//! [`DelaunayTriangulation::is_valid_delaunay()`]: crate::DelaunayTriangulation::is_valid_delaunay //! [`DelaunayTriangulation::validation_report()`]: crate::DelaunayTriangulation::validation_report //! //! # Examples @@ -1429,7 +1433,7 @@ impl Tds { /// An empty triangulation (no simplices) is trivially connected. /// /// Connectivity is a **topology-layer** (Level 3) invariant: it is not checked - /// by [`Tds::is_valid`] (Level 2), but it *is* checked by [`Triangulation::is_valid`]. + /// by [`Tds::is_valid`] (Level 2), but it *is* checked by [`Triangulation::is_valid_topology`]. /// This method exposes the underlying BFS so that diagnostic code and the /// `Triangulation`-layer check can both reuse the same primitive without going /// through a full `Triangulation` wrapper. @@ -1437,7 +1441,7 @@ impl Tds { /// Time complexity: O(N Β· D), where N is the number of simplices (each simplex has at most /// D+1 neighbors, so the BFS visits at most NΒ·(D+1) edges). /// - /// [`Triangulation::is_valid`]: crate::prelude::triangulation::Triangulation::is_valid + /// [`Triangulation::is_valid_topology`]: crate::prelude::triangulation::Triangulation::is_valid_topology /// /// # Examples /// diff --git a/src/core/tds/validation.rs b/src/core/tds/validation.rs index 0f783738..4fb44fab 100644 --- a/src/core/tds/validation.rs +++ b/src/core/tds/validation.rs @@ -1,5 +1,7 @@ //! TDS structural validation and diagnostic reporting. +#![forbid(unsafe_code)] + use super::errors::{ EntityKind, InvariantKind, InvariantViolation, NeighborValidationError, SharedFacetMismatchSide, TdsError, TriangulationValidationReport, @@ -302,7 +304,7 @@ impl Tds { /// Note: at the TDS structural layer (Level 2), isolated vertices (vertices not referenced by /// any simplex) are allowed, so `Vertex::incident_simplex` may be `None`. /// - /// Level 3 topology validation (`Triangulation::is_valid`) rejects isolated vertices. + /// Level 3 topology validation (`Triangulation::is_valid_topology`) rejects isolated vertices. /// /// However, any `incident_simplex` pointer that *is* present must: /// - point to an existing simplex key, and @@ -932,7 +934,7 @@ impl Tds { /// /// This is a **Level 2 (TDS structural)** check in the validation hierarchy. /// It intentionally does **not** validate individual vertices/simplices (Level 1), - /// nor triangulation topology (Level 3), nor the Delaunay property (Level 4). + /// nor triangulation topology (Level 3), faithful embedding (Level 4), or the Delaunay property (Level 5). /// /// # Structural invariants checked /// - Vertex UUID↔key mapping consistency @@ -958,6 +960,19 @@ impl Tds { /// /// Returns a [`TdsError`] if any structural invariant fails. /// + /// Checks whether the triangulation data structure is structurally valid. + /// + /// This is the canonical Level 2 fast-fail API. It returns the first + /// structural error that proves the TDS incidence graph is invalid. + /// + /// For an actionable first diagnostic use + /// [`structure_diagnostic`](Self::structure_diagnostic). For all checkable + /// structural failures use [`structure_report`](Self::structure_report). + /// + /// # Errors + /// + /// Returns a [`TdsError`] if any structural invariant fails. + /// /// # Examples /// /// ```rust @@ -993,7 +1008,7 @@ impl Tds { /// ``` pub fn is_valid(&self) -> Result<(), TdsError> { // Fast-fail: return the first violated invariant. - // For full diagnostics across all structural invariants, use `validation_report()`. + // For full diagnostics across all structural invariants, use `structure_report()`. self.validate_vertex_mappings()?; self.validate_simplex_mappings()?; @@ -1016,6 +1031,28 @@ impl Tds { Ok(()) } + /// Returns the first actionable Level 2 structural diagnostic, if any. + /// + /// This is the repair/retry-oriented counterpart to + /// [`is_valid`](Self::is_valid): it preserves the + /// [`InvariantKind`] grouping used by aggregate reports while still + /// returning at most one local failure. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::tds::Tds; + /// + /// let tds = Tds::<(), (), 2>::empty(); + /// assert!(tds.structure_diagnostic().is_none()); + /// ``` + #[must_use] + pub fn structure_diagnostic(&self) -> Option { + self.structure_report() + .err() + .and_then(|report| report.violations.into_iter().next()) + } + /// Performs cumulative validation for Levels 1–2. /// /// This validates: @@ -1096,10 +1133,10 @@ impl Tds { self.is_valid() } - /// Runs structural validation checks and returns a report containing **all** failed invariants. + /// Runs Level 2 structure checks and returns all checkable structural failures. /// - /// Unlike [`is_valid()`](Self::is_valid), this method does **not** stop at the - /// first error. Instead it records a [`TdsError`] for each + /// Unlike [`is_valid`](Self::is_valid), this method does + /// **not** stop at the first error. Instead it records a [`TdsError`] for each /// invariant group that fails and returns them as a /// [`TriangulationValidationReport`]. /// @@ -1110,22 +1147,28 @@ impl Tds { /// key-reference failure (and any vertex-incidence failures) and skips derived /// invariants that assume key validity. /// - /// This is primarily intended for debugging, diagnostics, and tests that - /// want to surface every violated invariant at once. + /// This is primarily intended for debugging, diagnostics, tests, and repair + /// planning that need local structured failures instead of only the first + /// error. /// /// **Note**: This does NOT check the Delaunay property. Use - /// `DelaunayTriangulation::is_valid()` (Level 4) or `DelaunayTriangulation::validate()` (Levels 1–4) - /// for geometric validation. + /// `Triangulation::validate_embedding()` for Levels 1–4, `DelaunayTriangulation::is_valid_delaunay()` + /// for Level 5 only, or `DelaunayTriangulation::validate()` for cumulative Levels 1–5. /// /// # Errors /// /// Returns a [`TriangulationValidationReport`] containing all invariant /// violations if any validation step fails. - #[expect( - clippy::too_many_lines, - reason = "validation report aggregation is intentionally linear and simplex nomenclature makes existing names longer" - )] - pub(crate) fn validation_report(&self) -> Result<(), TriangulationValidationReport> { + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::*; + /// + /// let tds: Tds<(), (), 2> = Tds::empty(); + /// assert!(tds.structure_report().is_ok()); + /// ``` + pub fn structure_report(&self) -> Result<(), TriangulationValidationReport> { let mut violations = Vec::new(); // 1. Mapping consistency (vertex + simplex UUID↔key mappings) @@ -1245,24 +1288,61 @@ impl Tds { }); } - // 8. Connectivity (topology-layer invariant; reported here for comprehensive diagnostics). - // - // Note: connectivity is NOT part of Level-2 `is_valid()` β€” it belongs at Level 3 - // (Triangulation::is_valid). It is included here in the diagnostic report so that - // `DelaunayTriangulation::validation_report()` surfaces it together with all other - // structural failures, even when the Triangulation wrapper is not available. - if !self.is_connected() { - violations.push(InvariantViolation { - kind: InvariantKind::Connectedness, - error: TdsError::InconsistentDataStructure { - message: format!( - "Disconnected triangulation: simplex neighbor graph is not a single \ - connected component ({} simplices total)", - self.simplices.len() - ), - } - .into(), - }); + if violations.is_empty() { + Ok(()) + } else { + Err(TriangulationValidationReport { violations }) + } + } + + /// Generate a cumulative validation report for Levels 1–2. + /// + /// This report combines Level 1 element validity with the Level 2 + /// [`structure_report`](Self::structure_report). + /// + /// # Errors + /// + /// Returns `Err(TriangulationValidationReport)` containing all checkable + /// Level 1-2 invariant violations. + pub fn validation_report(&self) -> Result<(), TriangulationValidationReport> { + let mut violations = Vec::new(); + + for (_vertex_key, vertex) in &self.vertices { + if let Err(report) = (*vertex).vertex_report() { + violations.extend(report.violations.into_iter().map(|source| { + InvariantViolation { + kind: InvariantKind::VertexValidity, + error: TdsError::InvalidVertex { + vertex_id: vertex.uuid(), + source, + } + .into(), + } + })); + } + } + + for (simplex_key, simplex) in &self.simplices { + if let Err(report) = simplex.simplex_report() { + violations.extend(report.violations.into_iter().map(|source| { + let error = self.simplex_uuid_from_key(simplex_key).map_or_else( + || TdsError::InconsistentDataStructure { + message: format!( + "Simplex key {simplex_key:?} has no UUID mapping during validation", + ), + }, + |simplex_id| TdsError::InvalidSimplex { simplex_id, source }, + ); + InvariantViolation { + kind: InvariantKind::SimplexValidity, + error: error.into(), + } + })); + } + } + + if let Err(report) = self.structure_report() { + violations.extend(report.violations); } if violations.is_empty() { diff --git a/src/core/validation.rs b/src/core/validation.rs index 4d1c0b8b..4cd1db91 100644 --- a/src/core/validation.rs +++ b/src/core/validation.rs @@ -4,25 +4,32 @@ //! triangulation-level validation pipeline: //! //! - **Level 1** element validity remains implemented next to the element types: -//! [`Vertex::is_valid`](crate::prelude::Vertex::is_valid) and -//! [`Simplex::is_valid`](crate::prelude::tds::Simplex::is_valid). +//! [`Vertex::is_valid`](crate::prelude::Vertex::is_valid) / +//! [`Vertex::vertex_report`](crate::prelude::Vertex::vertex_report) and +//! [`Simplex::is_valid`](crate::prelude::tds::Simplex::is_valid) / +//! [`Simplex::simplex_report`](crate::prelude::tds::Simplex::simplex_report). //! - **Level 2** structural validation remains implemented by //! [`Tds`](crate::prelude::tds::Tds). //! - **Level 3** topological validation is orchestrated here for //! [`Triangulation`](crate::Triangulation). +//! - **Level 4** faithful embedded-geometry validation is implemented by +//! [`Triangulation::validate_embedding`](crate::Triangulation::validate_embedding). //! -//! Delaunay-specific Level 4 validation lives in [`crate::validation`]. Keeping +//! Delaunay-specific Level 5 validation lives in [`crate::validation`]. Keeping //! the module boundary at the generic triangulation layer avoids one file per //! validation level while still making the layering explicit. //! //! # Validation Hierarchy //! -//! The library provides **four levels** of validation, each building on the previous: +//! The library provides **five levels** of validation, each building on the previous: //! //! ## Level 1: Element Validity //! -//! - **Methods**: [`Simplex::is_valid()`](crate::prelude::tds::Simplex::is_valid), -//! [`Vertex::is_valid()`](crate::prelude::Vertex::is_valid) +//! - **Methods**: +//! [`Simplex::is_valid()`](crate::prelude::tds::Simplex::is_valid), +//! [`Simplex::simplex_report()`](crate::prelude::tds::Simplex::simplex_report), +//! [`Vertex::is_valid()`](crate::prelude::Vertex::is_valid), +//! [`Vertex::vertex_report()`](crate::prelude::Vertex::vertex_report) //! - **Checks**: Basic data integrity (coordinate validity, UUID presence, proper initialization) //! - **Cost**: O(1) per element //! @@ -41,7 +48,7 @@ //! //! ## Level 3: Manifold Topology //! -//! - **Method**: [`Triangulation::is_valid()`](crate::prelude::triangulation::Triangulation::is_valid) +//! - **Method**: [`Triangulation::is_valid_topology()`](crate::prelude::triangulation::Triangulation::is_valid_topology) //! - **Checks**: //! - **Codimension-1 incidence**: each facet is one-sided or two-sided //! - **Topology-aware boundary manifoldness**: true boundary facets are closed ("no boundary of boundary") @@ -53,14 +60,23 @@ //! Use [`Triangulation::validate()`](crate::prelude::triangulation::Triangulation::validate) //! for cumulative Levels 1–3. //! -//! ## Level 4: Delaunay Property +//! ## Level 4: Faithful Embedding //! -//! - **Method**: [`DelaunayTriangulation::is_valid()`](crate::DelaunayTriangulation::is_valid) +//! - **Method**: [`Triangulation::validate_embedding`](crate::prelude::triangulation::Triangulation::validate_embedding) +//! - **Checks**: Nondegenerate maximal simplices and no intersections outside shared faces +//! - **Cost**: O(NΒ²) worst case, dominated by pairwise simplex-intersection checks +//! +//! Use [`Triangulation::validate_embedding`](crate::prelude::triangulation::Triangulation::validate_embedding) +//! for cumulative Levels 1–4. +//! +//! ## Level 5: Delaunay Property +//! +//! - **Method**: [`DelaunayTriangulation::is_valid_delaunay()`](crate::DelaunayTriangulation::is_valid_delaunay) //! - **Checks**: Empty circumsphere property (no vertex inside any simplex's circumsphere) //! - **Cost**: O(NΓ—V) where N = simplices, V = vertices //! //! Use [`DelaunayTriangulation::validate()`](crate::DelaunayTriangulation::validate) -//! for cumulative Levels 1–4. +//! for cumulative Levels 1–5. //! //! ## Topology guarantees //! @@ -110,7 +126,7 @@ use crate::topology::manifold::{ validate_ridge_links_for_simplices, validate_vertex_links_from_validated_facet_map, }; use crate::topology::traits::topological_space::{GlobalTopology, TopologyError, TopologyKind}; -use std::time::Instant; +use std::time::{Duration, Instant}; use thiserror::Error; use uuid::Uuid; @@ -119,6 +135,7 @@ use uuid::Uuid; /// /// - `TopologyValidation(source)` β†’ `InvariantError::Tds(source)` (Level 1–2 preserved) /// - `TopologyValidationFailed { source }` β†’ `InvariantError::Triangulation(source)` (Level 3 preserved) +/// - `DelaunayValidationFailed { source }` β†’ `InvariantError::Delaunay(source)` (Level 5 preserved) /// - All other variants β†’ `InvariantError::Tds(InconsistentDataStructure { .. })` with `context` pub(crate) fn insertion_error_to_invariant_error( error: InsertionError, @@ -129,6 +146,7 @@ pub(crate) fn insertion_error_to_invariant_error( InsertionError::TopologyValidationFailed { source, .. } => { InvariantError::Triangulation(source) } + InsertionError::DelaunayValidationFailed { source } => InvariantError::Delaunay(source), other => InvariantError::Tds(TdsError::InconsistentDataStructure { message: format!("{context}: {other}"), }), @@ -826,7 +844,7 @@ impl Triangulation { Ok(()) } - /// Shared Level-3 topology validation sequence used by both [`is_valid`](Self::is_valid) + /// Shared Level-3 topology validation sequence used by both [`is_valid_topology`](Self::is_valid_topology) /// and [`is_valid_topology_only`](Self::is_valid_topology_only). /// /// Checks connectedness, manifold facet degree, closed boundary, ridge/vertex @@ -1194,11 +1212,11 @@ where /// DelaunayTriangulationBuilder::new(&vertices_4d).build::<()>()?; /// /// // Level 3: topology validation (manifold-with-boundary + Euler characteristic) - /// assert!(dt.as_triangulation().is_valid().is_ok()); + /// assert!(dt.as_triangulation().is_valid_topology().is_ok()); /// # Ok(()) /// # } /// ``` - pub fn is_valid(&self) -> Result<(), InvariantError> { + pub fn is_valid_topology(&self) -> Result<(), InvariantError> { self.validate_topology_core()?; // Check geometric orientation after manifold/link checks so topology-specific // diagnostics surface first when multiple invariants are violated. @@ -1206,9 +1224,132 @@ where Ok(()) } + /// Returns the first actionable Level 3 topology diagnostic, if any. + /// + /// This is the repair/retry-oriented counterpart to + /// [`is_valid_topology`](Self::is_valid_topology). It preserves the + /// [`InvariantKind`] grouping used by aggregate reports while returning at + /// most one local failure. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// assert!(dt.as_triangulation().topology_diagnostic().is_none()); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn topology_diagnostic(&self) -> Option { + self.topology_report() + .err() + .and_then(|report| report.violations.into_iter().next()) + } + + /// Generate a Level 3 topology report. + /// + /// This report checks topology-layer invariants only. It assumes the TDS + /// structure is already valid; use [`validation_report`](Self::validation_report) + /// for cumulative Levels 1-4 diagnostics. + /// + /// # Errors + /// + /// Returns `Err(TriangulationValidationReport)` when one or more checkable + /// topology-layer invariants fail. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// assert!(dt.as_triangulation().topology_report().is_ok()); + /// # Ok(()) + /// # } + /// ``` + pub fn topology_report(&self) -> Result<(), TriangulationValidationReport> { + let mut violations = Vec::new(); + + if let Err(source) = self.validate_global_connectedness() { + violations.push(InvariantViolation { + kind: InvariantKind::Connectedness, + error: InvariantError::Triangulation(source), + }); + } + + match self.tds.build_facet_to_simplices_map() { + Ok(facet_to_simplices) => { + match ValidatedFacetDegreeMap::try_from_facet_map(&facet_to_simplices) { + Ok(validated_facets) => { + if let Err(source) = + self.validate_topology_core_from_validated_facet_map(validated_facets) + { + violations.push(InvariantViolation { + kind: InvariantKind::Topology, + error: source, + }); + } + } + Err(source) => { + violations.push(InvariantViolation { + kind: InvariantKind::Topology, + error: source.into(), + }); + } + } + } + Err(source) => { + violations.push(InvariantViolation { + kind: InvariantKind::Topology, + error: source.into(), + }); + } + } + + if let Err(source) = self.validate_geometric_simplex_orientation() { + violations.push(InvariantViolation { + kind: InvariantKind::Topology, + error: source.into(), + }); + } + + if let Err(source) = self.validate_at_completion() { + violations.push(InvariantViolation { + kind: InvariantKind::Topology, + error: source, + }); + } + + if violations.is_empty() { + Ok(()) + } else { + Err(TriangulationValidationReport { violations }) + } + } + /// Validates topological invariants **without** geometric orientation checks. /// - /// This is identical to [`is_valid`](Self::is_valid) but omits the + /// This is identical to [`is_valid_topology`](Self::is_valid_topology) but omits the /// `validate_geometric_simplex_orientation()` step. It is intended for /// explicit combinatorial construction where the user-provided vertex /// orderings may produce negative determinants that are nonetheless @@ -1297,7 +1438,7 @@ where /// /// This validates: /// - **Level 1–2** via [`Tds::validate`](crate::prelude::tds::Tds::validate) - /// - **Level 3** via [`Triangulation::is_valid`](Self::is_valid) + /// - **Level 3** via [`Triangulation::is_valid_topology`](Self::is_valid_topology) /// - **Completion-time PL-manifold check** via [`Triangulation::validate_at_completion`](Self::validate_at_completion) /// /// # Errors @@ -1353,8 +1494,9 @@ where /// Generate a comprehensive validation report for Levels 1–3. /// - /// This is intended for debugging/telemetry where you want to see *all* violated - /// invariants, not just the first one. + /// This is intended for debugging, telemetry, tests, and repair planning + /// where you want to see all checkable violated invariants, not just the + /// first one. /// /// # Notes /// - If UUID↔key mappings are inconsistent, this returns only mapping failures (other @@ -1364,14 +1506,33 @@ where /// # Errors /// /// Returns `Err(TriangulationValidationReport)` containing all invariant violations. - pub(crate) fn validation_report(&self) -> Result<(), TriangulationValidationReport> + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{DelaunayResult, DelaunayTriangulationBuilder}; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// delaunay::vertex![0.0, 0.0, 0.0]?, + /// delaunay::vertex![1.0, 0.0, 0.0]?, + /// delaunay::vertex![0.0, 1.0, 0.0]?, + /// delaunay::vertex![0.0, 0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// assert!(dt.as_triangulation().validation_report().is_ok()); + /// # Ok(()) + /// # } + /// ``` + pub fn validation_report(&self) -> Result<(), TriangulationValidationReport> where U: DataType, V: DataType, { let mut violations: Vec = Vec::new(); - // Level 2 (structural): reuse the TDS report. + // Levels 1-2: reuse the TDS cumulative report. match self.tds.validation_report() { Ok(()) => {} Err(report) => { @@ -1387,38 +1548,9 @@ where } } - // Level 1 (element validity): vertices - for (_vertex_key, vertex) in self.tds.vertices() { - if let Err(source) = (*vertex).is_valid() { - violations.push(InvariantViolation { - kind: InvariantKind::VertexValidity, - error: InvariantError::Tds(TdsError::InvalidVertex { - vertex_id: vertex.uuid(), - source, - }), - }); - } - } - - // Level 1 (element validity): simplices - for (_simplex_key, simplex) in self.tds.simplices() { - if let Err(source) = simplex.is_valid() { - violations.push(InvariantViolation { - kind: InvariantKind::SimplexValidity, - error: InvariantError::Tds(TdsError::InvalidSimplex { - simplex_id: simplex.uuid(), - source, - }), - }); - } - } - - // Level 3 (topology) - if let Err(e) = self.is_valid() { - violations.push(InvariantViolation { - kind: InvariantKind::Topology, - error: e, - }); + // Level 3: topology. + if let Err(report) = self.topology_report() { + violations.extend(report.violations); } if violations.is_empty() { @@ -1432,6 +1564,7 @@ where /// /// - `InvariantError::Tds(e)` β†’ `InsertionError::TopologyValidation(e)` /// - `InvariantError::Triangulation(e)` β†’ `InsertionError::TopologyValidationFailed { source: e }` + /// - `InvariantError::Embedding(e)` β†’ `InsertionError::DelaunayValidationFailed { source: e.into() }` /// - `InvariantError::Delaunay(e)` β†’ `InsertionError::DelaunayValidationFailed { source: e }` pub(crate) fn invariant_error_to_insertion_error(err: InvariantError) -> InsertionError { match err { @@ -1440,6 +1573,9 @@ where context: InsertionTopologyValidationContext::InvariantConversion, source: tri_err, }, + InvariantError::Embedding(embedding_err) => InsertionError::DelaunayValidationFailed { + source: embedding_err.into(), + }, InvariantError::Delaunay(dt_err) => { InsertionError::DelaunayValidationFailed { source: dt_err } } @@ -1479,6 +1615,9 @@ where // even when global validation is throttled. Run this after topology // checks so topology diagnostics still surface first. self.validate_geometric_simplex_orientation()?; + let simplex_keys: SimplexKeyBuffer = self.tds.simplex_keys().collect(); + self.validate_local_embedding_nondegeneracy(&simplex_keys) + .map_err(InvariantError::Embedding)?; Ok(()) } @@ -1611,6 +1750,8 @@ where } self.validate_geometric_simplex_orientation_for_simplices(simplices)?; + self.validate_local_embedding_nondegeneracy(simplices) + .map_err(InvariantError::Embedding)?; Ok(()) } @@ -1637,6 +1778,24 @@ where } } + /// Reuses the Level 4 insertion-time guard for either caller-provided local + /// simplices or the full triangulation when no local scope is available. + fn validate_insertion_embedding_scope( + &self, + local_simplices: Option<&[SimplexKey]>, + ) -> Result<(), InvariantError> { + let all_simplex_keys; + let simplex_keys = if let Some(local_simplices) = local_simplices { + local_simplices + } else { + all_simplex_keys = self.tds.simplex_keys().collect::(); + &all_simplex_keys + }; + + self.validate_local_embedding_nondegeneracy(simplex_keys) + .map_err(InvariantError::Embedding) + } + pub(crate) fn validate_after_insertion_with_scope( &self, suspicion: SuspicionFlags, @@ -1648,7 +1807,10 @@ where log_validation_trigger_if_enabled(self.validation_policy, suspicion); match work { - InsertionValidationWork::FullValidation => self.is_valid(), + InsertionValidationWork::FullValidation => { + self.is_valid_topology()?; + self.validate_insertion_embedding_scope(local_simplices) + } InsertionValidationWork::RequiredTopologyLinks => local_simplices.map_or_else( || self.validate_required_topology_links(), |simplices| self.validate_required_topology_links_for_simplices(simplices), @@ -1716,7 +1878,7 @@ fn record_topology_validation_telemetry( /// Convert a duration to nanoseconds while saturating at `u64::MAX`. #[inline] -fn duration_nanos_saturating(duration: std::time::Duration) -> u64 { +fn duration_nanos_saturating(duration: Duration) -> u64 { u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) } @@ -1733,6 +1895,7 @@ mod tests { use crate::core::algorithms::incremental_insertion::CavityFillingError; use crate::core::algorithms::incremental_insertion::repair_neighbor_pointers; use crate::core::collections::NeighborBuffer; + use crate::core::embedding::TriangulationEmbeddingValidationError; use crate::core::facet::FacetError; use crate::core::operations::InsertionOutcome; use crate::core::simplex::Simplex; @@ -1745,6 +1908,7 @@ mod tests { use crate::repair::DelaunayRepairPolicy; use crate::triangulation::DelaunayTriangulation; use crate::validation::{DelaunayTriangulationValidationError, DelaunayVerificationError}; + use crate::vertex; use slotmap::KeyData; use std::{assert_matches, iter}; @@ -1762,6 +1926,10 @@ mod tests { } } + fn test_vertex(coords: [f64; D]) -> Vertex<(), D> { + vertex!(coords).unwrap() + } + fn insert_test_vertex_with_coords( tds: &mut Tds<(), (), D>, entries: &[(usize, f64)], @@ -1770,10 +1938,7 @@ mod tests { for &(axis, value) in entries { coords[axis] = value; } - tds.insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new(coords).unwrap(), - ) - .unwrap() + tds.insert_vertex_with_mapping(test_vertex(coords)).unwrap() } fn build_invalid_vertex_link_tds() -> (Tds<(), (), D>, VertexKey) { @@ -1811,12 +1976,8 @@ mod tests { for axis in 0..D { let mut coords = [0.0_f64; D]; coords[axis] = 1.0; - first_simplex_vertices.push( - tds.insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new(coords).unwrap(), - ) - .unwrap(), - ); + first_simplex_vertices + .push(tds.insert_vertex_with_mapping(test_vertex(coords)).unwrap()); } let mut second_simplex_vertices = vec![shared]; @@ -1824,12 +1985,8 @@ mod tests { let mut coords = [0.0_f64; D]; coords[0] = 10.0; coords[axis] += 1.0; - second_simplex_vertices.push( - tds.insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new(coords).unwrap(), - ) - .unwrap(), - ); + second_simplex_vertices + .push(tds.insert_vertex_with_mapping(test_vertex(coords)).unwrap()); } let _ = tds @@ -1852,35 +2009,23 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let a0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let a1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) .unwrap(); let a2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) .unwrap(); let b0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([10.0, 0.0])) .unwrap(); let b1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([11.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([11.0, 0.0])) .unwrap(); let b2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([10.0, 1.0])) .unwrap(); let _ = tds @@ -1901,29 +2046,19 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, -1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, -1.0])) .unwrap(); let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([2.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([2.0, 0.0])) .unwrap(); let _ = tds @@ -1947,17 +2082,17 @@ mod tests { fn unit_simplex_vertices() -> Vec> { let mut vertices = Vec::with_capacity(D + 1); - vertices.push(crate::core::vertex::Vertex::<(), _>::try_new([0.0_f64; D]).unwrap()); + vertices.push(test_vertex([0.0_f64; D])); for axis in 0..D { let mut coords = [0.0_f64; D]; coords[axis] = 1.0; - vertices.push(crate::core::vertex::Vertex::<(), _>::try_new(coords).unwrap()); + vertices.push(test_vertex(coords)); } vertices } fn unit_simplex_interior_vertex() -> Vertex<(), D> { - crate::core::vertex::Vertex::<(), _>::try_new([0.125_f64; D]).unwrap() + test_vertex([0.125_f64; D]) } fn build_single_tet() -> ( @@ -1967,24 +2102,16 @@ mod tests { ) { let mut tds: Tds<(), (), 3> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) .unwrap(); let ck = tds .insert_simplex_with_mapping( @@ -2002,6 +2129,35 @@ mod tests { ) } + fn build_degenerate_tet() -> (Triangulation, (), (), 3>, SimplexKey) { + let mut tds: Tds<(), (), 3> = Tds::empty(); + let v0 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(test_vertex([1.0, 1.0, 0.0])) + .unwrap(); + let ck = tds + .insert_simplex_with_mapping( + Simplex::try_new_with_data(vec![v0, v1, v2, v3], None).unwrap(), + ) + .unwrap(); + for vk in [v0, v1, v2, v3] { + tds.vertex_mut(vk).unwrap().set_incident_simplex(Some(ck)); + } + + ( + Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds), + ck, + ) + } + #[test] fn triangulation_validation_error_try_from_manifold_error_preserves_detail() { let tds_err = TdsError::InconsistentDataStructure { @@ -2211,9 +2367,9 @@ mod tests { #[test] fn try_global_topology_setter_rejects_closed_metadata_for_euclidean_boundary() { let vertices: Vec> = vec![ - Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0]), + test_vertex([1.0, 0.0]), + test_vertex([0.0, 1.0]), ]; let tds = Triangulation::, (), (), 2>::build_initial_simplex(&vertices).unwrap(); @@ -2234,7 +2390,7 @@ mod tests { ) ); assert_eq!(tri.global_topology(), GlobalTopology::Euclidean); - assert!(tri.is_valid().is_ok()); + assert!(tri.is_valid_topology().is_ok()); } #[test] @@ -2505,9 +2661,7 @@ mod tests { tri.set_validation_policy(ValidationPolicy::Always); let _ = tri .tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); assert_eq!(tri.number_of_simplices(), 0); @@ -2581,6 +2735,15 @@ mod tests { InvariantError::Triangulation(inner) ); + let delaunay_source = synthetic_delaunay_verification_error("delaunay"); + let error = InsertionError::DelaunayValidationFailed { + source: delaunay_source.clone(), + }; + assert_eq!( + insertion_error_to_invariant_error(error, "ctx"), + InvariantError::Delaunay(delaunay_source) + ); + let error = InsertionError::CavityFilling { reason: CavityFillingError::EmptyFanTriangulation, }; @@ -2672,12 +2835,10 @@ mod tests { let (mut tri, _, _) = build_single_tet(); let iso = tri .tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 0.5, 0.5]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.5, 0.5, 0.5])) .unwrap(); - match tri.is_valid() { + match tri.is_valid_topology() { Err(InvariantError::Triangulation(TriangulationValidationError::IsolatedVertex { vertex_key, .. @@ -2693,7 +2854,7 @@ mod tests { let tds = build_disconnected_two_triangles_tds_2d(); let tri = Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); - match tri.is_valid() { + match tri.is_valid_topology() { Err(InvariantError::Triangulation(TriangulationValidationError::Disconnected { simplex_count, })) => assert_eq!(simplex_count, 2), @@ -2720,9 +2881,7 @@ mod tests { let (mut tri, _, _) = build_single_tet(); let _ = tri .tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 0.5, 0.5]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.5, 0.5, 0.5])) .unwrap(); match tri.validate() { @@ -2738,11 +2897,11 @@ mod tests { #[test] fn validation_report_ok_for_valid_triangulation() { let vertices = [ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 0.5, 0.5]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), + test_vertex([0.5, 0.5, 0.5]), ]; let dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::try_new(&vertices).unwrap(); @@ -2754,9 +2913,7 @@ mod tests { let (mut tri, _, _) = build_single_tet(); let _ = tri .tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 0.5, 0.5]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.5, 0.5, 0.5])) .unwrap(); let report = tri.validation_report().unwrap_err(); @@ -2789,7 +2946,7 @@ mod tests { #[test] fn []() { let vertices: Vec> = vec![ - $(crate::core::vertex::Vertex::<(), _>::try_new($simplex_coords).unwrap()),+ + $(test_vertex($simplex_coords)),+ ]; let expected_vertices = vertices.len(); @@ -2799,7 +2956,7 @@ mod tests { .expect("simplex construction should succeed"); let tri = dt.as_triangulation(); - assert!(tri.is_valid().is_ok()); + assert!(tri.is_valid_topology().is_ok()); assert_eq!(tri.number_of_vertices(), expected_vertices); assert_eq!(tri.number_of_simplices(), 1); } @@ -2844,7 +3001,7 @@ mod tests { let tri: Triangulation, (), (), 3> = Triangulation::new_empty(FastKernel::new()); - assert!(tri.is_valid().is_ok()); + assert!(tri.is_valid_topology().is_ok()); } #[test] @@ -2852,24 +3009,16 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 1.0])) .unwrap(); let _ = tds @@ -2894,19 +3043,13 @@ mod tests { .unwrap(); let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 10.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([10.0, 10.0])) .unwrap(); let v5 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([11.0, 10.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([11.0, 10.0])) .unwrap(); let v6 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 11.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([10.0, 11.0])) .unwrap(); let _ = tds @@ -2937,7 +3080,7 @@ mod tests { tri.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); assert_matches!( - tri.is_valid(), + tri.is_valid_topology(), Err(InvariantError::Triangulation( TriangulationValidationError::Disconnected { .. } )) @@ -2945,7 +3088,7 @@ mod tests { tri.set_topology_guarantee(TopologyGuarantee::PLManifoldStrict); - match tri.is_valid() { + match tri.is_valid_topology() { Err(InvariantError::Triangulation( TriangulationValidationError::VertexLinkNotManifold { vertex_key, .. }, )) => assert_eq!(vertex_key, v0), @@ -2968,20 +3111,16 @@ mod tests { let mut v: [[VertexKey; M]; N] = [[VertexKey::from(KeyData::from_ffi(0)); M]; N]; for (i, row) in v.iter_mut().enumerate() { for (j, slot) in row.iter_mut().enumerate() { - let i_f = >::from(u32::try_from(i).unwrap()); - let j_f = >::from(u32::try_from(j).unwrap()); + let i_f = f64::from(u32::try_from(i).unwrap()); + let j_f = f64::from(u32::try_from(j).unwrap()); *slot = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([i_f, j_f, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([i_f, j_f, 0.0])) .unwrap(); } } let apex = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.5, 0.5, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.5, 0.5, 1.0])) .unwrap(); for i in 0..N { @@ -3021,7 +3160,7 @@ mod tests { tri.set_topology_guarantee(TopologyGuarantee::PLManifoldStrict); - match tri.is_valid() { + match tri.is_valid_topology() { Err(InvariantError::Triangulation( TriangulationValidationError::VertexLinkNotManifold { vertex_key, @@ -3043,34 +3182,22 @@ mod tests { let mut tds: Tds<(), (), 3> = Tds::empty(); let shared_edge_v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) .unwrap(); let shared_edge_v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) .unwrap(); let tet1_v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) .unwrap(); let tet1_v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) .unwrap(); let tet2_v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, -1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, -1.0, 0.0])) .unwrap(); let tet2_v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, -1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, -1.0])) .unwrap(); let _ = tds @@ -3094,7 +3221,7 @@ mod tests { let tri = Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds); - match tri.is_valid() { + match tri.is_valid_topology() { Err(InvariantError::Triangulation(TriangulationValidationError::Disconnected { simplex_count, })) => assert_eq!(simplex_count, 2), @@ -3105,10 +3232,10 @@ mod tests { #[test] fn validate_includes_tds_validation() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let dt = DelaunayTriangulation::try_new(&vertices).unwrap(); let tri = dt.as_triangulation(); @@ -3121,11 +3248,11 @@ mod tests { fn is_valid_rejects_bootstrap_phase_with_isolated_vertex() { let mut tri: Triangulation, (), (), 3> = Triangulation::new_empty(FastKernel::new()); - let vertex = crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(); + let vertex = test_vertex([0.0, 0.0, 0.0]); let expected_uuid = vertex.uuid(); let expected_vk = tri.tds.insert_vertex_with_mapping(vertex).unwrap(); - match tri.is_valid() { + match tri.is_valid_topology() { Err(InvariantError::Triangulation(TriangulationValidationError::IsolatedVertex { vertex_key, vertex_uuid, @@ -3140,10 +3267,10 @@ mod tests { #[test] fn is_valid_rejects_isolated_vertex_even_when_simplices_exist() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let tds = @@ -3154,13 +3281,11 @@ mod tests { let _isolated_vk = tri .tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 10.0, 10.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([10.0, 10.0, 10.0])) .unwrap(); assert_matches!( - tri.is_valid(), + tri.is_valid_topology(), Err(InvariantError::Triangulation( TriangulationValidationError::IsolatedVertex { .. } )) @@ -3171,21 +3296,9 @@ mod tests { fn is_valid_rejects_disconnected_even_when_euler_matches() { let mut tds: Tds<(), (), 1> = Tds::empty(); - let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0]).unwrap(), - ) - .unwrap(); - let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0]).unwrap(), - ) - .unwrap(); - let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([2.0]).unwrap(), - ) - .unwrap(); + let v0 = tds.insert_vertex_with_mapping(test_vertex([0.0])).unwrap(); + let v1 = tds.insert_vertex_with_mapping(test_vertex([1.0])).unwrap(); + let v2 = tds.insert_vertex_with_mapping(test_vertex([2.0])).unwrap(); let e0 = tds .insert_simplex_with_mapping(Simplex::try_new_with_data(vec![v0, v1], None).unwrap()) .unwrap(); @@ -3193,21 +3306,9 @@ mod tests { .insert_simplex_with_mapping(Simplex::try_new_with_data(vec![v1, v2], None).unwrap()) .unwrap(); - let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0]).unwrap(), - ) - .unwrap(); - let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([11.0]).unwrap(), - ) - .unwrap(); - let v5 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([12.0]).unwrap(), - ) - .unwrap(); + let v3 = tds.insert_vertex_with_mapping(test_vertex([10.0])).unwrap(); + let v4 = tds.insert_vertex_with_mapping(test_vertex([11.0])).unwrap(); + let v5 = tds.insert_vertex_with_mapping(test_vertex([12.0])).unwrap(); let c0 = tds .insert_simplex_with_mapping(Simplex::try_new_with_data(vec![v3, v4], None).unwrap()) .unwrap(); @@ -3248,7 +3349,7 @@ mod tests { assert_eq!(topology.expected, Some(1)); assert_eq!(topology.chi, 1); - match tri.is_valid() { + match tri.is_valid_topology() { Err(InvariantError::Triangulation(TriangulationValidationError::Disconnected { simplex_count, })) => assert_eq!(simplex_count, 5), @@ -3259,10 +3360,10 @@ mod tests { #[test] fn tds_is_valid_rejects_boundary_facet_has_neighbor() { let vertices_simplex_1 = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let mut tds = @@ -3271,24 +3372,16 @@ mod tests { let first_simplex_key = tds.simplex_keys().next().unwrap(); let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([10.0, 0.0, 0.0])) .unwrap(); let v5 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([11.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([11.0, 0.0, 0.0])) .unwrap(); let v6 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([10.0, 1.0, 0.0])) .unwrap(); let v7 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([10.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([10.0, 0.0, 1.0])) .unwrap(); let second_simplex_key = tds @@ -3316,29 +3409,19 @@ mod tests { let mut tds: Tds<(), (), 3> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) .unwrap(); let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 2.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 2.0])) .unwrap(); let _ = tds @@ -3365,34 +3448,22 @@ mod tests { let mut tds: Tds<(), (), 3> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 0.0])) .unwrap(); let v1 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0, 0.0])) .unwrap(); let v2 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0, 0.0])) .unwrap(); let v3 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 1.0])) .unwrap(); let v4 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 2.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 2.0])) .unwrap(); let v5 = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 3.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0, 3.0])) .unwrap(); let _ = tds @@ -3413,7 +3484,7 @@ mod tests { let tri = Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds); - match tri.is_valid() { + match tri.is_valid_topology() { Err(InvariantError::Triangulation(TriangulationValidationError::Disconnected { .. })) => {} @@ -3427,10 +3498,10 @@ mod tests { #[test] fn validation_report_returns_mapping_failures_only() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); @@ -3453,10 +3524,10 @@ mod tests { #[test] fn validation_report_includes_vertex_and_simplex_validity() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let tds = Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); @@ -3498,7 +3569,7 @@ mod tests { tri.set_validation_policy(ValidationPolicy::OnSuspicion); tri.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); - assert!(tri.is_valid().is_err()); + assert!(tri.is_valid_topology().is_err()); tri.validate_after_insertion_with_scope(SuspicionFlags::default(), None) .unwrap(); } @@ -3606,7 +3677,7 @@ mod tests { Some(&detail.repair_seed_simplices), ) .unwrap(); - tri.is_valid().unwrap(); + tri.is_valid_topology().unwrap(); } )+ } @@ -3621,9 +3692,7 @@ mod tests { let _ = tri .tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([5.0, 5.0, 5.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([5.0, 5.0, 5.0])) .unwrap(); let simplex = tri.tds.simplex_mut(ck).unwrap(); @@ -3658,10 +3727,10 @@ mod tests { #[test] fn validate_after_insertion_ok_for_valid_simplex() { let vertices = [ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::try_new(&vertices).unwrap(); @@ -3677,6 +3746,51 @@ mod tests { ); } + #[test] + fn validate_after_insertion_rejects_local_degenerate_simplex_embedding() { + let (tri, ck) = build_degenerate_tet(); + let mut scope = SimplexKeyBuffer::new(); + scope.push(ck); + + let err = tri + .validate_after_insertion_with_scope(SuspicionFlags::default(), Some(&scope)) + .unwrap_err(); + + assert_matches!( + err, + InvariantError::Embedding( + TriangulationEmbeddingValidationError::DegenerateSimplex { + simplex_key, + dimension: 3, + .. + } + ) if simplex_key == ck + ); + } + + #[test] + fn validate_after_insertion_full_validation_rejects_local_degenerate_simplex_embedding() { + let (mut tri, ck) = build_degenerate_tet(); + tri.set_validation_policy(ValidationPolicy::Always); + let mut scope = SimplexKeyBuffer::new(); + scope.push(ck); + + let err = tri + .validate_after_insertion_with_scope(SuspicionFlags::default(), Some(&scope)) + .unwrap_err(); + + assert_matches!( + err, + InvariantError::Embedding( + TriangulationEmbeddingValidationError::DegenerateSimplex { + simplex_key, + dimension: 3, + .. + } + ) if simplex_key == ck + ); + } + #[test] fn validate_at_completion_ok_for_pseudomanifold_empty() { let mut tri: Triangulation, (), (), 3> = @@ -3728,9 +3842,9 @@ mod tests { #[test] fn required_topology_validation_records_telemetry() { let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0]), + test_vertex([1.0, 0.0]), + test_vertex([0.0, 1.0]), ]; for guarantee in [ @@ -3747,7 +3861,7 @@ mod tests { let hint = tri.simplices().next().map(|(simplex_key, _)| simplex_key); let detail = tri .insert_with_statistics_seeded_indexed_detailed( - crate::core::vertex::Vertex::<(), _>::try_new([0.25, 0.25]).unwrap(), + test_vertex([0.25, 0.25]), None, hint, 0, diff --git a/src/core/vertex.rs b/src/core/vertex.rs index 897addb2..eb6cfa17 100644 --- a/src/core/vertex.rs +++ b/src/core/vertex.rs @@ -89,6 +89,30 @@ pub enum VertexValidationError { }, } +/// Aggregate report for standalone vertex validation failures. +/// +/// This is the Level 1 element-local report counterpart to +/// [`Vertex::is_valid`] and [`Vertex::vertex_diagnostic`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VertexValidationReport { + /// The ordered list of vertex invariant violations that occurred. + pub violations: Vec, +} + +impl VertexValidationReport { + /// Returns `true` if no violations were recorded. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.violations.is_empty() + } + + /// Returns the recorded vertex invariant violations. + #[must_use] + pub fn violations(&self) -> &[VertexValidationError] { + &self.violations + } +} + // ============================================================================= // CONVENIENCE MACROS AND HELPERS // ============================================================================= @@ -648,6 +672,39 @@ impl Vertex { // User data validation (if U: DataType requires it) could be added here. } + /// Returns the first standalone vertex validation diagnostic, if any. + #[must_use] + pub fn vertex_diagnostic(&self) -> Option { + self.is_valid().err() + } + + /// Runs standalone vertex validation and returns all checkable failures. + /// + /// Unlike [`is_valid`](Self::is_valid), this method does not + /// stop after the first invalid field. + /// + /// # Errors + /// + /// Returns a [`VertexValidationReport`] containing all checkable vertex + /// violations. + pub fn vertex_report(&self) -> Result<(), VertexValidationReport> { + let mut violations = Vec::new(); + + if let Err(source) = self.point.validate() { + violations.push(VertexValidationError::InvalidPoint { source }); + } + + if let Err(source) = validate_uuid(&self.uuid()) { + violations.push(VertexValidationError::InvalidUuid { source }); + } + + if violations.is_empty() { + Ok(()) + } else { + Err(VertexValidationReport { violations }) + } + } + /// Creates a vertex with a caller-provided UUID after validating it. /// /// This constructor is intended for serialization round-trips and other diff --git a/src/delaunay/builder.rs b/src/delaunay/builder.rs index 61132eb4..43fd6010 100644 --- a/src/delaunay/builder.rs +++ b/src/delaunay/builder.rs @@ -474,7 +474,7 @@ pub enum ExplicitConstructionError { #[source] source: Box, }, - /// Level 4 Delaunay validation failed before returning the wrapper. + /// Level 5 Delaunay validation failed before returning the wrapper. #[error("Delaunay validation failed during explicit construction: {source}")] DelaunayValidation { /// Underlying Delaunay validation error. @@ -483,7 +483,7 @@ pub enum ExplicitConstructionError { }, /// Explicit quotient connectivity is not supported for the requested topology. #[error( - "Explicit non-Euclidean connectivity is not supported for {topology:?}; Level 4 quotient validation is required" + "Explicit non-Euclidean connectivity is not supported for {topology:?}; quotient embedding validation is required" )] UnsupportedExplicitTopology { /// Requested global topology metadata. @@ -1436,7 +1436,7 @@ where source: Box::new(e), } })?; - dt.is_valid().map_err(|e| { + dt.is_valid_delaunay().map_err(|e| { TriangulationConstructionError::FinalDelaunayValidation { context: FinalDelaunayValidationContext::PeriodicQuotientDelaunay, source: e, @@ -1451,9 +1451,10 @@ where /// /// This is a purely combinatorial construction that assembles a valid TDS from /// the given connectivity without Delaunay point insertion. Euclidean explicit - /// meshes are validated at Levels 1–4 (elements, structure, topology, and the - /// Delaunay property). Non-Euclidean explicit connectivity is rejected because - /// it requires Level 4 quotient-topology validation before the public + /// meshes are validated at Levels 1–5 (elements, structure, topology, + /// embedding, and the Delaunay property). Non-Euclidean explicit + /// connectivity is rejected because it requires quotient embedding + /// validation before the public /// `DelaunayTriangulation` wrapper can accept it. /// /// # Algorithm @@ -1465,13 +1466,13 @@ where /// 5. Wrap in a validation candidate. /// 6. Normalize coherent orientation and promote to positive canonical sign /// via `normalize_and_promote_positive_orientation()`. - /// 7. Reject non-Euclidean explicit connectivity until Level 4 quotient + /// 7. Reject non-Euclidean explicit connectivity until quotient embedding /// validation exists. /// 8. Validate Levels 1–2 (TDS structural: `tds.validate()`). /// 9. Validate Level 3 topology (excluding geometric orientation). /// 10. Validate PL-manifold completion (vertex links, if required). /// 11. Validate geometric nondegeneracy (reject zero-volume simplices). - /// 12. Validate the Euclidean Level 4 Delaunay property. + /// 12. Validate the Euclidean Level 5 Delaunay property. fn build_explicit( kernel: &K, vertices: &[Vertex], @@ -1615,12 +1616,13 @@ where Ok(candidate.into_validated_delaunay(proof)) } - /// Enforces Level 4 validation before returning the Delaunay wrapper. + /// Enforces Level 5 validation before returning the Delaunay wrapper. /// /// The public return type is `DelaunayTriangulation`, so Euclidean explicit /// connectivity must prove the empty-circumsphere property before it crosses /// this API boundary. Explicit non-Euclidean topology is rejected earlier in - /// `build_explicit` until a Level 4 validator exists for quotient connectivity. + /// `build_explicit` until quotient embedding validation exists for explicit + /// connectivity. fn enforce_explicit_delaunay_property( candidate: &DelaunayTriangulationCandidate, ) -> Result @@ -1636,7 +1638,7 @@ where }) } - /// Rejects explicit quotient connectivity until Level 4 validation supports it. + /// Rejects explicit quotient connectivity until embedding validation supports it. fn reject_explicit_non_euclidean_topology( global_topology: GlobalTopology, ) -> Result<(), DelaunayTriangulationConstructionError> { diff --git a/src/delaunay/construction.rs b/src/delaunay/construction.rs index 421739c4..55d9d227 100644 --- a/src/delaunay/construction.rs +++ b/src/delaunay/construction.rs @@ -991,7 +991,7 @@ pub enum DelaunayConstructionFailure { reason: HullExtensionReason, }, - /// Level 4 Delaunay validation failed during insertion. + /// Level 5 Delaunay validation failed during insertion. #[error("Delaunay validation failed during insertion: {source}")] InsertionDelaunayValidation { /// Underlying Delaunay validation error. @@ -3676,7 +3676,7 @@ where // batch construction. tracing::debug!("post-construction: starting Delaunay validation (build)"); let delaunay_started = Instant::now(); - let delaunay_result = dt.is_valid(); + let delaunay_result = dt.is_valid_delaunay(); tracing::debug!( elapsed = ?delaunay_started.elapsed(), success = delaunay_result.is_ok(), @@ -3724,7 +3724,7 @@ where // batch construction. tracing::debug!("post-construction: starting Delaunay validation (build stats)"); let delaunay_started = Instant::now(); - let delaunay_result = dt.is_valid(); + let delaunay_result = dt.is_valid_delaunay(); let delaunay_elapsed = delaunay_started.elapsed(); stats .telemetry @@ -5987,7 +5987,7 @@ mod tests { dt.insert_vertex(*vertices.last().unwrap()).unwrap(); assert_eq!(dt.number_of_vertices(), $dim + 1); assert_eq!(dt.number_of_simplices(), 1); - assert!(dt.is_valid().is_ok()); + assert!(dt.is_valid_delaunay().is_ok()); } #[test] @@ -6005,7 +6005,7 @@ mod tests { dt.insert_vertex(vertex!($interior_point).unwrap()).unwrap(); assert_eq!(dt.number_of_vertices(), $dim + 2); assert!(dt.number_of_simplices() > 1); - assert!(dt.is_valid().is_ok()); + assert!(dt.is_valid_delaunay().is_ok()); } #[test] @@ -6027,8 +6027,8 @@ mod tests { dt_bootstrap.number_of_simplices(), dt_batch.number_of_simplices() ); - assert!(dt_bootstrap.is_valid().is_ok()); - assert!(dt_batch.is_valid().is_ok()); + assert!(dt_bootstrap.is_valid_delaunay().is_ok()); + assert!(dt_batch.is_valid_delaunay().is_ok()); } } }; diff --git a/src/delaunay/deletion.rs b/src/delaunay/deletion.rs index 546f849a..b7d7f7b7 100644 --- a/src/delaunay/deletion.rs +++ b/src/delaunay/deletion.rs @@ -40,10 +40,18 @@ pub enum DeleteVertexError { InvariantViolation { /// Structured invariant failure produced by the deletion attempt. #[from] - source: InvariantError, + source: Box, }, } +impl From for DeleteVertexError { + fn from(source: InvariantError) -> Self { + Self::InvariantViolation { + source: Box::new(source), + } + } +} + // ============================================================================= // VERTEX DELETION (Requires Numeric Scalar Bounds) // ============================================================================= @@ -74,7 +82,7 @@ where /// [`InvariantError::Triangulation`], and the pre-deletion state is restored. Both the inverse /// k=1 fast-path and fan triangulation may temporarily violate the Delaunay property in some /// cases. If the [`DelaunayRepairPolicy`](crate::DelaunayRepairPolicy) allows it, a flip-based - /// repair pass is run automatically after deletion. Otherwise, Level 4 validation is run without + /// repair pass is run automatically after deletion. Otherwise, Level 5 validation is run without /// mutating repair, and Delaunay violations roll back as invariant failures. /// /// The post-deletion repair and orientation canonicalization steps are @@ -87,7 +95,7 @@ where /// /// **Future Enhancement**: Delaunay-aware cavity retriangulation will be added for /// deletions. For now, local retriangulation can still require post-deletion flip repair; if - /// automatic repair is disabled and Level 4 validation detects a violation, deletion fails and + /// automatic repair is disabled and Level 5 validation detects a violation, deletion fails and /// rolls back. /// /// # Arguments @@ -113,7 +121,7 @@ where /// - Delaunay flip-based repair fails after deletion /// ([`DeleteVertexError::InvariantViolation`] wrapping [`InvariantError::Delaunay`] wrapping /// [`DelaunayTriangulationValidationError::RepairOperationFailed`]). - /// - Level 4 Delaunay validation fails after deletion when automatic repair is disabled + /// - Level 5 Delaunay validation fails after deletion when automatic repair is disabled /// ([`DeleteVertexError::InvariantViolation`] wrapping [`InvariantError::Delaunay`] wrapping /// [`DelaunayTriangulationValidationError::VerificationFailed`]). /// - Orientation canonicalization fails after repair @@ -173,6 +181,8 @@ where /// # Source(#[from] delaunay::DelaunayTriangulationConstructionError), /// # #[error(transparent)] /// # Coordinate(#[from] delaunay::prelude::geometry::CoordinateConversionError), + /// # #[error(transparent)] + /// # Delete(#[from] DeleteVertexError), /// # } /// # fn main() -> Result<(), ExampleError> { /// let vertices = [ @@ -188,13 +198,12 @@ where /// let err = dt /// .delete_vertex(vertex_key) /// .expect_err("deletion should leave an isolated vertex"); + /// let DeleteVertexError::InvariantViolation { source } = err else { + /// return Err(err.into()); + /// }; /// std::assert_matches!( - /// err, - /// DeleteVertexError::InvariantViolation { - /// source: InvariantError::Triangulation( - /// TriangulationValidationError::IsolatedVertex { .. } - /// ) - /// } + /// source.as_ref(), + /// InvariantError::Triangulation(TriangulationValidationError::IsolatedVertex { .. }) /// ); /// assert_eq!(dt.number_of_vertices(), 3); /// assert_eq!(dt.number_of_simplices(), 1); @@ -282,7 +291,9 @@ where ) })?; } else { - delaunay.is_valid().map_err(InvariantError::Delaunay)?; + delaunay + .is_valid_delaunay() + .map_err(InvariantError::Delaunay)?; } Ok(simplices_removed) @@ -414,25 +425,24 @@ mod tests { let result = dt.delete_vertex(vertex_key); let err = result.expect_err("forced repair failure should make deletion fail"); match err { - DeleteVertexError::InvariantViolation { - source: - InvariantError::Delaunay( - DelaunayTriangulationValidationError::RepairOperationFailed { - operation: DelaunayRepairOperation::VertexRemoval, - source, - }, - ), - } if matches!( - source.as_ref(), - DelaunayRepairError::NonConvergent { max_flips: 0, .. } - ) => {} - DeleteVertexError::InvariantViolation { - source: - InvariantError::Triangulation( - TriangulationValidationError::OrientationPromotionNonConvergence { .. }, - ) - | InvariantError::Tds(TdsError::FacetSharingViolation { .. }), - } => {} + DeleteVertexError::InvariantViolation { source } => match source.as_ref() { + InvariantError::Delaunay( + DelaunayTriangulationValidationError::RepairOperationFailed { + operation: DelaunayRepairOperation::VertexRemoval, + source, + }, + ) if matches!( + source.as_ref(), + DelaunayRepairError::NonConvergent { max_flips: 0, .. } + ) => {} + InvariantError::Triangulation( + TriangulationValidationError::OrientationPromotionNonConvergence { .. }, + ) + | InvariantError::Tds(TdsError::FacetSharingViolation { .. }) => {} + other => panic!( + "expected vertex-deletion rollback error from forced repair path, got {other:?}" + ), + }, other => panic!( "expected vertex-deletion rollback error from forced repair path, got {other:?}" ), @@ -664,7 +674,7 @@ mod tests { let deleted_uuid = vertices[4].uuid(); let mut dt: DelaunayTriangulation<_, (), (), 2> = DelaunayTriangulation::try_new(&vertices).unwrap(); - dt.is_valid().unwrap(); + dt.is_valid_delaunay().unwrap(); dt.set_topology_guarantee(TopologyGuarantee::PLManifold); dt.set_delaunay_repair_policy(DelaunayRepairPolicy::Never); @@ -690,13 +700,14 @@ mod tests { .delete_vertex(vertex_key) .expect_err("disabled repair should roll back a Level 4 violation"); + let DeleteVertexError::InvariantViolation { source } = err else { + panic!("expected invariant violation, got {err:?}"); + }; assert_matches!( - err, - DeleteVertexError::InvariantViolation { - source: InvariantError::Delaunay( - DelaunayTriangulationValidationError::VerificationFailed { .. }, - ), - } + source.as_ref(), + InvariantError::Delaunay( + DelaunayTriangulationValidationError::VerificationFailed { .. } + ) ); assert_eq!(dt.number_of_vertices(), vertex_count_before); assert_eq!(dt.number_of_simplices(), simplex_count_before); @@ -711,6 +722,6 @@ mod tests { spatial_index_before ); assert!(dt.vertices().any(|(_, v)| v.uuid() == deleted_uuid)); - dt.is_valid().unwrap(); + dt.is_valid_delaunay().unwrap(); } } diff --git a/src/delaunay/insertion.rs b/src/delaunay/insertion.rs index 831cdd4e..e536943c 100644 --- a/src/delaunay/insertion.rs +++ b/src/delaunay/insertion.rs @@ -610,7 +610,7 @@ where return Ok(()); } - self.is_valid() + self.is_valid_delaunay() .map_err(|e| InsertionError::DelaunayValidationFailed { source: e }) } } @@ -811,7 +811,7 @@ mod tests { dt.insert_vertex(vertex![0.0, 0.0, 1.0].unwrap()).unwrap(); assert_eq!(dt.number_of_simplices(), 1); // Initial simplex created - assert!(dt.is_valid().is_ok()); + assert!(dt.is_valid_delaunay().is_ok()); } /// When the primary per-insertion repair returns `NonConvergent`, the robust diff --git a/src/core/util/delaunay_validation.rs b/src/delaunay/property_validation.rs similarity index 83% rename from src/core/util/delaunay_validation.rs rename to src/delaunay/property_validation.rs index f085d4c7..bd6c5441 100644 --- a/src/core/util/delaunay_validation.rs +++ b/src/delaunay/property_validation.rs @@ -1,16 +1,16 @@ -//! Delaunay empty-circumsphere property validation utilities. +//! Delaunay empty-circumsphere property scans over bare TDS storage. +//! +//! This module is the reusable Level 5 property engine: it answers whether a +//! [`Tds`](crate::tds::Tds) violates the Delaunay empty-circumsphere condition and returns +//! repair-oriented keys for offending simplices, vertices, and neighbors. It +//! does not own wrapper-level validation policy, cumulative roll-up, or +//! construction proofs; those live in `validation`. #![forbid(unsafe_code)] -use crate::core::collections::ViolationBuffer; -#[cfg(any(test, feature = "diagnostics"))] -use crate::core::collections::{NeighborBuffer, SimplexVertexKeyBuffer}; -#[cfg(not(any(test, feature = "diagnostics")))] -use crate::core::simplex::SimplexValidationError; -#[cfg(any(test, feature = "diagnostics"))] +use crate::core::collections::{NeighborBuffer, SimplexVertexKeyBuffer, ViolationBuffer}; use crate::core::simplex::{NeighborSlot, SimplexValidationError}; use crate::core::tds::{SimplexKey, Tds, TdsError, VertexKey}; -use crate::core::traits::data_type::DataType; use crate::geometry::point::Point; use crate::geometry::predicates::InSphere; use crate::geometry::robust_predicates::robust_insphere; @@ -24,11 +24,16 @@ use thiserror::Error; /// /// ```rust /// use delaunay::prelude::tds::SimplexKey; -/// use delaunay::prelude::repair::DelaunayValidationError; +/// use delaunay::prelude::validation::DelaunayValidationError; /// use slotmap::KeyData; /// /// let simplex_key = SimplexKey::from(KeyData::from_ffi(1)); -/// let err = DelaunayValidationError::DelaunayViolation { simplex_key }; +/// let err = DelaunayValidationError::DelaunayViolation { +/// simplex_key, +/// simplex_vertices: Default::default(), +/// offending_vertex: None, +/// neighbor_simplices: Default::default(), +/// }; /// std::assert_matches!(err, DelaunayValidationError::DelaunayViolation { .. }); /// ``` #[derive(Clone, Debug, Error, PartialEq)] @@ -36,11 +41,24 @@ use thiserror::Error; pub enum DelaunayValidationError { /// A simplex violates the Delaunay property (has an external vertex inside its circumsphere). #[error( - "Simplex violates Delaunay property: simplex contains vertex that is inside circumsphere" + "Simplex {simplex_key:?} violates Delaunay property; offending vertex: {offending_vertex:?}" )] DelaunayViolation { - /// The key of the simplex that violates the Delaunay property + /// The key of the simplex that violates the Delaunay property. simplex_key: SimplexKey, + /// Vertex keys stored by the violating simplex at report time. + /// + /// Boxed to keep the error enum small while preserving typed repair + /// context. + simplex_vertices: Box, + /// First external vertex found inside the simplex circumsphere, if one could + /// be identified. + offending_vertex: Option, + /// Neighbor slots of the violating simplex, preserving facet-index order. + /// + /// Boxed to keep the error enum small while preserving typed repair + /// context. + neighbor_simplices: Box>, }, /// TDS data structure corruption or other structural issues detected during validation. #[error("TDS corruption: {source}")] @@ -75,15 +93,15 @@ pub enum DelaunayValidationError { /// Structured summary of Delaunay empty-circumsphere violations. /// -/// This diagnostic report is available with the `diagnostics` feature and is -/// intended for bug reports, regression tests, and local investigation. It -/// records stable TDS keys rather than copying all coordinates; callers can -/// look up coordinates, UUIDs, and simplex data in the original [`Tds`]. +/// This diagnostic report is intended for repair planning, bug reports, +/// regression tests, and local investigation. It records stable TDS keys rather +/// than copying all coordinates; callers can look up coordinates, UUIDs, and +/// simplex data in the original [`Tds`]. /// /// # Examples /// /// ```rust -/// use delaunay::prelude::diagnostics::delaunay_violation_report; +/// use delaunay::prelude::validation::delaunay_violation_report; /// use delaunay::prelude::*; /// /// # #[derive(Debug, thiserror::Error)] @@ -108,8 +126,6 @@ pub enum DelaunayValidationError { /// # Ok(()) /// # } /// ``` -#[cfg(any(test, feature = "diagnostics"))] -#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))] #[derive(Clone, Debug, PartialEq, Eq)] #[must_use] pub struct DelaunayViolationReport { @@ -125,31 +141,40 @@ pub struct DelaunayViolationReport { pub checked_simplices: usize, /// Simplices that failed the empty-circumsphere property. pub violating_simplices: ViolationBuffer, - /// Details for the first violating simplex, if one is still present in the TDS. - pub first_violation: Option, + /// Details for each violating simplex that was still present in the TDS. + pub violation_details: Vec, } -#[cfg(any(test, feature = "diagnostics"))] impl DelaunayViolationReport { /// Returns `true` when no Delaunay violations were found. /// /// # Examples /// /// ```rust - /// use delaunay::prelude::diagnostics::DelaunayViolationReport; + /// use delaunay::prelude::validation::DelaunayViolationReport; /// /// let report = DelaunayViolationReport { /// number_of_vertices: 0, /// number_of_simplices: 0, /// checked_simplices: 0, /// violating_simplices: Default::default(), - /// first_violation: None, + /// violation_details: Vec::new(), /// }; /// assert!(report.is_valid()); /// ``` #[must_use] pub fn is_valid(&self) -> bool { - self.violating_simplices.is_empty() + self.violating_simplices.is_empty() && self.violation_details.is_empty() + } + + /// Returns the first violating-simplex detail, if one is present. + /// + /// This borrowed view is derived from + /// [`violation_details`](Self::violation_details), avoiding a second owned + /// copy that could diverge from the report's canonical detail list. + #[must_use] + pub fn first_violation(&self) -> Option<&DelaunayViolationDetail> { + self.violation_details.first() } } @@ -165,8 +190,6 @@ impl DelaunayViolationReport { /// [`Boundary`](NeighborSlot::Boundary) hull facets, /// [`Unassigned`](NeighborSlot::Unassigned) missing wiring, and /// [`Neighbor`](NeighborSlot::Neighbor) simplex links. -#[cfg(any(test, feature = "diagnostics"))] -#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))] #[derive(Clone, Debug, PartialEq, Eq)] #[must_use] pub struct DelaunayViolationDetail { @@ -194,11 +217,7 @@ fn validate_simplex_delaunay( tds: &Tds, simplex_key: SimplexKey, simplex_vertex_points: &mut SmallVec<[Point; 8]>, -) -> Result, DelaunayValidationError> -where - U: DataType, - V: DataType, -{ +) -> Result, DelaunayValidationError> { Ok( first_delaunay_violation_witness(tds, simplex_key, simplex_vertex_points)? .map(|_| simplex_key), @@ -210,11 +229,7 @@ fn first_delaunay_violation_witness( tds: &Tds, simplex_key: SimplexKey, simplex_vertex_points: &mut SmallVec<[Point; 8]>, -) -> Result, DelaunayValidationError> -where - U: DataType, - V: DataType, -{ +) -> Result, DelaunayValidationError> { let Some(simplex) = tds.simplex(simplex_key) else { // Simplex doesn't exist (possibly removed), skip validation return Ok(None); @@ -237,10 +252,9 @@ where for &vkey in &simplex_vertex_keys { let Some(v) = tds.vertex(vkey) else { return Err(DelaunayValidationError::TriangulationState { - source: TdsError::InconsistentDataStructure { - message: format!( - "Simplex {simplex_key:?} references non-existent vertex {vkey:?}" - ), + source: TdsError::VertexNotFound { + vertex_key: vkey, + context: format!("Delaunay property validation for simplex {simplex_key:?}"), }, }); }; @@ -359,13 +373,9 @@ where /// This performs the expensive geometric check but intentionally does **not** run /// `tds.is_valid()` up front. Callers that want cumulative validation should run /// lower-layer checks separately. -pub(crate) fn is_delaunay_property_only( +pub fn is_delaunay_property_only( tds: &Tds, -) -> Result<(), DelaunayValidationError> -where - U: DataType, - V: DataType, -{ +) -> Result<(), DelaunayValidationError> { // Reusable buffer to minimize allocations let mut simplex_vertex_points: SmallVec<[Point; 8]> = SmallVec::with_capacity(D + 1); @@ -374,9 +384,15 @@ where if let Some(violating_simplex) = validate_simplex_delaunay(tds, simplex_key, &mut simplex_vertex_points)? { - return Err(DelaunayValidationError::DelaunayViolation { - simplex_key: violating_simplex, + let detail = build_violation_detail(tds, violating_simplex).unwrap_or_else(|| { + DelaunayViolationDetail { + simplex_key: violating_simplex, + simplex_vertices: SimplexVertexKeyBuffer::new(), + offending_vertex: None, + neighbor_simplices: NeighborBuffer::new(), + } }); + return Err(detail.into()); } } @@ -413,7 +429,7 @@ where /// /// ``` /// use delaunay::prelude::*; -/// use delaunay::prelude::repair::find_delaunay_violations; +/// use delaunay::prelude::validation::find_delaunay_violations; /// /// # #[derive(Debug, thiserror::Error)] /// # enum ExampleError { @@ -444,11 +460,7 @@ where pub fn find_delaunay_violations( tds: &Tds, simplices_to_check: Option<&[SimplexKey]>, -) -> Result -where - U: DataType, - V: DataType, -{ +) -> Result { let mut violating_simplices = ViolationBuffer::new(); let mut simplex_vertex_points: SmallVec<[Point; 8]> = SmallVec::with_capacity(D + 1); @@ -507,7 +519,7 @@ where /// Build a structured Delaunay violation report. /// /// This is the structured counterpart to -/// [`debug_print_first_delaunay_violation`]. It uses the same robust +/// `debug_print_first_delaunay_violation`. It uses the same robust /// empty-circumsphere scan as [`find_delaunay_violations`] and returns compact, /// key-based diagnostics that can be attached to bug reports or inspected by /// tests without relying on tracing output. @@ -528,7 +540,7 @@ where /// # Examples /// /// ```rust -/// use delaunay::prelude::diagnostics::delaunay_violation_report; +/// use delaunay::prelude::validation::delaunay_violation_report; /// use delaunay::prelude::*; /// /// # #[derive(Debug, thiserror::Error)] @@ -554,42 +566,32 @@ where /// # Ok(()) /// # } /// ``` -#[cfg(any(test, feature = "diagnostics"))] -#[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))] pub fn delaunay_violation_report( tds: &Tds, simplices_to_check: Option<&[SimplexKey]>, -) -> Result -where - U: DataType, - V: DataType, -{ +) -> Result { let violating_simplices = find_delaunay_violations(tds, simplices_to_check)?; let checked_simplices = simplices_to_check.map_or_else(|| tds.number_of_simplices(), <[_]>::len); - let first_violation = violating_simplices - .first() - .and_then(|&simplex_key| build_violation_detail(tds, simplex_key)); + let violation_details: Vec<_> = violating_simplices + .iter() + .filter_map(|&simplex_key| build_violation_detail(tds, simplex_key)) + .collect(); Ok(DelaunayViolationReport { number_of_vertices: tds.number_of_vertices(), number_of_simplices: tds.number_of_simplices(), checked_simplices, violating_simplices, - first_violation, + violation_details, }) } /// Builds the compact detail record for a violating simplex that still exists in the TDS. -#[cfg(any(test, feature = "diagnostics"))] fn build_violation_detail( tds: &Tds, simplex_key: SimplexKey, -) -> Option -where - U: DataType, - V: DataType, -{ +) -> Option { let simplex = tds.simplex(simplex_key)?; let simplex_vertices = simplex.vertices().iter().copied().collect(); let neighbor_simplices = simplex @@ -606,21 +608,27 @@ where } /// Finds one external vertex that witnesses a simplex's Delaunay violation, if available. -#[cfg(any(test, feature = "diagnostics"))] fn first_offending_vertex( tds: &Tds, simplex_key: SimplexKey, -) -> Option -where - U: DataType, - V: DataType, -{ +) -> Option { let mut simplex_vertex_points: SmallVec<[Point; 8]> = SmallVec::with_capacity(D + 1); first_delaunay_violation_witness(tds, simplex_key, &mut simplex_vertex_points) .ok() .flatten() } +impl From for DelaunayValidationError { + fn from(detail: DelaunayViolationDetail) -> Self { + Self::DelaunayViolation { + simplex_key: detail.simplex_key, + simplex_vertices: Box::new(detail.simplex_vertices), + offending_vertex: detail.offending_vertex, + neighbor_simplices: Box::new(detail.neighbor_simplices), + } + } +} + /// Debug helper: print detailed information about the first detected Delaunay /// violation (or all vertices if none are found) to aid in debugging. /// @@ -652,10 +660,7 @@ where pub fn debug_print_first_delaunay_violation( tds: &Tds, simplices_subset: Option<&[SimplexKey]>, -) where - U: DataType, - V: DataType, -{ +) { // First, build the structured report used by downstream diagnostics. let report = match delaunay_violation_report(tds, simplices_subset) { Ok(report) => report, @@ -800,22 +805,29 @@ mod tests { use crate::core::algorithms::incremental_insertion::repair_neighbor_pointers; use crate::core::simplex::{NeighborSlot, Simplex}; use crate::core::triangulation::Triangulation; + use crate::core::vertex::Vertex; use crate::geometry::kernel::FastKernel; use crate::geometry::point::Point; use crate::geometry::traits::coordinate::{CoordinateConversionError, InvalidCoordinateValue}; use crate::triangulation::DelaunayTriangulation; - use std::assert_matches; + use crate::vertex; + use slotmap::KeyData; + use std::{assert_matches, sync::Once}; + + fn test_vertex(coords: [f64; D]) -> Vertex<(), D> { + vertex!(coords).unwrap() + } #[test] fn delaunay_validator_reports_no_violations_for_simple_tetrahedron() { init_tracing(); - println!("Testing Delaunay validator and debug helper on a simple 3D tetrahedron"); + tracing::debug!("Testing Delaunay validator and debug helper on a simple 3D tetrahedron"); let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let dt = DelaunayTriangulation::try_new(&vertices).unwrap(); @@ -839,7 +851,7 @@ mod tests { } fn init_tracing() { - static INIT: std::sync::Once = std::sync::Once::new(); + static INIT: Once = Once::new(); INIT.call_once(|| { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")); @@ -854,24 +866,16 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let a = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let b = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) .unwrap(); let c = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) .unwrap(); let d = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.8, 0.8]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.8, 0.8])) .unwrap(); let simplex_1 = tds @@ -893,24 +897,16 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let a = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let b = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) .unwrap(); let c = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) .unwrap(); let d = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.8, 0.8]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.8, 0.8])) .unwrap(); let simplex_1 = tds @@ -922,8 +918,13 @@ mod tests { tds.assign_incident_simplices().unwrap(); match is_delaunay_property_only(&tds) { - Err(DelaunayValidationError::DelaunayViolation { simplex_key }) => { + Err(DelaunayValidationError::DelaunayViolation { + simplex_key, + offending_vertex, + .. + }) => { assert!(simplex_key == simplex_1 || simplex_key == simplex_2); + assert!(offending_vertex.is_some()); } other => panic!("Expected DelaunayViolation, got {other:?}"), } @@ -935,24 +936,16 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let a = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let b = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) .unwrap(); let c = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) .unwrap(); let d = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.8, 0.8]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.8, 0.8])) .unwrap(); let simplex_1 = tds @@ -1003,19 +996,13 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let a = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let b = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) .unwrap(); let c = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) .unwrap(); tds.insert_simplex_with_mapping(Simplex::try_new_with_data(vec![a, b, c], None).unwrap()) @@ -1031,8 +1018,8 @@ mod tests { #[test] fn numeric_predicate_error_display_includes_context() { - let simplex_key = SimplexKey::from(slotmap::KeyData::from_ffi(1)); - let vertex_key = VertexKey::from(slotmap::KeyData::from_ffi(2)); + let simplex_key = SimplexKey::from(KeyData::from_ffi(1)); + let vertex_key = VertexKey::from(KeyData::from_ffi(2)); let source = CoordinateConversionError::NonFiniteValue { coordinate_index: 0, coordinate_value: InvalidCoordinateValue::Nan, @@ -1063,10 +1050,10 @@ mod tests { fn delaunay_violation_report_summarizes_valid_tds() { init_tracing(); let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let dt = DelaunayTriangulation::try_new(&vertices).unwrap(); @@ -1076,7 +1063,7 @@ mod tests { assert_eq!(report.number_of_vertices, 4); assert_eq!(report.number_of_simplices, 1); assert_eq!(report.checked_simplices, 1); - assert!(report.first_violation.is_none()); + assert!(report.first_violation().is_none()); } #[test] @@ -1089,9 +1076,9 @@ mod tests { assert!(!report.is_valid()); assert_eq!(report.violating_simplices.len(), 1); let detail = report - .first_violation - .as_ref() + .first_violation() .expect("violating report should include first violation details"); + assert!(std::ptr::eq(detail, &report.violation_details[0])); assert!(detail.simplex_key == simplex_1 || detail.simplex_key == simplex_2); assert_eq!(detail.simplex_vertices.len(), 3); assert_eq!(detail.neighbor_simplices.len(), 3); @@ -1114,19 +1101,13 @@ mod tests { let mut tds: Tds<(), (), 2> = Tds::empty(); let a = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let b = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([1.0, 0.0])) .unwrap(); let c = tds - .insert_vertex_with_mapping( - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), - ) + .insert_vertex_with_mapping(test_vertex([0.0, 1.0])) .unwrap(); let simplex_key = tds .insert_simplex_with_mapping(Simplex::try_new_with_data(vec![a, b, c], None).unwrap()) @@ -1161,10 +1142,7 @@ mod tests { assert_eq!(report.checked_simplices, 2); assert_eq!(report.violating_simplices.as_slice(), &[simplex_1]); assert_eq!( - report - .first_violation - .as_ref() - .map(|detail| detail.simplex_key), + report.first_violation().map(|detail| detail.simplex_key), Some(simplex_1) ); } @@ -1173,10 +1151,10 @@ mod tests { fn delaunay_property_only_reports_triangulation_state_on_missing_vertex() { init_tracing(); let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let mut tds = @@ -1186,7 +1164,7 @@ mod tests { let simplex = tds.simplex(simplex_key).unwrap(); simplex.vertices().to_vec() }; - let invalid_vkey = VertexKey::from(slotmap::KeyData::from_ffi(u64::MAX)); + let invalid_vkey = VertexKey::from(KeyData::from_ffi(u64::MAX)); { let simplex = tds.simplex_mut(simplex_key).unwrap(); @@ -1201,16 +1179,25 @@ mod tests { } let err = is_delaunay_property_only(&tds).unwrap_err(); - assert_matches!(err, DelaunayValidationError::TriangulationState { .. }); + assert_matches!( + err, + DelaunayValidationError::TriangulationState { + source: TdsError::VertexNotFound { + vertex_key, + ref context, + }, + } if vertex_key == invalid_vkey + && context.contains(&format!("{simplex_key:?}")) + ); } #[test] fn is_delaunay_property_only_reports_invalid_simplex() { init_tracing(); let vertices = vec![ - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([1.0, 0.0]).unwrap(), - crate::core::vertex::Vertex::<(), _>::try_new([0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0]), + test_vertex([1.0, 0.0]), + test_vertex([0.0, 1.0]), ]; let mut tds = diff --git a/src/delaunay/query.rs b/src/delaunay/query.rs index 9f97bfb7..a763be3f 100644 --- a/src/delaunay/query.rs +++ b/src/delaunay/query.rs @@ -517,7 +517,7 @@ impl DelaunayTriangulation { /// Returns the insertion-time global topology validation policy used by the underlying /// triangulation. /// - /// This policy controls when Level 3 (`Triangulation::is_valid()`) is run automatically + /// This policy controls when Level 3 (`Triangulation::is_valid_topology()`) is run automatically /// during incremental insertion (as part of the topology safety net). /// /// # Examples @@ -920,6 +920,7 @@ impl DelaunayTriangulation { Ok(()) => Ok(()), Err(InvariantError::Tds(err)) => Err(err.into()), Err(InvariantError::Triangulation(err)) => Err(err.into()), + Err(InvariantError::Embedding(err)) => Err(err.into()), Err(InvariantError::Delaunay(err)) => Err(err), } } diff --git a/src/delaunay/repair.rs b/src/delaunay/repair.rs index bc493e98..f3b3dc7c 100644 --- a/src/delaunay/repair.rs +++ b/src/delaunay/repair.rs @@ -3,7 +3,7 @@ //! This module separates mutating Delaunay repair policy from validation-only //! checking. [`DelaunayRepairPolicy`] controls when construction and editing //! paths may run local flip repair, while [`DelaunayCheckPolicy`] controls -//! global Level 4 validation cadence without mutating topology. +//! global Level 5 Delaunay validation cadence without mutating topology. //! //! Import these APIs through [`crate::prelude::repair`](crate::prelude::repair) //! for downstream examples, tests, and applications. @@ -309,7 +309,7 @@ pub enum DelaunayCheckPolicy { /// /// Incremental insertion does not automatically run a final global check because there is no /// intrinsic β€œend” signal; call - /// [`DelaunayTriangulation::is_valid`](crate::DelaunayTriangulation::is_valid) + /// [`DelaunayTriangulation::is_valid_delaunay`](crate::DelaunayTriangulation::is_valid_delaunay) /// or /// [`DelaunayTriangulation::validate`](crate::DelaunayTriangulation::validate) /// when you are done inserting. @@ -326,7 +326,7 @@ impl DelaunayCheckPolicy { pub const fn should_check(self, insertion_count: usize) -> bool { match self { Self::EndOnly => false, - Self::EveryN(n) => insertion_count.is_multiple_of(n.get()), + Self::EveryN(n) => insertion_count != 0 && insertion_count.is_multiple_of(n.get()), } } } @@ -1527,7 +1527,7 @@ mod tests { fn check_policy_every_n_checks_on_multiples() { let every_2 = DelaunayCheckPolicy::EveryN(NonZeroUsize::new(2).unwrap()); - assert!(every_2.should_check(0)); + assert!(!every_2.should_check(0)); assert!(!every_2.should_check(1)); assert!(every_2.should_check(2)); assert!(!every_2.should_check(3)); diff --git a/src/delaunay/serialization.rs b/src/delaunay/serialization.rs index 715659b9..a0a7ea9d 100644 --- a/src/delaunay/serialization.rs +++ b/src/delaunay/serialization.rs @@ -154,7 +154,7 @@ mod tests { let message = err.to_string(); assert!( message.contains("Delaunay verification failed"), - "serde error should preserve the Level 4 validation failure: {message}" + "serde error should preserve the Level 5 validation failure: {message}" ); } diff --git a/src/delaunay/triangulation.rs b/src/delaunay/triangulation.rs index 43a32bde..83308c3b 100644 --- a/src/delaunay/triangulation.rs +++ b/src/delaunay/triangulation.rs @@ -25,7 +25,7 @@ use crate::core::triangulation::Triangulation; /// insertion (see [`DelaunayRepairPolicy`](crate::DelaunayRepairPolicy)). /// /// For applications requiring explicit verification, you can still call -/// [`is_valid`](Self::is_valid) (Level 4) or [`validate`](Self::validate) (Levels 1–4). +/// [`is_valid_delaunay`](Self::is_valid_delaunay) (Level 5) or [`validate`](Self::validate) (Levels 1–5). /// If flip-based repair fails to converge, insertion returns an error and the /// triangulation is left structurally valid but not guaranteed Delaunay. /// diff --git a/src/delaunay/validation.rs b/src/delaunay/validation.rs index e493dfbc..672c52e2 100644 --- a/src/delaunay/validation.rs +++ b/src/delaunay/validation.rs @@ -1,21 +1,26 @@ -//! Validation scheduling helpers for triangulation construction diagnostics. +//! Delaunay-level validation APIs, proofs, and construction diagnostics. //! -//! This module contains validation-control concepts that are orthogonal to the -//! Delaunay data structure itself. Keeping them here leaves -//! the crate root focused on construction, repair, and query logic. +//! This module owns validation at the [`DelaunayTriangulation`](crate::DelaunayTriangulation) +//! boundary: Level 5 fast-fail checks, first diagnostics, aggregate reports, +//! cumulative validation roll-up, and construction-time validation proofs. The +//! lower-level empty-circumsphere scan over bare [`Tds`](crate::tds::Tds) storage lives in +//! `property_validation`. #![forbid(unsafe_code)] use crate::core::algorithms::flips::{DelaunayRepairError, verify_delaunay_for_triangulation}; use crate::core::algorithms::incremental_insertion::InsertionError; +use crate::core::embedding::TriangulationEmbeddingValidationError; use crate::core::operations::DelaunayInsertionState; use crate::core::tds::{ InvariantError, InvariantKind, InvariantViolation, Tds, TdsError, TriangulationValidationReport, }; use crate::core::traits::data_type::DataType; use crate::core::triangulation::Triangulation; -use crate::core::util::{DelaunayValidationError, is_delaunay_property_only}; use crate::core::validation::{TopologyGuarantee, TriangulationValidationError}; +use crate::delaunay_property_validation::{ + DelaunayValidationError, delaunay_violation_report, is_delaunay_property_only, +}; use crate::geometry::kernel::Kernel; use crate::repair::DelaunayRepairOperation; use crate::topology::traits::topological_space::GlobalTopology; @@ -29,8 +34,8 @@ pub(crate) struct TdsStructureValidationProof(()); /// Proof that a candidate passed the full validation boundary for a Delaunay wrapper. /// -/// The proof is minted only after Levels 1–3 structural/topological validation and -/// the Level 4 Delaunay-property check succeed for the candidate's topology model. +/// The proof is minted only after Levels 1-4 triangulation validation and the +/// Level 5 Delaunay-property check succeed for the candidate's topology model. #[derive(Clone, Copy, Debug)] pub(crate) struct DelaunayTriangulationValidationProof(()); @@ -93,7 +98,7 @@ impl DelaunayTriangulationCandidate { /// promising Level 1-2 TDS structure at this boundary. General reconstruction /// must use [`Self::validate_delaunay_property`] plus /// [`Self::into_validated_delaunay`] so the returned wrapper carries the full - /// Levels 1-4 validation contract. + /// Levels 1-5 validation contract. pub(crate) fn into_structurally_valid_delaunay( self, _proof: TdsStructureValidationProof, @@ -146,17 +151,14 @@ where /// /// This preserves the public reconstruction contract for /// [`DelaunayTriangulation`]: a candidate cannot cross the boundary until - /// its underlying [`Triangulation`] passes Levels 1–3 validation and the - /// Level 4 Delaunay property is checked with the topology-appropriate + /// its underlying [`Triangulation`] passes Levels 1-3 validation. Euclidean + /// candidates additionally pass Level 4 embedded-geometry validation before + /// the Level 5 Delaunay property is checked with the topology-appropriate /// validator. pub(crate) fn validate_delaunay_property( &self, ) -> Result { - self.candidate.tri.validate().map_err(|e| match e { - InvariantError::Tds(tds_err) => tds_err.into(), - InvariantError::Triangulation(tri_err) => tri_err.into(), - InvariantError::Delaunay(dt_err) => dt_err, - })?; + self.candidate.tri.validate_embedding()?; if self.candidate.global_topology().is_euclidean() { is_delaunay_property_only(&self.candidate.tri.tds).map_err(|source| { @@ -165,18 +167,18 @@ where } })?; } else { - self.candidate.is_valid()?; + self.candidate.is_valid_delaunay()?; } Ok(DelaunayTriangulationValidationProof(())) } } -/// Typed source for Level 4 Delaunay verification failures. +/// Typed source for Level 5 Delaunay verification failures. /// /// Passive validation has two implementation paths: /// - flip-predicate verification via [`verify_delaunay_for_triangulation`], used by -/// [`DelaunayTriangulation::is_valid`](crate::DelaunayTriangulation::is_valid) +/// [`DelaunayTriangulation::is_valid_delaunay`](crate::DelaunayTriangulation::is_valid_delaunay) /// - empty-circumsphere validation via `is_delaunay_property_only`, used when /// reconstructing Euclidean triangulations from raw [`Tds`] /// @@ -238,7 +240,7 @@ impl From for DelaunayVerificationError { } } -/// Discriminant for compact Level 4 verification-source summaries. +/// Discriminant for compact Level 5 verification-source summaries. /// /// # Examples /// @@ -276,13 +278,14 @@ impl From<&DelaunayVerificationError> for DelaunayVerificationErrorKind { /// Errors that can occur during Delaunay triangulation validation and repair. /// -/// The first three variants are returned by [`DelaunayTriangulation::validate`](crate::DelaunayTriangulation::validate) -/// (validation Levels 1–4): +/// The first four variants are returned by [`DelaunayTriangulation::validate`](crate::DelaunayTriangulation::validate) +/// (validation Levels 1-5): /// - [`Tds`](Self::Tds) β€” element or TDS structural errors (Levels 1–2). /// - [`Triangulation`](Self::Triangulation) β€” topology errors (Level 3). -/// - [`VerificationFailed`](Self::VerificationFailed) β€” Delaunay property violation (Level 4). +/// - [`Embedding`](Self::Embedding) β€” embedded-geometry errors (Level 4). +/// - [`VerificationFailed`](Self::VerificationFailed) β€” Delaunay property violation (Level 5). /// -/// [`DelaunayTriangulation::is_valid`](crate::DelaunayTriangulation::is_valid) returns only the Level 4 +/// [`DelaunayTriangulation::is_valid_delaunay`](crate::DelaunayTriangulation::is_valid_delaunay) returns only the Level 5 /// [`VerificationFailed`](Self::VerificationFailed) variant. /// /// The repair-failure variants are **not** returned by `validate()` or @@ -326,11 +329,15 @@ pub enum DelaunayTriangulationValidationError { #[error(transparent)] Triangulation(Box), + /// Lower-layer embedded-geometry validation error (Level 4). + #[error(transparent)] + Embedding(Box), + /// Flip-based Delaunay verification detected a violation. /// - /// This is returned by [`DelaunayTriangulation::is_valid`](crate::DelaunayTriangulation::is_valid) when the fast + /// This is returned by [`DelaunayTriangulation::is_valid_delaunay`](crate::DelaunayTriangulation::is_valid_delaunay) when the fast /// O(simplices) flip-predicate scan finds a Delaunay violation. The error is - /// a Level 4 (Delaunay property) issue, not a Level 1–2 structural problem. + /// a Level 5 (Delaunay property) issue, not a Level 1–2 structural problem. /// The [`DelaunayVerificationError`] source distinguishes flip-predicate /// validation from empty-circumsphere reconstruction validation. #[error("Delaunay verification failed: {source}")] @@ -373,13 +380,25 @@ impl From for DelaunayTriangulationValidationError } } +impl From for DelaunayTriangulationValidationError { + fn from(source: TriangulationEmbeddingValidationError) -> Self { + match source { + TriangulationEmbeddingValidationError::Tds(source) => Self::Tds(source), + TriangulationEmbeddingValidationError::Triangulation(source) => { + Self::Triangulation(source) + } + source => Self::Embedding(Box::new(source)), + } + } +} + /// Cadence for explicit validation checkpoints during construction diagnostics. /// /// This is separate from [`crate::ValidationPolicy`], /// which controls automatic insertion-time validation inside /// [`crate::Triangulation`]. Diagnostic /// harnesses can use this cadence for explicit periodic -/// [`DelaunayTriangulation::is_valid`](crate::DelaunayTriangulation::is_valid) +/// [`DelaunayTriangulation::is_valid_delaunay`](crate::DelaunayTriangulation::is_valid_delaunay) /// checks without overloading repair policy or exposing raw `Option` /// scheduling in logs. /// @@ -477,7 +496,7 @@ where // VALIDATION // ------------------------------------------------------------------------- - /// Validates the Delaunay empty-circumsphere property (Level 4). + /// Validates the Delaunay empty-circumsphere property (Level 5). /// /// This is the Delaunay layer's `is_valid`: it checks **only** the Delaunay property /// and intentionally does **not** run lower-layer validation. @@ -490,7 +509,7 @@ where /// /// # Errors /// - /// Returns a [`DelaunayTriangulationValidationError`] if Level 4 verification + /// Returns a [`DelaunayTriangulationValidationError`] if Level 5 verification /// detects a Delaunay violation, or if the underlying triangulation state is /// inconsistent and prevents geometric predicates from being evaluated. The /// [`VerificationFailed`](DelaunayTriangulationValidationError::VerificationFailed) @@ -512,12 +531,12 @@ where /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices_4d).build::<()>()?; /// - /// // Level 4: Delaunay property only - /// assert!(dt.is_valid().is_ok()); + /// // Level 5: Delaunay property only + /// assert!(dt.is_valid_delaunay().is_ok()); /// # Ok(()) /// # } /// ``` - pub fn is_valid(&self) -> Result<(), DelaunayTriangulationValidationError> { + pub fn is_valid_delaunay(&self) -> Result<(), DelaunayTriangulationValidationError> { // Use fast flip-based verification (O(simplices) instead of O(simplices Γ— vertices)) self.is_delaunay_via_flips().map_err(|source| { DelaunayTriangulationValidationError::VerificationFailed { @@ -526,6 +545,108 @@ where }) } + /// Returns the first actionable Level 5 Delaunay diagnostic, if any. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// assert!(dt.delaunay_diagnostic().is_none()); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn delaunay_diagnostic(&self) -> Option { + self.delaunay_report() + .err() + .and_then(|report| report.violations.into_iter().next()) + } + + /// Builds a Level 5 Delaunay-property report. + /// + /// Euclidean triangulations use the all-violations empty-circumsphere scan. + /// Non-Euclidean topologies currently use the topology-aware flip verifier + /// and report the first violation it finds; this avoids applying an + /// ordinary Euclidean circumsphere scan to periodic charts. + /// + /// # Errors + /// + /// Returns `Err(TriangulationValidationReport)` when one or more checkable + /// Level 5 violations are found or when Delaunay predicates cannot be + /// evaluated. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::construction::{ + /// DelaunayResult, DelaunayTriangulationBuilder, vertex, + /// }; + /// + /// # fn main() -> DelaunayResult<()> { + /// let vertices = [ + /// vertex![0.0, 0.0]?, + /// vertex![1.0, 0.0]?, + /// vertex![0.0, 1.0]?, + /// ]; + /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; + /// + /// assert!(dt.delaunay_report().is_ok()); + /// # Ok(()) + /// # } + /// ``` + pub fn delaunay_report(&self) -> Result<(), TriangulationValidationReport> { + if self.global_topology().is_euclidean() { + return match delaunay_violation_report(self.tds(), None) { + Ok(report) if report.is_valid() => Ok(()), + Ok(report) => Err(TriangulationValidationReport { + violations: report + .violation_details + .into_iter() + .map(|detail| InvariantViolation { + kind: InvariantKind::DelaunayProperty, + error: InvariantError::Delaunay( + DelaunayTriangulationValidationError::VerificationFailed { + source: Box::new(DelaunayVerificationError::from( + DelaunayValidationError::from(detail), + )), + }, + ), + }) + .collect(), + }), + Err(source) => Err(TriangulationValidationReport { + violations: vec![InvariantViolation { + kind: InvariantKind::DelaunayProperty, + error: InvariantError::Delaunay( + DelaunayTriangulationValidationError::VerificationFailed { + source: Box::new(DelaunayVerificationError::from(source)), + }, + ), + }], + }), + }; + } + + self.is_valid_delaunay() + .map_err(|error| TriangulationValidationReport { + violations: vec![InvariantViolation { + kind: InvariantKind::DelaunayProperty, + error: InvariantError::Delaunay(error), + }], + }) + } + /// Verify the Delaunay property via fast O(simplices) flip predicates. /// /// This checks the Delaunay property by testing all possible flip configurations @@ -563,16 +684,17 @@ where verify_delaunay_for_triangulation(&self.tri) } - /// Performs cumulative validation for Levels 1–4. + /// Performs cumulative validation for Levels 1–5. /// /// This validates: - /// - **Levels 1–3** via [`Triangulation::validate`](crate::Triangulation::validate) - /// - **Level 4** via [`DelaunayTriangulation::is_valid`](Self::is_valid) + /// - **Levels 1–4** via [`Triangulation::validate_embedding`](crate::Triangulation::validate_embedding) + /// - **Level 5** via [`DelaunayTriangulation::is_valid_delaunay`](Self::is_valid_delaunay) /// /// # Errors /// - /// Returns a [`DelaunayTriangulationValidationError`] if Levels 1–3 validation fails or if the - /// Delaunay property check (Level 4) fails. + /// Returns a [`DelaunayTriangulationValidationError`] if lower-layer validation fails, if + /// Euclidean embedded-geometry validation fails, or if the Delaunay property check (Level 5) + /// fails. /// /// # Examples /// @@ -590,18 +712,14 @@ where /// ]; /// let dt = DelaunayTriangulationBuilder::new(&vertices_4d).build::<()>()?; /// - /// // Levels 1–4: elements + structure + topology + Delaunay property + /// // Levels 1–5: elements + structure + topology + embedding + Delaunay property /// assert!(dt.validate().is_ok()); /// # Ok(()) /// # } /// ``` pub fn validate(&self) -> Result<(), DelaunayTriangulationValidationError> { - self.tri.validate().map_err(|e| match e { - InvariantError::Tds(tds_err) => tds_err.into(), - InvariantError::Triangulation(tri_err) => tri_err.into(), - InvariantError::Delaunay(dt_err) => dt_err, - })?; - self.is_valid() + self.tri.validate_embedding()?; + self.is_valid_delaunay() } /// Generate a comprehensive validation report for the full validation hierarchy. @@ -612,7 +730,7 @@ where /// # Notes /// - If UUID↔key mappings are inconsistent, this returns only mapping failures (other /// checks may produce misleading secondary errors). - /// - This report is **cumulative** across Levels 1–4. + /// - This report is **cumulative** across Levels 1–5. /// /// # Errors /// @@ -643,16 +761,36 @@ where // Levels 1–3: reuse the Triangulation layer report. match self.tri.validation_report() { Ok(()) => { - // Level 4 (Delaunay property) - if let Err(e) = self.is_valid() { - return Err(TriangulationValidationReport { - violations: vec![InvariantViolation { - kind: InvariantKind::DelaunayProperty, - error: e.into(), - }], - }); + // Level 4 (embedded geometry) + let embedding_report = + self.tri + .embedding_report() + .map_err(|error| TriangulationValidationReport { + violations: vec![InvariantViolation { + kind: InvariantKind::Embedding, + error: error.into(), + }], + })?; + let mut violations = Vec::new(); + if !embedding_report.is_valid() { + violations.extend(embedding_report.violations.into_iter().map(|error| { + InvariantViolation { + kind: InvariantKind::Embedding, + error: error.into(), + } + })); + } + + // Level 5 (Delaunay property) + if let Err(delaunay_report) = self.delaunay_report() { + violations.extend(delaunay_report.violations); + } + + if violations.is_empty() { + Ok(()) + } else { + Err(TriangulationValidationReport { violations }) } - Ok(()) } Err(mut report) => { // If mappings are inconsistent, return the lower-layer report unchanged. @@ -665,16 +803,29 @@ where return Err(report); } - // Level 4 (Delaunay property) - if let Err(source) = self.is_delaunay_via_flips() { - report.violations.push(InvariantViolation { - kind: InvariantKind::DelaunayProperty, - error: InvariantError::Delaunay( - DelaunayTriangulationValidationError::VerificationFailed { - source: Box::new(DelaunayVerificationError::from(source)), - }, - ), - }); + // Level 4 (embedded geometry) + match self.tri.embedding_report() { + Ok(embedding_report) => { + report + .violations + .extend(embedding_report.violations.into_iter().map(|error| { + InvariantViolation { + kind: InvariantKind::Embedding, + error: InvariantError::Embedding(error), + } + })); + } + Err(source) => { + report.violations.push(InvariantViolation { + kind: InvariantKind::Embedding, + error: InvariantError::Embedding(source), + }); + } + } + + // Level 5 (Delaunay property) + if let Err(delaunay_report) = self.delaunay_report() { + report.violations.extend(delaunay_report.violations); } if report.violations.is_empty() { @@ -714,9 +865,10 @@ where /// via `try_from_tds` validates with [`GlobalTopology::Euclidean`]. Use /// [`try_from_tds_with_topology_context`](Self::try_from_tds_with_topology_context) if you /// need to validate toroidal or other non-default topology metadata during reconstruction. - /// - Euclidean reconstruction validates Level 4 with the crate's robust - /// empty-circumsphere validator, independent of the supplied runtime kernel. - /// The supplied kernel is stored for later queries and insertions. + /// - Euclidean reconstruction validates Level 4 embedded geometry, then + /// validates Level 5 with the crate's robust empty-circumsphere validator, + /// independent of the supplied runtime kernel. The supplied kernel is + /// stored for later queries and insertions. /// /// # Examples /// @@ -748,7 +900,7 @@ where /// # Errors /// /// Returns [`DelaunayTriangulationValidationError`] if the TDS violates - /// structural, topological, or Delaunay invariants. + /// structural, topological, embedded-geometry, or Delaunay invariants. pub fn try_from_tds( tds: Tds, kernel: K, @@ -764,7 +916,7 @@ where /// Create a validated `DelaunayTriangulation` from a `Tds` with an explicit topology guarantee. /// /// The candidate is assembled with the requested guarantee, then validated - /// at Levels 1–4 before being returned. + /// at Levels 1-5 before being returned. /// /// # Examples /// @@ -799,7 +951,7 @@ where /// # Errors /// /// Returns [`DelaunayTriangulationValidationError`] if the TDS violates - /// structural, topological, or Delaunay invariants. + /// structural, topological, embedded-geometry, or Delaunay invariants. pub fn try_from_tds_with_topology_guarantee( tds: Tds, kernel: K, @@ -855,8 +1007,8 @@ where /// # Errors /// /// Returns [`DelaunayTriangulationValidationError`] if the TDS violates - /// structural, topological, or Delaunay invariants under the supplied - /// topology context. + /// structural, topological, embedded-geometry, or Delaunay invariants under + /// the supplied topology context. pub fn try_from_tds_with_topology_context( tds: Tds, kernel: K, @@ -884,10 +1036,16 @@ mod tests { use crate::core::tds::{SimplexKey, TriangulationConstructionState, VertexKey}; use crate::core::vertex::Vertex; use crate::geometry::kernel::AdaptiveKernel; + use crate::vertex; + use slotmap::KeyData; use std::assert_matches; use std::{error::Error, sync::Once}; use uuid::Uuid; + fn test_vertex(coords: [f64; D]) -> Vertex<(), D> { + vertex!(coords).unwrap() + } + fn init_tracing() { static INIT: Once = Once::new(); INIT.call_once(|| { @@ -903,16 +1061,16 @@ mod tests { fn non_delaunay_quad_tds() -> Tds<(), (), 2> { let mut tds: Tds<(), (), 2> = Tds::empty(); let v0 = tds - .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.0, 0.0]).unwrap()) + .insert_vertex_with_mapping(test_vertex([0.0, 0.0])) .unwrap(); let v1 = tds - .insert_vertex_with_mapping(Vertex::<(), _>::try_new([4.0, 0.0]).unwrap()) + .insert_vertex_with_mapping(test_vertex([4.0, 0.0])) .unwrap(); let v2 = tds - .insert_vertex_with_mapping(Vertex::<(), _>::try_new([4.0, 2.0]).unwrap()) + .insert_vertex_with_mapping(test_vertex([4.0, 2.0])) .unwrap(); let v3 = tds - .insert_vertex_with_mapping(Vertex::<(), _>::try_new([1.0, 2.0]).unwrap()) + .insert_vertex_with_mapping(test_vertex([1.0, 2.0])) .unwrap(); tds.insert_simplex_with_mapping( @@ -929,6 +1087,46 @@ mod tests { tds } + fn tds_from_2d_vertices_and_simplices( + coords: &[[f64; 2]], + simplices: &[Vec], + ) -> Tds<(), (), 2> { + let mut tds: Tds<(), (), 2> = Tds::empty(); + let vertex_keys: Vec<_> = coords + .iter() + .map(|coords| { + tds.insert_vertex_with_mapping(test_vertex(*coords)) + .unwrap() + }) + .collect(); + + for simplex_vertices in simplices { + let vertices: Vec<_> = simplex_vertices + .iter() + .map(|&index| vertex_keys[index]) + .collect(); + tds.insert_simplex_with_mapping(Simplex::try_new_with_data(vertices, None).unwrap()) + .unwrap(); + } + + tds.construction_state = TriangulationConstructionState::Constructed; + tds.assign_neighbors().unwrap(); + tds.assign_incident_simplices().unwrap(); + tds + } + + fn unchecked_test_delaunay_from_tds( + tds: Tds<(), (), D>, + ) -> DelaunayTriangulation, (), (), D> { + DelaunayTriangulationCandidate::assemble( + tds, + AdaptiveKernel::new(), + TopologyGuarantee::Pseudomanifold, + GlobalTopology::Euclidean, + ) + .into_repairable_delaunay_for_test() + } + fn synthetic_flip_verification_source(message: &str) -> DelaunayVerificationError { let _ = message; DelaunayVerificationError::from(DelaunayRepairError::PostconditionFailed { @@ -1000,6 +1198,9 @@ mod tests { let simplex_key = SimplexKey::default(); let source = DelaunayVerificationError::from(DelaunayValidationError::DelaunayViolation { simplex_key, + simplex_vertices: Default::default(), + offending_vertex: None, + neighbor_simplices: Default::default(), }); assert_eq!( @@ -1014,6 +1215,7 @@ mod tests { source.as_ref(), DelaunayValidationError::DelaunayViolation { simplex_key: actual, + .. } if *actual == simplex_key ); } @@ -1072,7 +1274,7 @@ mod tests { #[test] fn triangulation_variant_display_delegates_to_source() { let inner = TriangulationValidationError::IsolatedVertex { - vertex_key: VertexKey::from(slotmap::KeyData::from_ffi(1)), + vertex_key: VertexKey::from(KeyData::from_ffi(1)), vertex_uuid: Uuid::nil(), }; let err = DelaunayTriangulationValidationError::from(inner); @@ -1093,7 +1295,7 @@ mod tests { err, DelaunayTriangulationValidationError::VerificationFailed { .. } ), - "expected Level 4 validation failure, got {err:?}" + "expected Level 5 validation failure, got {err:?}" ); } @@ -1101,10 +1303,10 @@ mod tests { fn try_from_tds_rejects_structural_validation_failure() { init_tracing(); let vertices = [ - Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::try_new(&vertices).unwrap(); @@ -1127,17 +1329,17 @@ mod tests { fn try_from_tds_rejects_topology_validation_failure() { init_tracing(); let vertices = [ - Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::try_new(&vertices).unwrap(); let mut tds = dt.tds().clone(); let _ = tds - .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.5, 0.5, 0.5]).unwrap()) + .insert_vertex_with_mapping(test_vertex([0.5, 0.5, 0.5])) .unwrap(); let err = DelaunayTriangulation::try_from_tds(tds, AdaptiveKernel::new()) @@ -1156,10 +1358,10 @@ mod tests { fn test_validation_report_ok_for_valid_triangulation() { init_tracing(); let vertices = [ - Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let dt: DelaunayTriangulation<_, (), (), 3> = @@ -1171,10 +1373,10 @@ mod tests { fn test_validation_report_returns_mapping_failures_only() { init_tracing(); let vertices = [ - Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let mut dt: DelaunayTriangulation<_, (), (), 3> = @@ -1206,10 +1408,10 @@ mod tests { fn test_validation_report_includes_vertex_incidence_violation() { init_tracing(); let vertices = [ - Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let mut dt: DelaunayTriangulation<_, (), (), 3> = @@ -1232,14 +1434,41 @@ mod tests { ); } + #[test] + fn validation_report_includes_delaunay_after_embedding_violations() { + init_tracing(); + let tds = tds_from_2d_vertices_and_simplices( + &[[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0], [1.0, -1.0]], + &[vec![0, 1, 2], vec![2, 1, 3], vec![3, 2, 4]], + ); + let dt = unchecked_test_delaunay_from_tds(tds); + + let report = dt.validation_report().unwrap_err(); + + assert!( + report + .violations + .iter() + .any(|v| v.kind == InvariantKind::Embedding), + "expected embedding violation in report: {report:?}" + ); + assert!( + report + .violations + .iter() + .any(|v| v.kind == InvariantKind::DelaunayProperty), + "expected Delaunay violation in report: {report:?}" + ); + } + #[test] fn test_dt_validate_maps_tds_error_to_tds_variant() { init_tracing(); let vertices = [ - Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::try_new(&vertices).unwrap(); @@ -1262,10 +1491,10 @@ mod tests { fn test_dt_validate_maps_topology_error_to_triangulation_variant() { init_tracing(); let vertices = [ - Vertex::<(), _>::try_new([0.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([1.0, 0.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 1.0, 0.0]).unwrap(), - Vertex::<(), _>::try_new([0.0, 0.0, 1.0]).unwrap(), + test_vertex([0.0, 0.0, 0.0]), + test_vertex([1.0, 0.0, 0.0]), + test_vertex([0.0, 1.0, 0.0]), + test_vertex([0.0, 0.0, 1.0]), ]; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::try_new(&vertices).unwrap(); @@ -1273,7 +1502,7 @@ mod tests { // Add an isolated vertex so Level 3 (topology) fails. let _ = dt .tds_mut() - .insert_vertex_with_mapping(Vertex::<(), _>::try_new([0.5, 0.5, 0.5]).unwrap()) + .insert_vertex_with_mapping(test_vertex([0.5, 0.5, 0.5])) .unwrap(); match dt.validate() { diff --git a/src/geometry/embedding.rs b/src/geometry/embedding.rs new file mode 100644 index 00000000..ed41778a --- /dev/null +++ b/src/geometry/embedding.rs @@ -0,0 +1,764 @@ +//! Pure predicates for labeled simplex embeddings. +//! +//! This module has no TDS, topology, or triangulation storage dependencies. It +//! answers geometric questions about labeled maximal simplices after another +//! layer has chosen the appropriate affine chart. + +#![forbid(unsafe_code)] + +use crate::core::collections::{MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer}; +use crate::geometry::traits::coordinate::InvalidCoordinateValue; +use la_stack::{BigInt, BigRational, FromPrimitive, Signed}; +use thiserror::Error; + +/// Stack-backed buffer for per-simplex embedding labels and coordinates. +pub type SimplexEmbeddingBuffer = SmallBuffer; + +#[derive(Clone, Debug, PartialEq)] +pub struct LabeledSimplexEmbedding { + labels: SimplexEmbeddingBuffer, + coordinates: SimplexEmbeddingBuffer<[f64; D]>, +} + +impl LabeledSimplexEmbedding { + /// Builds a labeled D-simplex embedding after checking arity and finite coordinates. + pub fn try_new( + labels: impl IntoIterator, + coordinates: impl IntoIterator, + ) -> Result { + let labels: SimplexEmbeddingBuffer = labels.into_iter().collect(); + let coordinates: SimplexEmbeddingBuffer<[f64; D]> = coordinates.into_iter().collect(); + + if labels.len() != coordinates.len() { + return Err( + LabeledSimplexEmbeddingError::LabelCoordinateLengthMismatch { + label_count: labels.len(), + coordinate_count: coordinates.len(), + }, + ); + } + + let expected = D + 1; + if labels.len() != expected { + return Err(LabeledSimplexEmbeddingError::InvalidArity { + expected, + actual: labels.len(), + }); + } + + for (vertex_index, coords) in coordinates.iter().enumerate() { + for (coordinate_index, coordinate) in coords.iter().enumerate() { + if !coordinate.is_finite() { + return Err(LabeledSimplexEmbeddingError::NonFiniteCoordinate { + vertex_index, + coordinate_index, + coordinate_value: InvalidCoordinateValue::from_debug(coordinate), + }); + } + } + } + + Ok(Self { + labels, + coordinates, + }) + } + + /// Returns labels in the same order as the simplex coordinates. + pub fn labels(&self) -> &[L] { + &self.labels + } + + /// Returns the D-dimensional coordinates paired with [`labels`](Self::labels). + pub fn coordinates(&self) -> &[[f64; D]] { + &self.coordinates + } + + /// Returns an embedding translated by integer multiples of the periodic domain. + /// + /// The translated coordinates are re-validated so overflow to non-finite + /// values becomes a typed embedding error rather than a hidden predicate + /// input. + pub fn try_translated( + &self, + periods: &[f64; D], + shift: &[i32; D], + ) -> Result + where + L: Clone, + { + validate_periods(periods)?; + + let mut translated_coordinates = self.coordinates.clone(); + for coords in &mut translated_coordinates { + for axis in 0..D { + coords[axis] = f64::from(shift[axis]).mul_add(periods[axis], coords[axis]); + } + } + Self::try_new(self.labels.iter().cloned(), translated_coordinates) + } +} + +/// Errors produced while parsing a labeled simplex embedding. +#[derive(Clone, Debug, Error, PartialEq)] +#[non_exhaustive] +pub enum LabeledSimplexEmbeddingError { + /// The label and coordinate iterators produced different lengths. + #[error("label count {label_count} does not match coordinate count {coordinate_count}")] + LabelCoordinateLengthMismatch { + /// Number of labels supplied by the caller. + label_count: usize, + /// Number of coordinate rows supplied by the caller. + coordinate_count: usize, + }, + /// The embedding did not contain exactly D + 1 vertices. + #[error("invalid simplex embedding arity: expected {expected}, got {actual}")] + InvalidArity { + /// Required vertex count for one maximal D-simplex. + expected: usize, + /// Actual vertex count supplied by the caller. + actual: usize, + }, + /// A coordinate was NaN or infinite. + #[error( + "non-finite coordinate at vertex {vertex_index}, coordinate {coordinate_index}: {coordinate_value}" + )] + NonFiniteCoordinate { + /// Index of the vertex with the invalid coordinate. + vertex_index: usize, + /// Coordinate axis containing the invalid value. + coordinate_index: usize, + /// Classified invalid floating-point value. + coordinate_value: InvalidCoordinateValue, + }, + /// A periodic domain period was invalid. + #[error(transparent)] + InvalidPeriodicDomainPeriod { + /// Underlying invalid-period error. + #[from] + source: PeriodicSimplexSpanError, + }, +} + +/// Errors produced while checking a simplex against periodic-domain periods. +#[derive(Clone, Debug, Error, PartialEq)] +#[non_exhaustive] +pub enum PeriodicSimplexSpanError { + /// A period was NaN or infinite. + #[error("non-finite periodic period at axis {axis}: {period}")] + NonFinitePeriod { + /// Periodic axis with the invalid period. + axis: usize, + /// Classified invalid period value. + period: InvalidCoordinateValue, + }, + /// A finite period was zero or negative. + #[error("non-positive periodic period at axis {axis}: {period}")] + NonPositivePeriod { + /// Periodic axis with the invalid period. + axis: usize, + /// Raw finite non-positive period. + period: f64, + }, +} + +/// Barycentric witness showing where two simplex embeddings overlap illegally. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SimplexIntersectionWitness { + /// Labels appearing in both simplex embeddings. + pub shared: SimplexEmbeddingBuffer, + /// Labels from the first simplex with positive witness weight outside the shared face. + pub first_only_witness: SimplexEmbeddingBuffer, + /// Labels from the second simplex with positive witness weight outside the shared face. + pub second_only_witness: SimplexEmbeddingBuffer, +} + +/// Failure modes for exact simplex-intersection validation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SimplexIntersectionFailure { + /// The first simplex basis is singular, so barycentric coordinates are undefined. + SingularBarycentricBasis, + /// The simplices intersect at a point involving non-shared vertices. + IntersectionOutsideSharedFace(SimplexIntersectionWitness), +} + +/// Coordinate-span witness for a simplex that is too wide for one periodic chart. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PeriodicSimplexSpan { + /// Periodic axis whose coordinate span reaches or exceeds the period. + pub axis: usize, + /// Coordinate span along [`axis`](Self::axis). + pub span: f64, + /// Fundamental-domain period along [`axis`](Self::axis). + pub period: f64, +} + +/// Returns the closed coordinate range of a simplex along one axis. +pub fn coordinate_range_for_axis( + simplex: &LabeledSimplexEmbedding, + axis: usize, +) -> Option<(f64, f64)> { + if axis >= D { + return None; + } + + Some(simplex.coordinates.iter().fold( + (f64::INFINITY, f64::NEG_INFINITY), + |(min_coord, max_coord), coords| (min_coord.min(coords[axis]), max_coord.max(coords[axis])), + )) +} + +/// Returns whether two simplex axis-aligned bounding boxes overlap. +pub fn axis_aligned_bounding_boxes_overlap( + first: &LabeledSimplexEmbedding, + second: &LabeledSimplexEmbedding, +) -> bool { + (0..D).all(|axis| { + let Some((first_min, first_max)) = coordinate_range_for_axis(first, axis) else { + return false; + }; + let Some((second_min, second_max)) = coordinate_range_for_axis(second, axis) else { + return false; + }; + first_max >= second_min && second_max >= first_min + }) +} + +/// Finds the first periodic axis whose simplex span cannot fit in one chart. +/// +/// # Errors +/// +/// Returns [`PeriodicSimplexSpanError`] when any period is non-finite or not +/// strictly positive. +pub fn try_periodic_simplex_span( + simplex: &LabeledSimplexEmbedding, + periods: &[f64; D], +) -> Result, PeriodicSimplexSpanError> { + validate_periods(periods)?; + + for (axis, &period) in periods.iter().enumerate() { + let (min_coord, max_coord) = coordinate_range_for_axis(simplex, axis) + .expect("axis generated from periods.iter().enumerate() must be valid"); + let span = max_coord - min_coord; + if span >= period { + return Ok(Some(PeriodicSimplexSpan { axis, span, period })); + } + } + Ok(None) +} + +/// Proves that every periodic-domain period is finite and strictly positive. +fn validate_periods(periods: &[f64; D]) -> Result<(), PeriodicSimplexSpanError> { + for (axis, &period) in periods.iter().enumerate() { + if !period.is_finite() { + return Err(PeriodicSimplexSpanError::NonFinitePeriod { + axis, + period: InvalidCoordinateValue::from_debug(&period), + }); + } + if period <= 0.0 { + return Err(PeriodicSimplexSpanError::NonPositivePeriod { axis, period }); + } + } + Ok(()) +} + +/// Validates that two simplex embeddings meet only along labels they share. +/// +/// This is the pure geometric core of Level 4 overlap validation. It uses +/// exact rational barycentric arithmetic after coordinates have been parsed as +/// finite f64 values. +pub fn validate_simplex_embeddings_intersect_only_in_shared_faces( + first: &LabeledSimplexEmbedding, + second: &LabeledSimplexEmbedding, +) -> Result<(), SimplexIntersectionFailure> +where + L: Clone + Eq, +{ + let shared_labels = shared_labels(first, second); + let second_vertices_in_first = barycentric_coordinates_of_vertices(second, first)?; + let intersection_vertices = intersection_polytope_vertices(&second_vertices_in_first); + + for beta in intersection_vertices { + let alpha = alpha_from_beta(&beta, &second_vertices_in_first); + let first_only_witness_labels = + positive_nonshared_labels(&alpha, first.labels(), &shared_labels); + let second_only_witness_labels = + positive_nonshared_labels(&beta, second.labels(), &shared_labels); + + if !first_only_witness_labels.is_empty() || !second_only_witness_labels.is_empty() { + return Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace( + SimplexIntersectionWitness { + shared: shared_labels, + first_only_witness: first_only_witness_labels, + second_only_witness: second_only_witness_labels, + }, + )); + } + } + + Ok(()) +} + +/// Collects labels common to two simplex embeddings so witnesses can distinguish shared faces. +fn shared_labels( + first: &LabeledSimplexEmbedding, + second: &LabeledSimplexEmbedding, +) -> SimplexEmbeddingBuffer +where + L: Clone + Eq, +{ + first + .labels() + .iter() + .filter(|label| second.labels().contains(label)) + .cloned() + .collect() +} + +/// Expresses every vertex of one simplex in the barycentric basis of another. +fn barycentric_coordinates_of_vertices( + vertices: &LabeledSimplexEmbedding, + basis: &LabeledSimplexEmbedding, +) -> Result>, SimplexIntersectionFailure> { + vertices + .coordinates() + .iter() + .map(|coords| barycentric_coordinates(coords, basis)) + .collect() +} + +/// Computes exact barycentric coordinates of one point in one simplex basis. +fn barycentric_coordinates( + point: &[f64; D], + simplex: &LabeledSimplexEmbedding, +) -> Result, SimplexIntersectionFailure> { + if D == 0 { + return Ok(vec![rational_one()]); + } + + let origin = &simplex.coordinates()[0]; + let mut matrix = vec![vec![rational_zero(); D]; D]; + let mut rhs = vec![rational_zero(); D]; + + for axis in 0..D { + let origin_coord = rational_from_f64(origin[axis]); + rhs[axis] = rational_from_f64(point[axis]) - origin_coord.clone(); + for (column, matrix_value) in matrix[axis].iter_mut().enumerate() { + *matrix_value = + rational_from_f64(simplex.coordinates()[column + 1][axis]) - origin_coord.clone(); + } + } + + let lambdas = solve_rational_system(matrix, rhs) + .ok_or(SimplexIntersectionFailure::SingularBarycentricBasis)?; + let lambda_sum = lambdas + .iter() + .fold(rational_zero(), |acc, value| acc + value.clone()); + let mut barycentric = Vec::with_capacity(D + 1); + barycentric.push(rational_one() - lambda_sum); + barycentric.extend(lambdas); + Ok(barycentric) +} + +/// Enumerates candidate vertices of the intersection polytope in second-simplex weights. +fn intersection_polytope_vertices( + second_vertices_in_first: &[Vec], +) -> Vec> { + let variable_count = second_vertices_in_first.len(); + let active_count = variable_count.saturating_sub(1); + let constraint_count = variable_count * 2; + let mut active_set = Vec::with_capacity(active_count); + let mut vertices = Vec::new(); + + enumerate_active_sets( + constraint_count, + active_count, + 0, + &mut active_set, + &mut |active_constraints| { + if let Some(beta) = + intersection_vertex_for_active_set(second_vertices_in_first, active_constraints) + && beta_is_feasible(&beta, second_vertices_in_first) + { + vertices.push(beta); + } + }, + ); + + vertices +} + +/// Recursively enumerates active constraint sets for the simplex-intersection LP. +fn enumerate_active_sets( + constraint_count: usize, + active_count: usize, + start: usize, + active_set: &mut Vec, + on_active_set: &mut F, +) where + F: FnMut(&[usize]), +{ + if active_set.len() == active_count { + on_active_set(active_set); + return; + } + + let remaining = active_count - active_set.len(); + let last_start = constraint_count.saturating_sub(remaining); + for constraint in start..=last_start { + active_set.push(constraint); + enumerate_active_sets( + constraint_count, + active_count, + constraint + 1, + active_set, + on_active_set, + ); + active_set.pop(); + } +} + +/// Solves one active-constraint system and returns the candidate beta weights. +fn intersection_vertex_for_active_set( + second_vertices_in_first: &[Vec], + active_constraints: &[usize], +) -> Option> { + let variable_count = second_vertices_in_first.len(); + let mut matrix = Vec::with_capacity(variable_count); + let mut rhs = Vec::with_capacity(variable_count); + + matrix.push(vec![rational_one(); variable_count]); + rhs.push(rational_one()); + + for &constraint in active_constraints { + matrix.push(constraint_coefficients( + second_vertices_in_first, + constraint, + )); + rhs.push(rational_zero()); + } + + solve_rational_system(matrix, rhs) +} + +/// Builds coefficients for either a beta non-negativity or alpha non-negativity constraint. +fn constraint_coefficients( + second_vertices_in_first: &[Vec], + constraint: usize, +) -> Vec { + let variable_count = second_vertices_in_first.len(); + if constraint < variable_count { + let mut coefficients = vec![rational_zero(); variable_count]; + coefficients[constraint] = rational_one(); + return coefficients; + } + + let alpha_index = constraint - variable_count; + second_vertices_in_first + .iter() + .map(|barycentric| barycentric[alpha_index].clone()) + .collect() +} + +/// Checks whether beta weights and the induced alpha weights are all non-negative. +fn beta_is_feasible(beta: &[BigRational], second_vertices_in_first: &[Vec]) -> bool { + beta.iter().all(|value| !value.is_negative()) + && alpha_from_beta(beta, second_vertices_in_first) + .iter() + .all(|value| !value.is_negative()) +} + +/// Converts second-simplex beta weights into first-simplex alpha weights. +fn alpha_from_beta( + beta: &[BigRational], + second_vertices_in_first: &[Vec], +) -> Vec { + let variable_count = second_vertices_in_first.len(); + let mut alpha = vec![rational_zero(); variable_count]; + + for (beta_index, beta_value) in beta.iter().enumerate() { + for (alpha_index, alpha_value) in alpha.iter_mut().enumerate() { + *alpha_value = alpha_value.clone() + + beta_value.clone() * second_vertices_in_first[beta_index][alpha_index].clone(); + } + } + + alpha +} + +/// Returns labels whose barycentric coordinates witness mass outside the shared face. +fn positive_nonshared_labels( + barycentric: &[BigRational], + labels: &[L], + shared_labels: &[L], +) -> SimplexEmbeddingBuffer +where + L: Clone + Eq, +{ + labels + .iter() + .zip(barycentric) + .filter(|(label, coordinate)| !shared_labels.contains(label) && coordinate.is_positive()) + .map(|(label, _coordinate)| label.clone()) + .collect() +} + +#[expect( + clippy::needless_range_loop, + reason = "index-based elimination keeps pivot row/column operations explicit" +)] +/// Solves a square rational linear system by Gaussian elimination. +fn solve_rational_system( + mut matrix: Vec>, + mut rhs: Vec, +) -> Option> { + let dimension = rhs.len(); + if matrix.len() != dimension || matrix.iter().any(|row| row.len() != dimension) { + return None; + } + + for pivot_col in 0..dimension { + let pivot_row = + (pivot_col..dimension).find(|&row| matrix[row][pivot_col] != rational_zero())?; + if pivot_row != pivot_col { + matrix.swap(pivot_col, pivot_row); + rhs.swap(pivot_col, pivot_row); + } + + let pivot_value = matrix[pivot_col][pivot_col].clone(); + for row in pivot_col + 1..dimension { + if matrix[row][pivot_col] == rational_zero() { + continue; + } + let factor = matrix[row][pivot_col].clone() / pivot_value.clone(); + matrix[row][pivot_col] = rational_zero(); + for col in pivot_col + 1..dimension { + matrix[row][col] = + matrix[row][col].clone() - factor.clone() * matrix[pivot_col][col].clone(); + } + rhs[row] = rhs[row].clone() - factor * rhs[pivot_col].clone(); + } + } + + let mut solution = vec![rational_zero(); dimension]; + for row in (0..dimension).rev() { + let mut sum = rhs[row].clone(); + for col in row + 1..dimension { + sum -= matrix[row][col].clone() * solution[col].clone(); + } + solution[row] = sum / matrix[row][row].clone(); + } + + Some(solution) +} + +/// Converts a finite f64 to an exact rational value for barycentric predicates. +fn rational_from_f64(value: f64) -> BigRational { + BigRational::from_f64(value) + .expect("validated finite f64 coordinates must convert to BigRational") +} + +/// Returns the additive identity used throughout exact barycentric arithmetic. +fn rational_zero() -> BigRational { + BigRational::from_integer(BigInt::from(0)) +} + +/// Returns the multiplicative identity used throughout exact barycentric arithmetic. +fn rational_one() -> BigRational { + BigRational::from_integer(BigInt::from(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::assert_matches; + + #[test] + fn labeled_simplex_embedding_rejects_label_coordinate_length_mismatch() { + let err = + LabeledSimplexEmbedding::<_, 2>::try_new(vec![0, 1, 2], vec![[0.0, 0.0], [1.0, 0.0]]) + .unwrap_err(); + + assert_matches!( + err, + LabeledSimplexEmbeddingError::LabelCoordinateLengthMismatch { + label_count: 3, + coordinate_count: 2, + } + ); + } + + #[test] + fn labeled_simplex_embedding_rejects_invalid_arity() { + let err = LabeledSimplexEmbedding::<_, 2>::try_new( + vec![0, 1, 2, 3], + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], + ) + .unwrap_err(); + + assert_matches!( + err, + LabeledSimplexEmbeddingError::InvalidArity { + expected: 3, + actual: 4, + } + ); + } + + #[test] + fn coordinate_range_rejects_out_of_bounds_axis() { + let simplex = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + ) + .unwrap(); + + assert_eq!(coordinate_range_for_axis(&simplex, 2), None); + } + + #[test] + fn disjoint_triangles_do_not_intersect_outside_shared_face() { + let first = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + ) + .unwrap(); + let second = LabeledSimplexEmbedding::try_new( + vec![3, 4, 5], + vec![[2.0, 2.0], [3.0, 2.0], [2.0, 3.0]], + ) + .unwrap(); + + assert!( + validate_simplex_embeddings_intersect_only_in_shared_faces(&first, &second).is_ok() + ); + } + + #[test] + fn labeled_simplex_embedding_rejects_non_finite_coordinates() { + let err = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[0.0, 0.0], [1.0, f64::NAN], [0.0, 1.0]], + ) + .unwrap_err(); + + assert_matches!( + err, + LabeledSimplexEmbeddingError::NonFiniteCoordinate { + vertex_index: 1, + coordinate_index: 1, + coordinate_value: InvalidCoordinateValue::Nan, + } + ); + } + + #[test] + fn translated_embedding_rejects_non_finite_coordinates() { + let simplex = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + ) + .unwrap(); + + let err = simplex + .try_translated(&[f64::MAX, 1.0], &[2, 0]) + .unwrap_err(); + + assert_matches!( + err, + LabeledSimplexEmbeddingError::NonFiniteCoordinate { + vertex_index: 0, + coordinate_index: 0, + coordinate_value: InvalidCoordinateValue::PositiveInfinity, + } + ); + } + + #[test] + fn translated_embedding_rejects_invalid_periods() { + let simplex = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + ) + .unwrap(); + + let err = simplex.try_translated(&[1.0, -1.0], &[0, 1]).unwrap_err(); + + assert_matches!( + err, + LabeledSimplexEmbeddingError::InvalidPeriodicDomainPeriod { + source: PeriodicSimplexSpanError::NonPositivePeriod { + axis: 1, + period: -1.0, + }, + } + ); + } + + #[test] + fn crossing_triangles_report_positive_nonshared_witnesses() { + let first = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[0.0, 0.0], [2.0, 0.0], [0.0, 2.0]], + ) + .unwrap(); + let second = LabeledSimplexEmbedding::try_new( + vec![3, 4, 5], + vec![[2.0, 2.0], [1.0, -1.0], [3.0, 2.0]], + ) + .unwrap(); + + let err = validate_simplex_embeddings_intersect_only_in_shared_faces(&first, &second) + .unwrap_err(); + assert_matches!( + err, + SimplexIntersectionFailure::IntersectionOutsideSharedFace(witness) + if witness.first_only_witness.iter().any(|label| [0, 1, 2].contains(label)) + && witness.second_only_witness.iter().any(|label| [3, 4, 5].contains(label)) + ); + } + + #[test] + fn spanning_periodic_simplex_is_detected() { + let simplex = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 0.25]], + ) + .unwrap(); + + let span = try_periodic_simplex_span(&simplex, &[1.0, 1.0]) + .unwrap() + .unwrap(); + assert_eq!(span.axis, 0); + assert_eq!(span.span, 1.0); + assert_eq!(span.period, 1.0); + } + + #[test] + fn periodic_simplex_span_rejects_invalid_periods() { + let simplex = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[0.0, 0.0], [0.5, 0.0], [0.0, 0.25]], + ) + .unwrap(); + + let non_finite = try_periodic_simplex_span(&simplex, &[f64::NAN, 1.0]).unwrap_err(); + assert_matches!( + non_finite, + PeriodicSimplexSpanError::NonFinitePeriod { + axis: 0, + period: InvalidCoordinateValue::Nan, + } + ); + + let non_positive = try_periodic_simplex_span(&simplex, &[1.0, 0.0]).unwrap_err(); + assert_matches!( + non_positive, + PeriodicSimplexSpanError::NonPositivePeriod { + axis: 1, + period: 0.0, + } + ); + } +} diff --git a/src/geometry/util/triangulation_generation.rs b/src/geometry/util/triangulation_generation.rs index 97b74774..726510c6 100644 --- a/src/geometry/util/triangulation_generation.rs +++ b/src/geometry/util/triangulation_generation.rs @@ -1160,7 +1160,7 @@ mod tests { triangulation_2d.number_of_vertices() ); assert_eq!(triangulation_2d.dim(), 2); - triangulation_2d.is_valid().unwrap(); + triangulation_2d.is_valid_delaunay().unwrap(); // Test 3D triangulation creation with data let triangulation_3d = try_generate_random_triangulation::( @@ -1177,7 +1177,7 @@ mod tests { triangulation_3d.number_of_vertices() ); assert_eq!(triangulation_3d.dim(), 3); - triangulation_3d.is_valid().unwrap(); + triangulation_3d.is_valid_delaunay().unwrap(); // Exercise repeatable construction with two deterministic seeds. let triangulation_seeded = try_generate_random_triangulation::<(), (), 2>( @@ -1196,8 +1196,8 @@ mod tests { ) .unwrap(); - triangulation_seeded.is_valid().unwrap(); - triangulation_different_seed.is_valid().unwrap(); + triangulation_seeded.is_valid_delaunay().unwrap(); + triangulation_different_seed.is_valid_delaunay().unwrap(); assert!( triangulation_seeded.number_of_vertices() >= 3, "Expected at least 3 vertices in seeded 2D triangulation, got {}", @@ -1276,14 +1276,14 @@ mod tests { generate_random_triangulation_in_range::<(), (), 2>(nonzero(10), range, None, Some(42)) .unwrap(); assert_eq!(triangulation.dim(), 2); - triangulation.is_valid().unwrap(); + triangulation.is_valid_delaunay().unwrap(); let builder_triangulation = RandomTriangulationBuilder::new_in_range(nonzero(10), range) .seed(43) .build::<(), (), 2>() .unwrap(); assert_eq!(builder_triangulation.dim(), 2); - builder_triangulation.is_valid().unwrap(); + builder_triangulation.is_valid_delaunay().unwrap(); let guaranteed_triangulation = generate_random_triangulation_in_range_with_topology_guarantee::<(), (), 2>( @@ -1298,7 +1298,7 @@ mod tests { guaranteed_triangulation.topology_guarantee(), TopologyGuarantee::Pseudomanifold ); - guaranteed_triangulation.is_valid().unwrap(); + guaranteed_triangulation.is_valid_delaunay().unwrap(); } #[test] @@ -1310,7 +1310,7 @@ mod tests { .unwrap(); assert_eq!(triangulation.dim(), 2); assert!(triangulation.number_of_vertices() >= 3); - triangulation.is_valid().unwrap(); + triangulation.is_valid_delaunay().unwrap(); let triangulation_with_data = RandomTriangulationBuilder::try_new(nonzero(10), (-5.0, 5.0)) .unwrap() @@ -1327,7 +1327,7 @@ mod tests { triangulation_with_data.number_of_vertices() ); assert!(vertex_data.iter().all(|&data| data == 7)); - triangulation_with_data.is_valid().unwrap(); + triangulation_with_data.is_valid_delaunay().unwrap(); let too_few_vertices = RandomTriangulationBuilder::try_new(nonzero(2), (-1.0, 1.0)) .unwrap() diff --git a/src/lib.rs b/src/lib.rs index 6a955741..c91bed14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,7 @@ //! and what errors mean. //! //! In particular, this document covers: -//! - The validation hierarchy and invariant stack (Levels 1–4) +//! - The validation hierarchy and invariant stack (Levels 1–5) //! - Topological guarantees (`TopologyGuarantee`) and insertion-time validation policy (`ValidationPolicy`) //! - High-level error semantics and programming contract (transactional operations, duplicate rejection) //! @@ -30,7 +30,7 @@ //! repairs, diagnostics, and statistics). //! //! - **docs/validation.md**: -//! Formal definitions of validation Levels 1–4, their costs, and guidance on when +//! Formal definitions of validation Levels 1–5, their costs, and guidance on when //! each level should be applied. //! //! - **docs/diagnostics.md**: @@ -59,10 +59,10 @@ //! | Random points / triangulations for examples and tests | `use delaunay::prelude::generators::*` | //! | Hilbert ordering and quantization utilities | `use delaunay::prelude::ordering::*` | //! | Unified Pachner move workflow | `use delaunay::prelude::pachner::*` | -//! | Delaunay repair and flip-based Level 4 validation | `use delaunay::prelude::repair::*` | +//! | Delaunay repair and flip-based Level 5 validation | `use delaunay::prelude::repair::*` | //! | Delaunayize workflow (repair + flip) | `use delaunay::prelude::delaunayize::*` | //! | Construction telemetry diagnostics | `use delaunay::prelude::diagnostics::*` | -//! | Construction validation cadence/policy | `use delaunay::prelude::validation::*` | +//! | Validation policies, errors, reports, and Level 5 diagnostics | `use delaunay::prelude::validation::*` | //! | Topology validation, Euler characteristic, ridge queries | `use delaunay::prelude::topology::validation::*` | //! | Topological spaces, topology traits, lifted toroidal IDs | `use delaunay::prelude::topology::spaces::*` | //! | Low-level TDS simplices, facets, keys | `use delaunay::prelude::tds::*` | @@ -97,7 +97,7 @@ //! //! ## Examples (contract-oriented) //! -//! ### Validation hierarchy (Levels 1–4) +//! ### Validation hierarchy (Levels 1–5) //! //! ```rust //! use delaunay::prelude::construction::{ @@ -119,10 +119,13 @@ //! // Levels 1–3: elements + structural + topology //! assert!(dt.as_triangulation().validate().is_ok()); //! -//! // Level 4 only: Delaunay property (assumes Levels 1–3) -//! assert!(dt.is_valid().is_ok()); +//! // Levels 1–4: elements + structural + topology + faithful embedding +//! assert!(dt.as_triangulation().validate_embedding().is_ok()); //! -//! // Levels 1–4: full cumulative validation +//! // Level 5 only: Delaunay property (assumes Levels 1–4) +//! assert!(dt.is_valid_delaunay().is_ok()); +//! +//! // Levels 1–5: full cumulative validation //! assert!(dt.validate().is_ok()); //! # Ok(()) //! # } @@ -199,8 +202,10 @@ //! - **Simplex shape** – exactly D+1 distinct vertex keys, valid UUID, and neighbor buffer length //! (if present) is D+1. //! -//! These checks are surfaced via [`Vertex::is_valid`](crate::tds::Vertex::is_valid) and -//! [`Simplex::is_valid`](crate::tds::Simplex::is_valid), and are automatically run by +//! These checks are surfaced via [`Vertex::is_valid`](crate::tds::Vertex::is_valid), +//! [`Vertex::vertex_report`](crate::tds::Vertex::vertex_report), +//! [`Simplex::is_valid`](crate::tds::Simplex::is_valid), and +//! [`Simplex::simplex_report`](crate::tds::Simplex::simplex_report), and are automatically run by //! [`Tds::validate`](crate::tds::Tds::validate) (Levels 1–2). //! //! - [`Tds`](crate::tds::Tds) (Triangulation Data Structure) @@ -221,41 +226,53 @@ //! - [`Triangulation`] builds on the TDS and validates //! **manifold topology**. //! Level 3 (topology) validation is performed by -//! [`Triangulation::is_valid`](crate::Triangulation::is_valid) (Level 3 only) and +//! [`Triangulation::is_valid_topology`](crate::Triangulation::is_valid_topology) (Level 3 only) and //! [`Triangulation::validate`](crate::Triangulation::validate) (Levels 1–3), which: //! - Strengthens facet incidence to the **manifold facet property**: //! one-sided facets are valid only when the declared topology admits //! boundary; two-sided facets are interior. //! - Checks the **Euler characteristic** of the triangulation (using the topology module). //! +//! - [`Triangulation`] also validates the **faithfulness of the embedding** in +//! the active affine chart. Level 4 (embedding) validation is performed by +//! [`Triangulation::is_valid_embedding`](crate::Triangulation::is_valid_embedding) (Level 4 only) and +//! [`Triangulation::validate_embedding`](crate::Triangulation::validate_embedding) (Levels 1–4). +//! Euclidean topology is checked directly in its ambient chart; toroidal +//! topology is checked in periodic covering-space charts. +//! //! - [`DelaunayTriangulation`] builds on //! `Triangulation` and validates the **geometric** Delaunay condition. -//! Level 4 (Delaunay property) validation is performed by -//! [`DelaunayTriangulation::is_valid`](crate::DelaunayTriangulation::is_valid) (Level 4 only) and -//! [`DelaunayTriangulation::validate`](crate::DelaunayTriangulation::validate) (Levels 1–4). +//! Level 5 (Delaunay property) validation is performed by +//! [`DelaunayTriangulation::is_valid_delaunay`](crate::DelaunayTriangulation::is_valid_delaunay) (Level 5 only) and +//! [`DelaunayTriangulation::validate`](crate::DelaunayTriangulation::validate) (Levels 1–5). //! Batch construction runs final Delaunay validation before returning. -//! Incremental insertion can run global Level 4 checks according to +//! Incremental insertion can run global Level 5 checks according to //! [`DelaunayCheckPolicy`](crate::repair::DelaunayCheckPolicy). If robust //! fallback and repair cannot certify a checked result, the operation returns a //! typed error rather than silently accepting a known violation. //! //! ## Validation //! -//! The crate exposes four validation levels (element β†’ structural β†’ manifold β†’ Delaunay). The +//! The crate exposes five validation levels +//! (element β†’ structural β†’ topology β†’ embedding β†’ Delaunay). The //! canonical guide (when to use each level, complexity, examples, troubleshooting) lives in //! `docs/validation.md`: //! //! //! In brief: -//! - Level 1 (elements / `Vertex` + `Simplex`): `Vertex::is_valid()` / `Simplex::is_valid()` for element -//! checks, or `dt.tds().validate()` for Levels 1–2. +//! - Level 1 (elements / `Vertex` + `Simplex`): `Vertex::is_valid()` / +//! `Simplex::is_valid()` for fast checks, or `vertex_report()` / +//! `simplex_report()` for element-local diagnostics. //! - Level 2 (structural / `Tds`): `dt.tds().is_valid()` for a quick check, or `dt.tds().validate()` for //! Levels 1–2. -//! - Level 3 (topology / `Triangulation`): `dt.as_triangulation().is_valid()` for topology-only checks, or +//! - Level 3 (topology / `Triangulation`): `dt.as_triangulation().is_valid_topology()` for topology-only checks, or //! `dt.as_triangulation().validate()` for Levels 1–3. -//! - Level 4 (Delaunay / `DelaunayTriangulation`): `dt.is_valid()` for the empty-circumsphere property, or -//! `dt.validate()` for Levels 1–4. -//! - Full diagnostics: `dt.validation_report()` returns all violated invariants across Levels 1–4. +//! - Level 4 (embedding / `Triangulation`): `dt.as_triangulation().validate_embedding()` for cumulative +//! faithful embedded-geometry checks, or `dt.as_triangulation().embedding_report()` for layer-local diagnostics. +//! - Level 5 (Delaunay / `DelaunayTriangulation`): `dt.is_valid_delaunay()` for the Delaunay +//! property, or `dt.delaunay_report()` for layer-local diagnostics. +//! - Cumulative Delaunay validation: `dt.validate()` for Levels 1–5, or +//! `dt.validation_report()` for full diagnostics. //! //! ### Automatic topology validation during insertion (`ValidationPolicy`) //! @@ -269,7 +286,8 @@ //! mandatory local topology checks still run during insertion, while full Level 3 validation is a //! caller-owned explicit checkpoint. //! -//! This automatic pass only runs Level 3 (`Triangulation::is_valid()`). It does **not** run Level 4. +//! This automatic pass only runs Level 3 (`Triangulation::is_valid_topology()`). It does **not** run +//! Level 4 embedding validation or Level 5 Delaunay validation. //! //! ```rust //! use delaunay::prelude::construction::{ @@ -382,8 +400,8 @@ //! [`InsertionError::DuplicateCoordinates`](crate::prelude::insertion::InsertionError::DuplicateCoordinates). //! Duplicate UUIDs return //! [`InsertionError::DuplicateUuid`](crate::prelude::insertion::InsertionError::DuplicateUuid). -//! - **Explicit verification**: Use `dt.validate()` for cumulative verification (Levels 1–4), or -//! `dt.is_valid()` for Level 4 only. +//! - **Explicit verification**: Use `dt.validate()` for cumulative verification (Levels 1–5), or +//! `dt.is_valid_delaunay()` for Level 5 only. #![expect( clippy::multiple_crate_versions, @@ -559,6 +577,8 @@ mod core { /// Generic triangulation construction helpers. pub mod construction; pub mod edge; + /// Embedded Euclidean geometry validation for generic triangulations. + pub mod embedding; pub mod facet; /// Incremental insertion for generic triangulations. pub mod insertion; @@ -600,7 +620,6 @@ mod core { pub mod util { pub(crate) mod canonical_points; pub mod deduplication; - pub mod delaunay_validation; pub mod facet_keys; pub mod facet_utils; pub mod hashing; @@ -611,7 +630,6 @@ mod core { // Re-export utility internals within the private core namespace. pub use deduplication::*; - pub use delaunay_validation::*; pub use facet_keys::*; pub use facet_utils::*; pub use hashing::*; @@ -659,6 +677,9 @@ pub mod geometry { } /// Validated coordinate-range types. pub mod coordinate_range; + // Pure Level 4 embedding predicates are crate-internal implementation + // machinery; downstream users should use `Triangulation::embedding_report`. + pub(crate) mod embedding; #[macro_use] pub mod matrix; /// Geometric kernel abstraction (CGAL-style). @@ -714,6 +735,9 @@ pub mod builder; /// Batch construction options, errors, statistics, and policy helpers. #[path = "delaunay/construction.rs"] pub mod construction; +/// TDS-level implementation helpers for Level 5 Delaunay-property scans. +#[path = "delaunay/property_validation.rs"] +mod delaunay_property_validation; /// Read-only Delaunay query, traversal, and accessor methods. #[path = "delaunay/query.rs"] pub(crate) mod delaunay_query; @@ -749,7 +773,7 @@ pub(crate) mod serialization; /// Delaunay triangulation layer with incremental insertion. #[path = "delaunay/triangulation.rs"] pub(crate) mod triangulation; -/// Validation scheduling helpers for triangulation diagnostics. +/// Delaunay-level validation APIs, reports, and construction diagnostics. #[path = "delaunay/validation.rs"] pub mod validation; @@ -785,6 +809,12 @@ pub use crate::core::algorithms::pl_manifold_repair::{ pub use crate::core::construction::{ FinalDelaunayValidationContext, FinalTopologyValidationContext, TriangulationConstructionError, }; +pub use crate::core::embedding::{ + PeriodicDomainPeriodError, TriangulationEmbeddingIntersectionDetail, + TriangulationEmbeddingSimplexDetail, TriangulationEmbeddingSimplexPairDetail, + TriangulationEmbeddingValidationError, TriangulationEmbeddingValidationErrorKind, + TriangulationEmbeddingValidationReport, +}; pub use crate::core::insertion::DuplicateDetectionMetrics; pub use crate::core::operations::{ InsertionOutcome, InsertionResult, InsertionStatistics, RepairDecision, RepairSkipReason, @@ -792,15 +822,15 @@ pub use crate::core::operations::{ }; pub use crate::core::triangulation::Triangulation; pub use crate::core::util::DeduplicationError; -pub use crate::core::util::{DelaunayValidationError, find_delaunay_violations}; -#[cfg(feature = "diagnostics")] -pub use crate::core::util::{ - DelaunayViolationDetail, DelaunayViolationReport, debug_print_first_delaunay_violation, - delaunay_violation_report, -}; pub use crate::core::validation::{ TopologyGuarantee, TriangulationValidationError, ValidationConfigurationError, ValidationPolicy, }; +#[cfg(feature = "diagnostics")] +pub use crate::delaunay_property_validation::debug_print_first_delaunay_violation; +pub use crate::delaunay_property_validation::{ + DelaunayValidationError, DelaunayViolationDetail, DelaunayViolationReport, + delaunay_violation_report, find_delaunay_violations, +}; pub use crate::deletion::DeleteVertexError; pub use crate::io::visualization::{ AdjacencyRecord, MESH_EXPORT_SCHEMA, MESH_EXPORT_SCHEMA_VERSION, MeshAdjacencyRecord, @@ -814,6 +844,9 @@ pub use crate::repair::{ DelaunayCheckPolicy, DelaunayRepairHeuristicConfig, DelaunayRepairHeuristicSeeds, DelaunayRepairOperation, DelaunayRepairOutcome, DelaunayRepairPolicy, }; +pub use crate::tds::{ + InvariantError, InvariantKind, InvariantViolation, TriangulationValidationReport, +}; pub use crate::triangulation::*; pub use crate::validation::{ DelaunayTriangulationValidationError, DelaunayVerificationError, DelaunayVerificationErrorKind, @@ -1158,10 +1191,14 @@ pub mod prelude { DelaunayTriangulationConstructionErrorWithStatistics, DelaunayTriangulationValidationError, DelaunayVerificationError, DelaunayVerificationErrorKind, DuplicateDetectionMetrics, FinalDelaunayValidationContext, FinalTopologyValidationContext, InitialSimplexStrategy, - InsertionOrderStrategy, InsertionResult, PlManifoldRepairError, PlManifoldRepairStats, - RepairDecision, RepairSkipReason, RetryPolicy, TopologicalOperation, TopologyGuarantee, - Triangulation, TriangulationConstructionError, TriangulationValidationError, - ValidationConfigurationError, ValidationPolicy, try_vertices_from_points, + InsertionOrderStrategy, InsertionResult, PeriodicDomainPeriodError, PlManifoldRepairError, + PlManifoldRepairStats, RepairDecision, RepairSkipReason, RetryPolicy, TopologicalOperation, + TopologyGuarantee, Triangulation, TriangulationConstructionError, + TriangulationEmbeddingIntersectionDetail, TriangulationEmbeddingSimplexDetail, + TriangulationEmbeddingSimplexPairDetail, TriangulationEmbeddingValidationError, + TriangulationEmbeddingValidationErrorKind, TriangulationEmbeddingValidationReport, + TriangulationValidationError, TriangulationValidationReport, ValidationConfigurationError, + ValidationPolicy, try_vertices_from_points, }; // Re-export utility items, but avoid exporting the util module names themselves. @@ -1177,8 +1214,12 @@ pub mod prelude { try_hilbert_sort_by_stable, try_hilbert_sort_by_unstable, try_hilbert_sorted_indices, }; pub use crate::core::util::{ - DeduplicationError, DelaunayValidationError, dedup_vertices_epsilon, dedup_vertices_exact, - filter_vertices_excluding, find_delaunay_violations, try_dedup_vertices_epsilon, + DeduplicationError, dedup_vertices_epsilon, dedup_vertices_exact, + filter_vertices_excluding, try_dedup_vertices_epsilon, + }; + pub use crate::delaunay_property_validation::{ + DelaunayValidationError, DelaunayViolationDetail, DelaunayViolationReport, + delaunay_violation_report, find_delaunay_violations, }; pub use crate::query::{ JaccardComputationError, extract_edge_set, extract_facet_identifier_set, @@ -1285,7 +1326,10 @@ pub mod prelude { pub use crate::geometry::traits::coordinate::CoordinateValidationError; pub use crate::geometry::util::{InvalidPositiveScalar, RandomPointGenerationError}; pub use crate::repair::DelaunayRepairPolicy; - pub use crate::tds::{SimplexValidationError, Vertex, VertexValidationError}; + pub use crate::tds::{ + SimplexValidationError, SimplexValidationReport, Vertex, VertexValidationError, + VertexValidationReport, + }; pub use crate::topology::traits::{ GlobalTopology, GlobalTopologyModelError, TopologyKind, ToroidalConstructionMode, ToroidalDomain, ToroidalDomainError, @@ -1359,8 +1403,12 @@ pub mod prelude { }; pub use crate::vertex; pub use crate::{ - InsertionError, SpatialIndexConstructionFailure, TopologyGuarantee, Triangulation, - TriangulationConstructionError, TriangulationValidationError, + InsertionError, PeriodicDomainPeriodError, SpatialIndexConstructionFailure, + TopologyGuarantee, Triangulation, TriangulationConstructionError, + TriangulationEmbeddingIntersectionDetail, TriangulationEmbeddingSimplexDetail, + TriangulationEmbeddingSimplexPairDetail, TriangulationEmbeddingValidationError, + TriangulationEmbeddingValidationErrorKind, TriangulationEmbeddingValidationReport, + TriangulationValidationError, TriangulationValidationReport, ValidationConfigurationError, ValidationPolicy, }; } @@ -1502,7 +1550,7 @@ pub mod prelude { }; } - /// Flip-based Delaunay repair, diagnostics, and Level 4 validation. + /// Flip-based Delaunay repair, diagnostics, and Level 5 validation. /// /// ```rust /// use delaunay::prelude::repair::{ @@ -1542,7 +1590,10 @@ pub mod prelude { DelaunayTriangulationValidationError, DelaunayVerificationError, DelaunayVerificationErrorKind, }; - pub use crate::{DelaunayValidationError, find_delaunay_violations}; + pub use crate::{ + DelaunayValidationError, DelaunayViolationDetail, DelaunayViolationReport, + delaunay_violation_report, find_delaunay_violations, + }; pub use crate::{ TopologyGuarantee, Triangulation, ValidationConfigurationError, ValidationPolicy, }; @@ -1559,7 +1610,7 @@ pub mod prelude { pub use crate::{PlManifoldRepairError, PlManifoldRepairStats}; } - /// Validation scheduling helpers for construction diagnostics. + /// Delaunay-level validation APIs, reports, and construction diagnostics. /// /// # Examples /// @@ -1574,9 +1625,17 @@ pub mod prelude { pub use crate::validation::*; pub use crate::{ DelaunayTriangulationValidationError, DelaunayVerificationError, - DelaunayVerificationErrorKind, TopologyGuarantee, TriangulationValidationError, + DelaunayVerificationErrorKind, PeriodicDomainPeriodError, TopologyGuarantee, + TriangulationEmbeddingIntersectionDetail, TriangulationEmbeddingSimplexDetail, + TriangulationEmbeddingSimplexPairDetail, TriangulationEmbeddingValidationError, + TriangulationEmbeddingValidationErrorKind, TriangulationEmbeddingValidationReport, + TriangulationValidationError, TriangulationValidationReport, ValidationConfigurationError, ValidationPolicy, }; + pub use crate::{ + DelaunayValidationError, DelaunayViolationDetail, DelaunayViolationReport, + delaunay_violation_report, find_delaunay_violations, + }; } /// Focused exports for collection types used throughout the crate. @@ -1724,15 +1783,15 @@ pub mod prelude { #[cfg(feature = "diagnostics")] #[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))] pub use crate::algorithms::verify_conflict_region_completeness; + #[cfg(feature = "diagnostics")] + #[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))] + pub use crate::debug_print_first_delaunay_violation; pub use crate::diagnostics::{ BatchLocalRepairTrigger, ConstructionTelemetry, LocalRepairSample, }; pub use crate::tds::NeighborSlot; - #[cfg(feature = "diagnostics")] - #[cfg_attr(docsrs, doc(cfg(feature = "diagnostics")))] pub use crate::{ - DelaunayViolationDetail, DelaunayViolationReport, debug_print_first_delaunay_violation, - delaunay_violation_report, + DelaunayViolationDetail, DelaunayViolationReport, delaunay_violation_report, }; } diff --git a/src/topology/traits/global_topology_model.rs b/src/topology/traits/global_topology_model.rs index 0552e4d1..85985c41 100644 --- a/src/topology/traits/global_topology_model.rs +++ b/src/topology/traits/global_topology_model.rs @@ -125,6 +125,16 @@ pub trait GlobalTopologyModel { None } + /// Indicates whether this topology has an affine chart suitable for Level 4 + /// embedded-geometry validation. + /// + /// Euclidean topology validates directly in its ambient chart. Toroidal + /// topology validates in its periodic covering-space charts. Curved models + /// return `false` until they provide model-specific chart validators. + fn supports_affine_embedding_validation(&self) -> bool { + false + } + /// Indicates whether periodic facet/signature behavior is available. /// /// Returns `true` for periodic topologies that support lattice-offset tracking on simplices. @@ -178,6 +188,10 @@ impl GlobalTopologyModel for EuclideanModel { } Ok(coords) } + + fn supports_affine_embedding_validation(&self) -> bool { + true + } } /// Toroidal behavior model for domain wrapping and lattice-offset lifting. @@ -283,6 +297,10 @@ impl GlobalTopologyModel for ToroidalModel { Some(self.domain) } + fn supports_affine_embedding_validation(&self) -> bool { + true + } + fn supports_periodic_facet_signatures(&self) -> bool { matches!(self.mode, ToroidalConstructionMode::PeriodicImagePoint) } @@ -522,6 +540,23 @@ impl GlobalTopologyModel for GlobalTopologyModelAdapter { } } } + + fn supports_affine_embedding_validation(&self) -> bool { + match self { + Self::Euclidean(model) => { + GlobalTopologyModel::::supports_affine_embedding_validation(model) + } + Self::Toroidal(model) => { + GlobalTopologyModel::::supports_affine_embedding_validation(model) + } + Self::Spherical(model) => { + GlobalTopologyModel::::supports_affine_embedding_validation(model) + } + Self::Hyperbolic(model) => { + GlobalTopologyModel::::supports_affine_embedding_validation(model) + } + } + } } #[cfg(test)] diff --git a/src/topology/traits/topological_space.rs b/src/topology/traits/topological_space.rs index 31e6329c..1c15eba3 100644 --- a/src/topology/traits/topological_space.rs +++ b/src/topology/traits/topological_space.rs @@ -135,7 +135,7 @@ pub enum ToroidalConstructionMode { /// /// No coordinate canonicalization or image-point expansion is performed. The /// current Delaunay builder rejects non-Euclidean explicit connectivity - /// because Level 4 quotient Delaunay validation is not implemented for that + /// because quotient embedding validation is not implemented for that /// construction path. Explicit, } diff --git a/tests/benchmark_flip_fixtures.rs b/tests/benchmark_flip_fixtures.rs index 4869e53c..fc74abf5 100644 --- a/tests/benchmark_flip_fixtures.rs +++ b/tests/benchmark_flip_fixtures.rs @@ -21,17 +21,21 @@ use std::assert_matches; use delaunay::flips::{FacetHandle, FlipError, RidgeHandle}; use delaunay::prelude::construction::{ - DelaunayConstructionFailure, DelaunayTriangulationConstructionError, + DelaunayConstructionFailure, DelaunayConstructionRetryFailure, + DelaunayTriangulationConstructionError, }; use delaunay::prelude::tds::{FacetError, SimplexKey}; +use delaunay::prelude::validation::{ + DelaunayTriangulationValidationError, TriangulationEmbeddingValidationError, +}; use slotmap::KeyData; use flip_fixtures::{ ADVERSARIAL_POINTS_2D, ADVERSARIAL_POINTS_3D, ADVERSARIAL_POINTS_4D, ADVERSARIAL_POINTS_5D, - STABLE_POINTS_2D, STABLE_POINTS_3D, STABLE_POINTS_4D, STABLE_POINTS_5D, + DEGENERATE_POINTS_3D, STABLE_POINTS_2D, STABLE_POINTS_3D, STABLE_POINTS_4D, STABLE_POINTS_5D, }; use flip_workflows::{ - CandidateFilter, FlipWorkflowError, assert_same_topology, build_flip_dt, + CandidateFilter, FlipTriangulation, FlipWorkflowError, assert_same_topology, build_flip_dt, facet_support_touches_adversarial_feature, flippable_k2_facet, flippable_k3_ridge, forward_k2, forward_k3, largest_volume_simplex, ridge_support_touches_adversarial_feature, roundtrip_k1, simplex_touches_adversarial_feature, snapshot_topology, verify_k1_roundtrip, @@ -96,6 +100,23 @@ fn empty_flip_fixture_returns_construction_error() { } } +/// Verifies malformed flip fixtures fail with a typed degeneracy source. +#[test] +fn degenerate_flip_fixture_is_rejected_instead_of_sanitized() { + let err = build_flip_dt(DEGENERATE_POINTS_3D).expect_err("degenerate fixture should not build"); + + match err { + FlipWorkflowError::Construction { dimension, source } => { + assert_eq!(dimension, 3); + assert!( + construction_error_is_degenerate(&source), + "degenerate fixture should fail with a typed degeneracy source: {source:#?}" + ); + } + other => panic!("unexpected degenerate fixture error: {other}"), + } +} + /// Verifies that adversarial filtering does not silently accept stable supports. #[test] fn adversarial_filter_rejects_stable_fixture_supports() { @@ -263,9 +284,7 @@ fn topology_mismatch_reports_jaccard_diagnostics() { /// Verifies all selected 2D public flip workflows for one fixture. fn verify_2d_fixture(points: &[[f64; 2]], filter: CandidateFilter) { let base_dt = build_flip_dt(points).expect("2D benchmark flip fixture should build"); - base_dt - .validate() - .expect("2D benchmark flip fixture should validate"); + assert_topology_and_delaunay_valid(&base_dt, "2D benchmark flip fixture"); let simplex_key = largest_volume_simplex(&base_dt, filter) .expect("2D benchmark fixture should provide a selected k=1 simplex"); @@ -298,9 +317,7 @@ fn verify_2d_fixture(points: &[[f64; 2]], filter: CandidateFilter) { /// Verifies all selected 3D public flip workflows for one fixture. fn verify_3d_fixture(points: &[[f64; 3]], filter: CandidateFilter) { let base_dt = build_flip_dt(points).expect("3D benchmark flip fixture should build"); - base_dt - .validate() - .expect("3D benchmark flip fixture should validate"); + assert_topology_and_delaunay_valid(&base_dt, "3D benchmark flip fixture"); let simplex_key = largest_volume_simplex(&base_dt, filter) .expect("3D benchmark fixture should provide a selected k=1 simplex"); @@ -345,9 +362,7 @@ fn verify_3d_fixture(points: &[[f64; 3]], filter: CandidateFilter) { /// Verifies all selected roundtrip-capable public flip workflows for one dimension. fn verify_roundtrip_fixture(points: &[[f64; D]], filter: CandidateFilter) { let base_dt = build_flip_dt(points).expect("benchmark flip fixture should build"); - base_dt - .validate() - .expect("benchmark flip fixture should validate"); + assert_topology_and_delaunay_valid(&base_dt, "benchmark flip fixture"); let simplex_key = largest_volume_simplex(&base_dt, filter) .expect("benchmark fixture should provide a selected k=1 simplex"); @@ -386,6 +401,46 @@ fn verify_roundtrip_fixture(points: &[[f64; D]], filter: Candida .expect("k=3 roundtrip should recover the same triangulation"); } +fn assert_topology_and_delaunay_valid(dt: &FlipTriangulation, context: &str) { + dt.as_triangulation() + .validate() + .unwrap_or_else(|err| panic!("{context} should pass Levels 1-3: {err}")); + dt.is_valid_delaunay() + .unwrap_or_else(|err| panic!("{context} should pass Level 5: {err}")); +} + +fn construction_error_is_degenerate(error: &DelaunayTriangulationConstructionError) -> bool { + match error { + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::GeometricDegeneracy { .. }, + ) => true, + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::FinalDelaunayValidation { source, .. }, + ) => validation_error_is_degenerate(source), + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::ShuffledRetryExhausted { source, .. }, + ) => match source.as_ref() { + DelaunayConstructionRetryFailure::Construction { source } => { + construction_error_is_degenerate(source) + } + DelaunayConstructionRetryFailure::DelaunayValidation { .. } => false, + _ => false, + }, + _ => false, + } +} + +fn validation_error_is_degenerate(error: &DelaunayTriangulationValidationError) -> bool { + matches!( + error, + DelaunayTriangulationValidationError::Embedding(source) + if matches!( + source.as_ref(), + TriangulationEmbeddingValidationError::DegenerateSimplex { .. } + ) + ) +} + /// Creates a synthetic simplex key that cannot be live in fixture triangulations. fn missing_simplex_key() -> SimplexKey { SimplexKey::from(KeyData::from_ffi(u64::MAX)) diff --git a/tests/dedup_batch_construction.rs b/tests/dedup_batch_construction.rs index ce7d9d8d..d9f49083 100644 --- a/tests/dedup_batch_construction.rs +++ b/tests/dedup_batch_construction.rs @@ -3,7 +3,7 @@ //! These tests verify that: //! - Explicit batch dedup removes exact duplicates during batch construction //! - Explicit Hilbert-sort dedup collapses quantization-resolution collisions -//! - The resulting triangulations are valid (Levels 1–4) +//! - The resulting triangulations are valid (Levels 1–5) //! - Simplex-level coordinate uniqueness validation catches no violations post-dedup //! - Explicit `DedupPolicy::Exact` works for non-Hilbert orderings //! @@ -152,7 +152,7 @@ macro_rules! gen_dedup_batch_tests { ); assert!(dt.number_of_simplices() > 0); - // Full validation (Levels 1–4) including coordinate uniqueness + // Full validation (Levels 1–5) including coordinate uniqueness let validation = dt.validate(); assert!( validation.is_ok(), diff --git a/tests/delaunay_edge_cases.rs b/tests/delaunay_edge_cases.rs index ac737c9b..e2373540 100644 --- a/tests/delaunay_edge_cases.rs +++ b/tests/delaunay_edge_cases.rs @@ -259,7 +259,7 @@ fn debug_issue_120_empty_circumsphere_5d() { test_debug_warn!("[Issue #120 debug] robust repair error: {err}"); } } - if let Err(err) = dt_robust.is_valid() { + if let Err(err) = dt_robust.is_valid_delaunay() { test_debug_warn!("[Issue #120 debug] robust triangulation still invalid: {err:?}"); } let mut rng = rand::rngs::StdRng::seed_from_u64(0x1200_5eed); @@ -270,7 +270,7 @@ fn debug_issue_120_empty_circumsphere_5d() { &shuffled, TopologyGuarantee::PLManifold, ) { - if dt_alt.is_valid().is_ok() { + if dt_alt.is_valid_delaunay().is_ok() { test_debug_info!( "[Issue #120 debug] found valid triangulation after shuffle attempt {}", attempt + 1 @@ -294,7 +294,7 @@ fn debug_issue_120_empty_circumsphere_5d() { } } - if let Err(err) = dt.is_valid() { + if let Err(err) = dt.is_valid_delaunay() { #[cfg(feature = "diagnostics")] { debug_print_first_delaunay_violation(dt.tds(), None); @@ -771,33 +771,15 @@ fn test_cube_vertices_3d() { delaunay::prelude::Vertex::<(), _>::try_new([1.0, 1.0, 1.0]).unwrap(), ]; - let dt: DelaunayTriangulation<_, (), (), 3> = - DelaunayTriangulation::try_new_with_topology_guarantee( - &vertices, - TopologyGuarantee::PLManifold, - ) - .unwrap(); + let err = DelaunayTriangulation::<_, (), (), 3>::try_new_with_topology_guarantee( + &vertices, + TopologyGuarantee::PLManifold, + ) + .expect_err("exact cube corners should fail before storing a zero-volume simplex"); - // The eight cube corners are cospherical, so this intentionally exercises - // degenerate construction. Under `TopologyGuarantee::PLManifold`, - // `DelaunayTriangulation::try_new_with_topology_guarantee` may omit one - // boundary vertex while preserving a valid PL-manifold. This edge-case test - // checks that construction succeeds with a non-empty triangulation, not that - // the cospherical cube is meshed into a specific tetrahedralization. - let vertex_count = dt.number_of_vertices(); - assert!( - (7..=8).contains(&vertex_count), - "test_cube_vertices_3d using TopologyGuarantee::PLManifold should retain 7 or 8 vertices, got {vertex_count}" - ); - let simplex_count = dt.number_of_simplices(); assert!( - simplex_count >= 1, - "test_cube_vertices_3d using TopologyGuarantee::PLManifold should produce at least one simplex, got {simplex_count}" - ); - let validation = dt.is_valid(); - assert!( - validation.is_ok(), - "test_cube_vertices_3d using TopologyGuarantee::PLManifold should produce a valid triangulation: {validation:?}" + format!("{err:?}").contains("DegenerateSimplex"), + "cube-corner failure should preserve the embedding degeneracy source: {err:?}" ); } diff --git a/tests/delaunay_repair_fallback.rs b/tests/delaunay_repair_fallback.rs index 17cd63f7..09ea14d7 100644 --- a/tests/delaunay_repair_fallback.rs +++ b/tests/delaunay_repair_fallback.rs @@ -220,7 +220,7 @@ fn explicit_repair_call_validates_result() { .expect("Triangulation should be valid after explicit repair"); // Verify Delaunay property specifically - dt.is_valid() + dt.is_valid_delaunay() .expect("Should satisfy Delaunay property after repair"); } diff --git a/tests/euler_characteristic.rs b/tests/euler_characteristic.rs index 8ede820c..f80ab95b 100644 --- a/tests/euler_characteristic.rs +++ b/tests/euler_characteristic.rs @@ -318,7 +318,7 @@ fn test_2d_toroidal_explicit_construction_rejected() { .global_topology(topology) .topology_guarantee(TopologyGuarantee::Pseudomanifold) .build::<()>() - .expect_err("explicit toroidal connectivity requires a Level 4 quotient validator"); + .expect_err("explicit toroidal connectivity requires a quotient embedding validator"); match err { DelaunayTriangulationConstructionError::ExplicitConstruction( @@ -400,7 +400,7 @@ fn test_3d_toroidal_explicit_construction_rejected() { .global_topology(topology) .topology_guarantee(TopologyGuarantee::Pseudomanifold) .build::<()>() - .expect_err("explicit toroidal connectivity requires a Level 4 quotient validator"); + .expect_err("explicit toroidal connectivity requires a quotient embedding validator"); match err { DelaunayTriangulationConstructionError::ExplicitConstruction( @@ -521,7 +521,7 @@ test_complex_with_interior!( delaunay::prelude::Vertex::<(), _>::try_new([0.0, 1.0, 0.0, 0.0]).unwrap(), delaunay::prelude::Vertex::<(), _>::try_new([0.0, 0.0, 1.0, 0.0]).unwrap(), delaunay::prelude::Vertex::<(), _>::try_new([0.0, 0.0, 0.0, 1.0]).unwrap(), - delaunay::prelude::Vertex::<(), _>::try_new([0.25, 0.25, 0.25, 0.25]).unwrap(), // Interior point + delaunay::prelude::Vertex::<(), _>::try_new([0.21, 0.17, 0.13, 0.11]).unwrap(), // Interior point ], 0 // SΒ³ has Ο‡ = 0 ); diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index e9212424..a6d4bfe7 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -1416,7 +1416,7 @@ where if inserted_this_loop && had_simplices && validation_cadence.should_validate(summary.inserted) - && let Err(e) = dt.as_triangulation().is_valid() + && let Err(e) = dt.as_triangulation().is_valid_topology() { println!("Topology validation failed at idx={idx}: {e}"); let outcome = if let Err(report) = dt.validation_report() { @@ -1512,7 +1512,7 @@ where } println!(); - println!("Running validation_report (Levels 1–4)..."); + println!("Running validation_report (Levels 1–5)..."); let t_validate = Instant::now(); let validation_result = dt.validation_report(); println!("validation_report wall time: {:?}", t_validate.elapsed()); diff --git a/tests/pachner_roundtrip.rs b/tests/pachner_roundtrip.rs index 59d40b8f..08bec492 100644 --- a/tests/pachner_roundtrip.rs +++ b/tests/pachner_roundtrip.rs @@ -41,27 +41,44 @@ struct TopologySnapshot { simplex_vertex_uuids: Vec>, } +fn topology_and_delaunay_valid( + dt: &DelaunayTriangulation, (), (), D>, +) -> bool { + dt.as_triangulation().validate().is_ok() && dt.is_valid_delaunay().is_ok() +} + +fn assert_topology_and_delaunay_valid( + dt: &DelaunayTriangulation, (), (), D>, + context: &str, +) { + dt.as_triangulation() + .validate() + .unwrap_or_else(|err| panic!("{context} should pass Levels 1-3: {err}")); + dt.is_valid_delaunay() + .unwrap_or_else(|err| panic!("{context} should pass Level 5: {err}")); +} + #[test] fn public_pachner_roundtrips_preserve_stable_4d_topology() { let base = build_stable_dt_4d(); - base.validate().expect("stable 4D fixture should validate"); + assert_topology_and_delaunay_valid(&base, "stable 4D fixture"); let before = snapshot_topology(&base); let mut k1 = base.clone(); roundtrip_k1(&mut k1); - k1.validate().expect("k=1 roundtrip should validate"); + assert_topology_and_delaunay_valid(&k1, "k=1 roundtrip"); assert_eq!(snapshot_topology(&k1), before); let k2_facet = flippable_k2_facet(&base); let mut k2 = base.clone(); roundtrip_k2(&mut k2, k2_facet); - k2.validate().expect("k=2 roundtrip should validate"); + assert_topology_and_delaunay_valid(&k2, "k=2 roundtrip"); assert_eq!(snapshot_topology(&k2), before); let k3_ridge = flippable_k3_ridge(&base); let mut k3 = base; roundtrip_k3(&mut k3, k3_ridge); - k3.validate().expect("k=3 roundtrip should validate"); + assert_topology_and_delaunay_valid(&k3, "k=3 roundtrip"); assert_eq!(snapshot_topology(&k3), before); } @@ -169,8 +186,7 @@ fn edge_to_facet_query_tracks_2d_k2_mutation_freshness() { .unwrap() .is_some() ); - dt.validate() - .expect("2D k=2 mutation-freshness fixture should remain valid"); + assert_topology_and_delaunay_valid(&dt, "2D k=2 mutation-freshness fixture"); } /// Attempts a stale k=1 insert through the public `DelaunayResult` alias. @@ -223,8 +239,7 @@ fn build_flippable_dt_2d() -> Dt2 { .expect("explicit 2D fixture connectivity should parse") .build_with_kernel::<_, ()>(&RobustKernel::new()) .expect("stable 2D fixture should build"); - dt.validate() - .expect("stable 2D fixture should validate before local edits"); + assert_topology_and_delaunay_valid(&dt, "stable 2D fixture before local edits"); dt } @@ -245,7 +260,8 @@ fn flippable_k2_facet_2d(dt: &Dt2) -> FacetHandle { ) .expect("interior 2D facet index should be valid"); let mut trial = dt.clone(); - if trial.attempt_pachner(PachnerMove::K2 { facet }).is_ok() && trial.validate().is_ok() + if trial.attempt_pachner(PachnerMove::K2 { facet }).is_ok() + && topology_and_delaunay_valid(&trial) { return facet; } @@ -594,7 +610,7 @@ fn flippable_k2_facet(dt: &Dt4) -> FacetHandle { if trial .attempt_pachner(PachnerMove::K2Inverse { edge }) .is_ok() - && trial.validate().is_ok() + && topology_and_delaunay_valid(&trial) { return facet; } @@ -651,7 +667,7 @@ fn flippable_k3_ridge(dt: &Dt4) -> RidgeHandle { if trial .attempt_pachner(PachnerMove::K3Inverse { triangle }) .is_ok() - && trial.validate().is_ok() + && topology_and_delaunay_valid(&trial) { return ridge; } diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 11d3c81e..0534ad91 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -160,22 +160,33 @@ use delaunay::prelude::triangulation::{ }; use delaunay::prelude::try_vertices_from_points as prelude_try_vertices_from_points; use delaunay::prelude::validation::{ - TopologyGuarantee as FocusedValidationTopologyGuarantee, ValidationCadence, + DelaunayValidationError as FocusedDelaunayValidationError, + DelaunayViolationDetail as FocusedDelaunayViolationDetail, + DelaunayViolationReport as FocusedDelaunayViolationReport, + PeriodicDomainPeriodError as FocusedPeriodicDomainPeriodError, + TopologyGuarantee as FocusedValidationTopologyGuarantee, + TriangulationValidationReport as FocusedValidationReport, ValidationCadence, ValidationConfigurationError as FocusedValidationConfigurationError, ValidationPolicy as FocusedValidationPolicy, + delaunay_violation_report as focused_delaunay_violation_report, + find_delaunay_violations as focused_find_delaunay_violations, }; use delaunay::prelude::{ CoordinateRange as RootCoordinateRange, DelaunayError as RootDelaunayError, - DelaunayResult as RootDelaunayResult, EdgeIndex as RootEdgeIndex, + DelaunayResult as RootDelaunayResult, DelaunayViolationDetail as RootDelaunayViolationDetail, + DelaunayViolationReport as RootDelaunayViolationReport, EdgeIndex as RootEdgeIndex, FlipFailureKind as RootFlipFailureKind, FlipOrientationCheckStage as RootFlipOrientationCheckStage, GlobalTopology as RootGlobalTopology, GlobalTopologyModelError as RootGlobalTopologyModelError, - IncidenceView as RootIncidenceView, SecureHashMap, SecureHashSet, - SimplexNeighborIndex as RootSimplexNeighborIndex, TopologyError as RootTopologyError, - TopologyKind as RootTopologyKind, ToroidalConstructionMode as RootToroidalConstructionMode, - ToroidalDomain as RootToroidalDomain, ToroidalDomainError as RootToroidalDomainError, + IncidenceView as RootIncidenceView, PeriodicDomainPeriodError as RootPeriodicDomainPeriodError, + SecureHashMap, SecureHashSet, SimplexNeighborIndex as RootSimplexNeighborIndex, + TopologyError as RootTopologyError, TopologyKind as RootTopologyKind, + ToroidalConstructionMode as RootToroidalConstructionMode, ToroidalDomain as RootToroidalDomain, + ToroidalDomainError as RootToroidalDomainError, TriangulationAdjacency as RootTriangulationAdjacency, - ValidationConfigurationError as RootValidationConfigurationError, vertex as root_vertex, + TriangulationValidationReport as RootTriangulationValidationReport, + ValidationConfigurationError as RootValidationConfigurationError, + delaunay_violation_report as root_delaunay_violation_report, vertex as root_vertex, }; use delaunay::query::{ AllFacetsIter as QueryFacadeAllFacetsIter, BoundaryFacetsIter as QueryFacadeBoundaryFacetsIter, @@ -477,7 +488,12 @@ fn construction_prelude_exports_common_delaunay_error_aliases() { let simplex_key = SimplexKey::from(KeyData::from_ffi(1)); let validation = ConstructionDelaunayTriangulationValidationError::VerificationFailed { source: Box::new(ConstructionDelaunayVerificationError::from( - DelaunayValidationError::DelaunayViolation { simplex_key }, + DelaunayValidationError::DelaunayViolation { + simplex_key, + simplex_vertices: Default::default(), + offending_vertex: None, + neighbor_simplices: Default::default(), + }, )), }; assert_matches!( @@ -549,7 +565,7 @@ fn construction_prelude_covers_typed_construction_failure_variants() { ); assert_eq!( FinalDelaunayValidationContext::PeriodicQuotientDelaunay.to_string(), - "periodic quotient failed final Level 4 Delaunay validation" + "periodic quotient failed final Level 5 Delaunay validation" ); assert_eq!( InsertionTopologyValidationContext::PostInsertion.to_string(), @@ -1359,6 +1375,66 @@ fn validation_prelude_covers_configuration_error() { validation_policy: TriangulationValidationPolicy::Never, } ); + + let focused_report = FocusedValidationReport { + violations: Vec::new(), + }; + let root_report: RootTriangulationValidationReport = focused_report; + assert!(root_report.is_empty()); + + let focused_period_error = FocusedPeriodicDomainPeriodError::NonPositivePeriod { + axis: 0, + period: 0.0, + }; + assert_matches!( + focused_period_error, + FocusedPeriodicDomainPeriodError::NonPositivePeriod { axis: 0, .. } + ); + + let root_period_error = RootPeriodicDomainPeriodError::NonFinitePeriod { + axis: 1, + period: InvalidCoordinateValue::PositiveInfinity, + }; + assert_matches!( + root_period_error, + RootPeriodicDomainPeriodError::NonFinitePeriod { axis: 1, .. } + ); +} + +#[test] +fn validation_prelude_covers_delaunay_property_diagnostics() -> Result<(), PreludeExportTestError> { + let tds: Tds<(), (), 2> = Tds::empty(); + + let violations = focused_find_delaunay_violations(&tds, None)?; + assert!(violations.is_empty()); + + let report = focused_delaunay_violation_report(&tds, None)?; + let focused_report: FocusedDelaunayViolationReport = report; + let _focused_detail: Option = None; + assert!(focused_report.is_valid()); + + let simplex_key = SimplexKey::from(KeyData::from_ffi(11)); + let focused_error = FocusedDelaunayValidationError::DelaunayViolation { + simplex_key, + simplex_vertices: Default::default(), + offending_vertex: None, + neighbor_simplices: Default::default(), + }; + assert_matches!( + focused_error, + FocusedDelaunayValidationError::DelaunayViolation { + simplex_key: key, + offending_vertex: None, + .. + } if key == simplex_key + ); + + let root_report = root_delaunay_violation_report(&tds, None)?; + let root_typed_report: RootDelaunayViolationReport = root_report; + let _root_typed_detail: Option = None; + assert!(root_typed_report.is_valid()); + + Ok(()) } fn simplex_prelude_vertices( diff --git a/tests/proptest_delaunay_triangulation.rs b/tests/proptest_delaunay_triangulation.rs index 32a6c07f..5b51824d 100644 --- a/tests/proptest_delaunay_triangulation.rs +++ b/tests/proptest_delaunay_triangulation.rs @@ -348,7 +348,7 @@ fn has_no_cospherical_5_tuples_3d(vertices: &[Vertex<(), 3>]) -> bool { /// Assert the layered validation contract we rely on in these properties: /// - Levels 1–3 only (elements + structure + topology) -/// - Level 4 (Delaunay empty-circumsphere) is intentionally NOT asserted here +/// - Levels 4–5 (embedding + Delaunay empty-circumsphere) are intentionally NOT asserted here macro_rules! prop_assert_levels_1_to_3_valid { ($dim:expr, $dt:expr, $context:expr) => {{ let validation = ($dt).as_triangulation().validate(); @@ -1012,7 +1012,7 @@ proptest! { } let dt = dt.prop_assume_ok()?; - // Verify the triangulation satisfies the Delaunay property (Level 4) + // Verify the triangulation satisfies the Delaunay property (Level 5) // Use fast O(N) flip-based verification instead of O(NΓ—V) brute-force let delaunay_result = dt.is_delaunay_via_flips(); prop_assert!( @@ -1115,7 +1115,7 @@ macro_rules! gen_high_dim_delaunay_smoke { let delaunay_result = dt.is_delaunay_via_flips(); prop_assert!( delaunay_result.is_ok(), - "{}D active smoke triangulation should satisfy Level 4 Delaunay validation: {:?}", + "{}D active smoke triangulation should satisfy Level 5 Delaunay validation: {:?}", $dim, delaunay_result.err() ); @@ -1166,7 +1166,7 @@ macro_rules! gen_high_dim_delaunay_smoke { &cloud_dt, "active smoke duplicate-cloud construction" ); - let cloud_delaunay = cloud_dt.is_valid(); + let cloud_delaunay = cloud_dt.is_valid_delaunay(); prop_assert!( cloud_delaunay.is_ok(), "{}D active smoke duplicate cloud should be globally Delaunay: {:?}", @@ -1276,7 +1276,7 @@ macro_rules! gen_insertion_order_robustness_test { /// - Both triangulations are structurally/topologically valid (Levels 1–3) /// - Same vertex counts (all input points successfully inserted) /// - /// The Delaunay property (Level 4) is not asserted here (see Issue #120). + /// The Delaunay property (Level 5) is not asserted here (see Issue #120). /// /// **Note**: The exact edge sets may differ between different insertion orders, as /// Delaunay triangulation is not unique for degenerate/co-spherical point sets. @@ -1356,8 +1356,8 @@ macro_rules! gen_insertion_order_robustness_test { // TODO: Once bistellar flips are implemented to ensure unique canonical triangulations, // add explicit Level-4 checks here: - // prop_assert!(dt_a.is_valid().is_ok(), "{}D: Triangulation A must satisfy Delaunay property", $dim); - // prop_assert!(dt_b.is_valid().is_ok(), "{}D: Triangulation B must satisfy Delaunay property", $dim); + // prop_assert!(dt_a.is_valid_delaunay().is_ok(), "{}D: Triangulation A must satisfy Delaunay property", $dim); + // prop_assert!(dt_b.is_valid_delaunay().is_ok(), "{}D: Triangulation B must satisfy Delaunay property", $dim); // Bistellar flips will produce canonical triangulations, making edge-set comparison more meaningful. } } @@ -2039,7 +2039,7 @@ macro_rules! gen_duplicate_cloud_test { // Delaunay validity (Level 4) for kept subset let validate_start = std::time::Instant::now(); - let delaunay = dt.is_valid(); + let delaunay = dt.is_valid_delaunay(); let validate_elapsed = validate_start.elapsed(); if log_coverage { tracing::info!( diff --git a/tests/proptest_flips.rs b/tests/proptest_flips.rs index 67ef8f61..90059af5 100644 --- a/tests/proptest_flips.rs +++ b/tests/proptest_flips.rs @@ -168,7 +168,7 @@ fn assert_valid( context: &str, ) -> Result<(), TestCaseError> { triangulation - .is_valid() + .is_valid_topology() .map_err(|err| TestCaseError::fail(format!("{context} invariant check failed: {err:?}")))?; triangulation .validate() diff --git a/tests/proptest_serialization.rs b/tests/proptest_serialization.rs index bf0260ce..e76eae07 100644 --- a/tests/proptest_serialization.rs +++ b/tests/proptest_serialization.rs @@ -42,9 +42,16 @@ fn finite_coordinate() -> impl Strategy { /// Macro to generate serialization property tests for a given dimension macro_rules! test_serialization_properties { - ($dim:literal, $min_vertices:literal, $max_vertices:literal $(, #[$attr:meta])*) => { + ($dim:literal, $min_vertices:literal, $max_vertices:literal $(, cases = $cases:literal)? $(, #[$attr:meta])*) => { pastey::paste! { proptest! { + $( + #![proptest_config(proptest::test_runner::Config { + cases: $cases, + ..proptest::test_runner::Config::default() + })] + )? + /// Property: Triangulation structure preserved after JSON roundtrip $(#[$attr])* #[test] @@ -238,6 +245,6 @@ macro_rules! test_serialization_properties { // Generate tests for dimensions 2-5 // Parameters: dimension, min_vertices, max_vertices test_serialization_properties!(2, 4, 10); -test_serialization_properties!(3, 5, 12); +test_serialization_properties!(3, 5, 12, cases = 8); test_serialization_properties!(4, 6, 14, #[cfg(feature = "slow-tests")]); test_serialization_properties!(5, 7, 16, #[cfg(feature = "slow-tests")]); diff --git a/tests/regressions.rs b/tests/regressions.rs index 357602e0..7724e787 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -186,7 +186,7 @@ fn regression_empty_circumsphere_2d_minimal_case() { ) .unwrap(); - if dt.is_valid().is_err() { + if dt.is_valid_delaunay().is_err() { #[cfg(feature = "diagnostics")] debug_print_first_delaunay_violation(dt.tds(), None); } @@ -194,7 +194,7 @@ fn regression_empty_circumsphere_2d_minimal_case() { dt.repair_delaunay_with_flips().unwrap(); assert!( - dt.is_valid().is_ok(), + dt.is_valid_delaunay().is_ok(), "2D triangulation should be a valid PL-manifold after global flip repair" ); } @@ -322,7 +322,7 @@ fn regression_issue_307_4d_bulk_repair_keeps_positive_orientation() { ); assert_eq!(stats.total_skipped(), 0); assert!( - dt.as_triangulation().is_valid().is_ok(), + dt.as_triangulation().is_valid_topology().is_ok(), "bulk repair must leave all simplices in positive geometric orientation", ); assert!( diff --git a/tests/semgrep/docs/validation_levels.md b/tests/semgrep/docs/validation_levels.md new file mode 100644 index 00000000..55239a9f --- /dev/null +++ b/tests/semgrep/docs/validation_levels.md @@ -0,0 +1,22 @@ +# Validation Level Fixture + +// ruleid: delaunay.docs.no-stale-four-level-validation-hierarchy +The crate provides a 4-level validation hierarchy. + +// ruleid: delaunay.docs.no-stale-four-level-validation-hierarchy +The library provides four levels of validation. + +// ruleid: delaunay.docs.no-stale-four-level-validation-hierarchy +Level 4: Delaunay Property + +// ruleid: delaunay.docs.no-stale-four-level-validation-hierarchy +The Delaunay property (Level 4) is checked here. + +// ruleid: delaunay.docs.no-stale-four-level-validation-hierarchy +Delaunay Level 4 validation belongs in the Delaunay module. + +// ok: delaunay.docs.no-stale-four-level-validation-hierarchy +The crate provides a 5-level validation hierarchy. + +// ok: delaunay.docs.no-stale-four-level-validation-hierarchy +Level 5: Delaunay Property diff --git a/tests/semgrep/src/project_rules/rust_style.rs b/tests/semgrep/src/project_rules/rust_style.rs index c81d6080..4788be52 100644 --- a/tests/semgrep/src/project_rules/rust_style.rs +++ b/tests/semgrep/src/project_rules/rust_style.rs @@ -139,6 +139,45 @@ pub fn production_debug_assert_bypass(value: usize) { debug_assert!(value > 0); } +pub struct ValidationApiNamingFixture; + +impl ValidationApiNamingFixture { + // ruleid: delaunay.rust.validation-api-naming-standard + pub fn is_valid(&self) -> Result<(), ()> { + Ok(()) + } + + // ruleid: delaunay.rust.validation-api-naming-standard + pub fn embedding_validation_report(&self) -> Result<(), ()> { + Ok(()) + } + + // ok: delaunay.rust.validation-api-naming-standard + pub fn is_valid_embedding(&self) -> Result<(), ()> { + Ok(()) + } + + // ok: delaunay.rust.validation-api-naming-standard + pub fn embedding_diagnostic(&self) -> Option<()> { + None + } + + // ok: delaunay.rust.validation-api-naming-standard + pub fn embedding_report(&self) -> Result<(), ()> { + Ok(()) + } + + // ok: delaunay.rust.validation-api-naming-standard + pub fn validate(&self) -> Result<(), ()> { + Ok(()) + } + + // ok: delaunay.rust.validation-api-naming-standard + pub fn validation_report(&self) -> Result<(), ()> { + Ok(()) + } +} + // ruleid: delaunay.rust.no-legacy-coordinate-generic-api type LegacyPoint = Point; diff --git a/tests/triangulation_builder.rs b/tests/triangulation_builder.rs index 5eda2191..d58e3ac0 100644 --- a/tests/triangulation_builder.rs +++ b/tests/triangulation_builder.rs @@ -222,7 +222,7 @@ fn test_builder_canonicalized_toroidal_validates_2d() { ); } -/// Level 4 (Delaunay property) validation passes after toroidal-domain input canonicalization. +/// Level 5 (Delaunay property) validation passes after toroidal-domain input canonicalization. #[test] fn test_builder_canonicalized_toroidal_delaunay_property_valid_2d() { let vertices = vec![ @@ -514,7 +514,7 @@ macro_rules! gen_toroidal_validation_test { gen_toroidal_validation_test!(2, levels_1_to_4, true); #[test] -fn test_builder_toroidal_3d_validates_level_1_to_4() { +fn test_builder_toroidal_3d_fails_fast_until_scalable_quotient() { let vertices = vec![ Vertex::<(), _>::try_new([0.2_f64, 0.3, 0.4]).unwrap(), Vertex::<(), _>::try_new([0.8, 0.1, 0.2]).unwrap(), @@ -525,29 +525,25 @@ fn test_builder_toroidal_3d_validates_level_1_to_4() { Vertex::<(), _>::try_new([0.9, 0.2, 0.6]).unwrap(), ]; let kernel = RobustKernel::new(); - let dt = DelaunayTriangulationBuilder::new(&vertices) + let err = DelaunayTriangulationBuilder::new(&vertices) .try_toroidal([1.0_f64; 3]) .unwrap() .build_with_kernel::<_, ()>(&kernel) - .expect("compact periodic 3D quotient should validate after #413"); + .expect_err("compact periodic 3D quotient remains pending scalable selection"); - assert_eq!(dt.number_of_vertices(), vertices.len()); - assert!( - dt.global_topology().is_periodic(), - "global_topology should use periodic image-point construction" - ); - assert!( - dt.tds().is_valid().is_ok(), - "TDS structural validity should pass for periodic 3D" - ); - assert!( - dt.as_triangulation().validate().is_ok(), - "Levels 1-3 topology validation should pass for periodic 3D" - ); - assert!( - dt.validate().is_ok(), - "Levels 1-4 validation should pass for periodic 3D" - ); + match err { + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::PeriodicQuotientSelectionIncompleteCoverage { + dimension, + covered_vertex_count, + canonical_vertex_count, + }, + ) => { + assert_eq!(dimension, 3); + assert!(covered_vertex_count < canonical_vertex_count); + } + other => panic!("expected 3D periodic quotient coverage guardrail, got {other:?}"), + } } macro_rules! gen_toroidal_high_dim_guardrail_test { @@ -615,10 +611,10 @@ fn test_builder_toroidal_large_dimension_fails_before_expansion_math() { } /// Explicit 7-vertex torus (Heawood triangulation) with `GlobalTopology::Toroidal` -/// is rejected until explicit non-Euclidean construction has Level 4 validation. +/// is rejected until explicit non-Euclidean construction has quotient embedding validation. /// /// The 14-triangle closed mesh has Ο‡ = 0 (torus), but explicit quotient -/// connectivity cannot yet be validated against the Level 4 Delaunay property. +/// connectivity cannot yet be validated against a faithful quotient embedding. #[test] fn test_explicit_toroidal_heawood_torus_rejected() { // Regular heptagon: 7 well-separated points, no 3 collinear. @@ -644,7 +640,7 @@ fn test_explicit_toroidal_heawood_torus_rejected() { .unwrap() .global_topology(topology) .build::<()>() - .expect_err("explicit toroidal connectivity requires a Level 4 quotient validator"); + .expect_err("explicit toroidal connectivity requires a quotient embedding validator"); match err { DelaunayTriangulationConstructionError::ExplicitConstruction( @@ -1079,7 +1075,7 @@ fn test_explicit_non_delaunay_mesh() { ); assert!( err.to_string().contains("Delaunay validation failed"), - "error should identify the Level 4 validation failure: {err}" + "error should identify the Level 5 validation failure: {err}" ); } @@ -1129,7 +1125,7 @@ fn test_explicit_preserves_vertex_data() { ); } -/// Full `validate()` (Levels 1–4) on a Delaunay-compatible explicit mesh. +/// Full `validate()` (Levels 1–5) on a Delaunay-compatible explicit mesh. #[test] fn test_explicit_validate_delaunay_mesh() { // Use a known Delaunay configuration: the standard simplex. From 3f2d41d1f2450cec92a3d8bc4bd77730d366c0fe Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 27 Jun 2026 18:47:54 -0700 Subject: [PATCH 2/3] feat(validation)!: enforce faithful embedding validation (#449) - Add Level 4 embedding reports for degenerate simplices, duplicate simplex labels, illegal simplex intersections, and periodic chart-span failures. - Expose pure labeled-simplex embedding predicates through geometry and focused geometry preludes. - Run changed-scope embedding guards during insertion and include embedding validation in cumulative triangulation, repair, and Delaunay validation before Level 5 checks. - Document the validation hierarchy, embedding references, benchmark coverage, and local Clippy parity with GitHub code scanning. BREAKING CHANGE: cumulative validation, insertion, repair, and topology-change APIs can now fail with Level 4 embedding errors before Delaunay-property checks; callers matching validation, construction, or insertion errors must handle the new embedding variants. LabeledSimplexEmbedding::try_new now requires Eq labels and rejects duplicates, and SimplexIntersectionFailure is non-exhaustive with IntersectionOutsideSharedFace { witness } instead of the previous tuple variant. --- REFERENCES.md | 26 +- benches/common/flip_fixtures.rs | 28 + benches/common/flip_workflows.rs | 39 +- benches/profiling_suite.rs | 10 + docs/ORIENTATION_SPEC.md | 5 +- docs/api_design.md | 4 +- docs/architecture/module_map.md | 2 + docs/architecture/prelude_reference.md | 2 +- docs/dev/commands.md | 14 +- docs/dev/debug_env_vars.md | 1 + docs/invariants.md | 5 +- docs/validation.md | 52 +- docs/workflows.md | 2 +- justfile | 14 +- src/core/algorithms/flips.rs | 13 + src/core/algorithms/incremental_insertion.rs | 33 + src/core/construction.rs | 9 + src/core/embedding.rs | 821 +++++++++++++++++- src/core/insertion.rs | 26 +- src/core/repair.rs | 2 + src/core/validation.rs | 199 ++++- src/delaunay/construction.rs | 45 +- src/delaunay/deletion.rs | 2 +- src/delaunay/property_validation.rs | 7 +- src/delaunay/query.rs | 8 +- src/delaunay/validation.rs | 4 +- src/geometry/embedding.rs | 413 ++++++++- src/geometry/point.rs | 20 + src/lib.rs | 31 +- tests/README.md | 3 + tests/benchmark_flip_fixtures.rs | 203 ++++- tests/delaunay_edge_cases.rs | 41 +- tests/large_scale_debug.rs | 96 +- tests/pachner_roundtrip.rs | 63 +- tests/prelude_exports.rs | 234 +++-- tests/proptest_delaunay_triangulation.rs | 4 +- tests/proptest_flips.rs | 3 + tests/regressions.rs | 3 + tests/semgrep/src/project_rules/rust_style.rs | 5 + 39 files changed, 2148 insertions(+), 344 deletions(-) diff --git a/REFERENCES.md b/REFERENCES.md index 2aabf1b3..7b7f75c3 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -50,7 +50,7 @@ These references support specialized features and high-dimensional computations ### High-Dimensional Computational Geometry -- Avis, D., and Bremner, D. "How Good Are Convex Hull Algorithms?" *Computational Geometry* 7, +- Avis, D., Bremner, D., and Seidel, R. "How Good Are Convex Hull Algorithms?" *Computational Geometry* 7, no. 5-6 (1997): 265-301. DOI: [10.1016/S0925-7721(96)00023-5](https://doi.org/10.1016/S0925-7721(96)00023-5) - Chazelle, B. "An Optimal Convex Hull Algorithm in Any Fixed Dimension." *Discrete & Computational Geometry* 10, no. 4 (1993): 377-409. DOI: [10.1007/BF02573985](https://doi.org/10.1007/BF02573985) @@ -241,7 +241,7 @@ These references ensure the library's geometric computations are mathematically ### Geometric Tie-Breaking and Deterministic Perturbations -- Burnikel, C., Funke, S., and Mehlhorn, K. "Exact Geometric Computation Made Easy." +- Burnikel, C., Fleischer, R., Mehlhorn, K., and Schirra, S. "Efficient Exact Geometric Computation Made Easy." *Proceedings of the Fifteenth Annual Symposium on Computational Geometry* (1999): 341-350. DOI: [10.1145/304893.304988](https://doi.org/10.1145/304893.304988) - Yap, C. K. "Towards Exact Geometric Computation." @@ -306,7 +306,7 @@ These references inform the library's performance optimization strategies and me ### Memory-Efficient Data Structures -- Blandford, D.K., Blelloch, G.E., Dahle, C., and Karp, R. "Compact Representations of Simplicial Meshes in Two and Three Dimensions." +- Blandford, D.K., Blelloch, G.E., Cardoze, D.E., and Kadow, C. "Compact Representations of Simplicial Meshes in Two and Three Dimensions." *International Journal of Computational Geometry & Applications* 15, no. 1 (2005): 3-24. DOI: [10.1142/S0218195905001580](https://doi.org/10.1142/S0218195905001580) - Geuzaine, C., and Remacle, J.-F. "Gmsh: A 3-D Finite Element Mesh Generator @@ -360,3 +360,23 @@ library (facet degree, closed-boundary checks, and links of simplices). - Rourke, C. P., and Sanderson, B. J. *Introduction to Piecewise-Linear Topology*. Springer, 1972. - Stillwell, J. *Euler's Gem: The Polyhedron Formula and the Birth of Topology*. Princeton University Press, 2010. - Zomorodian, A. *Topology for Computing*. Cambridge University Press, 2005. + +## Embedded-Geometry Overlap Detection (Level 4 Validation) + +These references support the embedded-geometry (Level 4) validator, which certifies that maximal +simplices are nondegenerate and intersect only in their shared faces. Candidate overlapping pairs +are found with a sweep-and-prune broad phase over axis-aligned bounding boxes before exact rational +barycentric intersection tests are applied. Two axis-aligned boxes intersect if and only if their +projections overlap on every coordinate axis (the separating-axis test), and sorting boxes by their +lower endpoint on one axis while retiring boxes whose upper endpoint precedes the current lower +endpoint examines a superset of all axis-overlapping pairs, so no intersecting pair is skipped. + +- Baraff, D. "Dynamic Simulation of Non-Penetrating Rigid Bodies." PhD thesis, Cornell University, 1992. + Introduces coordinate sorting ("sort and sweep") for axis-aligned bounding-box overlap detection. + Available at: +- Cohen, J. D., Lin, M. C., Manocha, D., and Ponamgi, M. "I-COLLIDE: An Interactive and Exact Collision + Detection System for Large-Scale Environments." *Proceedings of the 1995 Symposium on Interactive 3D + Graphics* (1995): 189-196. DOI: [10.1145/199404.199437](https://doi.org/10.1145/199404.199437) +- Ericson, C. *Real-Time Collision Detection*. Morgan Kaufmann, 2005. ISBN: 978-1-55860-732-3. + (Chapter 7: sweep-and-prune broad phase; Chapters 4-5: axis-aligned bounding boxes and the + separating-axis test for box intersection.) diff --git a/benches/common/flip_fixtures.rs b/benches/common/flip_fixtures.rs index d7c92084..dec005fe 100644 --- a/benches/common/flip_fixtures.rs +++ b/benches/common/flip_fixtures.rs @@ -45,6 +45,13 @@ pub const STABLE_POINTS_3D: &[[f64; 3]] = &[ /// /// The unit 4-simplex hull plus clustered but non-degenerate interior vertices /// is the control case for 4D k=1/k=2/k=3 roundtrip benchmarks. +#[cfg_attr( + not(feature = "slow-tests"), + allow( + dead_code, + reason = "4D fixture certification is gated behind slow-tests in the integration suite" + ) +)] pub const STABLE_POINTS_4D: &[[f64; 4]] = &[ [0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0], @@ -64,6 +71,13 @@ pub const STABLE_POINTS_4D: &[[f64; 4]] = &[ /// /// The unit 5-simplex hull plus clustered but non-degenerate interior vertices /// is the control case for 5D k=1/k=2/k=3 roundtrip benchmarks. +#[cfg_attr( + not(feature = "slow-tests"), + allow( + dead_code, + reason = "5D fixture certification is gated behind slow-tests in the integration suite" + ) +)] pub const STABLE_POINTS_5D: &[[f64; 5]] = &[ [0.0, 0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0, 0.0], @@ -121,6 +135,13 @@ pub const ADVERSARIAL_POINTS_3D: &[[f64; 3]] = &[ /// Combines a D+2 cospherical set from the simplex vertices plus one extra /// hypercube corner, near-boundary interior points, nearly degenerate interior /// clustering, and a large-coordinate hull point. +#[cfg_attr( + not(feature = "slow-tests"), + allow( + dead_code, + reason = "4D fixture certification is gated behind slow-tests in the integration suite" + ) +)] pub const ADVERSARIAL_POINTS_4D: &[[f64; 4]] = &[ [0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0], @@ -142,6 +163,13 @@ pub const ADVERSARIAL_POINTS_4D: &[[f64; 4]] = &[ /// Combines a D+2 cospherical set from the simplex vertices plus one extra /// hypercube corner, near-boundary interior points, nearly degenerate interior /// clustering, and a large-coordinate hull point. +#[cfg_attr( + not(feature = "slow-tests"), + allow( + dead_code, + reason = "5D fixture certification is gated behind slow-tests in the integration suite" + ) +)] pub const ADVERSARIAL_POINTS_5D: &[[f64; 5]] = &[ [0.0, 0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0, 0.0], diff --git a/benches/common/flip_workflows.rs b/benches/common/flip_workflows.rs index 42255b63..a446fd92 100644 --- a/benches/common/flip_workflows.rs +++ b/benches/common/flip_workflows.rs @@ -303,6 +303,13 @@ pub enum FlipWorkflowError { }, /// A flip reported three inserted triangle vertices that do not form a real triangle. + #[cfg_attr( + not(feature = "slow-tests"), + allow( + dead_code, + reason = "k=3 inverse roundtrip diagnostics are exercised by slow 4D/5D fixture tests" + ) + )] #[error("{move_kind} flip reported an invalid inserted triangle: {source}")] InvalidInsertedTriangle { /// Flip move kind. @@ -321,16 +328,6 @@ pub enum FlipWorkflowError { #[source] source: DelaunayTriangulationValidationError, }, - - /// A roundtrip produced a triangulation that failed topology validation. - #[error("{context} produced invalid topology after roundtrip: {source}")] - InvalidTopologyAfterRoundtrip { - /// Roundtrip context label. - context: String, - /// Underlying topology validation failure. - #[source] - source: Box, - }, } /// Jaccard report category used by @@ -1007,6 +1004,13 @@ pub fn forward_k3( /// flip does not report an inserted triangle, or /// [`FlipWorkflowError::InverseFlipFailed`] when the inverse triangle move /// fails. +#[cfg_attr( + not(feature = "slow-tests"), + allow( + dead_code, + reason = "k=3 inverse roundtrips are exercised by slow 4D/5D fixture tests" + ) +)] pub fn roundtrip_k3( dt: &mut FlipTriangulation, ridge: RidgeHandle, @@ -1097,6 +1101,13 @@ pub fn verify_k2_roundtrip( /// Returns an error when snapshotting, the k=3 roundtrip, /// [`DelaunayTriangulation::validate`] validation, or exact topology comparison /// fails. +#[cfg_attr( + not(feature = "slow-tests"), + allow( + dead_code, + reason = "k=3 inverse roundtrips are exercised by slow 4D/5D fixture tests" + ) +)] pub fn verify_k3_roundtrip( base_dt: &FlipTriangulation, ridge: RidgeHandle, @@ -1113,13 +1124,7 @@ fn validate_topology_and_delaunay( dt: &FlipTriangulation, context: &str, ) -> FlipWorkflowResult<()> { - dt.as_triangulation().validate().map_err(|source| { - FlipWorkflowError::InvalidTopologyAfterRoundtrip { - context: context.to_string(), - source: Box::new(source), - } - })?; - dt.is_valid_delaunay() + dt.validate() .map_err(|source| FlipWorkflowError::InvalidAfterRoundtrip { context: context.to_string(), source, diff --git a/benches/profiling_suite.rs b/benches/profiling_suite.rs index afcaad23..56893083 100644 --- a/benches/profiling_suite.rs +++ b/benches/profiling_suite.rs @@ -1072,6 +1072,16 @@ macro_rules! benchmark_validation_components_dimension { }); }); + group.bench_function("validate_embedding", |b| { + b.iter(|| { + if let Err(error) = black_box(dt.as_triangulation().validate_embedding()) { + abort_benchmark(format_args!( + "embedding validation should pass for benchmark triangulation: {error}" + )); + } + }); + }); + group.bench_function("is_valid_delaunay", |b| { b.iter(|| { if let Err(error) = black_box(dt.is_valid_delaunay()) { diff --git a/docs/ORIENTATION_SPEC.md b/docs/ORIENTATION_SPEC.md index 2458e474..0854cd7a 100644 --- a/docs/ORIENTATION_SPEC.md +++ b/docs/ORIENTATION_SPEC.md @@ -202,8 +202,9 @@ drive repair, but replacement-simplex orientation itself uses `robust_orientatio and then enforces the Delaunay property. - `.try_toroidal([..])` builds an image-point triangulation and then runs orientation normalization, lifted geometric orientation validation, final - Levels 1-3 topology validation, and final Level 5 Delaunay validation before - returning the quotient triangulation. + Levels 1-3 topology validation, Level 4 embedding validation in periodic + covering-space charts, and final Level 5 Delaunay validation before returning + the quotient triangulation. ## Degenerate Simplices diff --git a/docs/api_design.md b/docs/api_design.md index 7b505b01..875e3cf9 100644 --- a/docs/api_design.md +++ b/docs/api_design.md @@ -163,8 +163,8 @@ for topology guarantee and validation policy details. typed repair diagnostics where available, for example `RepairOperationFailed { operation, source }`. - **Validation**: The active `ValidationPolicy` (set with - `dt.try_set_validation_policy(...)` or `dt.set_validation_policy(...)`) governs automatic topology validation for - subsequent construction/modification operations + `dt.try_set_validation_policy(...)` or `dt.set_validation_policy(...)`) governs automatic topology and + changed-scope embedding guards for subsequent construction/modification operations ## Pachner Move API Reference diff --git a/docs/architecture/module_map.md b/docs/architecture/module_map.md index cb6f563e..512eb50f 100644 --- a/docs/architecture/module_map.md +++ b/docs/architecture/module_map.md @@ -63,6 +63,8 @@ PL-manifold validation. Ridge ownership therefore belongs in `src/topology/`. - `coordinate_range.rs` - validated coordinate-range value type for random point and triangulation generator APIs. +- `embedding.rs` - pure labeled-simplex embedding predicates and witnesses + used by generic Level 4 validation. - `kernel.rs` - kernel abstraction (`AdaptiveKernel`, `RobustKernel`, `FastKernel`) and `ExactPredicates` marker trait. - `point.rs` - finite/NaN-aware point operations. diff --git a/docs/architecture/prelude_reference.md b/docs/architecture/prelude_reference.md index f140163d..73c8fcc2 100644 --- a/docs/architecture/prelude_reference.md +++ b/docs/architecture/prelude_reference.md @@ -18,7 +18,7 @@ they exercise. | Low-level incremental insertion building blocks | `use delaunay::prelude::insertion::*` | | Post-construction vertex deletion errors and keys | `use delaunay::prelude::deletion::*` | | Low-level TDS simplices, facets, keys, and validation reports | `use delaunay::prelude::tds::*` | -| Points, coordinate ranges, kernels, predicates, and geometric measures | `use delaunay::prelude::geometry::*` | +| Points, simplex embeddings, coordinate ranges, kernels, predicates, and geometric measures | `use delaunay::prelude::geometry::*` | | Random points or triangulations for examples, tests, and benchmarks | `use delaunay::prelude::generators::*` | | Read-only traversal, adjacency, convex hulls, and comparison helpers | `use delaunay::prelude::query::*` | | Topological spaces, topology traits, and lifted toroidal IDs | `use delaunay::prelude::topology::spaces::*` | diff --git a/docs/dev/commands.md b/docs/dev/commands.md index ed7a7262..0896b27b 100644 --- a/docs/dev/commands.md +++ b/docs/dev/commands.md @@ -174,10 +174,10 @@ examples, or benchmarks. just check-fast ``` -`rust-core-check` runs core library Clippy in the default and all-features -configurations. `clippy-all-targets` is available as an optional broad sweep, -but it is not part of `just ci` because tests, examples, and benchmark harnesses -own their own validation buckets. +`rust-core-check` runs all-targets Clippy in the default and all-features +configurations. This intentionally includes the all-targets, all-features +surface uploaded by the PR Clippy SARIF workflow, so `just ci` fails locally on +the same warning classes that would become GitHub code-scanning annotations. --- @@ -236,7 +236,7 @@ correctness invariants throughout. `just ci` is the comprehensive error-catching validation path used by GitHub Actions. It is a flat union of leaf validators rather than a nested call to `just check`. The target classes are kept separate: `rust-core-check` covers -formatting, core library Clippy, rustdoc, and Semgrep; `bench-compile` compiles +formatting, all-targets Clippy, rustdoc, and Semgrep; `bench-compile` compiles benchmark harnesses once; `test-rust-ci` compiles and runs Rust lib unit tests and release integration tests in one release-profile nextest invocation; `test-doc` compiles and runs Rust doctests once in release profile; @@ -616,8 +616,8 @@ CI enforces: - examples Rust warnings are denied by the manifest lint policy and Clippy warnings are -denied by the `just clippy-core` invocations. Keep any intentional warning-level -exceptions explicit in `Cargo.toml`. +denied by the `just clippy` / `just clippy-all-targets` invocations. Keep any +intentional warning-level exceptions explicit in `Cargo.toml`. Agents must ensure changes pass the appropriate local validator before proposing patches. Use the validation matrix above for final handoff: core diff --git a/docs/dev/debug_env_vars.md b/docs/dev/debug_env_vars.md index ada45694..a4254513 100644 --- a/docs/dev/debug_env_vars.md +++ b/docs/dev/debug_env_vars.md @@ -134,6 +134,7 @@ and release builds. | `DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED` | **value** | Vertex shuffle seed | | `DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY` | **value** | Incremental progress interval; batch fallback if the canonical knob is unset | | `DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY` | **value** | Validation interval | +| `DELAUNAY_LARGE_DEBUG_VALIDATION` | **value** | Validation scope: `full` (Levels 1-5, default) or `construction` (Levels 1-3 + Level 5, skips Level 4) | | `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY` | **value** | Batch/incremental repair interval (default: 1) | | `DELAUNAY_LARGE_DEBUG_REPAIR_MAX_FLIPS` | **value** | Flip budget override | | `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS` | **value** | Timeout (0 = no cap) | diff --git a/docs/invariants.md b/docs/invariants.md index 4a01aa5b..e588dc59 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -129,8 +129,9 @@ Cumulative validation is exposed through the `validate` / `validation_report` AP [`docs/validation.md`](validation.md). Automatic validation during construction is intentionally topology-oriented: `ValidationPolicy` -controls Level 3 checks during insertion, while Level 4 embedding and Level 5 Delaunay validation -remain explicit certification steps for workflows that need them. +controls Level 3 checks during insertion and can enable local insertion-time embedding checks. +Full/global Level 4 embedding certification and Level 5 Delaunay validation remain explicit +certification steps for workflows that need them. --- diff --git a/docs/validation.md b/docs/validation.md index 7978740e..7602d051 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -64,17 +64,19 @@ as a full audit and as input to future repair workflows. The library always provides **explicit** validation APIs (Levels 1–5) that you can call when you need them. Separately, incremental construction (`new()` / `insert*()`) can run an **automatic** -*Level 3* topology validation pass after an insertion attempt, controlled by a -`ValidationPolicy` on the triangulation. +global Level 3 topology pass plus changed-scope Level 4 embedding guards after an insertion attempt, +controlled by a `ValidationPolicy` on the triangulation. -This is a performance vs certainty knob: Level 3 (`Triangulation::is_valid_topology()`) is -relatively expensive, so the default behavior is to validate only when something -looks β€œoff”. +This is a performance vs certainty knob: Level 3 (`Triangulation::is_valid_topology()`) and +full pairwise Level 4 (`Triangulation::is_valid_embedding()`) are relatively expensive, so the +default behavior is to run automatic global topology and changed-scope embedding checks only when +something looks β€œoff”. ### What is validated automatically? -Only **Level 3** (`Triangulation::is_valid_topology()`), using the triangulation’s current -`TopologyGuarantee` (default: `PLManifold`): +When the policy triggers automatic validation, it runs **Level 3** +(`Triangulation::is_valid_topology()`), using the triangulation’s current `TopologyGuarantee` +(default: `PLManifold`): - Codimension-1 manifoldness (facet degree: 1 or 2 incident simplices per facet) - Codimension-2 boundary manifoldness (the boundary is closed; "no boundary of boundary") @@ -87,9 +89,12 @@ Only **Level 3** (`Triangulation::is_valid_topology()`), using the triangulation Note: neighbor-pointer consistency is a **Level 2** structural invariant checked by `Tds::is_valid()` / `Tds::validate()`, and is intentionally not part of Level 3. -Automatic validation does **not** run Level 4 embedding validation or Level 5 Delaunay -empty-circumsphere validation. If you need geometric verification, call -`dt.as_triangulation().validate_embedding()`, `dt.is_valid_delaunay()`, or `dt.validate()` explicitly. +The same automatic validation pass then runs **Level 4** embedding guards for the changed simplex +scope. It always checks changed simplices for degeneracy and checks changed-vs-current pairwise +intersections. It does **not** rescan old-vs-old simplex pairs. It also does **not** run Level 5 +Delaunay empty-circumsphere validation. If you need a complete embedding or +Delaunay-property check, call `dt.as_triangulation().validate_embedding()`, `dt.is_valid_delaunay()`, +`dt.delaunay_report()`, or `dt.validate()` explicitly. ### Default: derived from `TopologyGuarantee` @@ -98,8 +103,8 @@ uses `ValidationPolicy::ExplicitOnly`, `PLManifoldStrict` uses `ValidationPolicy::Always`, and `Pseudomanifold` uses `ValidationPolicy::OnSuspicion`. -With `ValidationPolicy::OnSuspicion`, Level 3 validation runs only when insertion -deviates from the happy-path and trips internal **suspicion flags**, e.g.: +With `ValidationPolicy::OnSuspicion`, global Level 3 plus changed-scope Level 4 guards run only when +insertion deviates from the happy-path and trips internal **suspicion flags**, e.g.: - A perturbation retry was required (geometric degeneracy). - The insertion fell back to a conservative β€œstar-split” of the containing simplex. @@ -108,15 +113,18 @@ deviates from the happy-path and trips internal **suspicion flags**, e.g.: ### Available policies -- `ValidationPolicy::Never`: never run full Level 3 automatically; compatible only with +- `ValidationPolicy::Never`: never run automatic global Level 3/changed-scope Level 4 checks; compatible only with `TopologyGuarantee::Pseudomanifold`. -- `ValidationPolicy::ExplicitOnly` *(default for `PLManifold`)*: run full Level 3 - only through explicit validation calls while still keeping topology checks required - by the active `TopologyGuarantee`. -- `ValidationPolicy::OnSuspicion` *(default for `Pseudomanifold`)*: run Level 3 +- `ValidationPolicy::ExplicitOnly` *(default for `PLManifold`)*: do not run policy-triggered + global Level 3/changed-scope Level 4 checks during insertion; caller-owned explicit validation + APIs remain available, and insertion still keeps topology checks required by the active + `TopologyGuarantee`. +- `ValidationPolicy::OnSuspicion` *(default for `Pseudomanifold`)*: run global Level 3/changed-scope Level 4 checks only when insertion is suspicious. -- `ValidationPolicy::Always`: run Level 3 after every insertion attempt (slowest, best for tests). -- `ValidationPolicy::DebugOnly`: always run Level 3 in debug builds; in release behaves like `OnSuspicion`. +- `ValidationPolicy::Always`: run global Level 3/changed-scope Level 4 checks after every insertion attempt + (slowest, best for tests). +- `ValidationPolicy::DebugOnly`: always run global Level 3/changed-scope Level 4 checks in debug builds; in release + behaves like `OnSuspicion`. ### Example: configuring validation policy @@ -485,6 +493,10 @@ model-specific chart validators are implemented. bounding-box pruning before exact rational barycentric witness construction. - **Space**: O(DΒ²) to O(simplices) temporary space depending on the number of candidate overlaps. +For the broad-phase overlap-detection references, see the +[Embedded-Geometry Overlap Detection](../REFERENCES.md#embedded-geometry-overlap-detection-level-4-validation) +section of `REFERENCES.md`. + ### When to Use - **Tests**: After construction or manual edits when embedded correctness matters. @@ -710,7 +722,7 @@ pub fn my_algorithm( ```rust use delaunay::prelude::query::*; use delaunay::prelude::tds::{InvariantError, TdsError}; -use delaunay::{ +use delaunay::prelude::validation::{ DelaunayTriangulationValidationError, TriangulationEmbeddingValidationError, }; diff --git a/docs/workflows.md b/docs/workflows.md index 3a6bf145..8a4e6fa1 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -59,7 +59,7 @@ let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty() // Enforce stricter topology checks. dt.set_topology_guarantee(TopologyGuarantee::PLManifoldStrict); -// In tests/debugging, validate Level 3 after every insertion. +// In tests/debugging, validate global Level 3 and changed-scope Level 4 after every insertion. dt.set_validation_policy(ValidationPolicy::Always); ``` diff --git a/justfile b/justfile index 0613580f..c0b30ed3 100644 --- a/justfile +++ b/justfile @@ -282,16 +282,12 @@ clean: rm -rf coverage # Code quality and formatting -clippy: clippy-core +clippy: clippy-all-targets clippy-all-targets: cargo clippy --workspace --all-targets -- -D warnings -W clippy::pedantic -W clippy::nursery -W clippy::cargo cargo clippy --workspace --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W clippy::cargo -clippy-core: - cargo clippy --workspace --lib -- -D warnings -W clippy::pedantic -W clippy::nursery -W clippy::cargo - cargo clippy --workspace --lib --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W clippy::cargo - # Coverage analysis for local development (HTML output) coverage: _ensure-cargo-llvm-cov mkdir -p target/llvm-cov @@ -347,7 +343,7 @@ help-workflows: @echo " just ci # GitHub-equivalent union of every validation bucket" @echo "" @echo "Focused validation:" - @echo " just rust-core-check # Formatting, core clippy, docs, and Semgrep" + @echo " just rust-core-check # Formatting, all-targets Clippy, docs, and Semgrep" @echo " just python-ci # Python lint/typecheck + pytest" @echo " just notebook-check # Notebook hygiene + fast headless execution" @echo " just markdown-ci # Markdown lint + spell check" @@ -655,11 +651,15 @@ perf-large-scale-smoke max_secs="60": _ensure-nextest echo "" echo "β–Ά ${dimension}: ${test_name} (${n_points} vertices, ${max_secs}s cap)" + # Construction wall-clock guard: validate Levels 1-3 + Level 5 only. + # Level 4 embedding overlap validation runs at scale under `just test-slow` + # (full scope); see issue #482. if env \ DELAUNAY_BULK_PROGRESS_EVERY="$progress_every" \ DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS="$max_secs" \ "$n_env=$n_points" \ DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1 \ + DELAUNAY_LARGE_DEBUG_VALIDATION=construction \ cargo nextest run --release --profile slow --features slow-tests --test large_scale_debug "$test_name" -- --exact --nocapture 2>&1 | tee "$log_file"; then echo "βœ… ${dimension} completed within the ${max_secs}s test-runtime cap" case_status="PASS" @@ -890,7 +890,7 @@ python-sync: _ensure-uv python-typecheck: _ensure-uv uv run ty check scripts/ --error all -rust-core-check: fmt-check clippy-core doc-check semgrep semgrep-test +rust-core-check: fmt-check clippy-all-targets doc-check semgrep semgrep-test @echo "βœ… Rust core checks complete!" # Repository-owned Semgrep rules for project-specific Rust diagnostics. diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index ca95df54..fe083bb7 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -39,6 +39,7 @@ use crate::core::collections::{ SimplexKeyBuffer, SmallBuffer, }; use crate::core::edge::{EdgeKey, EdgeKeyError}; +use crate::core::embedding::TriangulationEmbeddingValidationErrorKind; use crate::core::facet::{AllFacetsIter, FacetError, FacetHandle, facet_key_from_vertices}; use crate::core::operations::TopologicalOperation; use crate::core::simplex::{NeighborSlot, Simplex, SimplexValidationError}; @@ -3454,6 +3455,12 @@ pub enum FlipNeighborWiringError { /// Structured validation reason. reason: FlipNeighborDelaunayValidationFailureKind, }, + /// Embedding validation failed while preparing flip neighbor wiring. + #[error("embedding validation error reached flip neighbor wiring: {reason:?}")] + EmbeddingValidation { + /// Structured embedding-validation reason. + reason: TriangulationEmbeddingValidationErrorKind, + }, /// Delaunay repair failed while preparing flip neighbor wiring. #[error("Delaunay repair error reached flip neighbor wiring: {reason}")] DelaunayRepair { @@ -3537,6 +3544,9 @@ impl From for FlipNeighborWiringError { InsertionError::DelaunayValidationFailed { source } => Self::DelaunayValidation { reason: source.into(), }, + InsertionError::EmbeddingValidationFailed { source } => Self::EmbeddingValidation { + reason: TriangulationEmbeddingValidationErrorKind::from(&source), + }, InsertionError::DelaunayRepairFailed { source, context: _ } => Self::DelaunayRepair { reason: FlipNeighborRepairFailure::from(*source), }, @@ -5070,6 +5080,9 @@ const fn insertion_error_kind(source: &InsertionError) -> InsertionErrorKind { InsertionError::DelaunayValidationFailed { .. } => { InsertionErrorKind::DelaunayValidationFailed } + InsertionError::EmbeddingValidationFailed { .. } => { + InsertionErrorKind::EmbeddingValidationFailed + } InsertionError::DelaunayRepairFailed { .. } => InsertionErrorKind::DelaunayRepairFailed, InsertionError::DuplicateCoordinates { .. } => InsertionErrorKind::DuplicateCoordinates, InsertionError::DuplicateUuid { .. } => InsertionErrorKind::DuplicateUuid, diff --git a/src/core/algorithms/incremental_insertion.rs b/src/core/algorithms/incremental_insertion.rs index 69840282..31d29404 100644 --- a/src/core/algorithms/incremental_insertion.rs +++ b/src/core/algorithms/incremental_insertion.rs @@ -38,6 +38,7 @@ use crate::core::collections::{ use crate::core::construction::{ FinalDelaunayValidationContext, FinalTopologyValidationContext, TriangulationConstructionError, }; +use crate::core::embedding::TriangulationEmbeddingValidationError; use crate::core::facet::{FacetError, FacetHandle}; use crate::core::simplex::{NeighborSlot, Simplex, SimplexValidationError}; use crate::core::tds::{ @@ -631,6 +632,14 @@ pub enum InitialSimplexUnexpectedInsertionStage { source: DelaunayTriangulationValidationError, }, + /// Embedding validation escaped initial-simplex construction. + #[error("embedding validation failed during insertion: {source}")] + EmbeddingValidation { + /// Underlying embedding validation error. + #[source] + source: TriangulationEmbeddingValidationError, + }, + /// Topology validation escaped initial-simplex construction. #[error("{context}: {source}")] TopologyValidation { @@ -839,6 +848,13 @@ impl From for InitialSimplexConstructionError { ), } } + TriangulationConstructionError::InsertionEmbeddingValidation { source } => { + Self::UnexpectedInsertionStage { + reason: Box::new( + InitialSimplexUnexpectedInsertionStage::EmbeddingValidation { source }, + ), + } + } TriangulationConstructionError::OrientationCanonicalizationGeometric { source } | TriangulationConstructionError::OrientationCanonicalizationInternal { source } => { Self::UnexpectedInsertionStage { @@ -1038,6 +1054,8 @@ pub enum InsertionErrorKind { HullExtension, /// Delaunay validation failed after insertion. DelaunayValidationFailed, + /// Embedded-geometry validation failed after insertion. + EmbeddingValidationFailed, /// Flip-based Delaunay repair failed. DelaunayRepairFailed, /// Duplicate coordinates were supplied. @@ -1064,6 +1082,8 @@ pub enum InsertionErrorSourceKind { Tds(TdsErrorKind), /// Triangulation-layer topology validation failed. Triangulation(TriangulationValidationErrorKind), + /// Level 4 embedding validation failed. + Embedding, /// Level 5 Delaunay validation failed. Delaunay(DelaunayValidationErrorKind), /// Flip repair failed. @@ -1624,6 +1644,17 @@ pub enum InsertionError { source: DelaunayTriangulationValidationError, }, + /// Global embedding validation failed after insertion. + /// + /// This indicates the triangulation is structurally and topologically valid + /// but violates the embedded-geometry invariant (Level 4). + #[error("Embedding validation failed: {source}")] + EmbeddingValidationFailed { + /// The structured Level 4 validation error. + #[source] + source: TriangulationEmbeddingValidationError, + }, + /// Flip-based Delaunay repair failed. /// /// This variant is used when a Delaunay repair pass (local or fallback) @@ -1861,6 +1892,7 @@ impl InsertionError { // `NonManifoldTopology` variant. Self::NeighborWiring { .. } | Self::Location(_) + | Self::EmbeddingValidationFailed { .. } | Self::DelaunayValidationFailed { .. } | Self::DelaunayRepairFailed { .. } | Self::DuplicateCoordinates { .. } @@ -1985,6 +2017,7 @@ impl InsertionError { | InitialSimplexUnexpectedInsertionStage::OrientationCanonicalization { .. } | InitialSimplexUnexpectedInsertionStage::Location { .. } | InitialSimplexUnexpectedInsertionStage::DelaunayValidation { .. } + | InitialSimplexUnexpectedInsertionStage::EmbeddingValidation { .. } | InitialSimplexUnexpectedInsertionStage::SpatialIndexConstruction { .. } => false, } } diff --git a/src/core/construction.rs b/src/core/construction.rs index ef8d88f2..26927ffb 100644 --- a/src/core/construction.rs +++ b/src/core/construction.rs @@ -13,6 +13,7 @@ use crate::core::algorithms::incremental_insertion::{ }; use crate::core::algorithms::locate::{ConflictError, LocateError}; use crate::core::collections::{MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer}; +use crate::core::embedding::TriangulationEmbeddingValidationError; use crate::core::simplex::{Simplex, SimplexValidationError}; use crate::core::tds::{ InvariantError, SimplexKey, Tds, TdsConstructionError, TdsError, VertexKey, @@ -508,6 +509,14 @@ pub enum TriangulationConstructionError { source: DelaunayTriangulationValidationError, }, + /// Level 4 embedding validation failed during incremental construction. + #[error("Embedding validation failed during insertion: {source}")] + InsertionEmbeddingValidation { + /// Underlying embedding validation error. + #[source] + source: TriangulationEmbeddingValidationError, + }, + /// Level 3 topology validation failed during incremental construction. #[error("{context}: {source}")] InsertionTopologyValidation { diff --git a/src/core/embedding.rs b/src/core/embedding.rs index 5afe6093..dba7d059 100644 --- a/src/core/embedding.rs +++ b/src/core/embedding.rs @@ -7,6 +7,8 @@ #![forbid(unsafe_code)] +use core::ops::ControlFlow; + use crate::core::collections::{ FastHashSet, MAX_PRACTICAL_DIMENSION_SIZE, SimplexVertexKeyBuffer, SimplexVertexUuidBuffer, SmallBuffer, @@ -160,6 +162,27 @@ pub enum TriangulationEmbeddingValidationError { source: GlobalTopologyModelError, }, + /// A simplex embedding reused a vertex label. + #[error( + "simplex {simplex_uuid} (key {simplex_key:?}) has duplicate embedding label {vertex_key:?} ({vertex_uuid}) at indices {first_index} and {duplicate_index}" + )] + DuplicateSimplexEmbeddingLabel { + /// Key of the simplex with duplicate labels. + simplex_key: SimplexKey, + /// UUID of the simplex with duplicate labels. + simplex_uuid: Uuid, + /// Vertex-level diagnostic details for the malformed simplex. + detail: Box, + /// Duplicated vertex key. + vertex_key: VertexKey, + /// UUID of the duplicated vertex. + vertex_uuid: Uuid, + /// First embedding slot containing the label. + first_index: usize, + /// Later embedding slot containing the same label. + duplicate_index: usize, + }, + /// A simplex has exactly zero orientation and therefore zero D-volume. #[error("simplex {simplex_uuid} (key {simplex_key:?}) is degenerate in dimension {dimension}")] DegenerateSimplex { @@ -336,6 +359,8 @@ pub enum TriangulationEmbeddingValidationErrorKind { UnsupportedTopology, /// Topology-specific coordinate lifting failed. TopologyLifting, + /// A simplex embedding reused a vertex label. + DuplicateSimplexEmbeddingLabel, /// A simplex has zero D-volume. DegenerateSimplex, /// Coordinate validation failed at the predicate boundary. @@ -365,6 +390,9 @@ impl From<&TriangulationEmbeddingValidationError> for TriangulationEmbeddingVali Self::UnsupportedTopology } TriangulationEmbeddingValidationError::TopologyLifting { .. } => Self::TopologyLifting, + TriangulationEmbeddingValidationError::DuplicateSimplexEmbeddingLabel { .. } => { + Self::DuplicateSimplexEmbeddingLabel + } TriangulationEmbeddingValidationError::DegenerateSimplex { .. } => { Self::DegenerateSimplex } @@ -410,7 +438,11 @@ pub struct TriangulationEmbeddingValidationReport { pub number_of_simplices: usize, /// Number of simplex embeddings prepared for Level 4 validation. pub checked_simplices: usize, - /// Number of simplex pairs considered for overlap validation. + /// Number of candidate simplex pairs examined by the overlap broad phase. + /// + /// For Euclidean charts this counts pairs whose bounding boxes overlap + /// after the sweep-and-prune broad phase; for periodic charts it counts all + /// non-degenerate pairs (exhaustive enumeration). pub checked_simplex_pairs: usize, /// Ordered list of Level 4 embedding violations. pub violations: Vec, @@ -541,27 +573,51 @@ impl EmbeddedSimplex { &self, vertex_index: usize, ) -> Result, TriangulationEmbeddingValidationError> { - let vertex_key = self.embedding.labels()[vertex_index]; - let vertex_uuid = self.vertex_uuids[vertex_index]; - Point::try_new(self.embedding.coordinates()[vertex_index]).map_err(|source| { - TriangulationEmbeddingValidationError::CoordinateValidation { - simplex_key: self.key, - simplex_uuid: self.uuid, - vertex_key, - vertex_uuid, - source, + self.embedding.point_at(vertex_index).ok_or_else(|| { + TdsError::DimensionMismatch { + expected: self.embedding.labels().len(), + actual: vertex_index.saturating_add(1), + context: format!( + "embedded simplex {:?} (key {:?}) point index during Level 4 validation", + self.uuid, self.key, + ), } + .into() }) } + /// Finds a labeled vertex in this embedded simplex and validates its point coordinates. + /// + /// The full-facet shortcut uses keys rather than coordinate indices so its + /// orientation predicates stay tied to the same vertex identities reported + /// in Level 4 diagnostics. + fn point_for_key( + &self, + vertex_key: VertexKey, + ) -> Result, TriangulationEmbeddingValidationError> { + let vertex_index = self + .embedding + .labels() + .iter() + .position(|candidate| *candidate == vertex_key) + .ok_or_else(|| TdsError::VertexNotFound { + vertex_key, + context: format!( + "embedded simplex {:?} (key {:?}) facet-side validation", + self.uuid, self.key, + ), + })?; + self.point_at(vertex_index) + } + /// Maps witness vertex keys back to UUIDs from this simplex snapshot. fn vertex_uuids_for_keys(&self, vertex_keys: &[VertexKey]) -> SimplexVertexUuidBuffer { let mut uuids = SimplexVertexUuidBuffer::with_capacity(vertex_keys.len()); uuids.extend(vertex_keys.iter().filter_map(|vertex_key| { self.vertex_keys .iter() - .position(|candidate| candidate == vertex_key) - .map(|index| self.vertex_uuids[index]) + .zip(&self.vertex_uuids) + .find_map(|(candidate, &uuid)| (candidate == vertex_key).then_some(uuid)) })); uuids } @@ -593,6 +649,20 @@ fn labeled_simplex_error_to_embedding_error( coordinate_count, } => (label_count, coordinate_count), LabeledSimplexEmbeddingError::InvalidArity { expected, actual } => (expected, actual), + LabeledSimplexEmbeddingError::DuplicateLabel { + first_index, + duplicate_index, + } => { + return duplicate_simplex_embedding_label_error( + simplex_key, + simplex.uuid(), + vertex_keys, + vertex_uuids, + first_index, + duplicate_index, + "duplicate embedding label during embedding validation", + ); + } LabeledSimplexEmbeddingError::NonFiniteCoordinate { vertex_index, coordinate_index, @@ -658,6 +728,51 @@ fn labeled_simplex_error_to_embedding_error( .into() } +/// Preserves duplicate embedding labels as structured Level 4 diagnostics. +fn duplicate_simplex_embedding_label_error( + simplex_key: SimplexKey, + simplex_uuid: Uuid, + vertex_keys: &SimplexVertexKeyBuffer, + vertex_uuids: &SimplexVertexUuidBuffer, + first_index: usize, + duplicate_index: usize, + context: &'static str, +) -> TriangulationEmbeddingValidationError { + let Some(&vertex_key) = vertex_keys.get(first_index) else { + return TdsError::DimensionMismatch { + expected: vertex_keys.len(), + actual: first_index.saturating_add(1), + context: format!("{context} for simplex {simplex_uuid} (key {simplex_key:?})"), + } + .into(); + }; + let Some(&vertex_uuid) = vertex_uuids.get(first_index) else { + return TdsError::DimensionMismatch { + expected: vertex_uuids.len(), + actual: first_index.saturating_add(1), + context: format!( + "{context} vertex UUID for simplex {simplex_uuid} (key {simplex_key:?})" + ), + } + .into(); + }; + + TriangulationEmbeddingValidationError::DuplicateSimplexEmbeddingLabel { + simplex_key, + simplex_uuid, + detail: Box::new(TriangulationEmbeddingSimplexDetail { + key: simplex_key, + uuid: simplex_uuid, + vertices: vertex_keys.clone(), + vertex_uuids: vertex_uuids.clone(), + }), + vertex_key, + vertex_uuid, + first_index, + duplicate_index, + } +} + /// Converts translated embedded-simplex construction failures into the same /// key- and UUID-rich public diagnostics as the primary embedding path. fn labeled_simplex_error_to_embedded_simplex_error( @@ -670,6 +785,20 @@ fn labeled_simplex_error_to_embedded_simplex_error( coordinate_count, } => (label_count, coordinate_count), LabeledSimplexEmbeddingError::InvalidArity { expected, actual } => (expected, actual), + LabeledSimplexEmbeddingError::DuplicateLabel { + first_index, + duplicate_index, + } => { + return duplicate_simplex_embedding_label_error( + simplex.key, + simplex.uuid, + &simplex.vertex_keys, + &simplex.vertex_uuids, + first_index, + duplicate_index, + "duplicate translated embedding label during embedding validation", + ); + } LabeledSimplexEmbeddingError::NonFiniteCoordinate { vertex_index, coordinate_index, @@ -891,21 +1020,20 @@ impl Triangulation { } } - for (first_index, first) in simplices.iter().enumerate() { - for second in &simplices[first_index + 1..] { - if invalid_simplex_keys.contains(&first.key) - || invalid_simplex_keys.contains(&second.key) - { - continue; - } - report.checked_simplex_pairs += 1; + let (checked_simplex_pairs, _) = for_each_candidate_simplex_pair::( + &simplices, + &invalid_simplex_keys, + periodic_periods, + |first, second| { if let Err(error) = validate_topology_aware_simplex_pair(first, second, periodic_periods) { report.violations.push(error); } - } - } + ControlFlow::Continue(()) + }, + ); + report.checked_simplex_pairs = checked_simplex_pairs; Ok(report) } @@ -1000,6 +1128,81 @@ impl Triangulation { Ok(()) } + /// Validates the Level 4 embedding invariant for a changed simplex scope. + /// + /// Insertion and repair already assume the pre-existing triangulation was + /// embedding-valid before the local mutation. Under that precondition, only + /// the changed simplices can introduce a new nondegenerate-simplex or + /// pairwise-intersection violation, so this checks each scoped simplex + /// against every candidate it can intersect instead of rescanning all old + /// simplex pairs. + pub(crate) fn validate_embedding_for_simplices( + &self, + local_simplices: &[SimplexKey], + ) -> Result<(), TriangulationEmbeddingValidationError> { + if local_simplices.is_empty() { + return Ok(()); + } + + let topology_model = self.global_topology.model(); + if !topology_model.supports_affine_embedding_validation() { + return Err(TriangulationEmbeddingValidationError::UnsupportedTopology { + topology: self.global_topology.kind(), + dimension: D, + }); + } + + let mut local_simplex_keys = FastHashSet::default(); + local_simplex_keys.reserve(local_simplices.len()); + for &simplex_key in local_simplices { + if !self.tds.contains_simplex(simplex_key) { + return Err(TdsError::SimplexNotFound { + simplex_key, + context: "scoped embedding validation".to_string(), + } + .into()); + } + local_simplex_keys.insert(simplex_key); + } + + let simplices = self.collect_embedded_simplices()?; + let periodic_domain = topology_model.periodic_domain(); + let periodic_periods = periodic_domain.map(|domain| *domain.periods()); + + for simplex in &simplices { + if !local_simplex_keys.contains(&simplex.key) { + continue; + } + validate_simplex_nondegenerate(simplex)?; + if let Some(domain) = periodic_domain { + validate_periodic_simplex_chart(simplex, domain.periods())?; + } + } + + let empty_skip = FastHashSet::default(); + let (_, violation) = + for_each_scoped_candidate_simplex_pair::( + &simplices, + &empty_skip, + &local_simplex_keys, + periodic_periods, + |first, second| match validate_topology_aware_simplex_pair( + first, + second, + periodic_periods, + ) { + Ok(()) => ControlFlow::Continue(()), + Err(error) => ControlFlow::Break(error), + }, + ); + + if let Some(error) = violation { + return Err(error); + } + + Ok(()) + } + /// Collects all simplex embeddings after applying the topology model's active chart. fn collect_embedded_simplices( &self, @@ -1048,17 +1251,23 @@ impl Triangulation { simplices.push(embedded); } - for (first_index, first) in simplices.iter().enumerate() { - for second in &simplices[first_index + 1..] { - if let Err(error) = - validate_topology_aware_simplex_pair(first, second, periodic_periods) - { - return Ok(Some(error)); - } - } - } + let empty_skip: FastHashSet = FastHashSet::default(); + let (_, violation) = + for_each_candidate_simplex_pair::( + &simplices, + &empty_skip, + periodic_periods, + |first, second| match validate_topology_aware_simplex_pair( + first, + second, + periodic_periods, + ) { + Ok(()) => ControlFlow::Continue(()), + Err(error) => ControlFlow::Break(error), + }, + ); - Ok(None) + Ok(violation) } } @@ -1070,6 +1279,9 @@ fn validate_topology_aware_simplex_pair( ) -> Result<(), TriangulationEmbeddingValidationError> { let Some(periods) = periodic_periods else { if bounding_boxes_overlap(first, second) { + if try_validate_full_facet_pair(first, second)? { + return Ok(()); + } validate_simplex_pair_intersection(first, second)?; } return Ok(()); @@ -1080,6 +1292,133 @@ fn validate_topology_aware_simplex_pair( validate_periodic_translates(first, second, &periods, &shift_ranges, 0, &mut shift) } +/// Uses an exact side-of-facet test for adjacent simplices sharing a full facet. +/// +/// When two nondegenerate D-simplices share D vertices, their intersection is +/// exactly the shared facet iff the two opposite vertices lie on opposite sides +/// of the shared facet. This avoids the more expensive barycentric intersection +/// solver for the common adjacent-pair case while preserving the same Level 4 +/// error shape for invalid same-side embeddings. +fn try_validate_full_facet_pair( + first: &EmbeddedSimplex, + second: &EmbeddedSimplex, +) -> Result { + let mut shared = SimplexVertexKeyBuffer::new(); + let mut first_only = SimplexVertexKeyBuffer::new(); + let mut second_only = SimplexVertexKeyBuffer::new(); + + for &vertex_key in &first.vertex_keys { + if second.vertex_keys.contains(&vertex_key) { + shared.push(vertex_key); + } else { + first_only.push(vertex_key); + } + } + for &vertex_key in &second.vertex_keys { + if !first.vertex_keys.contains(&vertex_key) { + second_only.push(vertex_key); + } + } + + if shared.len() != D || first_only.len() != 1 || second_only.len() != 1 { + return Ok(false); + } + + let first_orientation = orientation_against_shared_facet(first, &shared, first_only[0])?; + let second_orientation = orientation_against_shared_facet(second, &shared, second_only[0])?; + match (first_orientation, second_orientation) { + (Orientation::POSITIVE, Orientation::NEGATIVE) + | (Orientation::NEGATIVE, Orientation::POSITIVE) => Ok(true), + ( + Orientation::POSITIVE | Orientation::NEGATIVE, + Orientation::POSITIVE | Orientation::NEGATIVE, + ) => Err(shared_facet_same_side_intersection( + first, + second, + shared, + first_only, + second_only, + )), + (Orientation::DEGENERATE, _) => { + Err(TriangulationEmbeddingValidationError::DegenerateSimplex { + simplex_key: first.key, + simplex_uuid: first.uuid, + detail: Box::new(first.detail()), + dimension: D, + }) + } + (_, Orientation::DEGENERATE) => { + Err(TriangulationEmbeddingValidationError::DegenerateSimplex { + simplex_key: second.key, + simplex_uuid: second.uuid, + detail: Box::new(second.detail()), + dimension: D, + }) + } + } +} + +/// Computes which side of a shared facet the opposite vertex occupies. +/// +/// The point order is the shared facet vertices followed by one opposite +/// vertex, so the sign can be compared between adjacent simplices without +/// constructing a barycentric intersection system. +fn orientation_against_shared_facet( + simplex: &EmbeddedSimplex, + shared: &SimplexVertexKeyBuffer, + opposite: VertexKey, +) -> Result { + let mut points = SmallBuffer::, MAX_PRACTICAL_DIMENSION_SIZE>::with_capacity(D + 1); + for &vertex_key in shared { + points.push(simplex.point_for_key(vertex_key)?); + } + points.push(simplex.point_for_key(opposite)?); + + robust_orientation(&points).map_err(|source| { + TriangulationEmbeddingValidationError::PredicateFailed { + simplex_key: simplex.key, + simplex_uuid: simplex.uuid, + detail: Box::new(simplex.detail()), + source, + } + }) +} + +/// Builds the standard Level 4 overlap diagnostic for a failed facet-side test. +/// +/// Keeping the same [`TriangulationEmbeddingValidationError`] variant as the +/// barycentric path lets repair/report callers consume one error contract +/// regardless of which validator found the illegal intersection. +fn shared_facet_same_side_intersection( + first: &EmbeddedSimplex, + second: &EmbeddedSimplex, + shared_vertices: SimplexVertexKeyBuffer, + first_only_witness_vertices: SimplexVertexKeyBuffer, + second_only_witness_vertices: SimplexVertexKeyBuffer, +) -> TriangulationEmbeddingValidationError { + let shared_vertex_uuids = first.vertex_uuids_for_keys(&shared_vertices); + let first_only_witness_vertex_uuids = first.vertex_uuids_for_keys(&first_only_witness_vertices); + let second_only_witness_vertex_uuids = + second.vertex_uuids_for_keys(&second_only_witness_vertices); + + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { + first_simplex_key: first.key, + first_simplex_uuid: first.uuid, + second_simplex_key: second.key, + second_simplex_uuid: second.uuid, + detail: Box::new(TriangulationEmbeddingIntersectionDetail { + first_simplex: first.detail(), + second_simplex: second.detail(), + shared_vertices, + shared_vertex_uuids, + first_only_witness_vertices, + first_only_witness_vertex_uuids, + second_only_witness_vertices, + second_only_witness_vertex_uuids, + }), + } +} + /// Recursively checks every periodic translate that can overlap two simplex boxes. fn validate_periodic_translates( first: &EmbeddedSimplex, @@ -1267,7 +1606,7 @@ fn validate_simplex_pair_intersection( dimension: D, }, ), - Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace(witness)) => { + Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace { witness, .. }) => { let shared_vertex_uuids = first.vertex_uuids_for_keys(&witness.shared); let first_only_witness_vertex_uuids = first.vertex_uuids_for_keys(&witness.first_only_witness); @@ -1295,6 +1634,263 @@ fn validate_simplex_pair_intersection( } } +/// Axis-aligned bounding box for one embedded simplex, tagged with its index +/// in the validated simplex list. +#[derive(Clone, Copy, Debug)] +struct SimplexBoundingBox { + /// Index of the owning simplex in the embedded-simplex slice. + simplex_index: usize, + /// Per-axis lower bounds of the simplex vertices. + min: [f64; D], + /// Per-axis upper bounds of the simplex vertices. + max: [f64; D], +} + +impl SimplexBoundingBox { + /// Computes the bounding box of an embedded simplex from its lifted coordinates. + fn from_embedded(simplex_index: usize, simplex: &EmbeddedSimplex) -> Self { + let mut min = [f64::INFINITY; D]; + let mut max = [f64::NEG_INFINITY; D]; + for coords in simplex.embedding.coordinates() { + for (axis, &value) in coords.iter().enumerate() { + min[axis] = min[axis].min(value); + max[axis] = max[axis].max(value); + } + } + Self { + simplex_index, + min, + max, + } + } + + /// Returns whether two boxes overlap on every axis. + /// + /// Two axis-aligned boxes intersect if and only if their projections + /// overlap on every coordinate axis (the separating-axis test for AABBs; + /// see Ericson, *Real-Time Collision Detection*, ch. 4-5). + fn overlaps(&self, other: &Self) -> bool { + (0..D).all(|axis| self.max[axis] >= other.min[axis] && other.max[axis] >= self.min[axis]) + } +} + +/// Returns the axis with the largest global coordinate extent across all boxes. +/// +/// Sweeping along the widest axis keeps the active set small, which is what +/// makes sweep-and-prune near-linear in practice. +fn widest_extent_axis(boxes: &[SimplexBoundingBox]) -> usize { + let mut global_min = [f64::INFINITY; D]; + let mut global_max = [f64::NEG_INFINITY; D]; + for bounding_box in boxes { + for (axis, (&min, &max)) in bounding_box.min.iter().zip(&bounding_box.max).enumerate() { + global_min[axis] = global_min[axis].min(min); + global_max[axis] = global_max[axis].max(max); + } + } + (0..D) + .map(|axis| (axis, global_max[axis] - global_min[axis])) + .max_by(|(_, left), (_, right)| left.total_cmp(right)) + .map_or(0, |(axis, _)| axis) +} + +/// Visits candidate overlapping simplex pairs for Level 4 embedding validation. +/// +/// The all-pairs intersection test is `O(S^2)` in the number of simplices, +/// which dominates validation on large triangulations. For the Euclidean +/// affine chart this routine uses a **sweep-and-prune** broad phase over +/// axis-aligned bounding boxes (AABBs) to enumerate only pairs whose boxes +/// overlap, then hands each candidate to `on_pair` for the exact intersection +/// test. Returning [`ControlFlow::Break`] stops early (used by fast-fail +/// validation); the returned tuple reports the number of candidate pairs +/// examined and the break payload, if any. +/// +/// # Soundness ("provably misses nothing") +/// +/// Two AABBs intersect if and only if their projections overlap on every +/// coordinate axis (the separating-axis test for boxes). Sweep-and-prune sorts +/// boxes by their lower endpoint on one axis and, when processing a box `b`, +/// retires only active boxes whose upper endpoint precedes `b`'s lower endpoint +/// on that axis. Every still-active box therefore overlaps `b` on the sweep +/// axis, so the examined pairs are a superset of all pairs that overlap on +/// *every* axis. No intersecting simplex pair can be skipped, so replacing the +/// quadratic scan with this broad phase preserves Level 4 correctness while +/// only pruning pairs that provably cannot intersect. +/// +/// Periodic (toroidal) charts are excluded: a simplex near one boundary can +/// overlap another near the opposite boundary through a wrap-around translate +/// whose lifted-chart AABB is far away, so a lifted-coordinate sweep is not +/// sound. Those charts (and the degenerate `D == 0` chart, which has no sweep +/// axis) retain exhaustive pairwise enumeration until a periodic-aware broad +/// phase is added. +/// +/// # Complexity +/// +/// Euclidean: about `O(S log S)` for triangulations with bounded local overlap +/// (sorting dominates); worst case `O(S^2)` when many boxes overlap on the +/// sweep axis. Periodic: `O(S^2)`. +/// +/// # References +/// +/// - Cohen, Lin, Manocha, and Ponamgi, "I-COLLIDE" (1995): sweep-and-prune. +/// - Baraff, "Dynamic Simulation of Non-Penetrating Rigid Bodies" (1992): +/// coordinate sort-and-sweep. +/// - Ericson, *Real-Time Collision Detection* (2005), ch. 7 (sweep-and-prune) +/// and ch. 4-5 (AABB separating-axis test). +/// +/// See `REFERENCES.md`, "Embedded-Geometry Overlap Detection (Level 4 Validation)". +fn for_each_candidate_simplex_pair( + simplices: &[EmbeddedSimplex], + skip: &FastHashSet, + periodic_periods: Option<[f64; D]>, + on_pair: impl FnMut(&EmbeddedSimplex, &EmbeddedSimplex) -> ControlFlow, +) -> (usize, Option) { + // Lifted-chart AABBs cannot express wrap-around overlaps, and a degenerate + // 0-dimensional chart has no sweep axis, so both fall back to exhaustive + // pairwise enumeration. + if periodic_periods.is_some() || D == 0 { + return exhaustive_candidate_simplex_pairs(simplices, skip, on_pair); + } + sweep_and_prune_candidate_simplex_pairs(simplices, skip, on_pair) +} + +/// Visits candidate pairs where at least one simplex belongs to a changed scope. +fn for_each_scoped_candidate_simplex_pair( + simplices: &[EmbeddedSimplex], + skip: &FastHashSet, + scope: &FastHashSet, + periodic_periods: Option<[f64; D]>, + mut on_pair: impl FnMut(&EmbeddedSimplex, &EmbeddedSimplex) -> ControlFlow, +) -> (usize, Option) { + if scope.is_empty() { + return for_each_candidate_simplex_pair(simplices, skip, periodic_periods, on_pair); + } + if periodic_periods.is_some() || D == 0 { + return scoped_exhaustive_candidate_simplex_pairs(simplices, skip, scope, on_pair); + } + sweep_and_prune_candidate_simplex_pairs(simplices, skip, |first, second| { + if scope.contains(&first.key) || scope.contains(&second.key) { + on_pair(first, second) + } else { + ControlFlow::Continue(()) + } + }) +} + +/// Exhaustive `O(S^2)` pairwise enumeration over non-skipped simplices. +fn exhaustive_candidate_simplex_pairs( + simplices: &[EmbeddedSimplex], + skip: &FastHashSet, + mut on_pair: impl FnMut(&EmbeddedSimplex, &EmbeddedSimplex) -> ControlFlow, +) -> (usize, Option) { + let mut examined = 0_usize; + for (first_index, first_simplex) in simplices.iter().enumerate() { + if skip.contains(&first_simplex.key) { + continue; + } + + for second_simplex in &simplices[first_index + 1..] { + if skip.contains(&second_simplex.key) { + continue; + } + + examined += 1; + if let ControlFlow::Break(value) = on_pair(first_simplex, second_simplex) { + return (examined, Some(value)); + } + } + } + (examined, None) +} + +/// Exhaustive scoped pair enumeration for periodic charts. +/// +/// The periodic path cannot use lifted-coordinate sweep-and-prune, but a local +/// mutation only needs changed-vs-all pairs. This keeps automatic insertion +/// validation proportional to the changed scope instead of all old pairs. +fn scoped_exhaustive_candidate_simplex_pairs( + simplices: &[EmbeddedSimplex], + skip: &FastHashSet, + scope: &FastHashSet, + mut on_pair: impl FnMut(&EmbeddedSimplex, &EmbeddedSimplex) -> ControlFlow, +) -> (usize, Option) { + let mut examined = 0_usize; + for (local_index, local_simplex) in simplices.iter().enumerate() { + if !scope.contains(&local_simplex.key) || skip.contains(&local_simplex.key) { + continue; + } + + for (other_index, other_simplex) in simplices.iter().enumerate() { + if other_index == local_index || skip.contains(&other_simplex.key) { + continue; + } + if scope.contains(&other_simplex.key) && other_index < local_index { + continue; + } + + examined += 1; + let first_index = local_index.min(other_index); + let second_index = local_index.max(other_index); + if let ControlFlow::Break(value) = + on_pair(&simplices[first_index], &simplices[second_index]) + { + return (examined, Some(value)); + } + } + } + (examined, None) +} + +/// Sweep-and-prune broad phase over Euclidean simplex bounding boxes. +/// +/// See [`for_each_candidate_simplex_pair`] for the completeness argument and +/// references. +fn sweep_and_prune_candidate_simplex_pairs( + simplices: &[EmbeddedSimplex], + skip: &FastHashSet, + mut on_pair: impl FnMut(&EmbeddedSimplex, &EmbeddedSimplex) -> ControlFlow, +) -> (usize, Option) { + let mut boxes: Vec> = simplices + .iter() + .enumerate() + .filter(|(_, simplex)| !skip.contains(&simplex.key)) + .map(|(index, simplex)| SimplexBoundingBox::from_embedded(index, simplex)) + .collect(); + if boxes.len() < 2 { + return (0, None); + } + + let sweep_axis = widest_extent_axis(&boxes); + boxes.sort_unstable_by(|left, right| left.min[sweep_axis].total_cmp(&right.min[sweep_axis])); + + let mut active: Vec = Vec::new(); + let mut examined = 0_usize; + for current in 0..boxes.len() { + let current_min = boxes[current].min[sweep_axis]; + // Retire boxes that end before the current box begins on the sweep + // axis; they cannot overlap the current box or any later one. + active.retain(|&candidate| boxes[candidate].max[sweep_axis] >= current_min); + for &candidate in &active { + if !boxes[candidate].overlaps(&boxes[current]) { + continue; + } + examined += 1; + let first_index = boxes[candidate] + .simplex_index + .min(boxes[current].simplex_index); + let second_index = boxes[candidate] + .simplex_index + .max(boxes[current].simplex_index); + if let ControlFlow::Break(value) = + on_pair(&simplices[first_index], &simplices[second_index]) + { + return (examined, Some(value)); + } + } + active.push(current); + } + (examined, None) +} + #[cfg(test)] mod tests { use super::*; @@ -1302,8 +1898,10 @@ mod tests { use crate::core::tds::{Tds, TriangulationConstructionState}; use crate::core::triangulation::Triangulation; use crate::core::vertex::Vertex; + use crate::delaunay_property_validation::DelaunayValidationError; use crate::geometry::kernel::FastKernel; use crate::topology::traits::topological_space::{GlobalTopology, ToroidalConstructionMode}; + use crate::validation::{DelaunayTriangulationValidationError, DelaunayVerificationError}; use crate::vertex; use approx::assert_abs_diff_eq; use std::assert_matches; @@ -1417,6 +2015,34 @@ mod tests { assert!(tri.is_valid_embedding().is_ok()); } + #[test] + fn is_valid_embedding_rejects_full_facet_same_side_overlap() { + let coords = [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.25, 0.25, 0.5], + ]; + let tds = tds_from_vertices_and_simplices(&coords, &[vec![0, 1, 2, 3], vec![0, 2, 1, 4]]); + let tri = tri_from_tds(tds); + + let err = tri.is_valid_embedding().unwrap_err(); + + assert_matches!( + err, + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { + detail, + .. + } if detail.shared_vertices.len() == 3 + && detail.shared_vertex_uuids.len() == 3 + && detail.first_only_witness_vertices.len() == 1 + && detail.first_only_witness_vertex_uuids.len() == 1 + && detail.second_only_witness_vertices.len() == 1 + && detail.second_only_witness_vertex_uuids.len() == 1 + ); + } + #[test] fn validate_embedding_rejects_degenerate_simplex() { let coords = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]; @@ -1444,6 +2070,55 @@ mod tests { ); } + #[test] + fn is_valid_embedding_preserves_duplicate_label_detail() { + let coords = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]; + let (mut tds, simplex_keys) = + tds_from_vertices_and_simplices_with_keys(&coords, &[vec![0, 1, 2]]); + let simplex_key = simplex_keys[0]; + let (duplicate_key, middle_key, duplicate_uuid) = { + let simplex = tds + .simplex(simplex_key) + .expect("fixture simplex should exist"); + let duplicate_key = simplex.vertices()[0]; + let middle_key = simplex.vertices()[1]; + let duplicate_uuid = tds + .vertex(duplicate_key) + .expect("duplicate fixture vertex should exist") + .uuid(); + (duplicate_key, middle_key, duplicate_uuid) + }; + { + let simplex = tds + .simplex_mut(simplex_key) + .expect("fixture simplex should be mutable"); + simplex.clear_vertex_keys(); + simplex.push_vertex_key(duplicate_key); + simplex.push_vertex_key(middle_key); + simplex.push_vertex_key(duplicate_key); + } + let tri = tri_from_tds(tds); + + let err = tri.is_valid_embedding().unwrap_err(); + + assert_matches!( + err, + TriangulationEmbeddingValidationError::DuplicateSimplexEmbeddingLabel { + simplex_key: observed_simplex_key, + vertex_key, + vertex_uuid, + first_index: 0, + duplicate_index: 2, + detail, + .. + } if observed_simplex_key == simplex_key + && vertex_key == duplicate_key + && vertex_uuid == duplicate_uuid + && detail.vertices.len() == 3 + && detail.vertex_uuids.len() == 3 + ); + } + #[test] fn embedding_report_includes_degenerate_simplex_vertices() { let coords = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]; @@ -1481,6 +2156,38 @@ mod tests { ); } + #[test] + fn is_valid_embedding_sweep_and_prune_detects_interposed_overlap() { + // Regression guard for the sweep-and-prune broad phase: triangles A and + // B genuinely overlap (no shared vertices), but triangle C sits between + // them in the sweep-axis ordering while overlapping neither. A naive + // "compare only neighbors in sorted order" prune would drop the A/B + // pair; sweep-and-prune keeps A active across C and still reports the + // overlap, so the broad phase must not introduce a false negative. + let coords = [ + [0.0, 0.0], // 0 A + [10.0, 0.0], // 1 A + [0.0, 2.0], // 2 A + [3.0, -1.0], // 3 C (x between A and B, disjoint in y) + [4.0, -1.0], // 4 C + [3.5, -0.5], // 5 C + [4.5, -1.0], // 6 B (overlaps A) + [5.5, -1.0], // 7 B + [4.5, 2.0], // 8 B + ]; + let tds = tds_from_vertices_and_simplices( + &coords, + &[vec![0, 1, 2], vec![3, 4, 5], vec![6, 7, 8]], + ); + let tri = tri_from_tds(tds); + + let err = tri.is_valid_embedding().unwrap_err(); + assert_matches!( + err, + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { .. } + ); + } + #[test] fn embedding_report_includes_intersection_witness_vertices() { let coords = [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [2.0, 2.0], [1.0, -1.0]]; @@ -1631,6 +2338,27 @@ mod tests { TriangulationEmbeddingValidationErrorKind::DegenerateSimplex, ); + let duplicate_label_source = + TriangulationEmbeddingValidationError::DuplicateSimplexEmbeddingLabel { + simplex_key: SimplexKey::default(), + simplex_uuid: Uuid::nil(), + detail: Box::new(TriangulationEmbeddingSimplexDetail { + key: SimplexKey::default(), + uuid: Uuid::nil(), + vertices: SimplexVertexKeyBuffer::new(), + vertex_uuids: SimplexVertexUuidBuffer::new(), + }), + vertex_key: VertexKey::default(), + vertex_uuid: Uuid::nil(), + first_index: 0, + duplicate_index: 2, + }; + + assert_eq!( + TriangulationEmbeddingValidationErrorKind::from(&duplicate_label_source), + TriangulationEmbeddingValidationErrorKind::DuplicateSimplexEmbeddingLabel, + ); + let invalid_period_source = TriangulationEmbeddingValidationError::InvalidPeriodicDomainPeriod { simplex_key: SimplexKey::default(), @@ -1652,21 +2380,20 @@ mod tests { TriangulationEmbeddingValidationErrorKind::InvalidPeriodicDomainPeriod, ); - let unexpected_source = - TriangulationEmbeddingValidationError::UnexpectedValidationLayer { - kind: InvariantKind::DelaunayProperty, - source: Box::new(InvariantError::Delaunay( - crate::validation::DelaunayTriangulationValidationError::VerificationFailed { - source: Box::new(crate::validation::DelaunayVerificationError::from( - crate::delaunay_property_validation::DelaunayValidationError::TriangulationState { - source: TdsError::InconsistentDataStructure { - message: "synthetic higher-layer failure".to_string(), - }, + let unexpected_source = TriangulationEmbeddingValidationError::UnexpectedValidationLayer { + kind: InvariantKind::DelaunayProperty, + source: Box::new(InvariantError::Delaunay( + DelaunayTriangulationValidationError::VerificationFailed { + source: Box::new(DelaunayVerificationError::from( + DelaunayValidationError::TriangulationState { + source: TdsError::InconsistentDataStructure { + message: "synthetic higher-layer failure".to_string(), }, - )), - }, - )), - }; + }, + )), + }, + )), + }; assert_eq!( TriangulationEmbeddingValidationErrorKind::from(&unexpected_source), diff --git a/src/core/insertion.rs b/src/core/insertion.rs index be337ffe..56864cf5 100644 --- a/src/core/insertion.rs +++ b/src/core/insertion.rs @@ -330,6 +330,8 @@ struct TryInsertImplOk { /// out of the final conflict region so higher layers can revisit nearby /// Delaunay violations without rediscovering the inserted vertex star globally. repair_seed_simplices: SimplexKeyBuffer, + /// Live simplices whose embedded geometry was newly created by this insertion. + embedding_validation_simplices: SimplexKeyBuffer, /// Whether the insertion path can leave local Delaunay work for the caller. /// /// Clean interior Bowyer-Watson insertions preserve the Delaunay property. @@ -347,6 +349,8 @@ struct CavityInsertionOutcome { simplices_removed: usize, /// Simplices touched by insertion that should seed follow-up local repair. repair_seed_simplices: SimplexKeyBuffer, + /// Live simplices whose embedded geometry was newly created by this cavity fill. + embedding_validation_simplices: SimplexKeyBuffer, /// Whether this cavity path can leave Delaunay work for the caller. delaunay_repair_required: bool, } @@ -1123,7 +1127,7 @@ where .triangulation_mut() .validate_after_insertion_and_record_telemetry( insert_ok.suspicion, - &insert_ok.repair_seed_simplices, + &insert_ok.embedding_validation_simplices, telemetry, telemetry_mode, ); @@ -1192,7 +1196,7 @@ where let validation_result = self.validate_after_insertion_and_record_telemetry( fallback_ok.suspicion, - &fallback_ok.repair_seed_simplices, + &fallback_ok.embedding_validation_simplices, telemetry, telemetry_mode, ); @@ -1869,6 +1873,11 @@ where hint, simplices_removed: total_removed, repair_seed_simplices, + embedding_validation_simplices: new_simplices + .iter() + .copied() + .filter(|simplex_key| self.tds.contains_simplex(*simplex_key)) + .collect(), delaunay_repair_required: delaunay_repair_required || suspicion.is_suspicious(), }) } @@ -2056,6 +2065,7 @@ where simplices_removed: 0, suspicion, repair_seed_simplices: SimplexKeyBuffer::new(), + embedding_validation_simplices: SimplexKeyBuffer::new(), delaunay_repair_required: false, }); } else if num_vertices == D + 1 { @@ -2079,11 +2089,13 @@ where // Return first simplex key for hint caching let first_simplex = self.tds.simplex_keys().next(); + let embedding_validation_simplices = first_simplex.into_iter().collect(); return Ok(TryInsertImplOk { inserted: (v_key, first_simplex), simplices_removed: 0, suspicion, repair_seed_simplices: SimplexKeyBuffer::new(), + embedding_validation_simplices, delaunay_repair_required: false, }); } @@ -2279,6 +2291,7 @@ where simplices_removed: outcome.simplices_removed, suspicion, repair_seed_simplices: outcome.repair_seed_simplices, + embedding_validation_simplices: outcome.embedding_validation_simplices, delaunay_repair_required: outcome.delaunay_repair_required, }) } @@ -2314,6 +2327,8 @@ where simplices_removed: outcome.simplices_removed, suspicion, repair_seed_simplices: outcome.repair_seed_simplices, + embedding_validation_simplices: outcome + .embedding_validation_simplices, delaunay_repair_required: true, }); } @@ -2435,6 +2450,8 @@ where simplices_removed: outcome.simplices_removed, suspicion, repair_seed_simplices: outcome.repair_seed_simplices, + embedding_validation_simplices: outcome + .embedding_validation_simplices, delaunay_repair_required: true, }); } @@ -2699,6 +2716,11 @@ where simplices_removed: total_removed, suspicion, repair_seed_simplices, + embedding_validation_simplices: new_simplices + .iter() + .copied() + .filter(|simplex_key| self.tds.contains_simplex(*simplex_key)) + .collect(), delaunay_repair_required: true, }) } diff --git a/src/core/repair.rs b/src/core/repair.rs index 56174739..f4f73586 100644 --- a/src/core/repair.rs +++ b/src/core/repair.rs @@ -858,6 +858,8 @@ where { self.tds.is_valid().map_err(InvariantError::Tds)?; self.is_valid_topology()?; + self.is_valid_embedding() + .map_err(InvariantError::Embedding)?; } Ok(()) diff --git a/src/core/validation.rs b/src/core/validation.rs index 4cd1db91..434edfae 100644 --- a/src/core/validation.rs +++ b/src/core/validation.rs @@ -135,6 +135,7 @@ use uuid::Uuid; /// /// - `TopologyValidation(source)` β†’ `InvariantError::Tds(source)` (Level 1–2 preserved) /// - `TopologyValidationFailed { source }` β†’ `InvariantError::Triangulation(source)` (Level 3 preserved) +/// - `EmbeddingValidationFailed { source }` β†’ `InvariantError::Embedding(source)` (Level 4 preserved) /// - `DelaunayValidationFailed { source }` β†’ `InvariantError::Delaunay(source)` (Level 5 preserved) /// - All other variants β†’ `InvariantError::Tds(InconsistentDataStructure { .. })` with `context` pub(crate) fn insertion_error_to_invariant_error( @@ -146,6 +147,7 @@ pub(crate) fn insertion_error_to_invariant_error( InsertionError::TopologyValidationFailed { source, .. } => { InvariantError::Triangulation(source) } + InsertionError::EmbeddingValidationFailed { source } => InvariantError::Embedding(source), InsertionError::DelaunayValidationFailed { source } => InvariantError::Delaunay(source), other => InvariantError::Tds(TdsError::InconsistentDataStructure { message: format!("{context}: {other}"), @@ -471,10 +473,9 @@ fn invariant_error_from_topology_error(err: TopologyError) -> InvariantError { /// /// **Note**: [`TopologyGuarantee::PLManifold`] is incompatible with [`ValidationPolicy::Never`]. /// `PLManifold` requires at least caller-owned completion validation to certify full -/// PL-manifoldness. Use [`ValidationPolicy::ExplicitOnly`] when you want to run full -/// topology validation only through explicit validation calls, [`ValidationPolicy::OnSuspicion`] -/// for suspicion-triggered validation, or [`ValidationPolicy::Always`] for maximum safety during -/// incremental operations. +/// PL-manifoldness. Use [`ValidationPolicy::ExplicitOnly`] when callers own explicit validation +/// checkpoints, [`ValidationPolicy::OnSuspicion`] for suspicion-triggered validation, or +/// [`ValidationPolicy::Always`] for maximum safety during incremental operations. /// /// # Examples /// @@ -495,10 +496,10 @@ pub enum ValidationPolicy { /// full validation checkpoints are owned by the caller. Never, - /// Run full topology validation only when callers invoke explicit validation APIs. + /// Do not run policy-triggered global topology/changed-scope embedding validation during insertion. /// /// Mandatory local topology checks required by the active [`TopologyGuarantee`] still run - /// during insertion, but suspicion-triggered full Level 3 validation is disabled. + /// during insertion, but suspicion-triggered global Level 3/changed-scope Level 4 validation is disabled. ExplicitOnly, /// Validate only if the operation is suspicious (e.g. degeneracy). @@ -1333,13 +1334,6 @@ where }); } - if let Err(source) = self.validate_at_completion() { - violations.push(InvariantViolation { - kind: InvariantKind::Topology, - error: source, - }); - } - if violations.is_empty() { Ok(()) } else { @@ -1553,6 +1547,13 @@ where violations.extend(report.violations); } + if let Err(source) = self.validate_at_completion() { + violations.push(InvariantViolation { + kind: InvariantKind::Topology, + error: source, + }); + } + if violations.is_empty() { Ok(()) } else { @@ -1564,7 +1565,7 @@ where /// /// - `InvariantError::Tds(e)` β†’ `InsertionError::TopologyValidation(e)` /// - `InvariantError::Triangulation(e)` β†’ `InsertionError::TopologyValidationFailed { source: e }` - /// - `InvariantError::Embedding(e)` β†’ `InsertionError::DelaunayValidationFailed { source: e.into() }` + /// - `InvariantError::Embedding(e)` β†’ `InsertionError::EmbeddingValidationFailed { source: e }` /// - `InvariantError::Delaunay(e)` β†’ `InsertionError::DelaunayValidationFailed { source: e }` pub(crate) fn invariant_error_to_insertion_error(err: InvariantError) -> InsertionError { match err { @@ -1573,8 +1574,8 @@ where context: InsertionTopologyValidationContext::InvariantConversion, source: tri_err, }, - InvariantError::Embedding(embedding_err) => InsertionError::DelaunayValidationFailed { - source: embedding_err.into(), + InvariantError::Embedding(embedding_err) => InsertionError::EmbeddingValidationFailed { + source: embedding_err, }, InvariantError::Delaunay(dt_err) => { InsertionError::DelaunayValidationFailed { source: dt_err } @@ -1778,29 +1779,15 @@ where } } - /// Reuses the Level 4 insertion-time guard for either caller-provided local - /// simplices or the full triangulation when no local scope is available. - fn validate_insertion_embedding_scope( - &self, - local_simplices: Option<&[SimplexKey]>, - ) -> Result<(), InvariantError> { - let all_simplex_keys; - let simplex_keys = if let Some(local_simplices) = local_simplices { - local_simplices - } else { - all_simplex_keys = self.tds.simplex_keys().collect::(); - &all_simplex_keys - }; - - self.validate_local_embedding_nondegeneracy(simplex_keys) - .map_err(InvariantError::Embedding) - } - pub(crate) fn validate_after_insertion_with_scope( &self, suspicion: SuspicionFlags, local_simplices: Option<&[SimplexKey]>, - ) -> Result<(), InvariantError> { + ) -> Result<(), InvariantError> + where + U: DataType, + V: DataType, + { let Some(work) = self.validation_after_insertion_work(suspicion) else { return Ok(()); }; @@ -1808,8 +1795,13 @@ where log_validation_trigger_if_enabled(self.validation_policy, suspicion); match work { InsertionValidationWork::FullValidation => { - self.is_valid_topology()?; - self.validate_insertion_embedding_scope(local_simplices) + self.validate()?; + match local_simplices { + Some([]) => Ok(()), + Some(simplices) => self.validate_embedding_for_simplices(simplices), + None => self.is_valid_embedding(), + } + .map_err(InvariantError::Embedding) } InsertionValidationWork::RequiredTopologyLinks => local_simplices.map_or_else( || self.validate_required_topology_links(), @@ -1825,7 +1817,11 @@ where local_simplices: &[SimplexKey], telemetry: &mut InsertionTelemetry, telemetry_mode: InsertionTelemetryMode, - ) -> Result<(), InvariantError> { + ) -> Result<(), InvariantError> + where + U: DataType, + V: DataType, + { let validation_work = self.validation_after_insertion_work(suspicion); let validation_started = validation_work.and_then(|_| start_insertion_timing(telemetry_mode)); @@ -1894,12 +1890,14 @@ mod tests { use crate::core::algorithms::flips::{DelaunayRepairError, DelaunayRepairPostconditionFailure}; use crate::core::algorithms::incremental_insertion::CavityFillingError; use crate::core::algorithms::incremental_insertion::repair_neighbor_pointers; - use crate::core::collections::NeighborBuffer; + use crate::core::collections::{NeighborBuffer, SimplexVertexKeyBuffer}; use crate::core::embedding::TriangulationEmbeddingValidationError; use crate::core::facet::FacetError; use crate::core::operations::InsertionOutcome; use crate::core::simplex::Simplex; - use crate::core::tds::{GeometricError, NeighborValidationError, Tds}; + use crate::core::tds::{ + GeometricError, NeighborValidationError, Tds, TriangulationConstructionState, + }; use crate::core::vertex::Vertex; use crate::geometry::coordinate_range::CoordinateRange; use crate::geometry::kernel::FastKernel; @@ -1926,6 +1924,13 @@ mod tests { } } + const fn synthetic_embedding_error() -> TriangulationEmbeddingValidationError { + TriangulationEmbeddingValidationError::UnsupportedTopology { + topology: TopologyKind::Spherical, + dimension: 3, + } + } + fn test_vertex(coords: [f64; D]) -> Vertex<(), D> { vertex!(coords).unwrap() } @@ -2080,6 +2085,44 @@ mod tests { tds } + fn build_topologically_valid_self_overlapping_tds_2d() -> (Tds<(), (), 2>, SimplexKeyBuffer) { + let mut tds: Tds<(), (), 2> = Tds::empty(); + let mut vertices = Vec::new(); + for coords in [ + [1.850_341_970_997_476_4, 3.808_736_162_215_642_4], + [-1.705_108_018_057_679, 3.541_228_835_829_82], + [-1.151_312_061_387_885_3, 0.227_299_663_756_810_77], + [0.478_746_443_632_698_25, 2.055_189_799_064_582], + [-1.383_321_070_900_029, -1.797_028_018_114_396_3], + [3.030_089_610_961_752_6, 2.181_406_554_808_236_6], + ] { + vertices.push(tds.insert_vertex_with_mapping(test_vertex(coords)).unwrap()); + } + + let mut simplex_keys = SimplexKeyBuffer::new(); + for simplex_vertices in [ + [vertices[0], vertices[2], vertices[3]], + [vertices[5], vertices[3], vertices[2]], + [vertices[4], vertices[3], vertices[1]], + [vertices[3], vertices[4], vertices[0]], + [vertices[3], vertices[5], vertices[1]], + ] { + let simplex_vertices: SimplexVertexKeyBuffer = simplex_vertices.into_iter().collect(); + simplex_keys.push( + tds.insert_simplex_with_mapping( + Simplex::try_new_with_data(simplex_vertices, None).unwrap(), + ) + .unwrap(), + ); + } + + tds.construction_state = TriangulationConstructionState::Constructed; + tds.assign_neighbors().unwrap(); + tds.assign_incident_simplices().unwrap(); + + (tds, simplex_keys) + } + fn unit_simplex_vertices() -> Vec> { let mut vertices = Vec::with_capacity(D + 1); vertices.push(test_vertex([0.0_f64; D])); @@ -2685,6 +2728,19 @@ mod tests { } } + #[test] + fn validate_after_insertion_full_validation_includes_tds_layer() { + let (mut tri, [v0, _, _, _], _) = build_single_tet(); + let uuid = tri.tds.vertex(v0).unwrap().uuid(); + tri.tds.uuid_to_vertex_key.remove(&uuid); + tri.set_validation_policy(ValidationPolicy::Always); + + match tri.validate_after_insertion_with_scope(SuspicionFlags::default(), None) { + Err(InvariantError::Tds(TdsError::MappingInconsistency { .. })) => {} + other => panic!("Expected InvariantError::Tds(MappingInconsistency), got {other:?}"), + } + } + #[test] fn validation_after_insertion_work_matches_policy_and_link_requirements() { let tds = build_disconnected_two_triangles_tds_2d(); @@ -2735,6 +2791,15 @@ mod tests { InvariantError::Triangulation(inner) ); + let embedding_source = synthetic_embedding_error(); + let error = InsertionError::EmbeddingValidationFailed { + source: embedding_source.clone(), + }; + assert_eq!( + insertion_error_to_invariant_error(error, "ctx"), + InvariantError::Embedding(embedding_source) + ); + let delaunay_source = synthetic_delaunay_verification_error("delaunay"); let error = InsertionError::DelaunayValidationFailed { source: delaunay_source.clone(), @@ -2775,6 +2840,11 @@ mod tests { Triangulation::, (), (), 3>::invariant_error_to_insertion_error(inv); assert_matches!(ins, InsertionError::TopologyValidationFailed { .. }); + let inv = InvariantError::Embedding(synthetic_embedding_error()); + let ins = + Triangulation::, (), (), 3>::invariant_error_to_insertion_error(inv); + assert_matches!(ins, InsertionError::EmbeddingValidationFailed { .. }); + let inv = InvariantError::Delaunay(synthetic_delaunay_verification_error("test")); let ins = Triangulation::, (), (), 3>::invariant_error_to_insertion_error(inv); @@ -3791,6 +3861,55 @@ mod tests { ); } + #[test] + fn validate_after_insertion_full_validation_rejects_global_embedding_intersection() { + let (tds, scope) = build_topologically_valid_self_overlapping_tds_2d(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + tri.set_validation_policy(ValidationPolicy::Always); + + tri.is_valid_topology() + .expect("fixture should isolate a Level 4 embedding failure"); + + let err = tri + .validate_after_insertion_with_scope(SuspicionFlags::default(), Some(&scope[..1])) + .unwrap_err(); + + assert_matches!( + err, + InvariantError::Embedding( + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { .. } + ) + ); + } + + #[test] + fn validate_after_insertion_full_validation_checks_large_raw_embedding_scope() { + let (tds, scope) = build_topologically_valid_self_overlapping_tds_2d(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + tri.set_validation_policy(ValidationPolicy::Always); + + tri.is_valid_topology() + .expect("fixture should isolate a Level 4 embedding failure"); + + let mut large_scope = SimplexKeyBuffer::new(); + for _ in 0..9 { + large_scope.push(scope[0]); + } + + let err = tri + .validate_after_insertion_with_scope(SuspicionFlags::default(), Some(&large_scope)) + .unwrap_err(); + + assert_matches!( + err, + InvariantError::Embedding( + TriangulationEmbeddingValidationError::SimplexIntersectionOutsideSharedFace { .. } + ) + ); + } + #[test] fn validate_at_completion_ok_for_pseudomanifold_empty() { let mut tri: Triangulation, (), (), 3> = diff --git a/src/delaunay/construction.rs b/src/delaunay/construction.rs index 55d9d227..e1887814 100644 --- a/src/delaunay/construction.rs +++ b/src/delaunay/construction.rs @@ -63,6 +63,7 @@ use crate::core::construction::{ FinalDelaunayValidationContext, FinalTopologyValidationContext, PeriodicQuotientFacetKeyDerivationFailure, TriangulationConstructionError, }; +use crate::core::embedding::TriangulationEmbeddingValidationError; use crate::core::insertion::record_duplicate_detection_metrics; use crate::core::operations::{ DelaunayInsertionState, InsertionOutcome, InsertionResult, InsertionStatistics, @@ -999,6 +1000,14 @@ pub enum DelaunayConstructionFailure { source: DelaunayTriangulationValidationError, }, + /// Level 4 embedding validation failed during insertion. + #[error("embedding validation failed during insertion: {source}")] + InsertionEmbeddingValidation { + /// Underlying embedding validation error. + #[source] + source: TriangulationEmbeddingValidationError, + }, + /// Level 3 topology validation failed during insertion. #[error("topology validation failed during insertion: {context}: {source}")] InsertionTopologyValidation { @@ -1228,6 +1237,9 @@ impl From for DelaunayConstructionFailure { TriangulationConstructionError::InsertionDelaunayValidation { source } => { Self::InsertionDelaunayValidation { source } } + TriangulationConstructionError::InsertionEmbeddingValidation { source } => { + Self::InsertionEmbeddingValidation { source } + } TriangulationConstructionError::InsertionTopologyValidation { context, source } => { Self::InsertionTopologyValidation { context, source } } @@ -3816,16 +3828,12 @@ where // construction validation in finalize_bulk_construction catches any issues // that slip through. // - // Exception: PLManifoldStrict requires per-insertion vertex-link validation, - // so we must use ValidationPolicy::Always to satisfy that guarantee. + // PLManifoldStrict still uses OnSuspicion here: the topology guarantee + // independently forces RequiredTopologyLinks after every insertion, + // including vertex-link validation. Suspicious insertions still escalate + // to full validation without making every bulk insertion pay that cost. let original_validation_policy = dt.tri.validation_policy; - dt.tri.validation_policy = if dt - .tri - .topology_guarantee - .requires_vertex_links_during_insertion() - { - ValidationPolicy::Always - } else if dt.tri.topology_guarantee.requires_ridge_links() { + dt.tri.validation_policy = if dt.tri.topology_guarantee.requires_ridge_links() { ValidationPolicy::OnSuspicion } else { ValidationPolicy::DebugOnly @@ -3948,16 +3956,12 @@ where // per-insertion validation (see _with_construction_statistics variant for // rationale: O(nΒ²) avoidance + post-construction validation fallback). // - // Exception: PLManifoldStrict requires per-insertion vertex-link validation, - // so we must use ValidationPolicy::Always to satisfy that guarantee. + // PLManifoldStrict still uses OnSuspicion here: the topology guarantee + // independently forces RequiredTopologyLinks after every insertion, + // including vertex-link validation. Suspicious insertions still escalate + // to full validation without making every bulk insertion pay that cost. let original_validation_policy = dt.tri.validation_policy; - dt.tri.validation_policy = if dt - .tri - .topology_guarantee - .requires_vertex_links_during_insertion() - { - ValidationPolicy::Always - } else if dt.tri.topology_guarantee.requires_ridge_links() { + dt.tri.validation_policy = if dt.tri.topology_guarantee.requires_ridge_links() { ValidationPolicy::OnSuspicion } else { ValidationPolicy::DebugOnly @@ -5389,6 +5393,7 @@ where | DelaunayConstructionFailure::CanonicalizedUnsupportedGlobalTopology { .. } | DelaunayConstructionFailure::PeriodicImageConflictingGlobalTopology { .. } | DelaunayConstructionFailure::SpatialIndexConstruction { .. } + | DelaunayConstructionFailure::InsertionEmbeddingValidation { .. } | DelaunayConstructionFailure::InsertionTopologyValidation { .. } | DelaunayConstructionFailure::LocalRepairBudgetExceeded { .. } | DelaunayConstructionFailure::ShuffledRetryExhausted { .. } @@ -5476,6 +5481,7 @@ where | InsertionError::Location(_) | InsertionError::NonManifoldTopology { .. } | InsertionError::HullExtension { .. } + | InsertionError::EmbeddingValidationFailed { .. } | InsertionError::DelaunayValidationFailed { .. } | InsertionError::DuplicateCoordinates { .. } | InsertionError::PerturbedCoordinateInvalid { .. }) => { @@ -5568,6 +5574,9 @@ where InsertionError::DelaunayValidationFailed { source } => { TriangulationConstructionError::InsertionDelaunayValidation { source } } + InsertionError::EmbeddingValidationFailed { source } => { + TriangulationConstructionError::InsertionEmbeddingValidation { source } + } InsertionError::TopologyValidationFailed { context, source } => { TriangulationConstructionError::InsertionTopologyValidation { context, source } } diff --git a/src/delaunay/deletion.rs b/src/delaunay/deletion.rs index b7d7f7b7..555d9c91 100644 --- a/src/delaunay/deletion.rs +++ b/src/delaunay/deletion.rs @@ -698,7 +698,7 @@ mod tests { let err = dt .delete_vertex(vertex_key) - .expect_err("disabled repair should roll back a Level 4 violation"); + .expect_err("disabled repair should roll back a Level 5 violation"); let DeleteVertexError::InvariantViolation { source } = err else { panic!("expected invariant violation, got {err:?}"); diff --git a/src/delaunay/property_validation.rs b/src/delaunay/property_validation.rs index bd6c5441..1e1caf5d 100644 --- a/src/delaunay/property_validation.rs +++ b/src/delaunay/property_validation.rs @@ -812,7 +812,7 @@ mod tests { use crate::triangulation::DelaunayTriangulation; use crate::vertex; use slotmap::KeyData; - use std::{assert_matches, sync::Once}; + use std::{assert_matches, ptr, sync::Once}; fn test_vertex(coords: [f64; D]) -> Vertex<(), D> { vertex!(coords).unwrap() @@ -1078,7 +1078,10 @@ mod tests { let detail = report .first_violation() .expect("violating report should include first violation details"); - assert!(std::ptr::eq(detail, &report.violation_details[0])); + assert!(ptr::eq( + ptr::from_ref(detail), + ptr::from_ref(&report.violation_details[0]), + )); assert!(detail.simplex_key == simplex_1 || detail.simplex_key == simplex_2); assert_eq!(detail.simplex_vertices.len(), 3); assert_eq!(detail.neighbor_simplices.len(), 3); diff --git a/src/delaunay/query.rs b/src/delaunay/query.rs index a763be3f..0add4ea5 100644 --- a/src/delaunay/query.rs +++ b/src/delaunay/query.rs @@ -889,8 +889,12 @@ impl DelaunayTriangulation { /// structure is invalid while checking topology, or /// [`DelaunayTriangulationValidationError::Triangulation`] when Level 3 /// topology violates the requested metadata, for example when Euclidean - /// boundary facets are relabeled as closed spherical or toroidal topology. - /// The previous topology metadata is restored before the error is returned. + /// boundary facets are relabeled as closed spherical or toroidal topology, + /// [`DelaunayTriangulationValidationError::Embedding`] when Level 4 rejects + /// the requested embedding model, or + /// [`DelaunayTriangulationValidationError::VerificationFailed`] when Level 5 + /// Delaunay validation fails. The previous topology metadata is restored + /// before the error is returned. /// /// # Examples /// diff --git a/src/delaunay/validation.rs b/src/delaunay/validation.rs index 672c52e2..b8d226df 100644 --- a/src/delaunay/validation.rs +++ b/src/delaunay/validation.rs @@ -1198,9 +1198,9 @@ mod tests { let simplex_key = SimplexKey::default(); let source = DelaunayVerificationError::from(DelaunayValidationError::DelaunayViolation { simplex_key, - simplex_vertices: Default::default(), + simplex_vertices: Box::default(), offending_vertex: None, - neighbor_simplices: Default::default(), + neighbor_simplices: Box::default(), }); assert_eq!( diff --git a/src/geometry/embedding.rs b/src/geometry/embedding.rs index ed41778a..c1169067 100644 --- a/src/geometry/embedding.rs +++ b/src/geometry/embedding.rs @@ -2,11 +2,45 @@ //! //! This module has no TDS, topology, or triangulation storage dependencies. It //! answers geometric questions about labeled maximal simplices after another -//! layer has chosen the appropriate affine chart. +//! layer has chosen the appropriate affine chart. Algorithmic provenance for +//! the Level 4 overlap broad phase is summarized in `REFERENCES.md`, +//! "Embedded-Geometry Overlap Detection (Level 4 Validation)". +//! +//! Use [`Triangulation::embedding_report`](crate::Triangulation::embedding_report) +//! when validating a stored triangulation. Use this module directly when a +//! caller already has chart-local simplex coordinates and wants the pure +//! geometric Level 4 predicate without TDS storage. +//! +//! # Examples +//! +//! ```rust +//! use delaunay::prelude::geometry::{ +//! LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, SimplexIntersectionFailure, +//! validate_simplex_embeddings_intersect_only_in_shared_faces, +//! }; +//! +//! # fn main() -> Result<(), LabeledSimplexEmbeddingError> { +//! let first = LabeledSimplexEmbedding::try_new( +//! [0_usize, 1, 2], +//! [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], +//! )?; +//! let second = LabeledSimplexEmbedding::try_new( +//! [0_usize, 1, 3], +//! [[0.0, 0.0], [1.0, 0.0], [0.25, 0.25]], +//! )?; +//! +//! std::assert_matches!( +//! validate_simplex_embeddings_intersect_only_in_shared_faces(&first, &second), +//! Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace { .. }) +//! ); +//! # Ok(()) +//! # } +//! ``` #![forbid(unsafe_code)] use crate::core::collections::{MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer}; +use crate::geometry::point::{Point, ValidatedCoordinates}; use crate::geometry::traits::coordinate::InvalidCoordinateValue; use la_stack::{BigInt, BigRational, FromPrimitive, Signed}; use thiserror::Error; @@ -14,6 +48,12 @@ use thiserror::Error; /// Stack-backed buffer for per-simplex embedding labels and coordinates. pub type SimplexEmbeddingBuffer = SmallBuffer; +/// Validated coordinates for one labeled D-simplex in an affine chart. +/// +/// Labels implement [`Eq`] and are unique within the simplex so intersection +/// witnesses can distinguish shared faces from accidental duplicate vertices. +/// Coordinates are finite `f64` values ready to be converted into exact +/// predicate inputs by the triangulation-level embedding validator. #[derive(Clone, Debug, PartialEq)] pub struct LabeledSimplexEmbedding { labels: SimplexEmbeddingBuffer, @@ -21,11 +61,49 @@ pub struct LabeledSimplexEmbedding { } impl LabeledSimplexEmbedding { - /// Builds a labeled D-simplex embedding after checking arity and finite coordinates. + /// Builds a labeled D-simplex embedding after checking arity, uniqueness, and finite coordinates. + /// + /// This is the parse boundary for pure simplex-embedding predicates: callers + /// supply labels and coordinates in matching order, and the constructor + /// stores only embeddings with exactly `D + 1` distinct labels. Labels use + /// [`Eq`] because they represent vertex identity, not an approximate value. + /// + /// # Errors + /// + /// Returns [`LabeledSimplexEmbeddingError::LabelCoordinateLengthMismatch`] + /// when the two iterators produce different lengths, + /// [`LabeledSimplexEmbeddingError::InvalidArity`] when the simplex does not + /// contain exactly `D + 1` vertices, + /// [`LabeledSimplexEmbeddingError::DuplicateLabel`] when a label appears + /// more than once, or + /// [`LabeledSimplexEmbeddingError::NonFiniteCoordinate`] when any coordinate + /// is NaN or infinite. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::geometry::{ + /// LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, + /// }; + /// + /// # fn main() -> Result<(), LabeledSimplexEmbeddingError> { + /// let simplex = LabeledSimplexEmbedding::try_new( + /// ["a", "b", "c"], + /// [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + /// )?; + /// + /// assert_eq!(simplex.labels(), ["a", "b", "c"]); + /// assert_eq!(simplex.coordinates().len(), 3); + /// # Ok(()) + /// # } + /// ``` pub fn try_new( labels: impl IntoIterator, coordinates: impl IntoIterator, - ) -> Result { + ) -> Result + where + L: Eq, + { let labels: SimplexEmbeddingBuffer = labels.into_iter().collect(); let coordinates: SimplexEmbeddingBuffer<[f64; D]> = coordinates.into_iter().collect(); @@ -46,18 +124,20 @@ impl LabeledSimplexEmbedding { }); } - for (vertex_index, coords) in coordinates.iter().enumerate() { - for (coordinate_index, coordinate) in coords.iter().enumerate() { - if !coordinate.is_finite() { - return Err(LabeledSimplexEmbeddingError::NonFiniteCoordinate { - vertex_index, - coordinate_index, - coordinate_value: InvalidCoordinateValue::from_debug(coordinate), - }); - } + for (first_index, first_label) in labels.iter().enumerate() { + if let Some(duplicate_offset) = labels[first_index + 1..] + .iter() + .position(|label| label == first_label) + { + return Err(LabeledSimplexEmbeddingError::DuplicateLabel { + first_index, + duplicate_index: first_index + duplicate_offset + 1, + }); } } + validate_coordinate_rows(&coordinates)?; + Ok(Self { labels, coordinates, @@ -65,20 +145,95 @@ impl LabeledSimplexEmbedding { } /// Returns labels in the same order as the simplex coordinates. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::geometry::{ + /// LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, + /// }; + /// + /// # fn main() -> Result<(), LabeledSimplexEmbeddingError> { + /// let simplex = LabeledSimplexEmbedding::try_new( + /// ["left", "right", "apex"], + /// [[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]], + /// )?; + /// + /// assert_eq!(simplex.labels(), ["left", "right", "apex"]); + /// # Ok(()) + /// # } + /// ``` pub fn labels(&self) -> &[L] { &self.labels } /// Returns the D-dimensional coordinates paired with [`labels`](Self::labels). + /// + /// # Examples + /// + /// ```rust + /// use approx::assert_abs_diff_eq; + /// use delaunay::prelude::geometry::{ + /// LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, + /// }; + /// + /// # fn main() -> Result<(), LabeledSimplexEmbeddingError> { + /// let simplex = LabeledSimplexEmbedding::try_new( + /// [0_usize, 1, 2], + /// [[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]], + /// )?; + /// + /// assert_abs_diff_eq!(simplex.coordinates()[2][0], 0.5, epsilon = f64::EPSILON); + /// assert_abs_diff_eq!(simplex.coordinates()[2][1], 1.0, epsilon = f64::EPSILON); + /// # Ok(()) + /// # } + /// ``` pub fn coordinates(&self) -> &[[f64; D]] { &self.coordinates } + /// Rehydrates a validated coordinate row as a [`Point`] without rechecking finiteness. + pub(crate) fn point_at(&self, vertex_index: usize) -> Option> { + self.coordinates.get(vertex_index).copied().map(|coords| { + Point::from_validated_coordinates( + ValidatedCoordinates::from_prevalidated_finite_values(coords), + ) + }) + } + /// Returns an embedding translated by integer multiples of the periodic domain. /// /// The translated coordinates are re-validated so overflow to non-finite /// values becomes a typed embedding error rather than a hidden predicate /// input. + /// + /// # Errors + /// + /// Returns [`LabeledSimplexEmbeddingError::InvalidPeriodicDomainPeriod`] if a + /// period is non-finite or non-positive, or + /// [`LabeledSimplexEmbeddingError::NonFiniteCoordinate`] if translating by + /// `shift * period` produces a NaN or infinite coordinate. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::geometry::{ + /// LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, + /// }; + /// use approx::assert_abs_diff_eq; + /// + /// # fn main() -> Result<(), LabeledSimplexEmbeddingError> { + /// let simplex = LabeledSimplexEmbedding::try_new( + /// [0_usize, 1, 2], + /// [[0.0, 0.0], [0.5, 0.0], [0.0, 0.5]], + /// )?; + /// let translated = simplex.try_translated(&[1.0, 1.0], &[1, -1])?; + /// + /// assert_abs_diff_eq!(translated.coordinates()[0][0], 1.0, epsilon = f64::EPSILON); + /// assert_abs_diff_eq!(translated.coordinates()[0][1], -1.0, epsilon = f64::EPSILON); + /// # Ok(()) + /// # } + /// ``` pub fn try_translated( &self, periods: &[f64; D], @@ -95,10 +250,32 @@ impl LabeledSimplexEmbedding { coords[axis] = f64::from(shift[axis]).mul_add(periods[axis], coords[axis]); } } - Self::try_new(self.labels.iter().cloned(), translated_coordinates) + validate_coordinate_rows(&translated_coordinates)?; + Ok(Self { + labels: self.labels.clone(), + coordinates: translated_coordinates, + }) } } +/// Validates translated or newly parsed coordinate rows before predicate use. +fn validate_coordinate_rows( + coordinates: &SimplexEmbeddingBuffer<[f64; D]>, +) -> Result<(), LabeledSimplexEmbeddingError> { + for (vertex_index, coords) in coordinates.iter().enumerate() { + for (coordinate_index, coordinate) in coords.iter().enumerate() { + if !coordinate.is_finite() { + return Err(LabeledSimplexEmbeddingError::NonFiniteCoordinate { + vertex_index, + coordinate_index, + coordinate_value: InvalidCoordinateValue::from_debug(coordinate), + }); + } + } + } + Ok(()) +} + /// Errors produced while parsing a labeled simplex embedding. #[derive(Clone, Debug, Error, PartialEq)] #[non_exhaustive] @@ -119,6 +296,14 @@ pub enum LabeledSimplexEmbeddingError { /// Actual vertex count supplied by the caller. actual: usize, }, + /// A simplex label appeared more than once. + #[error("duplicate simplex embedding label at indices {first_index} and {duplicate_index}")] + DuplicateLabel { + /// First index containing the duplicated label. + first_index: usize, + /// Later index containing the same label. + duplicate_index: usize, + }, /// A coordinate was NaN or infinite. #[error( "non-finite coordinate at vertex {vertex_index}, coordinate {coordinate_index}: {coordinate_value}" @@ -174,12 +359,19 @@ pub struct SimplexIntersectionWitness { } /// Failure modes for exact simplex-intersection validation. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Error, Eq, PartialEq)] +#[non_exhaustive] pub enum SimplexIntersectionFailure { /// The first simplex basis is singular, so barycentric coordinates are undefined. + #[error("simplex barycentric basis is singular")] SingularBarycentricBasis, /// The simplices intersect at a point involving non-shared vertices. - IntersectionOutsideSharedFace(SimplexIntersectionWitness), + #[error("simplices intersect outside their shared face")] + #[non_exhaustive] + IntersectionOutsideSharedFace { + /// Barycentric witness for the illegal intersection. + witness: SimplexIntersectionWitness, + }, } /// Coordinate-span witness for a simplex that is too wide for one periodic chart. @@ -194,6 +386,33 @@ pub struct PeriodicSimplexSpan { } /// Returns the closed coordinate range of a simplex along one axis. +/// +/// Returns [`None`] when `axis >= D`. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::geometry::{ +/// LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, coordinate_range_for_axis, +/// }; +/// use approx::abs_diff_eq; +/// +/// # fn main() -> Result<(), LabeledSimplexEmbeddingError> { +/// let simplex = LabeledSimplexEmbedding::try_new( +/// [0_usize, 1, 2], +/// [[-1.0, 0.0], [2.0, 0.5], [0.0, 1.0]], +/// )?; +/// +/// std::assert_matches!( +/// coordinate_range_for_axis(&simplex, 0), +/// Some((min, max)) +/// if abs_diff_eq!(min, -1.0, epsilon = f64::EPSILON) +/// && abs_diff_eq!(max, 2.0, epsilon = f64::EPSILON) +/// ); +/// assert_eq!(coordinate_range_for_axis(&simplex, 2), None); +/// # Ok(()) +/// # } +/// ``` pub fn coordinate_range_for_axis( simplex: &LabeledSimplexEmbedding, axis: usize, @@ -209,6 +428,33 @@ pub fn coordinate_range_for_axis( } /// Returns whether two simplex axis-aligned bounding boxes overlap. +/// +/// This is a conservative broad-phase predicate: a `true` result means the +/// boxes overlap and exact simplex-intersection validation may be needed, not +/// that the simplices themselves necessarily intersect. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::geometry::{ +/// LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, +/// axis_aligned_bounding_boxes_overlap, +/// }; +/// +/// # fn main() -> Result<(), LabeledSimplexEmbeddingError> { +/// let first = LabeledSimplexEmbedding::try_new( +/// [0_usize, 1, 2], +/// [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], +/// )?; +/// let second = LabeledSimplexEmbedding::try_new( +/// [3_usize, 4, 5], +/// [[2.0, 2.0], [3.0, 2.0], [2.0, 3.0]], +/// )?; +/// +/// assert!(!axis_aligned_bounding_boxes_overlap(&first, &second)); +/// # Ok(()) +/// # } +/// ``` pub fn axis_aligned_bounding_boxes_overlap( first: &LabeledSimplexEmbedding, second: &LabeledSimplexEmbedding, @@ -230,6 +476,34 @@ pub fn axis_aligned_bounding_boxes_overlap( /// /// Returns [`PeriodicSimplexSpanError`] when any period is non-finite or not /// strictly positive. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::geometry::{ +/// LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, +/// PeriodicSimplexSpanError, try_periodic_simplex_span, +/// }; +/// +/// #[derive(Debug, thiserror::Error)] +/// enum ExampleError { +/// #[error(transparent)] +/// Embedding(#[from] LabeledSimplexEmbeddingError), +/// #[error(transparent)] +/// PeriodicSpan(#[from] PeriodicSimplexSpanError), +/// } +/// +/// # fn main() -> Result<(), ExampleError> { +/// let simplex = LabeledSimplexEmbedding::try_new( +/// [0_usize, 1, 2], +/// [[0.0, 0.0], [1.0, 0.0], [0.0, 0.25]], +/// )?; +/// +/// let span = try_periodic_simplex_span(&simplex, &[1.0, 2.0])?; +/// assert_eq!(span.map(|witness| witness.axis), Some(0)); +/// # Ok(()) +/// # } +/// ``` pub fn try_periodic_simplex_span( simplex: &LabeledSimplexEmbedding, periods: &[f64; D], @@ -237,8 +511,13 @@ pub fn try_periodic_simplex_span( validate_periods(periods)?; for (axis, &period) in periods.iter().enumerate() { - let (min_coord, max_coord) = coordinate_range_for_axis(simplex, axis) - .expect("axis generated from periods.iter().enumerate() must be valid"); + let (min_coord, max_coord) = simplex.coordinates().iter().fold( + (f64::INFINITY, f64::NEG_INFINITY), + |(min_coord, max_coord), coords| { + let coord = coords[axis]; + (min_coord.min(coord), max_coord.max(coord)) + }, + ); let span = max_coord - min_coord; if span >= period { return Ok(Some(PeriodicSimplexSpan { axis, span, period })); @@ -268,6 +547,39 @@ fn validate_periods(periods: &[f64; D]) -> Result<(), PeriodicSi /// This is the pure geometric core of Level 4 overlap validation. It uses /// exact rational barycentric arithmetic after coordinates have been parsed as /// finite f64 values. +/// +/// # Errors +/// +/// Returns [`SimplexIntersectionFailure::SingularBarycentricBasis`] when the +/// first simplex cannot define barycentric coordinates, or +/// [`SimplexIntersectionFailure::IntersectionOutsideSharedFace`] when the +/// two simplex interiors overlap away from their shared labels. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::geometry::{ +/// LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, SimplexIntersectionFailure, +/// validate_simplex_embeddings_intersect_only_in_shared_faces, +/// }; +/// +/// # fn main() -> Result<(), LabeledSimplexEmbeddingError> { +/// let first = LabeledSimplexEmbedding::try_new( +/// [0_usize, 1, 2], +/// [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], +/// )?; +/// let second = LabeledSimplexEmbedding::try_new( +/// [0_usize, 1, 3], +/// [[0.0, 0.0], [1.0, 0.0], [0.25, 0.25]], +/// )?; +/// +/// std::assert_matches!( +/// validate_simplex_embeddings_intersect_only_in_shared_faces(&first, &second), +/// Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace { .. }) +/// ); +/// # Ok(()) +/// # } +/// ``` pub fn validate_simplex_embeddings_intersect_only_in_shared_faces( first: &LabeledSimplexEmbedding, second: &LabeledSimplexEmbedding, @@ -287,13 +599,13 @@ where positive_nonshared_labels(&beta, second.labels(), &shared_labels); if !first_only_witness_labels.is_empty() || !second_only_witness_labels.is_empty() { - return Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace( - SimplexIntersectionWitness { + return Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace { + witness: SimplexIntersectionWitness { shared: shared_labels, first_only_witness: first_only_witness_labels, second_only_witness: second_only_witness_labels, }, - )); + }); } } @@ -572,8 +884,12 @@ fn rational_one() -> BigRational { #[cfg(test)] mod tests { use super::*; + use approx::assert_abs_diff_eq; use std::assert_matches; + #[derive(Clone)] + struct CloneOnlyLabel; + #[test] fn labeled_simplex_embedding_rejects_label_coordinate_length_mismatch() { let err = @@ -606,6 +922,23 @@ mod tests { ); } + #[test] + fn labeled_simplex_embedding_rejects_duplicate_labels() { + let err = LabeledSimplexEmbedding::<_, 2>::try_new( + vec![0, 1, 0], + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + ) + .unwrap_err(); + + assert_matches!( + err, + LabeledSimplexEmbeddingError::DuplicateLabel { + first_index: 0, + duplicate_index: 2, + } + ); + } + #[test] fn coordinate_range_rejects_out_of_bounds_axis() { let simplex = LabeledSimplexEmbedding::try_new( @@ -653,6 +986,21 @@ mod tests { ); } + #[test] + fn labeled_simplex_embedding_rehydrates_points_from_validated_rows() { + let simplex = LabeledSimplexEmbedding::try_new( + vec![0, 1, 2], + vec![[-0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + ) + .unwrap(); + + let point = simplex.point_at(0).expect("vertex index exists"); + + assert_eq!(point.coords()[0].to_bits(), 0.0_f64.to_bits()); + assert_eq!(point.coords()[1].to_bits(), 0.0_f64.to_bits()); + assert!(simplex.point_at(3).is_none()); + } + #[test] fn translated_embedding_rejects_non_finite_coordinates() { let simplex = LabeledSimplexEmbedding::try_new( @@ -696,6 +1044,25 @@ mod tests { ); } + #[test] + fn translated_embedding_requires_only_clone_labels() { + let simplex = LabeledSimplexEmbedding { + labels: vec![CloneOnlyLabel, CloneOnlyLabel, CloneOnlyLabel] + .into_iter() + .collect(), + coordinates: vec![[0.0, 0.0], [0.5, 0.0], [0.0, 0.5]] + .into_iter() + .collect(), + }; + + let translated = simplex + .try_translated(&[1.0, 1.0], &[1, 0]) + .expect("translation preserves already-validated labels"); + + assert_eq!(translated.labels().len(), 3); + assert_abs_diff_eq!(translated.coordinates()[1][0], 1.5, epsilon = f64::EPSILON); + } + #[test] fn crossing_triangles_report_positive_nonshared_witnesses() { let first = LabeledSimplexEmbedding::try_new( @@ -713,7 +1080,7 @@ mod tests { .unwrap_err(); assert_matches!( err, - SimplexIntersectionFailure::IntersectionOutsideSharedFace(witness) + SimplexIntersectionFailure::IntersectionOutsideSharedFace { witness, .. } if witness.first_only_witness.iter().any(|label| [0, 1, 2].contains(label)) && witness.second_only_witness.iter().any(|label| [3, 4, 5].contains(label)) ); @@ -731,8 +1098,8 @@ mod tests { .unwrap() .unwrap(); assert_eq!(span.axis, 0); - assert_eq!(span.span, 1.0); - assert_eq!(span.period, 1.0); + assert_abs_diff_eq!(span.span, 1.0, epsilon = f64::EPSILON); + assert_abs_diff_eq!(span.period, 1.0, epsilon = f64::EPSILON); } #[test] diff --git a/src/geometry/point.rs b/src/geometry/point.rs index 62f31446..49801804 100644 --- a/src/geometry/point.rs +++ b/src/geometry/point.rs @@ -46,6 +46,17 @@ impl ValidatedCoordinates { Ok(Self { values }) } + /// Builds validated coordinates from values whose finiteness was already proved. + #[inline] + pub(in crate::geometry) fn from_prevalidated_finite_values(mut values: [f64; D]) -> Self { + for coord in &mut values { + if *coord == 0.0 { + *coord = 0.0; + } + } + Self { values } + } + #[inline] pub(crate) const fn as_array(&self) -> &[f64; D] { &self.values @@ -1661,6 +1672,15 @@ mod tests { assert_eq!(hasher_pos_zero.finish(), hasher_neg_zero.finish()); } + #[test] + fn prevalidated_finite_coordinates_canonicalize_signed_zero() { + let coords = ValidatedCoordinates::from_prevalidated_finite_values([-0.0, 1.0]); + let point = Point::from_validated_coordinates(coords); + + assert_eq!(point.coords()[0].to_bits(), 0.0_f64.to_bits()); + assert_eq!(point.coords()[1].to_bits(), 1.0_f64.to_bits()); + } + #[test] fn point_hashmap_finite_values() { let mut map: HashMap, &str> = HashMap::new(); diff --git a/src/lib.rs b/src/lib.rs index c91bed14..6da0fa0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,7 +55,7 @@ //! | Post-construction vertex deletion errors and keys | `use delaunay::prelude::deletion::*` | //! | Read-only queries, traversal, convex hull | `use delaunay::prelude::query::*` | //! | Point location and conflict-region algorithms | `use delaunay::prelude::algorithms::*` | -//! | Geometry helpers, coordinate ranges, predicates, points | `use delaunay::prelude::geometry::*` | +//! | Geometry helpers, simplex embeddings, coordinate ranges, predicates, points | `use delaunay::prelude::geometry::*` | //! | Random points / triangulations for examples and tests | `use delaunay::prelude::generators::*` | //! | Hilbert ordering and quantization utilities | `use delaunay::prelude::ordering::*` | //! | Unified Pachner move workflow | `use delaunay::prelude::pachner::*` | @@ -274,20 +274,22 @@ //! - Cumulative Delaunay validation: `dt.validate()` for Levels 1–5, or //! `dt.validation_report()` for full diagnostics. //! -//! ### Automatic topology validation during insertion (`ValidationPolicy`) +//! ### Automatic topology and changed-scope embedding validation during insertion (`ValidationPolicy`) //! //! In addition to explicit validation calls, incremental construction (`new()` / `insert*()`) can run an -//! automatic **Level 3** topology validation pass after insertion, controlled by +//! automatic **global Level 3 plus changed-scope Level 4** validation pass after insertion, controlled by //! [`ValidationPolicy`](crate::prelude::validation::ValidationPolicy). //! //! The initial policy is derived from the active topology guarantee. The default //! [`TopologyGuarantee::PLManifold`](crate::prelude::TopologyGuarantee::PLManifold) //! uses [`ValidationPolicy::ExplicitOnly`](crate::prelude::validation::ValidationPolicy::ExplicitOnly): -//! mandatory local topology checks still run during insertion, while full Level 3 validation is a -//! caller-owned explicit checkpoint. +//! mandatory local topology and nondegenerate-embedding checks still run during insertion, while automatic +//! global-topology/changed-scope embedding validation is a caller-owned explicit checkpoint. //! -//! This automatic pass only runs Level 3 (`Triangulation::is_valid_topology()`). It does **not** run -//! Level 4 embedding validation or Level 5 Delaunay validation. +//! This automatic pass runs Level 3 (`Triangulation::is_valid_topology()`), changed-simplex +//! Level 4 nondegeneracy checks, and changed-vs-current Level 4 pairwise checks. It does +//! **not** run Level 5 Delaunay validation, and old-vs-old Level 4 rescans remain an explicit +//! `Triangulation::validate_embedding()` checkpoint. //! //! ```rust //! use delaunay::prelude::construction::{ @@ -677,9 +679,8 @@ pub mod geometry { } /// Validated coordinate-range types. pub mod coordinate_range; - // Pure Level 4 embedding predicates are crate-internal implementation - // machinery; downstream users should use `Triangulation::embedding_report`. - pub(crate) mod embedding; + /// Pure labeled-simplex embedding predicates used by Level 4 validation. + pub mod embedding; #[macro_use] pub mod matrix; /// Geometric kernel abstraction (CGAL-style). @@ -721,6 +722,7 @@ pub mod geometry { } pub use algorithms::*; pub use coordinate_range::*; + pub use embedding::*; pub use matrix::*; pub use point::*; pub use predicates::*; @@ -1697,13 +1699,20 @@ pub mod prelude { pub use crate::tds::*; } - /// Focused exports for geometry types, predicates, and helpers. + /// Focused exports for geometry types, simplex embeddings, predicates, and helpers. pub mod geometry { pub use crate::geometry::{ coordinate_range::{ CoordinateRange, CoordinateRangeBound, CoordinateRangeError, CoordinateRangeOrdering, InvalidCoordinateValue, }, + embedding::{ + LabeledSimplexEmbedding, LabeledSimplexEmbeddingError, PeriodicSimplexSpan, + PeriodicSimplexSpanError, SimplexEmbeddingBuffer, SimplexIntersectionFailure, + SimplexIntersectionWitness, axis_aligned_bounding_boxes_overlap, + coordinate_range_for_axis, try_periodic_simplex_span, + validate_simplex_embeddings_intersect_only_in_shared_faces, + }, kernel::{AdaptiveKernel, ExactPredicates, FastKernel, Kernel, RobustKernel}, matrix::{LaError, Matrix, MatrixError, determinant}, point::Point, diff --git a/tests/README.md b/tests/README.md index 7a5248ca..bdbed120 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,6 +16,9 @@ Correctness tests live in two routine buckets: Do not use `#[ignore]` as a slow-test marker. Slow correctness tests belong behind `#[cfg(feature = "slow-tests")]`; benchmark-style measurements belong in `benches/`; known limitations should be asserted explicitly instead of hidden. +High-dimensional benchmark-fixture certification that runs full Levels 1-5 +validation also belongs behind `slow-tests`; keep the 2D/3D fixture smoke checks +in the default suite. ## Test Categories diff --git a/tests/benchmark_flip_fixtures.rs b/tests/benchmark_flip_fixtures.rs index fc74abf5..9cf7e5a7 100644 --- a/tests/benchmark_flip_fixtures.rs +++ b/tests/benchmark_flip_fixtures.rs @@ -31,17 +31,31 @@ use delaunay::prelude::validation::{ use slotmap::KeyData; use flip_fixtures::{ - ADVERSARIAL_POINTS_2D, ADVERSARIAL_POINTS_3D, ADVERSARIAL_POINTS_4D, ADVERSARIAL_POINTS_5D, - DEGENERATE_POINTS_3D, STABLE_POINTS_2D, STABLE_POINTS_3D, STABLE_POINTS_4D, STABLE_POINTS_5D, + ADVERSARIAL_POINTS_2D, ADVERSARIAL_POINTS_3D, DEGENERATE_POINTS_3D, STABLE_POINTS_2D, + STABLE_POINTS_3D, }; +#[cfg(feature = "slow-tests")] +use flip_fixtures::{ + ADVERSARIAL_POINTS_4D, ADVERSARIAL_POINTS_5D, STABLE_POINTS_4D, STABLE_POINTS_5D, +}; +#[cfg(feature = "slow-tests")] +use flip_workflows::verify_k3_roundtrip; use flip_workflows::{ CandidateFilter, FlipTriangulation, FlipWorkflowError, assert_same_topology, build_flip_dt, facet_support_touches_adversarial_feature, flippable_k2_facet, flippable_k3_ridge, forward_k2, forward_k3, largest_volume_simplex, ridge_support_touches_adversarial_feature, roundtrip_k1, simplex_touches_adversarial_feature, snapshot_topology, verify_k1_roundtrip, - verify_k2_roundtrip, verify_k3_roundtrip, + verify_k2_roundtrip, }; +#[cfg(feature = "slow-tests")] +#[derive(Clone, Copy, Debug)] +enum RoundtripMove { + K1, + K2, + K3, +} + /// Verifies the stable and adversarial 2D public flip fixture workflows. #[test] fn flip_fixtures_cover_2d_workflows() { @@ -63,22 +77,100 @@ fn flip_fixtures_cover_3d_workflows() { } /// Verifies the stable and adversarial 4D public flip fixture workflows. +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_stable_4d_k1_workflow() { + verify_roundtrip_fixture_move(STABLE_POINTS_4D, CandidateFilter::Any, RoundtripMove::K1); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_stable_4d_k2_workflow() { + verify_roundtrip_fixture_move(STABLE_POINTS_4D, CandidateFilter::Any, RoundtripMove::K2); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_stable_4d_k3_workflow() { + verify_roundtrip_fixture_move(STABLE_POINTS_4D, CandidateFilter::Any, RoundtripMove::K3); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_adversarial_4d_k1_workflow() { + verify_roundtrip_fixture_move( + ADVERSARIAL_POINTS_4D, + CandidateFilter::TouchesAdversarialFeature, + RoundtripMove::K1, + ); +} + +#[cfg(feature = "slow-tests")] #[test] -fn flip_fixtures_cover_4d_workflows() { - verify_roundtrip_fixture(STABLE_POINTS_4D, CandidateFilter::Any); - verify_roundtrip_fixture( +fn flip_fixtures_cover_adversarial_4d_k2_workflow() { + verify_roundtrip_fixture_move( ADVERSARIAL_POINTS_4D, CandidateFilter::TouchesAdversarialFeature, + RoundtripMove::K2, + ); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_adversarial_4d_k3_workflow() { + verify_roundtrip_fixture_move( + ADVERSARIAL_POINTS_4D, + CandidateFilter::TouchesAdversarialFeature, + RoundtripMove::K3, ); } /// Verifies the stable and adversarial 5D public flip fixture workflows. +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_stable_5d_k1_workflow() { + verify_roundtrip_fixture_move(STABLE_POINTS_5D, CandidateFilter::Any, RoundtripMove::K1); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_stable_5d_k2_workflow() { + verify_roundtrip_fixture_move(STABLE_POINTS_5D, CandidateFilter::Any, RoundtripMove::K2); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_stable_5d_k3_workflow() { + verify_roundtrip_fixture_move(STABLE_POINTS_5D, CandidateFilter::Any, RoundtripMove::K3); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_adversarial_5d_k1_workflow() { + verify_roundtrip_fixture_move( + ADVERSARIAL_POINTS_5D, + CandidateFilter::TouchesAdversarialFeature, + RoundtripMove::K1, + ); +} + +#[cfg(feature = "slow-tests")] +#[test] +fn flip_fixtures_cover_adversarial_5d_k2_workflow() { + verify_roundtrip_fixture_move( + ADVERSARIAL_POINTS_5D, + CandidateFilter::TouchesAdversarialFeature, + RoundtripMove::K2, + ); +} + +#[cfg(feature = "slow-tests")] #[test] -fn flip_fixtures_cover_5d_workflows() { - verify_roundtrip_fixture(STABLE_POINTS_5D, CandidateFilter::Any); - verify_roundtrip_fixture( +fn flip_fixtures_cover_adversarial_5d_k3_workflow() { + verify_roundtrip_fixture_move( ADVERSARIAL_POINTS_5D, CandidateFilter::TouchesAdversarialFeature, + RoundtripMove::K3, ); } @@ -359,52 +451,66 @@ fn verify_3d_fixture(points: &[[f64; 3]], filter: CandidateFilter) { .expect("3D benchmark k=3 forward flip should preserve topology"); } -/// Verifies all selected roundtrip-capable public flip workflows for one dimension. -fn verify_roundtrip_fixture(points: &[[f64; D]], filter: CandidateFilter) { +/// Verifies one selected roundtrip-capable public flip workflow for one dimension. +#[cfg(feature = "slow-tests")] +fn verify_roundtrip_fixture_move( + points: &[[f64; D]], + filter: CandidateFilter, + roundtrip_move: RoundtripMove, +) { let base_dt = build_flip_dt(points).expect("benchmark flip fixture should build"); assert_topology_and_delaunay_valid(&base_dt, "benchmark flip fixture"); - let simplex_key = largest_volume_simplex(&base_dt, filter) - .expect("benchmark fixture should provide a selected k=1 simplex"); - if filter == CandidateFilter::TouchesAdversarialFeature { - assert!( - simplex_touches_adversarial_feature(&base_dt, simplex_key) - .expect("k=1 support should be inspectable"), - "adversarial k=1 support should touch an adversarial fixture feature" - ); - } - verify_k1_roundtrip(&base_dt, simplex_key, "k=1 n=1 ergodicity roundtrip") - .expect("k=1 roundtrip should recover the same triangulation"); - - let facet = flippable_k2_facet(&base_dt, true, filter) - .expect("benchmark fixture should provide a selected k=2 facet"); - if filter == CandidateFilter::TouchesAdversarialFeature { - assert!( - facet_support_touches_adversarial_feature(&base_dt, facet) - .expect("k=2 support should be inspectable"), - "adversarial k=2 support should touch an adversarial fixture feature" - ); - } - verify_k2_roundtrip(&base_dt, facet, "k=2 n=1 ergodicity roundtrip") - .expect("k=2 roundtrip should recover the same triangulation"); - - let ridge = flippable_k3_ridge(&base_dt, true, filter) - .expect("benchmark fixture should provide a selected k=3 ridge"); - if filter == CandidateFilter::TouchesAdversarialFeature { - assert!( - ridge_support_touches_adversarial_feature(&base_dt, ridge) - .expect("k=3 support should be inspectable"), - "adversarial k=3 support should touch an adversarial fixture feature" - ); + match roundtrip_move { + RoundtripMove::K1 => { + let simplex_key = largest_volume_simplex(&base_dt, filter) + .expect("benchmark fixture should provide a selected k=1 simplex"); + if filter == CandidateFilter::TouchesAdversarialFeature { + assert!( + simplex_touches_adversarial_feature(&base_dt, simplex_key) + .expect("k=1 support should be inspectable"), + "adversarial k=1 support should touch an adversarial fixture feature" + ); + } + verify_k1_roundtrip(&base_dt, simplex_key, "k=1 n=1 ergodicity roundtrip") + .expect("k=1 roundtrip should recover the same triangulation"); + } + RoundtripMove::K2 => { + let facet = flippable_k2_facet(&base_dt, true, filter) + .expect("benchmark fixture should provide a selected k=2 facet"); + if filter == CandidateFilter::TouchesAdversarialFeature { + assert!( + facet_support_touches_adversarial_feature(&base_dt, facet) + .expect("k=2 support should be inspectable"), + "adversarial k=2 support should touch an adversarial fixture feature" + ); + } + verify_k2_roundtrip(&base_dt, facet, "k=2 n=1 ergodicity roundtrip") + .expect("k=2 roundtrip should recover the same triangulation"); + } + RoundtripMove::K3 => { + let ridge = flippable_k3_ridge(&base_dt, true, filter) + .expect("benchmark fixture should provide a selected k=3 ridge"); + if filter == CandidateFilter::TouchesAdversarialFeature { + assert!( + ridge_support_touches_adversarial_feature(&base_dt, ridge) + .expect("k=3 support should be inspectable"), + "adversarial k=3 support should touch an adversarial fixture feature" + ); + } + verify_k3_roundtrip(&base_dt, ridge, "k=3 n=1 ergodicity roundtrip") + .expect("k=3 roundtrip should recover the same triangulation"); + } } - verify_k3_roundtrip(&base_dt, ridge, "k=3 n=1 ergodicity roundtrip") - .expect("k=3 roundtrip should recover the same triangulation"); } fn assert_topology_and_delaunay_valid(dt: &FlipTriangulation, context: &str) { dt.as_triangulation() .validate() .unwrap_or_else(|err| panic!("{context} should pass Levels 1-3: {err}")); + dt.as_triangulation() + .is_valid_embedding() + .unwrap_or_else(|err| panic!("{context} should pass Level 4 embedding: {err}")); dt.is_valid_delaunay() .unwrap_or_else(|err| panic!("{context} should pass Level 5: {err}")); } @@ -417,13 +523,18 @@ fn construction_error_is_degenerate(error: &DelaunayTriangulationConstructionErr DelaunayTriangulationConstructionError::Triangulation( DelaunayConstructionFailure::FinalDelaunayValidation { source, .. }, ) => validation_error_is_degenerate(source), + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::InsertionEmbeddingValidation { source }, + ) => matches!( + source, + TriangulationEmbeddingValidationError::DegenerateSimplex { .. } + ), DelaunayTriangulationConstructionError::Triangulation( DelaunayConstructionFailure::ShuffledRetryExhausted { source, .. }, ) => match source.as_ref() { DelaunayConstructionRetryFailure::Construction { source } => { construction_error_is_degenerate(source) } - DelaunayConstructionRetryFailure::DelaunayValidation { .. } => false, _ => false, }, _ => false, diff --git a/tests/delaunay_edge_cases.rs b/tests/delaunay_edge_cases.rs index e2373540..3d4cc9cc 100644 --- a/tests/delaunay_edge_cases.rs +++ b/tests/delaunay_edge_cases.rs @@ -20,6 +20,9 @@ use delaunay::prelude::generators::{ try_generate_random_triangulation_with_topology_guarantee, }; use delaunay::prelude::geometry::RobustKernel; +use delaunay::prelude::validation::{ + DelaunayTriangulationValidationError, TriangulationEmbeddingValidationError, +}; use rand::SeedableRng; use rand::seq::SliceRandom; use std::num::NonZeroUsize; @@ -71,6 +74,42 @@ fn is_geometric_degeneracy_or_retry_exhausted( } } +fn validation_error_is_degenerate_simplex(error: &DelaunayTriangulationValidationError) -> bool { + matches!( + error, + DelaunayTriangulationValidationError::Embedding(source) + if matches!( + source.as_ref(), + TriangulationEmbeddingValidationError::DegenerateSimplex { .. } + ) + ) +} + +fn construction_error_is_degenerate_simplex( + error: &DelaunayTriangulationConstructionError, +) -> bool { + match error { + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::FinalDelaunayValidation { source, .. }, + ) => validation_error_is_degenerate_simplex(source), + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::InsertionEmbeddingValidation { source }, + ) => matches!( + source, + TriangulationEmbeddingValidationError::DegenerateSimplex { .. } + ), + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::ShuffledRetryExhausted { source, .. }, + ) => match source.as_ref() { + DelaunayConstructionRetryFailure::Construction { source } => { + construction_error_is_degenerate_simplex(source) + } + _ => false, + }, + _ => false, + } +} + macro_rules! test_debug_info { ($($arg:tt)*) => {{ #[cfg(feature = "diagnostics")] @@ -778,7 +817,7 @@ fn test_cube_vertices_3d() { .expect_err("exact cube corners should fail before storing a zero-volume simplex"); assert!( - format!("{err:?}").contains("DegenerateSimplex"), + construction_error_is_degenerate_simplex(&err), "cube-corner failure should preserve the embedding degeneracy source: {err:?}" ); } diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index a6d4bfe7..99d65161 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -30,7 +30,7 @@ #![cfg_attr(not(feature = "slow-tests"), allow(dead_code))] //! //! Each should insert all vertices with zero skips, run final repair, and pass -//! `validation_report` for Levels 1–4. Use local harness output for exact +//! `validation_report` for Levels 1–5. Use local harness output for exact //! timing. //! //! Override defaults via environment variables: @@ -612,6 +612,42 @@ fn debug_mode_from_env() -> DebugMode { panic!("invalid DELAUNAY_LARGE_DEBUG_DEBUG_MODE={raw:?} (expected 'cadenced' or 'strict')"); } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ValidationScope { + /// Cumulative Levels 1–5, including the Level 4 embedding overlap scan. + Full, + /// Construction correctness only: Levels 1–3 (structure + topology) plus + /// Level 5 (Delaunay property), skipping the expensive Level 4 embedding + /// overlap scan. Used by the `perf-large-scale-smoke` wall-clock guard. + Construction, +} + +impl ValidationScope { + const fn name(self) -> &'static str { + match self { + Self::Full => "full", + Self::Construction => "construction", + } + } +} + +fn validation_scope_from_env() -> ValidationScope { + let Ok(raw) = env::var("DELAUNAY_LARGE_DEBUG_VALIDATION") else { + return ValidationScope::Full; + }; + + let raw = raw.trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("full") { + return ValidationScope::Full; + } + + if raw.eq_ignore_ascii_case("construction") { + return ValidationScope::Construction; + } + + panic!("invalid DELAUNAY_LARGE_DEBUG_VALIDATION={raw:?} (expected 'full' or 'construction')"); +} + fn seed_for_case(base_seed: u64, n_points: usize) -> u64 { const SEED_SALT: u64 = 0x9E37_79B9_7F4A_7C15; base_seed @@ -1208,6 +1244,7 @@ where } }); let validation_cadence = ValidationCadence::from_optional_every(validate_every); + let validation_scope = validation_scope_from_env(); println!("============================================="); println!("Large-scale triangulation debug: {dimension_name}"); @@ -1243,6 +1280,7 @@ where ConstructionMode::Incremental => println!(" progress_every:{progress_every}"), } println!(" validation_cadence: {validation_cadence:?}"); + println!(" validation_scope: {}", validation_scope.name()); println!(" allow_skips: {allow_skips}"); println!(" max_skip_pct: {max_skip_pct}"); println!(" skip_final_repair: {skip_final_repair}"); @@ -1512,19 +1550,53 @@ where } println!(); - println!("Running validation_report (Levels 1–5)..."); - let t_validate = Instant::now(); - let validation_result = dt.validation_report(); - println!("validation_report wall time: {:?}", t_validate.elapsed()); - match validation_result { - Ok(()) => println!("validation_report: OK"), - Err(report) => { - print_validation_report(&report); - let outcome = classify_validation_report(&report); - print_abort_summary::(&outcome, seed, n_points, "final validation"); - return outcome; + match validation_scope { + ValidationScope::Full => { + println!("Running validation_report (Levels 1–5)..."); + let t_validate = Instant::now(); + let validation_result = dt.validation_report(); + println!("validation_report wall time: {:?}", t_validate.elapsed()); + if let Err(report) = validation_result { + print_validation_report(&report); + let outcome = classify_validation_report(&report); + print_abort_summary::(&outcome, seed, n_points, "final validation"); + return outcome; + } + } + ValidationScope::Construction => { + // Levels 1–3 (structure + topology) plus the fast O(simplices) + // flip-based Level 5 Delaunay check. This skips the expensive Level 4 + // embedding overlap scan and the full report's all-violations Delaunay + // scan, keeping the wall-clock guard focused on construction + // correctness. Level 4 is exercised at scale by `just test-slow` + // (full scope); see issue #482. + println!("Running validation (Levels 1–3 + fast Level 5; embedding skipped)..."); + let t_validate = Instant::now(); + let topology_result = dt.as_triangulation().validation_report(); + let delaunay_result = if topology_result.is_ok() { + dt.is_valid_delaunay() + } else { + Ok(()) + }; + println!("validation wall time: {:?}", t_validate.elapsed()); + if let Err(report) = topology_result { + print_validation_report(&report); + let outcome = classify_validation_report(&report); + print_abort_summary::(&outcome, seed, n_points, "final validation"); + return outcome; + } + if let Err(error) = delaunay_result { + println!("Delaunay property (Level 5) validation failed: {error}"); + let outcome = DebugOutcome::ValidationFailure { + kind: InvariantKind::DelaunayProperty, + details: format!("{error}"), + }; + print_abort_summary::(&outcome, seed, n_points, "final validation"); + return outcome; + } } } + println!("validation_report: OK"); // If repair failed but validation passed, surface the repair failure as the outcome. // This ensures operators see repair non-convergence even when the triangulation diff --git a/tests/pachner_roundtrip.rs b/tests/pachner_roundtrip.rs index 08bec492..d69da68c 100644 --- a/tests/pachner_roundtrip.rs +++ b/tests/pachner_roundtrip.rs @@ -11,8 +11,10 @@ use delaunay::prelude::construction::{ use delaunay::prelude::geometry::RobustKernel; use delaunay::prelude::pachner::{ BistellarFlipKind, EdgeKey, EdgeKeyError, FacetHandle, FlipDirection, FlipError, PachnerMove, - PachnerMoveResult, PachnerMoves, RidgeHandle, SimplexKey, TriangleHandle, VertexKey, + PachnerMoveResult, PachnerMoves, SimplexKey, VertexKey, }; +#[cfg(feature = "slow-tests")] +use delaunay::prelude::pachner::{RidgeHandle, TriangleHandle}; use uuid::Uuid; type Dt4 = DelaunayTriangulation, (), (), 4>; @@ -44,7 +46,9 @@ struct TopologySnapshot { fn topology_and_delaunay_valid( dt: &DelaunayTriangulation, (), (), D>, ) -> bool { - dt.as_triangulation().validate().is_ok() && dt.is_valid_delaunay().is_ok() + dt.as_triangulation().validate().is_ok() + && dt.as_triangulation().is_valid_embedding().is_ok() + && dt.is_valid_delaunay().is_ok() } fn assert_topology_and_delaunay_valid( @@ -54,11 +58,15 @@ fn assert_topology_and_delaunay_valid( dt.as_triangulation() .validate() .unwrap_or_else(|err| panic!("{context} should pass Levels 1-3: {err}")); + dt.as_triangulation() + .is_valid_embedding() + .unwrap_or_else(|err| panic!("{context} should pass Level 4 embedding: {err}")); dt.is_valid_delaunay() .unwrap_or_else(|err| panic!("{context} should pass Level 5: {err}")); } #[test] +#[cfg(feature = "slow-tests")] fn public_pachner_roundtrips_preserve_stable_4d_topology() { let base = build_stable_dt_4d(); assert_topology_and_delaunay_valid(&base, "stable 4D fixture"); @@ -83,14 +91,42 @@ fn public_pachner_roundtrips_preserve_stable_4d_topology() { } #[test] -fn stale_pachner_requests_fail_without_mutating_topology() { +fn stale_k1_insert_request_fails_without_mutating_topology() { + let base = build_stable_dt_4d(); + assert_stale_k1_insert_preserves_topology(base); +} + +#[test] +fn stale_k1_remove_request_fails_without_mutating_topology() { + let base = build_stable_dt_4d(); + assert_stale_k1_remove_preserves_topology(base); +} + +#[test] +#[cfg(feature = "slow-tests")] +fn stale_k2_request_fails_without_mutating_topology() { + let base = build_stable_dt_4d(); + assert_stale_k2_preserves_topology(base); +} + +#[test] +#[cfg(feature = "slow-tests")] +fn stale_k2_inverse_request_fails_without_mutating_topology() { + let base = build_stable_dt_4d(); + assert_stale_k2_inverse_preserves_topology(base); +} + +#[test] +#[cfg(feature = "slow-tests")] +fn stale_k3_request_fails_without_mutating_topology() { let base = build_stable_dt_4d(); + assert_stale_k3_preserves_topology(base); +} - assert_stale_k1_insert_preserves_topology(base.clone()); - assert_stale_k1_remove_preserves_topology(base.clone()); - assert_stale_k2_preserves_topology(base.clone()); - assert_stale_k2_inverse_preserves_topology(base.clone()); - assert_stale_k3_preserves_topology(base.clone()); +#[test] +#[cfg(feature = "slow-tests")] +fn stale_k3_inverse_request_fails_without_mutating_topology() { + let base = build_stable_dt_4d(); assert_stale_k3_inverse_preserves_topology(base); } @@ -408,6 +444,7 @@ fn assert_stale_k1_remove_preserves_topology(mut dt: Dt4) { } /// Makes a k=2 facet proposal stale, then proves retrying it is failure-atomic. +#[cfg(feature = "slow-tests")] fn assert_stale_k2_preserves_topology(mut dt: Dt4) { let facet = flippable_k2_facet(&dt); let flipped = dt @@ -427,6 +464,7 @@ fn assert_stale_k2_preserves_topology(mut dt: Dt4) { } /// Makes an inverse k=2 edge proposal stale, then proves retrying it is failure-atomic. +#[cfg(feature = "slow-tests")] fn assert_stale_k2_inverse_preserves_topology(mut dt: Dt4) { let facet = flippable_k2_facet(&dt); let info = dt @@ -451,6 +489,7 @@ fn assert_stale_k2_inverse_preserves_topology(mut dt: Dt4) { } /// Makes a k=3 ridge proposal stale, then proves retrying it is failure-atomic. +#[cfg(feature = "slow-tests")] fn assert_stale_k3_preserves_topology(mut dt: Dt4) { let ridge = flippable_k3_ridge(&dt); let flipped = dt @@ -470,6 +509,7 @@ fn assert_stale_k3_preserves_topology(mut dt: Dt4) { } /// Makes an inverse k=3 triangle proposal stale, then proves retrying it is failure-atomic. +#[cfg(feature = "slow-tests")] fn assert_stale_k3_inverse_preserves_topology(mut dt: Dt4) { let ridge = flippable_k3_ridge(&dt); let info = dt @@ -558,6 +598,7 @@ fn simplex_centroid(dt: &Dt4, simplex_key: SimplexKey) -> [f64; 4] { } /// Applies a k=1 insert/remove pair and checks the reported move metadata. +#[cfg(feature = "slow-tests")] fn roundtrip_k1(dt: &mut Dt4) { let simplex_key = first_simplex(dt); let new_vertex: Vertex<(), 4> = vertex!(simplex_centroid(dt, simplex_key)).unwrap(); @@ -587,6 +628,7 @@ fn roundtrip_k1(dt: &mut Dt4) { } /// Searches the fixture for a k=2 facet that also supports the public inverse API. +#[cfg(feature = "slow-tests")] fn flippable_k2_facet(dt: &Dt4) -> FacetHandle { for (simplex_key, simplex) in dt.simplices() { let Some(neighbors) = simplex.neighbors() else { @@ -620,6 +662,7 @@ fn flippable_k2_facet(dt: &Dt4) -> FacetHandle { } /// Applies a k=2 forward/inverse pair and checks both move reports. +#[cfg(feature = "slow-tests")] fn roundtrip_k2(dt: &mut Dt4, facet: FacetHandle) { let info: PachnerMoveResult<4> = dt .attempt_pachner(PachnerMove::K2 { facet }) @@ -637,6 +680,7 @@ fn roundtrip_k2(dt: &mut Dt4, facet: FacetHandle) { } /// Parses the inserted face of a k=2 move into the edge expected by the inverse API. +#[cfg(feature = "slow-tests")] fn inserted_edge(dt: &Dt4, vertices: &[VertexKey]) -> EdgeKey { let [a, b] = vertices else { panic!( @@ -648,6 +692,7 @@ fn inserted_edge(dt: &Dt4, vertices: &[VertexKey]) -> EdgeKey { } /// Searches the fixture for a k=3 ridge that also supports the public inverse API. +#[cfg(feature = "slow-tests")] fn flippable_k3_ridge(dt: &Dt4) -> RidgeHandle { for (simplex_key, simplex) in dt.simplices() { for i in 0..simplex.number_of_vertices() { @@ -678,6 +723,7 @@ fn flippable_k3_ridge(dt: &Dt4) -> RidgeHandle { } /// Applies a k=3 forward/inverse pair and checks both move reports. +#[cfg(feature = "slow-tests")] fn roundtrip_k3(dt: &mut Dt4, ridge: RidgeHandle) { let info: PachnerMoveResult<4> = dt .attempt_pachner(PachnerMove::K3 { ridge }) @@ -696,6 +742,7 @@ fn roundtrip_k3(dt: &mut Dt4, ridge: RidgeHandle) { } /// Parses the inserted face of a k=3 move into the triangle expected by the inverse API. +#[cfg(feature = "slow-tests")] fn inserted_triangle(vertices: &[VertexKey]) -> TriangleHandle { let [a, b, c] = vertices else { panic!( diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 0534ad91..3100ad36 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -10,15 +10,32 @@ reason = "tests preserve typed construction, repair, and delaunayize errors" )] -use std::{assert_matches, mem::size_of, num::NonZeroUsize}; +use std::{assert_matches, error::Error, mem::size_of, num::NonZeroUsize}; -use approx::assert_relative_eq; +use approx::{abs_diff_eq, assert_relative_eq}; use slotmap::KeyData; +use delaunay::builder::DelaunayTriangulationBuilder as BuilderModuleBuilder; +use delaunay::construction::{ + ConstructionOptions as ConstructionModuleOptions, + InsertionOrderStrategy as ConstructionModuleInsertionOrderStrategy, +}; +use delaunay::delaunayize::{ + DelaunayizeConfig as DelaunayizeModuleConfig, DelaunayizeError as DelaunayizeModuleError, + delaunayize_by_flips as module_delaunayize_by_flips, +}; use delaunay::flips::{ - BistellarFlips, FlipOrientationCheckStage as DirectFlipOrientationCheckStage, + BistellarFlips, DelaunayRepairError as DirectDelaunayRepairError, + FlipFailureKind as DirectFlipFailureKind, + FlipOrientationCheckStage as DirectFlipOrientationCheckStage, +}; +use delaunay::geometry::{ + CoordinateConversionError as GeometryModuleCoordinateConversionError, + CoordinateRange as GeometryCoordinateRange, + LabeledSimplexEmbedding as GeometryModuleLabeledSimplexEmbedding, + validate_simplex_embeddings_intersect_only_in_shared_faces as geometry_module_validate_simplex_embeddings_intersect_only_in_shared_faces, }; -use delaunay::geometry::CoordinateRange as GeometryCoordinateRange; +use delaunay::pachner::PachnerMoves as DirectPachnerMoves; use delaunay::prelude::DelaunayValidationError; use delaunay::prelude::algorithms::LocateResult; #[cfg(feature = "diagnostics")] @@ -82,14 +99,17 @@ use delaunay::prelude::geometry::{ ArrayConversionFailureReason, CircumcenterError, CircumcenterFailureReason, CoordinateConversionError, CoordinateConversionValue, CoordinateValidationError, CoordinateValues, DegenerateGeometry, DegenerateMeasure, DegenerateSimplexReason, - FiniteCoordinateValue, InvalidCoordinateValue, LaError, MatrixError, Point, - QualitySimplexVerticesError, SurfaceMeasureError, ValueConversionError, - ValueConversionFailureReason, + FiniteCoordinateValue, InvalidCoordinateValue, LaError, LabeledSimplexEmbedding, + LabeledSimplexEmbeddingError, MatrixError, PeriodicSimplexSpan, PeriodicSimplexSpanError, + Point, QualitySimplexVerticesError, SimplexEmbeddingBuffer, SimplexIntersectionFailure, + SimplexIntersectionWitness, SurfaceMeasureError, ValueConversionError, + ValueConversionFailureReason, axis_aligned_bounding_boxes_overlap, coordinate_range_for_axis, + try_periodic_simplex_span, validate_simplex_embeddings_intersect_only_in_shared_faces, }; use delaunay::prelude::insertion::{ InitialSimplexConstructionError, InitialSimplexUnexpectedInsertionStage, InsertionError, - InsertionTopologyValidationContext, NeighborRebuildError, Tds as InsertionTds, - TdsMutationError, repair_neighbor_pointers_local, + InsertionErrorKind as FocusedInsertionErrorKind, InsertionTopologyValidationContext, + NeighborRebuildError, Tds as InsertionTds, TdsMutationError, repair_neighbor_pointers_local, }; use delaunay::prelude::ordering::{ HilbertBitDepth, HilbertError, HilbertQuantizedBatch, MAX_HILBERT_BITS, hilbert_index_in_range, @@ -173,19 +193,24 @@ use delaunay::prelude::validation::{ }; use delaunay::prelude::{ CoordinateRange as RootCoordinateRange, DelaunayError as RootDelaunayError, - DelaunayResult as RootDelaunayResult, DelaunayViolationDetail as RootDelaunayViolationDetail, + DelaunayResult as RootDelaunayResult, DelaunayTriangulation as RootDelaunayTriangulation, + DelaunayTriangulationBuilder as RootDelaunayTriangulationBuilder, + DelaunayViolationDetail as RootDelaunayViolationDetail, DelaunayViolationReport as RootDelaunayViolationReport, EdgeIndex as RootEdgeIndex, - FlipFailureKind as RootFlipFailureKind, + FacetIncidenceView as RootFacetIncidenceView, FlipFailureKind as RootFlipFailureKind, FlipOrientationCheckStage as RootFlipOrientationCheckStage, GlobalTopology as RootGlobalTopology, GlobalTopologyModelError as RootGlobalTopologyModelError, - IncidenceView as RootIncidenceView, PeriodicDomainPeriodError as RootPeriodicDomainPeriodError, - SecureHashMap, SecureHashSet, SimplexNeighborIndex as RootSimplexNeighborIndex, - TopologyError as RootTopologyError, TopologyKind as RootTopologyKind, + IncidenceView as RootIncidenceView, + InitialSimplexUnexpectedInsertionStage as RootInitialSimplexUnexpectedInsertionStage, + PeriodicDomainPeriodError as RootPeriodicDomainPeriodError, SecureHashMap, SecureHashSet, + SimplexNeighborIndex as RootSimplexNeighborIndex, TopologyError as RootTopologyError, + TopologyGuarantee as RootTopologyGuarantee, TopologyKind as RootTopologyKind, ToroidalConstructionMode as RootToroidalConstructionMode, ToroidalDomain as RootToroidalDomain, ToroidalDomainError as RootToroidalDomainError, TriangulationAdjacency as RootTriangulationAdjacency, TriangulationValidationReport as RootTriangulationValidationReport, ValidationConfigurationError as RootValidationConfigurationError, + ValidationPolicy as RootValidationPolicy, delaunay_violation_report as root_delaunay_violation_report, vertex as root_vertex, }; use delaunay::query::{ @@ -196,11 +221,23 @@ use delaunay::query::{ SimplexNeighborIndex as QueryFacadeSimplexNeighborIndex, TriangulationAdjacency as QueryFacadeTriangulationAdjacency, }; +use delaunay::repair::{ + DelaunayCheckPolicy as RepairModuleDelaunayCheckPolicy, + DelaunayRepairPolicy as RepairModuleDelaunayRepairPolicy, +}; use delaunay::topology::{ BoundaryFacetClassification as TopologyBoundaryFacetClassification, classify_boundary_facet as topology_classify_boundary_facet, }; +use delaunay::validation::{ + DelaunayTriangulationValidationError as ValidationModuleDelaunayTriangulationValidationError, + ValidationCadence as ValidationModuleCadence, +}; use delaunay::{ + ConstructionOptions as RootConstructionOptions, + DelaunayConstructionRetryFailure as RootConstructionRetryFailure, + DelaunayTriangulationConstructionError as RootDelaunayTriangulationConstructionError, + DelaunayTriangulationValidationError as RootDelaunayTriangulationValidationError, MESH_EXPORT_SCHEMA as RootMeshExportSchema, MeshExport as RootMeshExport, MeshExportError as RootMeshExportError, MeshExportValidationError as RootMeshExportValidationError, @@ -212,15 +249,15 @@ use delaunay::{ #[derive(Debug, thiserror::Error)] enum RootApiExportTestError { #[error(transparent)] - Construction(#[from] delaunay::DelaunayTriangulationConstructionError), + Construction(#[from] RootDelaunayTriangulationConstructionError), #[error(transparent)] - CoordinateConversion(#[from] delaunay::geometry::CoordinateConversionError), + CoordinateConversion(#[from] GeometryModuleCoordinateConversionError), #[error(transparent)] - Validation(#[from] delaunay::DelaunayTriangulationValidationError), + Validation(#[from] RootDelaunayTriangulationValidationError), #[error(transparent)] - DelaunayRepair(#[from] delaunay::flips::DelaunayRepairError), + DelaunayRepair(#[from] DirectDelaunayRepairError), #[error(transparent)] - Delaunayize(#[from] delaunay::delaunayize::DelaunayizeError), + Delaunayize(#[from] DelaunayizeModuleError), #[error(transparent)] MeshExport(#[from] RootMeshExportError), #[error(transparent)] @@ -279,8 +316,7 @@ enum PreludeExportTestError { const fn assert_bistellar_flips(_: &impl BistellarFlips<3, VertexData = ()>) {} /// Proves the root flips module exports the same public trait bound. -const fn assert_root_bistellar_flips(_: &impl delaunay::flips::BistellarFlips<3, VertexData = ()>) { -} +const fn assert_root_bistellar_flips(_: &impl BistellarFlips<3, VertexData = ()>) {} struct NonKernelMarker; @@ -291,7 +327,7 @@ const fn assert_bistellar_flips_without_kernel) {} /// Proves the root Pachner module exports the same unified workflow trait. -const fn assert_root_pachner_moves(_: &impl delaunay::pachner::PachnerMoves<3, VertexData = ()>) {} +const fn assert_root_pachner_moves(_: &impl DirectPachnerMoves<3, VertexData = ()>) {} /// Proves unified Pachner dispatch inherits the kernel-free explicit flip contract. const fn assert_pachner_moves_without_kernel>() {} @@ -341,6 +377,8 @@ fn assert_pachner_prelude_exports( const fn assert_send_sync_unpin() {} +const fn assert_error() {} + const fn assert_query_facet_incidence_trait_export(_: &T) where T: QueryFacetIncidenceAnalysis<(), (), 3> + ?Sized, @@ -454,7 +492,10 @@ fn construction_prelude_exports_common_delaunay_error_aliases() { DelaunayError::from(tds_mutation.clone()), DelaunayError::TdsMutation { source: err } if err.as_ref() == &tds_mutation ); +} +#[test] +fn construction_prelude_exports_validation_and_result_aliases() { let configuration = FocusedValidationConfigurationError::IncompatibleTopologyAndValidationPolicy { topology_guarantee: TopologyGuarantee::PLManifold, @@ -490,9 +531,9 @@ fn construction_prelude_exports_common_delaunay_error_aliases() { source: Box::new(ConstructionDelaunayVerificationError::from( DelaunayValidationError::DelaunayViolation { simplex_key, - simplex_vertices: Default::default(), + simplex_vertices: Box::default(), offending_vertex: None, - neighbor_simplices: Default::default(), + neighbor_simplices: Box::default(), }, )), }; @@ -695,22 +736,6 @@ fn construction_prelude_covers_retry_exhaustion_source() { #[test] fn root_exports_cover_flattened_public_api() -> Result<(), RootApiExportTestError> { - use delaunay::builder::DelaunayTriangulationBuilder as BuilderModuleBuilder; - use delaunay::construction::{ - ConstructionOptions as ConstructionModuleOptions, InsertionOrderStrategy, - }; - use delaunay::delaunayize::{ - DelaunayizeConfig as DelaunayizeModuleConfig, delaunayize_by_flips, - }; - use delaunay::repair::{DelaunayCheckPolicy, DelaunayRepairPolicy}; - use delaunay::validation::{DelaunayTriangulationValidationError, ValidationCadence}; - use delaunay::{ - ConstructionOptions, DelaunayConstructionRetryFailure as RootConstructionRetryFailure, - DelaunayTriangulation, DelaunayTriangulationBuilder, - InitialSimplexUnexpectedInsertionStage as RootInitialSimplexUnexpectedInsertionStage, - TopologyGuarantee, ValidationPolicy, - }; - let vertices = vec![ vertex![0.0, 0.0, 0.0]?, vertex![1.0, 0.0, 0.0]?, @@ -718,14 +743,14 @@ fn root_exports_cover_flattened_public_api() -> Result<(), RootApiExportTestErro vertex![0.0, 0.0, 1.0]?, ]; - let options: ConstructionOptions = - ConstructionModuleOptions::default().with_insertion_order(InsertionOrderStrategy::Input); + let options: RootConstructionOptions = ConstructionModuleOptions::default() + .with_insertion_order(ConstructionModuleInsertionOrderStrategy::Input); let builder: BuilderModuleBuilder<'_, (), 3> = - DelaunayTriangulationBuilder::new(&vertices).construction_options(options); - let mut dt: DelaunayTriangulation<_, (), (), 3> = builder.build::<()>()?; + RootDelaunayTriangulationBuilder::new(&vertices).construction_options(options); + let mut dt: RootDelaunayTriangulation<_, (), (), 3> = builder.build::<()>()?; - assert_eq!(dt.topology_guarantee(), TopologyGuarantee::PLManifold); - assert_eq!(dt.validation_policy(), ValidationPolicy::ExplicitOnly); + assert_eq!(dt.topology_guarantee(), RootTopologyGuarantee::PLManifold); + assert_eq!(dt.validation_policy(), RootValidationPolicy::ExplicitOnly); assert_matches!( RootInitialSimplexUnexpectedInsertionStage::NonManifoldTopology { facet_hash: 0x00C0_FFEE, @@ -747,16 +772,17 @@ fn root_exports_cover_flattened_public_api() -> Result<(), RootApiExportTestErro RootConstructionRetryFailure::Construction { .. } ); assert_matches!( - ValidationCadence::from_optional_every(Some(2)), - ValidationCadence::EveryN(every) if every.get() == 2 + ValidationModuleCadence::from_optional_every(Some(2)), + ValidationModuleCadence::EveryN(every) if every.get() == 2 ); assert_eq!( - DelaunayRepairPolicy::default(), - DelaunayRepairPolicy::EveryInsertion + RepairModuleDelaunayRepairPolicy::default(), + RepairModuleDelaunayRepairPolicy::EveryInsertion ); - assert!(!DelaunayCheckPolicy::default().should_check(1)); + assert!(!RepairModuleDelaunayCheckPolicy::default().should_check(1)); - let validation_result: Result<(), DelaunayTriangulationValidationError> = dt.validate(); + let validation_result: Result<(), ValidationModuleDelaunayTriangulationValidationError> = + dt.validate(); validation_result?; let root_mesh_export: RootMeshExport<3> = dt.to_mesh_export()?; assert_eq!(root_mesh_export.metadata.schema, RootMeshExportSchema); @@ -779,7 +805,7 @@ fn root_exports_cover_flattened_public_api() -> Result<(), RootApiExportTestErro assert_bistellar_flips(&dt); assert_root_bistellar_flips(&dt); - let outcome = delaunayize_by_flips(&mut dt, DelaunayizeModuleConfig::default())?; + let outcome = module_delaunayize_by_flips(&mut dt, DelaunayizeModuleConfig::default())?; assert!(!outcome.used_fallback_rebuild); assert!(outcome.topology_repair.succeeded); Ok(()) @@ -803,8 +829,8 @@ fn flip_exports_cover_orientation_check_stage() { RootFlipOrientationCheckStage::AfterTrialMutation ); assert_matches!( - delaunay::flips::FlipOrientationCheckStage::BeforeMutation, - delaunay::flips::FlipOrientationCheckStage::BeforeMutation + DirectFlipOrientationCheckStage::BeforeMutation, + DirectFlipOrientationCheckStage::BeforeMutation ); } @@ -856,7 +882,7 @@ fn assert_facet_incidence_exports( let incidence = facet_index .get(&facet_view.key()) .expect("fresh index should contain the facet view key"); - let root_incidence: delaunay::prelude::FacetIncidenceView<'_, '_, (), (), 3> = incidence; + let root_incidence: RootFacetIncidenceView<'_, '_, (), (), 3> = incidence; let query_incidence: QueryFacetIncidenceView<'_, '_, (), (), 3> = incidence; let tds_incidence: TdsFacetIncidenceView<'_, '_, (), (), 3> = incidence; @@ -1232,6 +1258,82 @@ fn geometry_prelude_covers_typed_error_variants() { ); } +#[test] +fn geometry_prelude_covers_simplex_embedding_validation() { + let first = + LabeledSimplexEmbedding::try_new([0_usize, 1, 2], [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]) + .expect("valid labeled simplex embedding"); + let second = + LabeledSimplexEmbedding::try_new([0_usize, 1, 3], [[0.0, 0.0], [1.0, 0.0], [0.25, 0.25]]) + .expect("valid labeled simplex embedding"); + + assert_matches!( + coordinate_range_for_axis(&first, 0), + Some((min, max)) + if abs_diff_eq!(min, 0.0, epsilon = f64::EPSILON) + && abs_diff_eq!(max, 1.0, epsilon = f64::EPSILON) + ); + assert!(axis_aligned_bounding_boxes_overlap(&first, &second)); + assert_matches!( + validate_simplex_embeddings_intersect_only_in_shared_faces(&first, &second), + Err(SimplexIntersectionFailure::IntersectionOutsideSharedFace { + witness: SimplexIntersectionWitness { + shared, + first_only_witness, + second_only_witness, + }, + .. + }) if shared.as_slice() == [0, 1] + && first_only_witness.as_slice() == [2] + && second_only_witness.as_slice() == [3] + ); + let module_root_simplex = GeometryModuleLabeledSimplexEmbedding::try_new( + [10_usize, 11, 12], + [[2.0, 2.0], [3.0, 2.0], [2.0, 3.0]], + ) + .expect("geometry module root re-exports labeled simplex embedding"); + geometry_module_validate_simplex_embeddings_intersect_only_in_shared_faces( + &first, + &module_root_simplex, + ) + .expect("geometry module root re-exports simplex-intersection validation"); + + let spanning_simplex = + LabeledSimplexEmbedding::try_new([4_usize, 5, 6], [[0.0, 0.0], [1.0, 0.0], [0.0, 0.25]]) + .expect("valid labeled simplex embedding"); + assert_matches!( + try_periodic_simplex_span(&spanning_simplex, &[1.0, 2.0]), + Ok(Some(PeriodicSimplexSpan { axis: 0, span, period })) + if abs_diff_eq!(span, 1.0, epsilon = f64::EPSILON) + && abs_diff_eq!(period, 1.0, epsilon = f64::EPSILON) + ); + + let duplicate = LabeledSimplexEmbedding::<_, 2>::try_new( + [7_usize, 7, 8], + [[0.0, 0.0], [0.5, 0.0], [0.0, 0.5]], + ); + assert_matches!( + duplicate, + Err(LabeledSimplexEmbeddingError::DuplicateLabel { + first_index: 0, + duplicate_index: 1 + }) + ); + assert_matches!( + try_periodic_simplex_span(&first, &[0.0, 1.0]), + Err(PeriodicSimplexSpanError::NonPositivePeriod { + axis: 0, + period: 0.0 + }) + ); + + let _labels: SimplexEmbeddingBuffer = [0, 1].into_iter().collect(); + assert_send_sync_unpin::(); + assert_send_sync_unpin::(); + assert_send_sync_unpin::>(); + assert_error::>(); +} + #[test] fn generator_prelude_covers_validated_coordinate_ranges() -> Result<(), PreludeExportTestError> { let generated_range = CoordinateRange::try_new(0.0_f64, 1.0)?; @@ -1244,9 +1346,11 @@ fn generator_prelude_covers_validated_coordinate_ranges() -> Result<(), PreludeE let _ = builder; let root_range = RootCoordinateRange::try_new(-1.0_f64, 1.0)?; - assert_eq!(root_range.bounds(), (-1.0, 1.0)); + assert_relative_eq!(root_range.bounds().0, -1.0, epsilon = f64::EPSILON); + assert_relative_eq!(root_range.bounds().1, 1.0, epsilon = f64::EPSILON); let geometry_range = GeometryCoordinateRange::try_new(-2.0_f64, -1.0)?; - assert_eq!(geometry_range.bounds(), (-2.0, -1.0)); + assert_relative_eq!(geometry_range.bounds().0, -2.0, epsilon = f64::EPSILON); + assert_relative_eq!(geometry_range.bounds().1, -1.0, epsilon = f64::EPSILON); assert_eq!(range_points.len(), 3); assert_eq!(grid_points.len(), 1); assert_matches!( @@ -1416,9 +1520,9 @@ fn validation_prelude_covers_delaunay_property_diagnostics() -> Result<(), Prelu let simplex_key = SimplexKey::from(KeyData::from_ffi(11)); let focused_error = FocusedDelaunayValidationError::DelaunayViolation { simplex_key, - simplex_vertices: Default::default(), + simplex_vertices: Box::default(), offending_vertex: None, - neighbor_simplices: Default::default(), + neighbor_simplices: Box::default(), }; assert_matches!( focused_error, @@ -1812,16 +1916,16 @@ fn diagnostic_preludes_cover_repair_apis() -> Result<(), PreludeExportTestError> FlipFailureKind::DegenerateSimplex ); let dangling_vertex_incidence = FlipError::DanglingVertexIncidence { - vertex_key: VertexKey::from(slotmap::KeyData::from_ffi(1)), - simplex_key: SimplexKey::from(slotmap::KeyData::from_ffi(2)), + vertex_key: VertexKey::from(KeyData::from_ffi(1)), + simplex_key: SimplexKey::from(KeyData::from_ffi(2)), }; assert_eq!( RootFlipFailureKind::from(&dangling_vertex_incidence), RootFlipFailureKind::DanglingVertexIncidence ); assert_eq!( - delaunay::flips::FlipFailureKind::from(&dangling_vertex_incidence), - delaunay::flips::FlipFailureKind::DanglingVertexIncidence + DirectFlipFailureKind::from(&dangling_vertex_incidence), + DirectFlipFailureKind::DanglingVertexIncidence ); let orientation_reason = DelaunayRepairOrientationCanonicalizationFailure::AfterFlipRepair { source: Box::new(InsertionError::DuplicateCoordinates { @@ -1830,7 +1934,7 @@ fn diagnostic_preludes_cover_repair_apis() -> Result<(), PreludeExportTestError> }; assert!(orientation_reason.to_string().contains("after flip repair")); let orientation_kind = DelaunayRepairOrientationCanonicalizationFailureKind::AfterFlipRepair { - source_kind: delaunay::prelude::insertion::InsertionErrorKind::DuplicateCoordinates, + source_kind: FocusedInsertionErrorKind::DuplicateCoordinates, }; assert_matches!( orientation_kind, diff --git a/tests/proptest_delaunay_triangulation.rs b/tests/proptest_delaunay_triangulation.rs index 5b51824d..c700f373 100644 --- a/tests/proptest_delaunay_triangulation.rs +++ b/tests/proptest_delaunay_triangulation.rs @@ -1355,7 +1355,7 @@ macro_rules! gen_insertion_order_robustness_test { // point sets, which is expected and valid behavior // TODO: Once bistellar flips are implemented to ensure unique canonical triangulations, - // add explicit Level-4 checks here: + // add explicit Level-5 checks here: // prop_assert!(dt_a.is_valid_delaunay().is_ok(), "{}D: Triangulation A must satisfy Delaunay property", $dim); // prop_assert!(dt_b.is_valid_delaunay().is_ok(), "{}D: Triangulation B must satisfy Delaunay property", $dim); // Bistellar flips will produce canonical triangulations, making edge-set comparison more meaningful. @@ -2037,7 +2037,7 @@ macro_rules! gen_duplicate_cloud_test { // Structural/topological validity (Levels 1–3) for kept subset prop_assert_levels_1_to_3_valid!($dim, &dt, "triangulation (kept subset)"); - // Delaunay validity (Level 4) for kept subset + // Delaunay validity (Level 5) for kept subset let validate_start = std::time::Instant::now(); let delaunay = dt.is_valid_delaunay(); let validate_elapsed = validate_start.elapsed(); diff --git a/tests/proptest_flips.rs b/tests/proptest_flips.rs index 90059af5..54c292a8 100644 --- a/tests/proptest_flips.rs +++ b/tests/proptest_flips.rs @@ -173,6 +173,9 @@ fn assert_valid( triangulation .validate() .map_err(|err| TestCaseError::fail(format!("{context} validation failed: {err:?}")))?; + triangulation.is_valid_embedding().map_err(|err| { + TestCaseError::fail(format!("{context} embedding validation failed: {err:?}")) + })?; Ok(()) } diff --git a/tests/regressions.rs b/tests/regressions.rs index 7724e787..56752504 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -193,6 +193,9 @@ fn regression_empty_circumsphere_2d_minimal_case() { dt.repair_delaunay_with_flips().unwrap(); + dt.as_triangulation() + .validate_embedding() + .expect("2D triangulation should preserve lower-layer invariants after global flip repair"); assert!( dt.is_valid_delaunay().is_ok(), "2D triangulation should be a valid PL-manifold after global flip repair" diff --git a/tests/semgrep/src/project_rules/rust_style.rs b/tests/semgrep/src/project_rules/rust_style.rs index 4788be52..db55ba72 100644 --- a/tests/semgrep/src/project_rules/rust_style.rs +++ b/tests/semgrep/src/project_rules/rust_style.rs @@ -172,6 +172,11 @@ impl ValidationApiNamingFixture { Ok(()) } + // ok: delaunay.rust.validation-api-naming-standard + pub fn validate_embedding(&self) -> Result<(), ()> { + Ok(()) + } + // ok: delaunay.rust.validation-api-naming-standard pub fn validation_report(&self) -> Result<(), ()> { Ok(()) From 0e0b3a86668b13603628c3f74459f32d53f85f57 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 27 Jun 2026 23:35:51 -0700 Subject: [PATCH 3/3] fix(validation)!: preserve Level 4 embedding failures (#449) - Preserve full embedding-validation errors through flip neighbor wiring so callers retain simplex, pair, and offending-vertex witness context. - Run Level 4 embedding validation during full post-insertion validation even when the local simplex scope is empty. - Clarify topology versus embedding report coverage and guard trusted finite-coordinate construction in debug builds. - Tighten regression coverage for SoS degeneracy, stale Pachner handles, toroidal validation guardrails, and flip benchmark fixtures. BREAKING CHANGE: FlipNeighborWiringError::EmbeddingValidation now stores the full TriangulationEmbeddingValidationError as source instead of a TriangulationEmbeddingValidationErrorKind classification. --- docs/property_testing_summary.md | 1 + src/core/algorithms/flips.rs | 69 +++++++++++++++++++--- src/core/validation.rs | 30 ++++++++-- src/geometry/point.rs | 20 ++++++- tests/README.md | 1 + tests/benchmark_flip_fixtures.rs | 9 ++- tests/pachner_roundtrip.rs | 30 ++++++++-- tests/proptest_serialization.rs | 2 +- tests/proptest_sos.proptest-regressions | 7 +++ tests/proptest_sos.rs | 78 ++++++++++++++++++++----- tests/triangulation_builder.rs | 4 ++ 11 files changed, 214 insertions(+), 37 deletions(-) create mode 100644 tests/proptest_sos.proptest-regressions diff --git a/docs/property_testing_summary.md b/docs/property_testing_summary.md index bc50eda0..69315ccc 100644 --- a/docs/property_testing_summary.md +++ b/docs/property_testing_summary.md @@ -149,6 +149,7 @@ Proptest automatically records minimized failures in Current generated corpus: - `tests/proptest_delaunay_triangulation.proptest-regressions` +- `tests/proptest_sos.proptest-regressions` ## Writing New Properties diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index fe083bb7..d5212a08 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -39,7 +39,7 @@ use crate::core::collections::{ SimplexKeyBuffer, SmallBuffer, }; use crate::core::edge::{EdgeKey, EdgeKeyError}; -use crate::core::embedding::TriangulationEmbeddingValidationErrorKind; +use crate::core::embedding::TriangulationEmbeddingValidationError; use crate::core::facet::{AllFacetsIter, FacetError, FacetHandle, facet_key_from_vertices}; use crate::core::operations::TopologicalOperation; use crate::core::simplex::{NeighborSlot, Simplex, SimplexValidationError}; @@ -3456,10 +3456,11 @@ pub enum FlipNeighborWiringError { reason: FlipNeighborDelaunayValidationFailureKind, }, /// Embedding validation failed while preparing flip neighbor wiring. - #[error("embedding validation error reached flip neighbor wiring: {reason:?}")] + #[error("embedding validation error reached flip neighbor wiring: {source}")] EmbeddingValidation { - /// Structured embedding-validation reason. - reason: TriangulationEmbeddingValidationErrorKind, + /// Underlying embedding validation error, preserving simplex/pair witness context. + #[source] + source: TriangulationEmbeddingValidationError, }, /// Delaunay repair failed while preparing flip neighbor wiring. #[error("Delaunay repair error reached flip neighbor wiring: {reason}")] @@ -3544,9 +3545,9 @@ impl From for FlipNeighborWiringError { InsertionError::DelaunayValidationFailed { source } => Self::DelaunayValidation { reason: source.into(), }, - InsertionError::EmbeddingValidationFailed { source } => Self::EmbeddingValidation { - reason: TriangulationEmbeddingValidationErrorKind::from(&source), - }, + InsertionError::EmbeddingValidationFailed { source } => { + Self::EmbeddingValidation { source } + } InsertionError::DelaunayRepairFailed { source, context: _ } => Self::DelaunayRepair { reason: FlipNeighborRepairFailure::from(*source), }, @@ -4105,6 +4106,7 @@ impl From<&FlipError> for FlipFailureKind { FlipError::NeighborWiring { reason } => match reason.as_ref() { FlipNeighborWiringError::TopologyValidation { .. } | FlipNeighborWiringError::DelaunayValidation { .. } + | FlipNeighborWiringError::EmbeddingValidation { .. } | FlipNeighborWiringError::TopologyValidationFailed { .. } => { Self::WiringValidation } @@ -10433,7 +10435,8 @@ mod tests { DelaunayRepairFailureContext, repair_neighbor_pointers, }; use crate::core::algorithms::locate::LocateResult; - use crate::core::collections::Uuid; + use crate::core::collections::{SimplexVertexKeyBuffer, SimplexVertexUuidBuffer, Uuid}; + use crate::core::embedding::TriangulationEmbeddingSimplexDetail; use crate::core::validation::TopologyGuarantee; use crate::geometry::kernel::{AdaptiveKernel, FastKernel}; use crate::geometry::traits::coordinate::CoordinateConversionValue; @@ -15258,6 +15261,56 @@ mod tests { ); } + #[test] + fn flip_neighbor_wiring_preserves_embedding_validation_source() { + let simplex_key = SimplexKey::from(KeyData::from_ffi(9_101)); + let simplex_uuid = Uuid::from_u128(0x9101); + let vertices: SimplexVertexKeyBuffer = [ + VertexKey::from(KeyData::from_ffi(9_201)), + VertexKey::from(KeyData::from_ffi(9_202)), + VertexKey::from(KeyData::from_ffi(9_203)), + ] + .into_iter() + .collect(); + let vertex_uuids: SimplexVertexUuidBuffer = [ + Uuid::from_u128(0x9201), + Uuid::from_u128(0x9202), + Uuid::from_u128(0x9203), + ] + .into_iter() + .collect(); + + let embedding_source = TriangulationEmbeddingValidationError::DegenerateSimplex { + simplex_key, + simplex_uuid, + detail: Box::new(TriangulationEmbeddingSimplexDetail { + key: simplex_key, + uuid: simplex_uuid, + vertices, + vertex_uuids, + }), + dimension: 2, + }; + + let embedding_wiring = + FlipNeighborWiringError::from(InsertionError::EmbeddingValidationFailed { + source: embedding_source.clone(), + }); + let FlipNeighborWiringError::EmbeddingValidation { source } = &embedding_wiring else { + panic!("expected preserved embedding validation source, got {embedding_wiring:?}"); + }; + assert_eq!(source, &embedding_source); + let error_source = embedding_wiring + .source() + .and_then(|source| source.downcast_ref::()) + .expect("embedding validation should remain the typed error source"); + assert_eq!(error_source, &embedding_source); + assert_eq!( + FlipFailureKind::from(&FlipError::from(embedding_wiring)), + FlipFailureKind::WiringValidation + ); + } + #[test] fn test_flip_neighbor_repair_diagnostics_preserve_summary_fields() { let diagnostics = sample_repair_diagnostics(); diff --git a/src/core/validation.rs b/src/core/validation.rs index 434edfae..0ca54c42 100644 --- a/src/core/validation.rs +++ b/src/core/validation.rs @@ -1261,8 +1261,10 @@ where /// Generate a Level 3 topology report. /// /// This report checks topology-layer invariants only. It assumes the TDS - /// structure is already valid; use [`validation_report`](Self::validation_report) - /// for cumulative Levels 1-4 diagnostics. + /// structure is already valid. Use [`validation_report`](Self::validation_report) + /// for cumulative Levels 1-3 diagnostics, and + /// [`embedding_report`](Self::embedding_report) for Level 4 embedded-geometry + /// diagnostics. /// /// # Errors /// @@ -1797,9 +1799,8 @@ where InsertionValidationWork::FullValidation => { self.validate()?; match local_simplices { - Some([]) => Ok(()), + Some([]) | None => self.is_valid_embedding(), Some(simplices) => self.validate_embedding_for_simplices(simplices), - None => self.is_valid_embedding(), } .map_err(InvariantError::Embedding) } @@ -3883,6 +3884,27 @@ mod tests { ); } + #[test] + fn validate_after_insertion_full_validation_checks_empty_local_embedding_scope() { + let (tds, _) = build_topologically_valid_self_overlapping_tds_2d(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + tri.set_validation_policy(ValidationPolicy::Always); + + tri.is_valid_topology() + .expect("fixture should isolate a Level 4 embedding failure"); + let expected_embedding_error = tri + .is_valid_embedding() + .expect_err("fixture should fail whole-triangulation Level 4 validation"); + + let empty_scope = SimplexKeyBuffer::new(); + let err = tri + .validate_after_insertion_with_scope(SuspicionFlags::default(), Some(&empty_scope)) + .unwrap_err(); + + assert_eq!(err, InvariantError::Embedding(expected_embedding_error)); + } + #[test] fn validate_after_insertion_full_validation_checks_large_raw_embedding_scope() { let (tds, scope) = build_topologically_valid_self_overlapping_tds_2d(); diff --git a/src/geometry/point.rs b/src/geometry/point.rs index 49801804..66eae662 100644 --- a/src/geometry/point.rs +++ b/src/geometry/point.rs @@ -46,9 +46,20 @@ impl ValidatedCoordinates { Ok(Self { values }) } - /// Builds validated coordinates from values whose finiteness was already proved. + /// Builds validated coordinates from finite values while preserving point equality semantics. + /// + /// Callers must prove finiteness before calling this trusted constructor. Debug builds assert + /// that contract, then the constructor canonicalizes signed zero so hashing and ordering match + /// [`Point`] equality. #[inline] pub(in crate::geometry) fn from_prevalidated_finite_values(mut values: [f64; D]) -> Self { + #[cfg(debug_assertions)] + { + assert!( + values.iter().all(|coord| coord.is_finite()), + "from_prevalidated_finite_values requires finite coordinates" + ); + } for coord in &mut values { if *coord == 0.0 { *coord = 0.0; @@ -1681,6 +1692,13 @@ mod tests { assert_eq!(point.coords()[1].to_bits(), 1.0_f64.to_bits()); } + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "from_prevalidated_finite_values requires finite coordinates")] + fn prevalidated_finite_coordinates_reject_non_finite_in_debug_builds() { + let _ = ValidatedCoordinates::<2>::from_prevalidated_finite_values([f64::NAN, 1.0]); + } + #[test] fn point_hashmap_finite_values() { let mut map: HashMap, &str> = HashMap::new(); diff --git a/tests/README.md b/tests/README.md index bdbed120..7059dd97 100644 --- a/tests/README.md +++ b/tests/README.md @@ -295,6 +295,7 @@ Proptest automatically captures minimal failing test cases in `.proptest-regress **Current Proptest Regression Files:** - `proptest_delaunay_triangulation.proptest-regressions` +- `proptest_sos.proptest-regressions` These generated property-test corpora are separate from fixed-bug integration regressions, which belong in [`regressions.rs`](./regressions.rs). diff --git a/tests/benchmark_flip_fixtures.rs b/tests/benchmark_flip_fixtures.rs index 9cf7e5a7..e836a7fc 100644 --- a/tests/benchmark_flip_fixtures.rs +++ b/tests/benchmark_flip_fixtures.rs @@ -66,10 +66,15 @@ fn flip_fixtures_cover_2d_workflows() { ); } -/// Verifies the stable and adversarial 3D public flip fixture workflows. +/// Verifies the stable 3D public flip fixture workflows. #[test] -fn flip_fixtures_cover_3d_workflows() { +fn flip_fixtures_cover_stable_3d_workflows() { verify_3d_fixture(STABLE_POINTS_3D, CandidateFilter::Any); +} + +/// Verifies the adversarial 3D public flip fixture workflows. +#[test] +fn flip_fixtures_cover_adversarial_3d_workflows() { verify_3d_fixture( ADVERSARIAL_POINTS_3D, CandidateFilter::TouchesAdversarialFeature, diff --git a/tests/pachner_roundtrip.rs b/tests/pachner_roundtrip.rs index d69da68c..83d9d673 100644 --- a/tests/pachner_roundtrip.rs +++ b/tests/pachner_roundtrip.rs @@ -22,6 +22,15 @@ type Dt2 = DelaunayTriangulation, (), (), 2>; const FLIPPABLE_POINTS_2D: &[[f64; 2]] = &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; +const MINIMAL_POINTS_4D: &[[f64; 4]] = &[ + [0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], +]; + +#[cfg(feature = "slow-tests")] const STABLE_POINTS_4D: &[[f64; 4]] = &[ [0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0], @@ -92,13 +101,13 @@ fn public_pachner_roundtrips_preserve_stable_4d_topology() { #[test] fn stale_k1_insert_request_fails_without_mutating_topology() { - let base = build_stable_dt_4d(); + let base = build_minimal_dt_4d(); assert_stale_k1_insert_preserves_topology(base); } #[test] fn stale_k1_remove_request_fails_without_mutating_topology() { - let base = build_stable_dt_4d(); + let base = build_minimal_dt_4d(); assert_stale_k1_remove_preserves_topology(base); } @@ -132,7 +141,7 @@ fn stale_k3_inverse_request_fails_without_mutating_topology() { #[test] fn stale_pachner_error_propagates_through_delaunay_result() { - let mut dt = build_stable_dt_4d(); + let mut dt = build_minimal_dt_4d(); let stale_simplex = first_simplex(&dt); let vertex_coords = simplex_centroid(&dt, stale_simplex); let vertex: Vertex<(), 4> = @@ -246,8 +255,19 @@ fn try_stale_k1_insert( } /// Builds the deterministic 4D fixture used to find reversible public Pachner moves. +#[cfg(feature = "slow-tests")] fn build_stable_dt_4d() -> Dt4 { - let vertices = STABLE_POINTS_4D + build_dt_4d(STABLE_POINTS_4D, "stable") +} + +/// Builds the smallest 4D fixture needed by stale-handle atomicity checks. +fn build_minimal_dt_4d() -> Dt4 { + build_dt_4d(MINIMAL_POINTS_4D, "minimal") +} + +/// Builds a deterministic 4D fixture with input-order construction. +fn build_dt_4d(points: &[[f64; 4]], fixture_name: &str) -> Dt4 { + let vertices = points .iter() .map(|coords| vertex!(*coords).unwrap()) .collect::>(); @@ -260,7 +280,7 @@ fn build_stable_dt_4d() -> Dt4 { TopologyGuarantee::PLManifold, options, ) - .expect("stable 4D fixture should build") + .unwrap_or_else(|err| panic!("{fixture_name} 4D fixture should build: {err}")) } /// Builds a deterministic 2D fixture with at least one public k=2 move. diff --git a/tests/proptest_serialization.rs b/tests/proptest_serialization.rs index e76eae07..94582c73 100644 --- a/tests/proptest_serialization.rs +++ b/tests/proptest_serialization.rs @@ -245,6 +245,6 @@ macro_rules! test_serialization_properties { // Generate tests for dimensions 2-5 // Parameters: dimension, min_vertices, max_vertices test_serialization_properties!(2, 4, 10); -test_serialization_properties!(3, 5, 12, cases = 8); +test_serialization_properties!(3, 5, 9, cases = 4); test_serialization_properties!(4, 6, 14, #[cfg(feature = "slow-tests")]); test_serialization_properties!(5, 7, 16, #[cfg(feature = "slow-tests")]); diff --git a/tests/proptest_sos.proptest-regressions b/tests/proptest_sos.proptest-regressions new file mode 100644 index 00000000..1b5bcdb7 --- /dev/null +++ b/tests/proptest_sos.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc aa847bdb2830e4b31c8db84741faf29df9e202ad98927726a304f4fbcd7617e9 # shrinks to raw = [[41, 0, 0], [41, 1, 0], [41, -1, 0], [41, 2, 0]], offset = [0, 0, 0] diff --git a/tests/proptest_sos.rs b/tests/proptest_sos.rs index 81556f28..07e8f897 100644 --- a/tests/proptest_sos.rs +++ b/tests/proptest_sos.rs @@ -4,7 +4,7 @@ //! test vectors. Correctness is verified by testing the mathematical //! invariants that a valid `SoS` implementation must satisfy: //! -//! - **Non-degeneracy**: always returns Β±1 for degenerate inputs +//! - **Non-degeneracy**: returns Β±1 for first-order-resolvable degenerate inputs //! - **Determinism**: same input always produces the same sign //! - **Translation invariance**: orientation sign is unchanged by translation //! - **Robustness**: never panics on arbitrary finite inputs @@ -14,7 +14,9 @@ //! //! - **Co-hyperplanar points** (orientation): D+1 points with last coordinate //! fixed to zero and integer values in the remaining Dβˆ’1 coordinates, -//! guaranteeing an exactly-zero orientation determinant. +//! guaranteeing an exactly-zero orientation determinant. Inputs whose +//! first-order `SoS` cofactors all vanish are a typed degeneracy case and are +//! filtered from the non-zero/determinism/translation properties. //! - **Hyper-rectangle vertices** (insphere): the origin corner, D adjacent //! axis-aligned corners, and the diagonally opposite corner of an //! integer-coordinate hyper-rectangle all lie on a common circumsphere, @@ -24,6 +26,7 @@ use delaunay::geometry::point::Point; use delaunay::geometry::sos::{sos_insphere_sign, sos_orientation_sign}; +use delaunay::geometry::traits::coordinate::{CoordinateConversionError, DegenerateSimplexReason}; use proptest::prelude::*; // ============================================================================= @@ -43,6 +46,35 @@ fn points_all_distinct(points: &[Point]) -> bool { .all(|i| ((i + 1)..points.len()).all(|j| points[i].coords() != points[j].coords())) } +/// Returns `Some(sign)` when the current `SoS` implementation can resolve the +/// first-order cofactor expansion, or `None` for its documented typed +/// all-cofactors-vanished degeneracy. +fn resolvable_sos_orientation_sign(points: &[Point]) -> Option { + match sos_orientation_sign(points) { + Ok(sign) => Some(sign), + Err(CoordinateConversionError::DegenerateSimplex { + reason: DegenerateSimplexReason::VanishingSosCofactors, + .. + }) => None, + Err(error) => panic!("unexpected SoS orientation error for finite D+1 points: {error}"), + } +} + +/// Checks the robustness contract for arbitrary finite inputs: a sign or a +/// typed all-cofactors-vanished degeneracy, but no panic or stringly failure. +const fn sos_result_is_sign_or_vanishing_degeneracy( + result: &Result, +) -> bool { + matches!(result, Ok(1 | -1)) + || matches!( + result, + Err(CoordinateConversionError::DegenerateSimplex { + reason: DegenerateSimplexReason::VanishingSosCofactors, + .. + }) + ) +} + // ============================================================================= // STRATEGIES // ============================================================================= @@ -80,7 +112,7 @@ macro_rules! gen_sos_tests { // ============================================================= proptest! { - /// `SoS` orientation returns Β±1 for exactly degenerate points. + /// `SoS` orientation returns Β±1 for first-order-resolvable degenerate points. #[test] fn []( raw in prop::collection::vec( @@ -103,12 +135,14 @@ macro_rules! gen_sos_tests { // distinct points β€” skip inputs with duplicates. prop_assume!(points_all_distinct(&points)); - let sign = sos_orientation_sign(&points).unwrap(); + let sign = resolvable_sos_orientation_sign(&points); + prop_assume!(sign.is_some()); + let sign = sign.expect("prop_assume accepted only resolvable SoS inputs"); prop_assert!(sign == 1 || sign == -1, "SoS orientation must return Β±1 in {}D, got {}", $dim, sign); } - /// `SoS` orientation is deterministic for degenerate points. + /// `SoS` orientation is deterministic for first-order-resolvable degenerate points. #[test] fn []( raw in prop::collection::vec( @@ -127,12 +161,15 @@ macro_rules! gen_sos_tests { .collect(); prop_assume!(points_all_distinct(&points)); - let s1 = sos_orientation_sign(&points).unwrap(); - let s2 = sos_orientation_sign(&points).unwrap(); + let s1 = resolvable_sos_orientation_sign(&points); + prop_assume!(s1.is_some()); + let s1 = s1.expect("prop_assume accepted only resolvable SoS inputs"); + let s2 = resolvable_sos_orientation_sign(&points) + .expect("repeated resolvable SoS input should remain resolvable"); prop_assert_eq!(s1, s2, "SoS must be deterministic in {}D", $dim); } - /// `SoS` orientation is translation-invariant for degenerate points. + /// `SoS` orientation is translation-invariant for first-order-resolvable degenerate points. /// /// The offset uses integers (not arbitrary f64) because SoS /// translation invariance relies on the "1" column cancelling @@ -159,7 +196,9 @@ macro_rules! gen_sos_tests { .collect(); prop_assume!(points_all_distinct(&points)); - let s1 = sos_orientation_sign(&points).unwrap(); + let s1 = resolvable_sos_orientation_sign(&points); + prop_assume!(s1.is_some()); + let s1 = s1.expect("prop_assume accepted only resolvable SoS inputs"); let translated: Vec> = points .iter() .map(|p| { @@ -169,7 +208,8 @@ macro_rules! gen_sos_tests { Point::try_new(coords).expect("finite point coordinates") }) .collect(); - let s2 = sos_orientation_sign(&translated).unwrap(); + let s2 = resolvable_sos_orientation_sign(&translated) + .expect("integer translation should preserve SoS cofactor resolvability"); prop_assert_eq!(s1, s2, "SoS orientation must be translation-invariant in {}D", $dim); } @@ -239,7 +279,7 @@ macro_rules! gen_sos_tests { // ============================================================= proptest! { - /// `SoS` orientation never panics and returns Β±1 for random inputs. + /// `SoS` orientation never panics on random inputs. #[test] fn []( points in prop::collection::vec( @@ -247,11 +287,14 @@ macro_rules! gen_sos_tests { ($dim + 1)..=($dim + 1), ), ) { - let sign = sos_orientation_sign(&points).unwrap(); - prop_assert!(sign == 1 || sign == -1); + prop_assert!( + sos_result_is_sign_or_vanishing_degeneracy( + &sos_orientation_sign(&points) + ) + ); } - /// `SoS` insphere never panics and returns Β±1 for random inputs. + /// `SoS` insphere never panics on random inputs. #[test] fn []( simplex in prop::collection::vec( @@ -260,8 +303,11 @@ macro_rules! gen_sos_tests { ), test in $uniform(finite_coord()).prop_map(|coords| Point::try_new(coords).expect("finite point coordinates")), ) { - let sign = sos_insphere_sign(&simplex, &test).unwrap(); - prop_assert!(sign == 1 || sign == -1); + prop_assert!( + sos_result_is_sign_or_vanishing_degeneracy( + &sos_insphere_sign(&simplex, &test) + ) + ); } } } diff --git a/tests/triangulation_builder.rs b/tests/triangulation_builder.rs index d58e3ac0..961bcd46 100644 --- a/tests/triangulation_builder.rs +++ b/tests/triangulation_builder.rs @@ -514,6 +514,10 @@ macro_rules! gen_toroidal_validation_test { gen_toroidal_validation_test!(2, levels_1_to_4, true); #[test] +#[cfg_attr( + debug_assertions, + ignore = "release-mode guardrail; debug/coverage quotient search is intentionally skipped" +)] fn test_builder_toroidal_3d_fails_fast_until_scalable_quotient() { let vertices = vec![ Vertex::<(), _>::try_new([0.2_f64, 0.3, 0.4]).unwrap(),