diff --git a/Cargo.lock b/Cargo.lock index 271f270e..ac0f228c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,9 +132,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -355,9 +355,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fnv" diff --git a/benches/ci_performance_suite.rs b/benches/ci_performance_suite.rs index 19a69fb9..4eb27998 100644 --- a/benches/ci_performance_suite.rs +++ b/benches/ci_performance_suite.rs @@ -160,9 +160,18 @@ fn operation_benchmark_ids(group: &str, prefix: &str) -> String { fn validation_benchmark_ids() -> String { [ - format!("validation/{{validate_3d,validate_3d_adversarial}}/{CANARY_COUNT_3D}"), - format!("validation/{{validate_4d,validate_4d_adversarial}}/{CANARY_COUNT_4D}"), - format!("validation/{{validate_5d,validate_5d_adversarial}}/{CANARY_COUNT_5D}"), + format!( + "validation/{{validate_2d,validate_2d_adversarial,is_valid_delaunay_2d,is_valid_delaunay_2d_adversarial,delaunay_report_2d,delaunay_report_2d_adversarial}}/{CANARY_COUNT_2D}" + ), + format!( + "validation/{{validate_3d,validate_3d_adversarial,is_valid_delaunay_3d,is_valid_delaunay_3d_adversarial,delaunay_report_3d,delaunay_report_3d_adversarial}}/{CANARY_COUNT_3D}" + ), + format!( + "validation/{{validate_4d,validate_4d_adversarial,is_valid_delaunay_4d,is_valid_delaunay_4d_adversarial,delaunay_report_4d,delaunay_report_4d_adversarial}}/{CANARY_COUNT_4D}" + ), + format!( + "validation/{{validate_5d,validate_5d_adversarial,is_valid_delaunay_5d,is_valid_delaunay_5d_adversarial,delaunay_report_5d,delaunay_report_5d_adversarial}}/{CANARY_COUNT_5D}" + ), ] .join(";") } @@ -238,10 +247,10 @@ fn api_benchmark_entries() -> Vec { }, ApiBenchmarkEntry { group: "validation", - public_api: "DelaunayTriangulation::validate", - dimensions: "3,4,5", + public_api: "DelaunayTriangulation::{validate,is_valid_delaunay,delaunay_report}", + dimensions: "2,3,4,5", benchmark_ids: validation_benchmark_ids(), - note: "cumulative_levels_1_through_5_on_well_conditioned_and_adversarial_inputs", + note: "cumulative_and_level_5_report_validation_on_well_conditioned_and_adversarial_inputs", }, ApiBenchmarkEntry { group: "incremental_insert", @@ -1306,6 +1315,48 @@ fn bench_validate_case( ); } +fn bench_delaunay_validation_case( + group: &mut BenchmarkGroup<'_, WallTime>, + dimension: usize, + dataset: Dataset, + count: usize, + dt: &BenchTriangulation, +) { + group.throughput(Throughput::Elements(count as u64)); + group.bench_function( + BenchmarkId::new( + format!("is_valid_delaunay_{dimension}d{}", dataset.suffix()), + count, + ), + |b| { + b.iter(|| match black_box(dt.is_valid_delaunay()) { + Ok(()) => {} + Err(error) => { + abort_benchmark(format_args!( + "{dimension}D benchmark triangulation should be Delaunay: {error}" + )); + } + }); + }, + ); + group.bench_function( + BenchmarkId::new( + format!("delaunay_report_{dimension}d{}", dataset.suffix()), + count, + ), + |b| { + b.iter(|| match black_box(dt.delaunay_report()) { + Ok(()) => {} + Err(error) => { + abort_benchmark(format_args!( + "{dimension}D benchmark triangulation should have a valid Delaunay report: {error:?}" + )); + } + }); + }, + ); +} + fn bench_insert_case( group: &mut BenchmarkGroup<'_, WallTime>, dimension: usize, @@ -1557,6 +1608,38 @@ fn benchmark_convex_hull_queries(c: &mut Criterion) { group.finish(); } +fn bench_validation_dimension( + group: &mut BenchmarkGroup<'_, WallTime>, + dimension: usize, + seed: u64, + count: usize, + include_cumulative_validation: bool, +) { + let triangulation = prepare_dt::(seed, count); + if include_cumulative_validation { + bench_validate_case( + group, + dimension, + Dataset::WellConditioned, + count, + &triangulation, + ); + } + bench_delaunay_validation_case( + group, + dimension, + Dataset::WellConditioned, + count, + &triangulation, + ); + + let adversarial = prepare_adv_dt::(seed, count); + if include_cumulative_validation { + bench_validate_case(group, dimension, Dataset::Adversarial, count, &adversarial); + } + bench_delaunay_validation_case(group, dimension, Dataset::Adversarial, count, &adversarial); +} + fn benchmark_validation(c: &mut Criterion) { print_manifest_once(); if discover_seeds_enabled() { @@ -1565,56 +1648,10 @@ fn benchmark_validation(c: &mut Criterion) { let mut group = c.benchmark_group("validation"); group.sample_size(15); - let dt_3d = prepare_dt::<3>(123, CANARY_COUNT_3D); - bench_validate_case( - &mut group, - 3, - Dataset::WellConditioned, - CANARY_COUNT_3D, - &dt_3d, - ); - let dt_3d_adversarial = prepare_adv_dt::<3>(123, CANARY_COUNT_3D); - bench_validate_case( - &mut group, - 3, - Dataset::Adversarial, - CANARY_COUNT_3D, - &dt_3d_adversarial, - ); - - let dt_4d = prepare_dt::<4>(456, CANARY_COUNT_4D); - bench_validate_case( - &mut group, - 4, - Dataset::WellConditioned, - CANARY_COUNT_4D, - &dt_4d, - ); - let dt_4d_adversarial = prepare_adv_dt::<4>(456, CANARY_COUNT_4D); - bench_validate_case( - &mut group, - 4, - Dataset::Adversarial, - CANARY_COUNT_4D, - &dt_4d_adversarial, - ); - - let dt_5d = prepare_dt::<5>(789, CANARY_COUNT_5D); - bench_validate_case( - &mut group, - 5, - Dataset::WellConditioned, - CANARY_COUNT_5D, - &dt_5d, - ); - let dt_5d_adversarial = prepare_adv_dt::<5>(789, CANARY_COUNT_5D); - bench_validate_case( - &mut group, - 5, - Dataset::Adversarial, - CANARY_COUNT_5D, - &dt_5d_adversarial, - ); + bench_validation_dimension::<2>(&mut group, 2, 42, CANARY_COUNT_2D, true); + bench_validation_dimension::<3>(&mut group, 3, 123, CANARY_COUNT_3D, true); + bench_validation_dimension::<4>(&mut group, 4, 456, CANARY_COUNT_4D, true); + bench_validation_dimension::<5>(&mut group, 5, 789, CANARY_COUNT_5D, true); group.finish(); } diff --git a/docs/validation.md b/docs/validation.md index f5cd22a9..ab7a6876 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -767,15 +767,22 @@ enough for future regular, weighted, Gabriel, alpha, constrained, or related pre - **Time**: - `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) + - `DelaunayTriangulation::delaunay_report()` (Level 5 only): O(simplices) when complete Euclidean + point-set provenance and robust local predicates certify a valid report; O(simplices × vertices) + for the exact fallback when Euclidean connectivity is unproven or the local certificate fails or + is inconclusive. + - `DelaunayTriangulation::validation_report()` (Levels 1–5): Levels 1-4 plus the certified + O(simplices) Level 5 path, or Levels 1-4 plus the O(simplices × vertices) Level 5 fallback. +- **Space**: O(1) validation workspace for the local verifier; report output and fallback working sets + scale with the number of reported violations. ### When to Use - **Critical Applications**: When Delaunay guarantees are essential (interpolation, mesh quality) - **Tests**: After construction to verify correctness - **Debug**: Investigating geometric issues or suspected violations -- **Avoid**: Hot loops (still O(simplices); use for spot checks / tests) +- **Avoid**: Report generation in hot loops, especially when connectivity is unproven and may require the + O(simplices × vertices) fallback. The boolean `is_valid_delaunay()` check remains O(simplices). ### Example @@ -841,7 +848,9 @@ Start: Do you need to validate? intersections, using bounding boxes before exact rational witness construction. - Level 5 `DelaunayTriangulation::is_valid_delaunay()` verifies the implemented Delaunay predicate family via local flip predicates after Level 4 realization validation. -- A brute-force empty-circumsphere check would be O(simplices × vertices) and is not used by `is_valid_delaunay()`. +- The O(simplices × vertices) brute-force empty-circumsphere check is not used by + `is_valid_delaunay()`, but report APIs use it when connectivity lacks complete-point-set provenance or + the local certificate fails or is inconclusive. In practice, `DelaunayTriangulation::validate()` is usually dominated by Level 3 Intrinsic PL Topology work or Level 4 pairwise realization checks, depending on mesh size and overlap candidates. @@ -1014,7 +1023,7 @@ converge, consider the opt-in heuristic rebuild fallback via | 4 | `Triangulation::validate_realization()` | `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) | +| — | `DelaunayTriangulation::validation_report()` | `delaunay` | Levels 1-4 + O(simplices) certified fast path; O(simplices × vertices) fallback | --- diff --git a/justfile b/justfile index afb796a3..4abeada3 100644 --- a/justfile +++ b/justfile @@ -28,7 +28,7 @@ taplo_version := "0.10.0" tectonic_version := "0.17.0" tex_fmt_version := "0.5.7" typos_version := "1.49.0" -uv_version := "0.12.3" +uv_version := "0.12.4" zizmor_version := "1.29.0" # Common cargo-llvm-cov arguments for all coverage runs. diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 5e4880ac..62677dbf 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -52,7 +52,7 @@ use crate::core::triangulation::Triangulation; use crate::core::util::stable_hash_u64_slice; use crate::core::validation::{TopologyGuarantee, TriangulationValidationError}; use crate::core::vertex::Vertex; -use crate::geometry::kernel::Kernel; +use crate::geometry::kernel::{Kernel, RobustKernel}; use crate::geometry::point::Point; use crate::geometry::predicates::{Orientation, simplex_orientation_fast_filter_sign}; use crate::geometry::robust_predicates::robust_orientation; @@ -7423,6 +7423,29 @@ where ) } +/// Verifies a complete Euclidean point-set triangulation with robust local predicates. +/// +/// This is the certificate predicate used by the structured Level 5 report. +/// It deliberately ignores the owner's generic kernel because the report's +/// fallback oracle is defined by the unperturbed robust empty-sphere predicate. +/// Callers must separately prove that the complex triangulates the complete +/// Euclidean point set; local predicates are not a global certificate for +/// arbitrary explicit or constrained connectivity. +/// +/// # Errors +/// +/// Returns any [`DelaunayRepairError`] surfaced by the topology-aware local +/// verifier, including predicate, connectivity, and postcondition failures. +pub(crate) fn verify_complete_euclidean_tds_via_robust_flip_predicates( + tds: &Tds, +) -> Result<(), DelaunayRepairError> +where + U: DataType, + V: DataType, +{ + verify_delaunay_with_topology(tds, &RobustKernel::new(), GlobalTopology::Euclidean) +} + /// Verify the Delaunay property via local flip predicates under a global topology model. /// /// For periodic topologies this evaluates predicates in lifted coordinates using the diff --git a/src/delaunay/construction.rs b/src/delaunay/construction.rs index 3daf5451..1fa0ad07 100644 --- a/src/delaunay/construction.rs +++ b/src/delaunay/construction.rs @@ -110,6 +110,7 @@ use crate::topology::traits::{ ToroidalDomainError, }; use crate::triangulation::DelaunayTriangulation; +use crate::triangulation::EuclideanDelaunayReportDomain; use crate::validation::{DelaunayTriangulationValidationError, DelaunayVerificationError}; use core::{cmp::Ordering, fmt}; use num_traits::ToPrimitive; @@ -4031,6 +4032,7 @@ where }, insertion_state: DelaunayInsertionState::new(), spatial_index: None, + euclidean_report_domain: EuclideanDelaunayReportDomain::CompletePointSet, }; // During batch construction, use suspicion-driven validation instead of @@ -4163,6 +4165,7 @@ where }, insertion_state: DelaunayInsertionState::new(), spatial_index: None, + euclidean_report_domain: EuclideanDelaunayReportDomain::CompletePointSet, }; // During batch construction, use suspicion-driven validation instead of @@ -5068,9 +5071,11 @@ where /// Creates an empty Delaunay wrapper with explicit validation and topology context. /// - /// Repair and builder paths use this before inserting vertices so subsequent - /// topology validation observes the same global topology as the source - /// triangulation or construction mode. + /// Repair and builder paths use this before incrementally inserting every + /// vertex, so Euclidean callers establish a complete point-set domain while + /// subsequent topology validation observes the same global topology as the + /// source triangulation or construction mode. Callers that populate the TDS + /// through another assembly path must reset the report domain to `Unproven`. pub(crate) fn with_empty_kernel_and_topology_context( kernel: K, topology_guarantee: TopologyGuarantee, @@ -5086,6 +5091,11 @@ where ), insertion_state: DelaunayInsertionState::new(), spatial_index: HashGridIndex::try_new(duplicate_tolerance).ok(), + euclidean_report_domain: if global_topology.is_euclidean() { + EuclideanDelaunayReportDomain::CompletePointSet + } else { + EuclideanDelaunayReportDomain::Unproven + }, } } } diff --git a/src/delaunay/deletion.rs b/src/delaunay/deletion.rs index 4386137b..71309874 100644 --- a/src/delaunay/deletion.rs +++ b/src/delaunay/deletion.rs @@ -303,6 +303,7 @@ where if let Some(index) = delaunay.spatial_index.as_mut() { index.remove_vertex(&vertex_key, &removed_vertex_coords); } + delaunay.invalidate_euclidean_report_domain(); transaction.commit(); Ok(simplices_removed) } @@ -646,6 +647,10 @@ mod tests { dt.spatial_index = Some(spatial_index); assert!(dt.insertion_state.last_inserted_simplex.is_some()); assert!(dt.spatial_index.is_some()); + assert!( + dt.euclidean_report_domain.supports_local_certificate(), + "incremental construction should establish the report domain" + ); dt.set_delaunay_repair_policy(DelaunayRepairPolicy::Never); let removed_simplices = dt.delete_vertex(vertex_key).unwrap(); @@ -665,6 +670,10 @@ mod tests { ); assert!(!found_removed_key); assert!(dt.as_triangulation().validate().is_ok()); + assert!( + !dt.euclidean_report_domain.supports_local_certificate(), + "vertex deletion must revoke the complete-point-set proof" + ); } #[test] diff --git a/src/delaunay/pachner.rs b/src/delaunay/pachner.rs index 86fd5fba..84bc527f 100644 --- a/src/delaunay/pachner.rs +++ b/src/delaunay/pachner.rs @@ -1070,6 +1070,7 @@ where let result = self.tri.flip_k1_insert_topology(simplex_key, vertex); if result.is_ok() { self.invalidate_repair_caches(); + self.invalidate_euclidean_report_domain(); } result } @@ -1078,6 +1079,7 @@ where let result = self.tri.flip_k1_remove_topology(vertex_key); if result.is_ok() { self.invalidate_repair_caches(); + self.invalidate_euclidean_report_domain(); } result } @@ -1086,6 +1088,7 @@ where let result = self.tri.flip_k2_topology(facet); if result.is_ok() { self.invalidate_locate_hint_cache(); + self.invalidate_euclidean_report_domain(); } result } @@ -1097,6 +1100,7 @@ where let result = self.tri.flip_k2_inverse_from_edge_topology(edge); if result.is_ok() { self.invalidate_locate_hint_cache(); + self.invalidate_euclidean_report_domain(); } result } @@ -1105,6 +1109,7 @@ where let result = self.tri.flip_k3_topology(ridge); if result.is_ok() { self.invalidate_locate_hint_cache(); + self.invalidate_euclidean_report_domain(); } result } @@ -1116,6 +1121,7 @@ where let result = self.tri.flip_k3_inverse_from_triangle_topology(triangle); if result.is_ok() { self.invalidate_locate_hint_cache(); + self.invalidate_euclidean_report_domain(); } result } @@ -1129,10 +1135,13 @@ mod tests { use super::*; use crate::{ - DelaunayTriangulationBuilder, TopologyGuarantee, geometry::kernel::AdaptiveKernel, vertex, + DelaunayTriangulationBuilder, TopologyGuarantee, construction::ConstructionOptions, + geometry::kernel::AdaptiveKernel, triangulation::EuclideanDelaunayReportDomain, vertex, }; type Dt2 = DelaunayTriangulation, (), (), 2>; + type Dt3 = DelaunayTriangulation, (), (), 3>; + type Dt4 = DelaunayTriangulation, (), (), 4>; fn triangle_dt() -> Dt2 { let vertices: Vec> = vec![ @@ -1153,6 +1162,99 @@ mod tests { .expect("minimal triangulation should contain a simplex") } + /// Builds the smallest 3D complex supporting a k=2 forward/inverse roundtrip. + fn k2_roundtrip_dt() -> Dt3 { + let vertices = [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + [1.0, 1.0, 1.0], + ] + .map(|coords| vertex!(coords).expect("k=2 fixture vertex should be valid")); + let simplices = vec![vec![0, 1, 2, 3], vec![0, 1, 2, 4]]; + + DelaunayTriangulationBuilder::try_from_vertices_and_simplices(&vertices, &simplices) + .expect("k=2 fixture connectivity should parse") + .construction_options( + ConstructionOptions::default().without_final_delaunay_enforcement(), + ) + .build() + .expect("k=2 roundtrip fixture should build") + } + + /// Builds the smallest 4D complex supporting a k=3 forward/inverse roundtrip. + fn k3_roundtrip_dt() -> Dt4 { + let vertices = [ + [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, 0.0], + [0.0, 0.0, 0.0, 1.0], + [1.0, 1.0, 1.0, -1.0], + ] + .map(|coords| vertex!(coords).expect("k=3 fixture vertex should be valid")); + let simplices = vec![ + vec![0, 1, 2, 3, 4], + vec![0, 1, 2, 4, 5], + vec![0, 1, 2, 5, 3], + ]; + + DelaunayTriangulationBuilder::try_from_vertices_and_simplices(&vertices, &simplices) + .expect("k=3 fixture connectivity should parse") + .construction_options( + ConstructionOptions::default().without_final_delaunay_enforcement(), + ) + .build() + .expect("k=3 roundtrip fixture should build") + } + + /// Finds a facet whose topology-scope k=2 move succeeds on a cloned fixture. + fn flippable_k2_facet(dt: &Dt3) -> FacetHandle { + for facet in dt.facets() { + let facet = facet.expect("k=2 fixture facets should reborrow").handle(); + let mut trial = dt.clone(); + let Ok(proposal) = trial.propose_pachner(PachnerMove::K2 { facet }) else { + continue; + }; + if proposal.attempt_topology_on(&mut trial).is_ok() { + return facet; + } + } + panic!("k=2 fixture should contain a topology-scope move") + } + + /// Finds a ridge whose topology-scope k=3 move succeeds on a cloned fixture. + fn flippable_k3_ridge(dt: &Dt4) -> RidgeHandle { + for ridge in dt.ridge_handles() { + let ridge = ridge.expect("k=3 fixture ridges should reborrow"); + let mut trial = dt.clone(); + let Ok(proposal) = trial.propose_pachner(PachnerMove::K3 { ridge }) else { + continue; + }; + if proposal.attempt_topology_on(&mut trial).is_ok() { + return ridge; + } + } + panic!("k=3 fixture should contain a topology-scope move") + } + + /// Resolves the live edge reported as a k=2 move's inserted face. + fn inserted_edge( + dt: &DelaunayTriangulation, (), (), D>, + inserted_face_vertices: &[VertexKey], + ) -> EdgeKey { + let [a, b] = inserted_face_vertices else { + panic!("k=2 move should report an inserted edge") + }; + dt.edges() + .find(|edge| { + let (first, second) = edge.endpoints(); + (first == *a && second == *b) || (first == *b && second == *a) + }) + .expect("reported k=2 inserted edge should be live") + } + #[test] fn pachner_move_result_roundtrips_through_flip_info() { let mut removed_simplices = SimplexKeyBuffer::new(); @@ -1198,6 +1300,7 @@ mod tests { let mut dt = triangle_dt(); let simplex_key = first_simplex_key(&dt); let previous_generation = dt.topology_generation(); + assert!(dt.euclidean_report_domain.supports_local_certificate()); let result = dt .propose_pachner(PachnerMove::K1Insert { @@ -1216,6 +1319,104 @@ mod tests { dt.as_triangulation() .validate() .expect("topology-scope Pachner move should preserve Levels 1-3"); + assert!(!dt.euclidean_report_domain.supports_local_certificate()); + } + + #[test] + fn topology_scope_k1_remove_invalidates_euclidean_report_domain() { + let interior_vertex = vertex![0.25, 0.25].expect("interior vertex should be valid"); + let interior_uuid = interior_vertex.uuid(); + let vertices = vec![ + vertex![0.0, 0.0].expect("test vertex should be valid"), + vertex![1.0, 0.0].expect("test vertex should be valid"), + vertex![0.0, 1.0].expect("test vertex should be valid"), + interior_vertex, + ]; + let mut dt: Dt2 = DelaunayTriangulationBuilder::new(&vertices) + .build() + .expect("subdivided triangle should build"); + let interior_key = dt + .vertices() + .find_map(|(vertex_key, vertex)| (vertex.uuid() == interior_uuid).then_some(vertex_key)) + .expect("interior vertex should be present"); + assert!(dt.euclidean_report_domain.supports_local_certificate()); + + let result = dt + .propose_pachner(PachnerMove::K1Remove { + vertex_key: interior_key, + }) + .expect("interior vertex should support a k=1 inverse proposal") + .attempt_topology_on(&mut dt) + .expect("topology-scope k=1 inverse should commit"); + + assert_eq!(result.direction, FlipDirection::Inverse); + assert!(!dt.contains_vertex_key(interior_key)); + assert!(!dt.euclidean_report_domain.supports_local_certificate()); + dt.as_triangulation() + .validate() + .expect("topology-scope k=1 inverse should preserve Levels 1-3"); + } + + #[test] + fn topology_scope_k2_roundtrip_invalidates_euclidean_report_domain() { + let mut dt = k2_roundtrip_dt(); + let facet = flippable_k2_facet(&dt); + dt.euclidean_report_domain = EuclideanDelaunayReportDomain::CompletePointSet; + + let forward = dt + .propose_pachner(PachnerMove::K2 { facet }) + .expect("k=2 fixture facet should support a proposal") + .attempt_topology_on(&mut dt) + .expect("topology-scope k=2 move should commit"); + + assert!(!dt.euclidean_report_domain.supports_local_certificate()); + let edge = inserted_edge(&dt, &forward.inserted_face_vertices); + dt.euclidean_report_domain = EuclideanDelaunayReportDomain::CompletePointSet; + + let inverse = dt + .propose_pachner(PachnerMove::K2Inverse { edge }) + .expect("inserted edge should support the inverse proposal") + .attempt_topology_on(&mut dt) + .expect("topology-scope inverse k=2 move should commit"); + + assert_eq!(inverse.direction, FlipDirection::Inverse); + assert!(!dt.euclidean_report_domain.supports_local_certificate()); + dt.as_triangulation() + .validate() + .expect("topology-scope k=2 roundtrip should preserve Levels 1-3"); + } + + #[test] + fn topology_scope_k3_roundtrip_invalidates_euclidean_report_domain() { + let mut dt = k3_roundtrip_dt(); + let ridge = flippable_k3_ridge(&dt); + dt.euclidean_report_domain = EuclideanDelaunayReportDomain::CompletePointSet; + + let forward = dt + .propose_pachner(PachnerMove::K3 { ridge }) + .expect("k=3 fixture ridge should support a proposal") + .attempt_topology_on(&mut dt) + .expect("topology-scope k=3 move should commit"); + + assert!(!dt.euclidean_report_domain.supports_local_certificate()); + let [a, b, c] = forward.inserted_face_vertices.as_slice() else { + panic!("k=3 move should report an inserted triangle") + }; + let triangle = TriangleHandle::try_new(*a, *b, *c) + .expect("reported k=3 inserted triangle should be valid"); + dt.euclidean_report_domain = EuclideanDelaunayReportDomain::CompletePointSet; + + let inverse = dt + .propose_pachner(PachnerMove::K3Inverse { triangle }) + .expect("inserted triangle should support the inverse proposal") + .attempt_topology_on(&mut dt) + .expect("topology-scope inverse k=3 move should commit"); + + assert_eq!(inverse.direction, FlipDirection::Inverse); + assert!(!dt.euclidean_report_domain.supports_local_certificate()); + dt.as_triangulation() + .validate() + .expect("topology-scope k=3 roundtrip should preserve Levels 1-3"); } #[test] diff --git a/src/delaunay/query.rs b/src/delaunay/query.rs index e08c1b78..7ac14de8 100644 --- a/src/delaunay/query.rs +++ b/src/delaunay/query.rs @@ -44,7 +44,7 @@ use crate::topology::traits::topological_space::{GlobalTopology, TopologyError, use crate::topology::traits::{ GlobalTopologyModelError, global_topology_model::GlobalTopologyModel, }; -use crate::triangulation::DelaunayTriangulation; +use crate::triangulation::{DelaunayTriangulation, EuclideanDelaunayReportDomain}; use crate::validation::DelaunayTriangulationValidationError; use thiserror::Error; @@ -1233,6 +1233,11 @@ impl DelaunayTriangulation { self.insertion_state.last_inserted_simplex = None; } + /// Revokes the proof required to replace the global Euclidean report scan. + pub(crate) const fn invalidate_euclidean_report_domain(&mut self) { + self.euclidean_report_domain = EuclideanDelaunayReportDomain::Unproven; + } + pub(crate) fn invalidate_repair_caches(&mut self) { self.invalidate_locate_hint_cache(); self.spatial_index = None; @@ -1241,9 +1246,11 @@ impl DelaunayTriangulation { /// Returns mutable TDS access for crate-internal repair algorithms. /// /// Repair passes may rewrite topology and invalidate locate hints, so this - /// deliberately clears the ephemeral caches before handing out the borrow. + /// deliberately clears the ephemeral caches and complete-point-set proof + /// before handing out the borrow. pub(crate) fn tds_mut_for_repair(&mut self) -> &mut Tds { self.invalidate_repair_caches(); + self.invalidate_euclidean_report_domain(); &mut self.tri.tds } @@ -1713,8 +1720,14 @@ impl DelaunayTriangulation { &mut self, global_topology: GlobalTopology, ) -> Result<(), DelaunayTriangulationValidationError> { + let topology_changed = self.global_topology() != global_topology; match self.tri.try_set_global_topology(global_topology) { - Ok(()) => Ok(()), + Ok(()) => { + if topology_changed { + self.invalidate_euclidean_report_domain(); + } + Ok(()) + } Err(InvariantError::Tds(err)) => Err(err.into()), Err(InvariantError::Triangulation(err)) => Err(err.into()), Err(InvariantError::Realization(err)) => Err(err.into()), @@ -3242,6 +3255,7 @@ mod tests { ]; let mut dt: DelaunayTriangulation<_, (), (), 2> = DelaunayTriangulation::builder(&vertices).build().unwrap(); + assert!(dt.euclidean_report_domain.supports_local_certificate()); let err = dt .try_set_global_topology(GlobalTopology::Spherical) @@ -3259,9 +3273,51 @@ mod tests { ) ); assert_eq!(dt.global_topology(), GlobalTopology::Euclidean); + assert!(dt.euclidean_report_domain.supports_local_certificate()); assert!(dt.validate().is_ok()); } + #[test] + fn idempotent_global_topology_set_preserves_euclidean_report_domain() { + let vertices = standard_simplex_vertices::<2>(); + let mut dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulationBuilder::new(&vertices) + .build() + .unwrap(); + assert!(dt.euclidean_report_domain.supports_local_certificate()); + + dt.try_set_global_topology(GlobalTopology::Euclidean) + .unwrap(); + + assert!(dt.euclidean_report_domain.supports_local_certificate()); + } + + #[test] + fn successful_global_topology_change_invalidates_euclidean_report_domain() { + let mut dt: DelaunayTriangulation<_, (), (), 2> = DelaunayTriangulation::empty(); + dt.euclidean_report_domain = EuclideanDelaunayReportDomain::CompletePointSet; + + dt.try_set_global_topology(GlobalTopology::Hyperbolic) + .expect("empty topology should accept hyperbolic metadata"); + + assert_eq!(dt.global_topology(), GlobalTopology::Hyperbolic); + assert!(!dt.euclidean_report_domain.supports_local_certificate()); + } + + #[test] + fn mutable_repair_tds_access_invalidates_euclidean_report_domain() { + let vertices = standard_simplex_vertices::<2>(); + let mut dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulationBuilder::new(&vertices) + .build() + .unwrap(); + assert!(dt.euclidean_report_domain.supports_local_certificate()); + + let _ = dt.tds_mut_for_repair(); + + assert!(!dt.euclidean_report_domain.supports_local_certificate()); + } + #[test] fn test_set_delaunay_check_policy_updates_state() { init_tracing(); @@ -3308,6 +3364,7 @@ mod tests { tri: Triangulation::new_empty(FastKernel::new()), insertion_state: DelaunayInsertionState::new(), spatial_index: None, + euclidean_report_domain: EuclideanDelaunayReportDomain::Unproven, }; assert_eq!( @@ -3326,6 +3383,7 @@ mod tests { tri: Triangulation::new_empty(FastKernel::new()), insertion_state: DelaunayInsertionState::new(), spatial_index: None, + euclidean_report_domain: EuclideanDelaunayReportDomain::Unproven, }; dt.fill_simplex_data(|_, _| Payload); @@ -3340,6 +3398,7 @@ mod tests { tri: Triangulation::new_empty(FastKernel::new()), insertion_state: DelaunayInsertionState::new(), spatial_index: None, + euclidean_report_domain: EuclideanDelaunayReportDomain::Unproven, }; let data = SimplexSecondaryMap::new(); diff --git a/src/delaunay/serialization.rs b/src/delaunay/serialization.rs index c90eb85e..ffd843eb 100644 --- a/src/delaunay/serialization.rs +++ b/src/delaunay/serialization.rs @@ -94,6 +94,7 @@ mod tests { use crate::core::validation::{TopologyGuarantee, ValidationPolicy}; use crate::geometry::kernel::AdaptiveKernel; use crate::topology::traits::topological_space::GlobalTopology; + use crate::triangulation::EuclideanDelaunayReportDomain; use crate::vertex; use std::sync::Once; @@ -175,6 +176,7 @@ mod tests { }, insertion_state: DelaunayInsertionState::new(), spatial_index: None, + euclidean_report_domain: EuclideanDelaunayReportDomain::Unproven, }; let json = serde_json::to_string(&dt).unwrap(); diff --git a/src/delaunay/triangulation.rs b/src/delaunay/triangulation.rs index 1205d2a8..2c7a78dd 100644 --- a/src/delaunay/triangulation.rs +++ b/src/delaunay/triangulation.rs @@ -10,6 +10,31 @@ use crate::core::operations::DelaunayInsertionState; use crate::core::tds::{TopologyOwner, TopologyOwnerId}; use crate::core::triangulation::Triangulation; +/// Provenance required for replacing a global Euclidean empty-sphere scan with +/// local robust flip predicates. +/// +/// Local Delaunay predicates certify the global empty-sphere property only +/// when the complex triangulates the complete Euclidean point set. Incremental +/// point-set construction establishes that domain; explicit, reconstructed, +/// and quotient connectivity must continue to use the global scan unless a +/// stronger proof is added at their assembly boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EuclideanDelaunayReportDomain { + /// Incremental construction triangulated the complete Euclidean point set, + /// and no later mutation revoked its Levels 1–4 proof. + CompletePointSet, + /// The assembly path did not prove the local-to-global certificate domain. + Unproven, +} + +impl EuclideanDelaunayReportDomain { + /// Returns whether robust local predicates can certify a global empty report. + #[must_use] + pub const fn supports_local_certificate(self) -> bool { + matches!(self, Self::CompletePointSet) + } +} + /// Delaunay triangulation with incremental insertion support. /// /// # Type Parameters @@ -78,6 +103,16 @@ pub struct DelaunayTriangulation { /// cache can survive transactional rollbacks even if they leave behind stale /// keys from an insertion that did not commit. pub(crate) spatial_index: Option>, + /// Proof domain for the Euclidean local-to-global report fast path. + /// + /// `CompletePointSet` is valid only while the TDS remains the complete, + /// Levels 1–4-valid Euclidean triangulation established by incremental + /// point-set construction. Assembly from external connectivity, vertex + /// removal, mutable repair access, topology-only edits, and global-topology + /// changes must reset this to `Unproven` before the local certificate can + /// be reused. Idempotently reapplying the current topology preserves the + /// existing proof. + pub(crate) euclidean_report_domain: EuclideanDelaunayReportDomain, } impl TopologyOwner for DelaunayTriangulation { diff --git a/src/delaunay/validation.rs b/src/delaunay/validation.rs index ed2ca22c..15d96e67 100644 --- a/src/delaunay/validation.rs +++ b/src/delaunay/validation.rs @@ -9,9 +9,11 @@ #![forbid(unsafe_code)] use crate::core::algorithms::flips::{ - DelaunayRepairError, verify_triangulation_via_flip_predicates, + DelaunayRepairError, verify_complete_euclidean_tds_via_robust_flip_predicates, + verify_triangulation_via_flip_predicates, }; use crate::core::algorithms::incremental_insertion::InsertionError; +use crate::core::collections::ViolationBuffer; use crate::core::operations::DelaunayInsertionState; use crate::core::realization::TriangulationRealizationValidationError; use crate::core::tds::{ @@ -31,6 +33,7 @@ use crate::geometry::kernel::Kernel; use crate::repair::DelaunayRepairOperation; use crate::topology::traits::topological_space::GlobalTopology; use crate::triangulation::DelaunayTriangulation; +use crate::triangulation::EuclideanDelaunayReportDomain; use std::num::NonZeroUsize; use thiserror::Error; @@ -83,6 +86,7 @@ impl DelaunayTriangulationCandidate { }, insertion_state: DelaunayInsertionState::new(), spatial_index: None, + euclidean_report_domain: EuclideanDelaunayReportDomain::Unproven, }, } } @@ -599,10 +603,15 @@ where /// 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. + /// Euclidean triangulations produced by complete point-set insertion first + /// use robust local flip predicates as an O(simplices) validity certificate. + /// A successful certificate avoids the all-vertices empty-circumsphere scan. + /// Explicit, reconstructed, or otherwise unproven connectivity delegates + /// directly to the global scan; a failed or inconclusive local certificate + /// also falls back so invalid reports retain every violating simplex and the + /// same typed details. Non-Euclidean topologies use the topology-aware flip + /// verifier and report the first violation it finds, avoiding an ordinary + /// Euclidean circumsphere scan in periodic charts. /// /// # Errors /// @@ -631,7 +640,7 @@ where /// ``` pub fn delaunay_report(&self) -> Result<(), TriangulationValidationReport> { if self.global_topology().is_euclidean() { - return match tds_delaunay_violation_report(self.tds(), None) { + return match self.delaunay_violation_report(None) { Ok(report) if report.is_valid() => Ok(()), Ok(report) => Err(TriangulationValidationReport { violations: report @@ -675,8 +684,14 @@ where /// /// This is the high-level owner-bound counterpart to the TDS-level /// [`delaunay_violation_report`](crate::delaunay_violation_report) helper. - /// It keeps callers on the `DelaunayTriangulation` API while returning the - /// same typed, key-oriented diagnostics. + /// For a full Euclidean report over connectivity proven to triangulate the + /// complete point set, it first uses O(simplices) robust local flip predicates + /// as a validity certificate. Explicit, reconstructed, and other unproven + /// connectivity delegates directly to the TDS-level brute-force scan. A + /// failed or inconclusive certificate also delegates so the returned + /// violation set and typed diagnostics remain exact. Subset reports delegate + /// directly because a global invalidity outside the subset says nothing + /// about the requested simplices. /// /// # Errors /// @@ -708,6 +723,20 @@ where &self, simplices_to_check: Option<&[SimplexKey]>, ) -> Result { + if simplices_to_check.is_none() + && self.global_topology().is_euclidean() + && self.euclidean_report_domain.supports_local_certificate() + && verify_complete_euclidean_tds_via_robust_flip_predicates(self.tds()).is_ok() + { + return Ok(DelaunayViolationReport { + number_of_vertices: self.number_of_vertices(), + number_of_simplices: self.number_of_simplices(), + checked_simplices: self.number_of_simplices(), + violating_simplices: ViolationBuffer::new(), + violation_details: Vec::new(), + }); + } + tds_delaunay_violation_report(self.tds(), simplices_to_check) } @@ -1120,19 +1149,44 @@ where #[cfg(test)] mod tests { use super::*; + use crate::builder::DelaunayTriangulationBuilder; + use crate::construction::ConstructionOptions; use crate::core::algorithms::flips::{ DelaunayRepairDiagnostics, DelaunayRepairPostconditionFailure, RepairQueueOrder, }; use crate::core::simplex::Simplex; use crate::core::tds::{SimplexKey, TriangulationConstructionState, VertexKey}; use crate::core::vertex::Vertex; + use crate::geometry::coordinate_range::CoordinateRange; use crate::geometry::kernel::AdaptiveKernel; + use crate::geometry::point::Point; + use crate::geometry::traits::coordinate::CoordinateConversionError; + use crate::geometry::util::generate_random_points_in_range_seeded; use crate::vertex; use slotmap::KeyData; use std::assert_matches; use std::{error::Error, sync::Once}; use uuid::Uuid; + #[derive(Clone, Debug)] + struct PanickingKernel; + + impl Kernel for PanickingKernel { + type Scalar = f64; + + fn orientation(&self, _points: &[Point]) -> Result { + panic!("the structured report must not call its owner's kernel") + } + + fn in_sphere( + &self, + _simplex_points: &[Point], + _test_point: &Point, + ) -> Result { + panic!("the structured report must not call its owner's kernel") + } + } + fn test_vertex(coords: [f64; D]) -> Vertex<(), D> { vertex!(coords).unwrap() } @@ -1225,6 +1279,156 @@ mod tests { }) } + fn randomized_delaunay( + base_seed: u64, + ) -> DelaunayTriangulation, (), (), D> { + let bounds = CoordinateRange::try_new(-10.0, 10.0).unwrap(); + for offset in 0..32 { + let points = generate_random_points_in_range_seeded::( + D + 8, + bounds, + base_seed.wrapping_add(offset), + ) + .unwrap(); + let vertices: Vec<_> = points + .iter() + .map(|point| test_vertex(*point.coords())) + .collect(); + if let Ok(triangulation) = DelaunayTriangulationBuilder::new(&vertices).build() { + return triangulation; + } + } + + panic!("failed to build seeded randomized {D}D Delaunay test fixture"); + } + + fn shared_facet_flip_adversary() + -> DelaunayTriangulation, (), (), D> { + let dim = u32::try_from(D).unwrap(); + let high_apex_coordinate = 1.1 / f64::from(dim); + let mut vertices = Vec::with_capacity(D + 2); + vertices.push(test_vertex([0.0; D])); + for axis in 0..D { + let mut coordinates = [0.0; D]; + coordinates[axis] = 1.0; + vertices.push(test_vertex(coordinates)); + } + vertices.push(test_vertex([high_apex_coordinate; D])); + + let low_simplex = (0..=D).collect(); + let mut high_simplex: Vec = (1..=D).collect(); + high_simplex.push(D + 1); + let simplices = vec![low_simplex, high_simplex]; + + DelaunayTriangulationBuilder::try_from_vertices_and_simplices(&vertices, &simplices) + .unwrap() + .construction_options( + ConstructionOptions::default().without_final_delaunay_enforcement(), + ) + .build() + .unwrap() + } + + fn assert_randomized_full_report_matches_brute_force() { + for seed in [0x483, 0x483_0001, 0x483_0002] { + let triangulation = randomized_delaunay::(seed); + assert!(triangulation.verify_via_flip_predicates().is_ok()); + + let optimized = triangulation.delaunay_violation_report(None).unwrap(); + let brute_force = tds_delaunay_violation_report(triangulation.tds(), None).unwrap(); + assert_eq!(optimized, brute_force); + } + } + + fn assert_adversarial_full_report_matches_brute_force() { + let triangulation = shared_facet_flip_adversary::(); + assert!(triangulation.verify_via_flip_predicates().is_err()); + + let optimized = triangulation.delaunay_violation_report(None).unwrap(); + let brute_force = tds_delaunay_violation_report(triangulation.tds(), None).unwrap(); + assert_eq!(optimized, brute_force); + } + + macro_rules! generate_full_report_agreement_tests { + ($dimension:literal) => { + pastey::paste! { + #[test] + fn []() { + assert_randomized_full_report_matches_brute_force::<$dimension>(); + } + + #[test] + fn []() { + assert_adversarial_full_report_matches_brute_force::<$dimension>(); + } + } + }; + } + + generate_full_report_agreement_tests!(2); + generate_full_report_agreement_tests!(3); + generate_full_report_agreement_tests!(4); + generate_full_report_agreement_tests!(5); + + #[test] + fn complete_point_set_report_uses_robust_predicates_instead_of_owner_kernel() { + let source = randomized_delaunay::<2>(0x483_1001); + assert_eq!( + source.euclidean_report_domain, + EuclideanDelaunayReportDomain::CompletePointSet + ); + + let mut triangulation = DelaunayTriangulationCandidate::assemble( + source.tds().clone(), + PanickingKernel, + source.topology_guarantee(), + source.global_topology(), + ) + .into_repairable_delaunay_for_test(); + triangulation.euclidean_report_domain = EuclideanDelaunayReportDomain::CompletePointSet; + + let report = triangulation.delaunay_violation_report(None).unwrap(); + let brute_force = tds_delaunay_violation_report(triangulation.tds(), None).unwrap(); + assert_eq!(report, brute_force); + } + + #[test] + fn failed_complete_point_set_certificate_falls_back_to_global_report() { + let mut triangulation = shared_facet_flip_adversary::<2>(); + triangulation.euclidean_report_domain = EuclideanDelaunayReportDomain::CompletePointSet; + + let report = triangulation.delaunay_violation_report(None).unwrap(); + let brute_force = tds_delaunay_violation_report(triangulation.tds(), None).unwrap(); + assert!(!report.is_valid()); + assert_eq!(report, brute_force); + } + + #[test] + fn unproven_connectivity_bypasses_local_certificate() { + let tds = tds_from_2d_vertices_and_simplices( + &[[0.0, 0.0], [2.0, 0.0], [0.0, 2.0], [0.25, 0.25]], + &[vec![0, 1, 2]], + ); + assert!(verify_complete_euclidean_tds_via_robust_flip_predicates(&tds).is_ok()); + + let triangulation = DelaunayTriangulationCandidate::assemble( + tds, + PanickingKernel, + TopologyGuarantee::Pseudomanifold, + GlobalTopology::Euclidean, + ) + .into_repairable_delaunay_for_test(); + assert_eq!( + triangulation.euclidean_report_domain, + EuclideanDelaunayReportDomain::Unproven + ); + + let report = triangulation.delaunay_violation_report(None).unwrap(); + let brute_force = tds_delaunay_violation_report(triangulation.tds(), None).unwrap(); + assert!(!report.is_valid()); + assert_eq!(report, brute_force); + } + #[test] fn validation_cadence_maps_optional_every() { assert_eq!( diff --git a/src/lib.rs b/src/lib.rs index 56064edf..927cb126 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -864,7 +864,7 @@ pub use crate::tds::{ pub use crate::topology::spaces::spherical::{ SphericalMetric, SphericalPoint, SphericalPointError, }; -pub use crate::triangulation::*; +pub use crate::triangulation::DelaunayTriangulation; pub use crate::validation::{ DelaunayTriangulationValidationError, DelaunayVerificationError, DelaunayVerificationErrorKind, };