From d3b35ccedf953589b5c3488441a59ef3e2a30b7a Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 9 May 2026 10:59:50 -0700 Subject: [PATCH 01/15] perf: cadence batch repair for large construction - Add a batch repair policy option to ConstructionOptions so bulk insertion can repair local Delaunay fronts every N insertions. - Carry pending local repair seeds through batch construction and finalization so cadenced repairs cover the accumulated frontier. - Report construction telemetry for locate walks, conflict regions, global exterior scans, skipped-vertex budgets, and initial-simplex A/B runs in the large-scale debug harness. --- docs/dev/debug_env_vars.md | 9 +- src/core/operations.rs | 43 ++ src/core/triangulation.rs | 164 ++++-- src/triangulation/delaunay.rs | 918 +++++++++++++++++++++------------- tests/large_scale_debug.rs | 295 +++++++++-- 5 files changed, 1000 insertions(+), 429 deletions(-) diff --git a/docs/dev/debug_env_vars.md b/docs/dev/debug_env_vars.md index 72de08b0..c295a1ad 100644 --- a/docs/dev/debug_env_vars.md +++ b/docs/dev/debug_env_vars.md @@ -129,15 +129,18 @@ and release builds. | `DELAUNAY_LARGE_DEBUG_BALL_RADIUS` | **value** | Radius for ball distribution | | `DELAUNAY_LARGE_DEBUG_BOX_HALF_WIDTH` | **value** | Half-width for box distribution | | `DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE` | **value** | `new` (batch) or `incremental` | -| `DELAUNAY_LARGE_DEBUG_DEBUG_MODE` | **value** | `cadenced` or `strict` | +| `DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX` | **value** | Batch initial simplex strategy: `first` (default) or `balanced` | +| `DELAUNAY_LARGE_DEBUG_DEBUG_MODE` | **value** | `cadenced` (ridge-link) or `strict` (per-insertion vertex-link) | | `DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED` | **value** | Vertex shuffle seed | | `DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY` | **value** | Progress logging interval | | `DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY` | **value** | Validation interval | -| `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY` | **value** | Repair interval | +| `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY` | **value** | Batch/incremental repair interval (default: 4) | | `DELAUNAY_LARGE_DEBUG_REPAIR_MAX_FLIPS` | **value** | Flip budget override | | `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS` | **value** | Timeout (0 = no cap) | -| `DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS` | presence | Allow vertex insertion skips | +| `DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT` | **value** | Maximum skipped-vertex percentage before failing (default: 5.0) | +| `DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS` | presence | Allow any number of vertex insertion skips | | `DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR` | presence | Skip final global repair pass | +| `DELAUNAY_BATCH_REPAIR_TRACE` | presence | Trace cadenced batch-repair seed counts, flips, queues, and elapsed time | | `DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL` | **value** | Total prefix probes for bisect mode | | `DELAUNAY_LARGE_DEBUG_PREFIX_MAX_PROBES` | **value** | Max probes per bisect run | | `DELAUNAY_LARGE_DEBUG_PREFIX_MAX_RUNTIME_SECS` | **value** | Bisect probe timeout | diff --git a/src/core/operations.rs b/src/core/operations.rs index 1e52c313..4d190dbf 100644 --- a/src/core/operations.rs +++ b/src/core/operations.rs @@ -230,6 +230,49 @@ impl InsertionStatistics { } } +/// Release-visible telemetry for one transactional insertion. +/// +/// These counters are intended for aggregate diagnostics rather than stable performance +/// benchmarking. They let batch construction report whether insertion time is dominated by +/// point location, scan fallbacks, local conflict regions, or global exterior-point scans. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct InsertionTelemetry { + /// Number of point-location calls performed for this insertion. + pub locate_calls: usize, + /// Total facet-walk steps across all point-location calls. + pub locate_walk_steps_total: usize, + /// Maximum facet-walk steps taken by a single point-location call. + pub locate_walk_steps_max: usize, + /// Number of point-location calls that used the caller-provided hint. + pub locate_hint_uses: usize, + /// Number of point-location calls that fell back to a brute-force scan. + pub locate_scan_fallbacks: usize, + /// Number of point-location calls that ended inside a cell. + pub located_inside: usize, + /// Number of point-location calls that ended outside the convex hull. + pub located_outside: usize, + /// Number of point-location calls that ended on a lower-dimensional feature. + pub located_on_boundary: usize, + + /// Number of local conflict-region computations observed by insertion. + pub conflict_region_calls: usize, + /// Total number of cells in local conflict regions. + pub conflict_region_cells_total: usize, + /// Maximum number of cells in a single local conflict region. + pub conflict_region_cells_max: usize, + + /// Number of global exterior-point conflict scans. + pub global_conflict_scans: usize, + /// Total cells scanned by global exterior-point conflict scans. + pub global_conflict_cells_scanned: usize, + /// Total cells found by global exterior-point conflict scans. + pub global_conflict_cells_found_total: usize, + /// Maximum cells found by a single global exterior-point conflict scan. + pub global_conflict_cells_found_max: usize, + /// Wall-clock nanoseconds spent in global exterior-point conflict scans. + pub global_conflict_scan_nanos: u128, +} + /// Ephemeral insertion state used by Delaunay triangulations. #[derive(Clone, Copy, Debug)] pub(crate) struct DelaunayInsertionState { diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index ce7eab58..e7e1f9dc 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -113,13 +113,11 @@ use crate::core::algorithms::incremental_insertion::{ external_facets_for_boundary, fill_cavity, repair_neighbor_pointers, repair_neighbor_pointers_local, wire_cavity_neighbors, }; -#[cfg(debug_assertions)] -use crate::core::algorithms::locate::locate_with_stats; #[cfg(feature = "diagnostics")] use crate::core::algorithms::locate::verify_conflict_region_completeness; use crate::core::algorithms::locate::{ - ConflictError, LocateError, LocateResult, extract_cavity_boundary, find_conflict_region, - locate, locate_by_scan, + ConflictError, LocateError, LocateResult, LocateStats, extract_cavity_boundary, + find_conflict_region, locate, locate_by_scan, locate_with_stats, }; use crate::core::cell::{Cell, CellValidationError}; use crate::core::collections::spatial_hash_grid::HashGridIndex; @@ -131,7 +129,7 @@ use crate::core::collections::{ use crate::core::edge::EdgeKey; use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter, FacetHandle, facet_key_from_vertices}; use crate::core::operations::{ - InsertionOutcome, InsertionResult, InsertionStatistics, SuspicionFlags, + InsertionOutcome, InsertionResult, InsertionStatistics, InsertionTelemetry, SuspicionFlags, }; use crate::core::tds::{ CellKey, GeometricError, InvariantError, InvariantKind, InvariantViolation, Tds, @@ -166,6 +164,7 @@ use std::sync::{ OnceLock, atomic::{AtomicBool, AtomicU64, Ordering}, }; +use std::time::Instant; use thiserror::Error; use uuid::Uuid; @@ -877,8 +876,10 @@ enum InsertionSite<'a> { pub(crate) struct DetailedInsertionResult { /// Public insertion outcome returned to higher layers. pub outcome: InsertionOutcome, - /// Telemetry collected while attempting the insertion. + /// Public statistics collected while attempting the insertion. pub stats: InsertionStatistics, + /// Internal path telemetry collected while attempting the insertion. + pub telemetry: InsertionTelemetry, /// Extra cells that should widen the caller's local repair seed set. pub repair_seed_cells: CellKeyBuffer, } @@ -3608,6 +3609,7 @@ where bulk_index: Option, ) -> Result { let mut stats = InsertionStatistics::default(); + let mut telemetry = InsertionTelemetry::default(); let original_coords = *vertex.point().coords(); let original_uuid = vertex.uuid(); let mut current_vertex = vertex; @@ -3674,6 +3676,7 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Skipped { error }, stats, + telemetry, repair_seed_cells: CellKeyBuffer::new(), }); }; @@ -3721,6 +3724,7 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Skipped { error }, stats, + telemetry, repair_seed_cells: CellKeyBuffer::new(), }); } @@ -3751,6 +3755,7 @@ where hint, attempt, &tds_snapshot, + &mut telemetry, ) }; #[cfg(not(test))] @@ -3760,6 +3765,7 @@ where hint, attempt, &tds_snapshot, + &mut telemetry, ); match result { @@ -3790,6 +3796,7 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Inserted { vertex_key, hint }, stats, + telemetry, repair_seed_cells, }); } @@ -3805,6 +3812,7 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Skipped { error: e }, stats, + telemetry, repair_seed_cells: CellKeyBuffer::new(), }); } @@ -3861,6 +3869,7 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Skipped { error: e }, stats, + telemetry, // Skipped insertions do not mutate the triangulation, so any // intermediate cavity-seed hints are irrelevant to callers. repair_seed_cells: CellKeyBuffer::new(), @@ -4225,8 +4234,9 @@ where hint: Option, attempt: usize, tds_snapshot: &Tds, + telemetry: &mut InsertionTelemetry, ) -> Result { - let mut insert_ok = self.try_insert_impl(vertex, conflict_cells, hint)?; + let mut insert_ok = self.try_insert_impl(vertex, conflict_cells, hint, telemetry)?; if attempt > 0 { insert_ok.suspicion.perturbation_used = true; @@ -4245,6 +4255,7 @@ where hint, attempt, validation_err, + telemetry, ); } @@ -4263,9 +4274,14 @@ where hint: Option, attempt: usize, validation_err: InvariantError, + telemetry: &mut InsertionTelemetry, ) -> Result { let point = *vertex.point(); - let location = locate(&self.tds, &self.kernel, &point, hint); + let location = + locate_with_stats(&self.tds, &self.kernel, &point, hint).map(|(location, stats)| { + Self::record_locate_telemetry(telemetry, location, &stats); + location + }); let Ok(LocateResult::InsideCell(start_cell)) = location else { return Err(Self::invariant_error_to_insertion_error(validation_err)); @@ -4274,7 +4290,7 @@ where let mut star_conflict = CellKeyBuffer::new(); star_conflict.push(start_cell); - match self.try_insert_impl(vertex, Some(&star_conflict), Some(start_cell)) { + match self.try_insert_impl(vertex, Some(&star_conflict), Some(start_cell), telemetry) { Ok(mut fallback_ok) => { fallback_ok.suspicion.fallback_star_split = true; if attempt > 0 { @@ -5194,6 +5210,69 @@ where Ok(()) } + /// Records one point-location result into insertion telemetry. + #[inline] + fn record_locate_telemetry( + telemetry: &mut InsertionTelemetry, + location: LocateResult, + stats: &LocateStats, + ) { + telemetry.locate_calls = telemetry.locate_calls.saturating_add(1); + telemetry.locate_walk_steps_total = telemetry + .locate_walk_steps_total + .saturating_add(stats.walk_steps); + telemetry.locate_walk_steps_max = telemetry.locate_walk_steps_max.max(stats.walk_steps); + + if stats.used_hint { + telemetry.locate_hint_uses = telemetry.locate_hint_uses.saturating_add(1); + } + + if stats.fell_back_to_scan() { + telemetry.locate_scan_fallbacks = telemetry.locate_scan_fallbacks.saturating_add(1); + } + + match location { + LocateResult::InsideCell(_) => { + telemetry.located_inside = telemetry.located_inside.saturating_add(1); + } + LocateResult::Outside => { + telemetry.located_outside = telemetry.located_outside.saturating_add(1); + } + LocateResult::OnFacet(_, _) | LocateResult::OnEdge(_) | LocateResult::OnVertex(_) => { + telemetry.located_on_boundary = telemetry.located_on_boundary.saturating_add(1); + } + } + } + + #[inline] + fn record_conflict_region_telemetry(telemetry: &mut InsertionTelemetry, cells: usize) { + telemetry.conflict_region_calls = telemetry.conflict_region_calls.saturating_add(1); + telemetry.conflict_region_cells_total = + telemetry.conflict_region_cells_total.saturating_add(cells); + telemetry.conflict_region_cells_max = telemetry.conflict_region_cells_max.max(cells); + } + + #[inline] + fn record_global_conflict_scan_telemetry( + telemetry: &mut InsertionTelemetry, + cells_scanned: usize, + cells_found: usize, + elapsed_nanos: u128, + ) { + telemetry.global_conflict_scans = telemetry.global_conflict_scans.saturating_add(1); + telemetry.global_conflict_cells_scanned = telemetry + .global_conflict_cells_scanned + .saturating_add(cells_scanned); + telemetry.global_conflict_cells_found_total = telemetry + .global_conflict_cells_found_total + .saturating_add(cells_found); + telemetry.global_conflict_cells_found_max = + telemetry.global_conflict_cells_found_max.max(cells_found); + telemetry.global_conflict_scan_nanos = telemetry + .global_conflict_scan_nanos + .saturating_add(elapsed_nanos); + } + /// Internal implementation of insert without retry logic. /// Returns the result and the number of cells removed during repair. /// @@ -5208,6 +5287,7 @@ where vertex: Vertex, conflict_cells: Option<&CellKeyBuffer>, hint: Option, + telemetry: &mut InsertionTelemetry, ) -> Result { let mut suspicion = SuspicionFlags::default(); @@ -5268,51 +5348,26 @@ where }); } - // 3. Locate containing cell (for vertex D+2 and beyond) - #[cfg(debug_assertions)] - let (location, locate_stats) = { - #[cfg(debug_assertions)] - { - let log_locate = std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() - || std::env::var_os("DELAUNAY_DEBUG_LOCATE").is_some(); - if log_locate { - let (location, stats) = - locate_with_stats(&self.tds, &self.kernel, &point, hint)?; - (location, Some(stats)) - } else { - (locate(&self.tds, &self.kernel, &point, hint)?, None) - } - } - #[cfg(not(debug_assertions))] - { - (locate(&self.tds, &self.kernel, &point, hint)?, None) - } - }; - - #[cfg(not(debug_assertions))] - let location = locate(&self.tds, &self.kernel, &point, hint)?; + // 3. Locate containing cell (for vertex D+2 and beyond). + // + // `locate()` delegates to `locate_with_stats()`, so collecting the stats here keeps + // the same point-location algorithm while making release-mode batch diagnostics useful. + let (location, locate_stats) = locate_with_stats(&self.tds, &self.kernel, &point, hint)?; + Self::record_locate_telemetry(telemetry, location, &locate_stats); #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() || std::env::var_os("DELAUNAY_DEBUG_LOCATE").is_some() { - if let Some(stats) = locate_stats { - tracing::debug!( - point = ?point, - location = ?location, - start_cell = ?stats.start_cell, - used_hint = stats.used_hint, - walk_steps = stats.walk_steps, - fallback = ?stats.fallback, - "try_insert_impl: locate stats" - ); - } else { - tracing::debug!( - point = ?point, - location = ?location, - "try_insert_impl: locate result" - ); - } + tracing::debug!( + point = ?point, + location = ?location, + start_cell = ?locate_stats.start_cell, + used_hint = locate_stats.used_hint, + walk_steps = locate_stats.walk_steps, + fallback = ?locate_stats.fallback, + "try_insert_impl: locate stats" + ); } // 4. Determine the supported insertion site and any conflict cells it needs. @@ -5333,6 +5388,7 @@ where // Fallback: treat the containing cell as the conflict region, effectively performing // a star-split of that cell to keep the simplicial complex connected. let computed = find_conflict_region(&self.tds, &self.kernel, &point, start_cell)?; + Self::record_conflict_region_telemetry(telemetry, computed.len()); #[cfg(feature = "diagnostics")] if std::env::var_os("DELAUNAY_DEBUG_CONFLICT_VERIFY").is_some() { @@ -5368,6 +5424,7 @@ where } } (LocateResult::InsideCell(start_cell), Some(cells)) => { + Self::record_conflict_region_telemetry(telemetry, cells.len()); // If the caller provided an empty conflict region (can happen if the Delaunay layer // computes conflicts using a strict in-sphere test), we must still replace at least // one cell; otherwise we'd create no cavity, no new cells, and leave a dangling @@ -5401,7 +5458,15 @@ where if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { tracing::debug!("Outside insertion: starting global conflict-region scan"); } + let cells_scanned = self.tds.number_of_cells(); + let global_scan_started = Instant::now(); let computed = self.find_conflict_region_global(&point)?; + Self::record_global_conflict_scan_telemetry( + telemetry, + cells_scanned, + computed.len(), + global_scan_started.elapsed().as_nanos(), + ); if computed.is_empty() { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { @@ -5435,6 +5500,7 @@ where } } (LocateResult::Outside, Some(cells)) => { + Self::record_conflict_region_telemetry(telemetry, cells.len()); if cells.is_empty() { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index f3f53576..48773608 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -20,8 +20,8 @@ use crate::core::collections::{CellKeyBuffer, FastHashMap, FastHashSet, FastHash use crate::core::edge::EdgeKey; use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter}; use crate::core::operations::{ - DelaunayInsertionState, InsertionOutcome, InsertionStatistics, RepairDecision, - TopologicalOperation, + DelaunayInsertionState, InsertionOutcome, InsertionStatistics, InsertionTelemetry, + RepairDecision, TopologicalOperation, }; use crate::core::tds::{ CellKey, InvariantError, InvariantKind, InvariantViolation, Tds, TdsConstructionError, @@ -208,6 +208,10 @@ const fn local_repair_flip_budget(seed_cells_len: usize) -> usiz if raw > floor { raw } else { floor } } +fn batch_repair_trace_enabled() -> bool { + env::var_os("DELAUNAY_BATCH_REPAIR_TRACE").is_some() +} + thread_local! { static HEURISTIC_REBUILD_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -859,15 +863,23 @@ impl Default for RetryPolicy { /// /// ```rust /// use delaunay::prelude::triangulation::{ -/// ConstructionOptions, DedupPolicy, InsertionOrderStrategy, RetryPolicy, +/// ConstructionOptions, DedupPolicy, DelaunayRepairPolicy, InsertionOrderStrategy, RetryPolicy, /// }; +/// use std::num::NonZeroUsize; /// /// let options = ConstructionOptions::default() /// .with_insertion_order(InsertionOrderStrategy::Hilbert) /// .with_dedup_policy(DedupPolicy::Off) +/// .with_batch_repair_policy(DelaunayRepairPolicy::EveryN( +/// NonZeroUsize::new(4).unwrap(), +/// )) /// .with_retry_policy(RetryPolicy::Disabled); /// /// assert_eq!(options.insertion_order(), InsertionOrderStrategy::Hilbert); +/// assert_eq!( +/// options.batch_repair_policy(), +/// DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()), +/// ); /// ``` #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] @@ -876,6 +888,7 @@ pub struct ConstructionOptions { dedup_policy: DedupPolicy, initial_simplex: InitialSimplexStrategy, retry_policy: RetryPolicy, + batch_repair_policy: DelaunayRepairPolicy, /// When `true` (default), D<4 per-insertion repair falls back to a global /// `repair_delaunay_with_flips_k2_k3` pass when the bounded local pass /// cycles. Set to `false` for constructions where global repair could @@ -890,6 +903,7 @@ impl Default for ConstructionOptions { dedup_policy: DedupPolicy::default(), initial_simplex: InitialSimplexStrategy::default(), retry_policy: RetryPolicy::default(), + batch_repair_policy: DelaunayRepairPolicy::default(), use_global_repair_fallback: true, } } @@ -919,6 +933,12 @@ impl ConstructionOptions { self.retry_policy } + /// Returns the local Delaunay repair cadence used during batch construction. + #[must_use] + pub const fn batch_repair_policy(&self) -> DelaunayRepairPolicy { + self.batch_repair_policy + } + /// Sets the input ordering strategy used for batch construction. #[must_use] pub const fn with_insertion_order(mut self, insertion_order: InsertionOrderStrategy) -> Self { @@ -949,6 +969,16 @@ impl ConstructionOptions { self } + /// Sets the local Delaunay repair cadence used during batch construction. + #[must_use] + pub const fn with_batch_repair_policy( + mut self, + batch_repair_policy: DelaunayRepairPolicy, + ) -> Self { + self.batch_repair_policy = batch_repair_policy; + self + } + /// Disables the D<4 global repair fallback. #[must_use] pub(crate) const fn without_global_repair_fallback(mut self) -> Self { @@ -961,6 +991,152 @@ impl ConstructionOptions { // BATCH CONSTRUCTION STATISTICS // ============================================================================= +/// Aggregate release-visible telemetry collected during batch construction. +/// +/// These counters summarize the insertion path at a coarse level so large-scale debug runs can +/// distinguish point-location cost, scan fallback cost, local conflict-region size, and global +/// exterior conflict scans without enabling per-insertion tracing. +#[derive(Debug, Default, Clone)] +#[non_exhaustive] +pub struct ConstructionTelemetry { + /// Number of point-location calls performed during construction. + pub locate_calls: usize, + /// Total facet-walk steps across all point-location calls. + pub locate_walk_steps_total: usize, + /// Maximum facet-walk steps taken by a single point-location call. + pub locate_walk_steps_max: usize, + /// Number of point-location calls that used a caller-provided hint. + pub locate_hint_uses: usize, + /// Number of point-location calls that fell back to a brute-force scan. + pub locate_scan_fallbacks: usize, + /// Number of point-location calls that ended inside a cell. + pub located_inside: usize, + /// Number of point-location calls that ended outside the convex hull. + pub located_outside: usize, + /// Number of point-location calls that ended on a lower-dimensional feature. + pub located_on_boundary: usize, + + /// Number of local conflict-region computations observed during construction. + pub conflict_region_calls: usize, + /// Total number of cells in local conflict regions. + pub conflict_region_cells_total: usize, + /// Maximum number of cells in a single local conflict region. + pub conflict_region_cells_max: usize, + + /// Number of global exterior-point conflict scans. + pub global_conflict_scans: usize, + /// Total cells scanned by global exterior-point conflict scans. + pub global_conflict_cells_scanned: usize, + /// Total cells found by global exterior-point conflict scans. + pub global_conflict_cells_found_total: usize, + /// Maximum cells found by a single global exterior-point conflict scan. + pub global_conflict_cells_found_max: usize, + /// Wall-clock nanoseconds spent in global exterior-point conflict scans. + pub global_conflict_scan_nanos: u128, +} + +impl ConstructionTelemetry { + /// Returns true when any insertion-path telemetry was recorded. + #[must_use] + pub const fn has_data(&self) -> bool { + self.locate_calls > 0 || self.conflict_region_calls > 0 || self.global_conflict_scans > 0 + } + + /// Adds one insertion's telemetry into this construction summary. + pub(crate) fn record_insertion(&mut self, telemetry: &InsertionTelemetry) { + self.locate_calls = self.locate_calls.saturating_add(telemetry.locate_calls); + self.locate_walk_steps_total = self + .locate_walk_steps_total + .saturating_add(telemetry.locate_walk_steps_total); + self.locate_walk_steps_max = self + .locate_walk_steps_max + .max(telemetry.locate_walk_steps_max); + self.locate_hint_uses = self + .locate_hint_uses + .saturating_add(telemetry.locate_hint_uses); + self.locate_scan_fallbacks = self + .locate_scan_fallbacks + .saturating_add(telemetry.locate_scan_fallbacks); + self.located_inside = self.located_inside.saturating_add(telemetry.located_inside); + self.located_outside = self + .located_outside + .saturating_add(telemetry.located_outside); + self.located_on_boundary = self + .located_on_boundary + .saturating_add(telemetry.located_on_boundary); + + self.conflict_region_calls = self + .conflict_region_calls + .saturating_add(telemetry.conflict_region_calls); + self.conflict_region_cells_total = self + .conflict_region_cells_total + .saturating_add(telemetry.conflict_region_cells_total); + self.conflict_region_cells_max = self + .conflict_region_cells_max + .max(telemetry.conflict_region_cells_max); + + self.global_conflict_scans = self + .global_conflict_scans + .saturating_add(telemetry.global_conflict_scans); + self.global_conflict_cells_scanned = self + .global_conflict_cells_scanned + .saturating_add(telemetry.global_conflict_cells_scanned); + self.global_conflict_cells_found_total = self + .global_conflict_cells_found_total + .saturating_add(telemetry.global_conflict_cells_found_total); + self.global_conflict_cells_found_max = self + .global_conflict_cells_found_max + .max(telemetry.global_conflict_cells_found_max); + self.global_conflict_scan_nanos = self + .global_conflict_scan_nanos + .saturating_add(telemetry.global_conflict_scan_nanos); + } + + /// Merges another construction telemetry summary into this one. + fn merge_from(&mut self, other: &Self) { + self.locate_calls = self.locate_calls.saturating_add(other.locate_calls); + self.locate_walk_steps_total = self + .locate_walk_steps_total + .saturating_add(other.locate_walk_steps_total); + self.locate_walk_steps_max = self.locate_walk_steps_max.max(other.locate_walk_steps_max); + self.locate_hint_uses = self.locate_hint_uses.saturating_add(other.locate_hint_uses); + self.locate_scan_fallbacks = self + .locate_scan_fallbacks + .saturating_add(other.locate_scan_fallbacks); + self.located_inside = self.located_inside.saturating_add(other.located_inside); + self.located_outside = self.located_outside.saturating_add(other.located_outside); + self.located_on_boundary = self + .located_on_boundary + .saturating_add(other.located_on_boundary); + + self.conflict_region_calls = self + .conflict_region_calls + .saturating_add(other.conflict_region_calls); + self.conflict_region_cells_total = self + .conflict_region_cells_total + .saturating_add(other.conflict_region_cells_total); + self.conflict_region_cells_max = self + .conflict_region_cells_max + .max(other.conflict_region_cells_max); + + self.global_conflict_scans = self + .global_conflict_scans + .saturating_add(other.global_conflict_scans); + self.global_conflict_cells_scanned = self + .global_conflict_cells_scanned + .saturating_add(other.global_conflict_cells_scanned); + self.global_conflict_cells_found_total = self + .global_conflict_cells_found_total + .saturating_add(other.global_conflict_cells_found_total); + self.global_conflict_cells_found_max = self + .global_conflict_cells_found_max + .max(other.global_conflict_cells_found_max); + self.global_conflict_scan_nanos = self + .global_conflict_scan_nanos + .saturating_add(other.global_conflict_scan_nanos); + } +} + /// Aggregate statistics collected during batch construction. /// /// This summarizes the per-vertex [`InsertionStatistics`] generated by the incremental insertion @@ -990,6 +1166,9 @@ pub struct ConstructionStatistics { /// Maximum number of cells removed during repair for any single insertion. pub cells_removed_max: usize, + /// Aggregate insertion-path telemetry. + pub telemetry: ConstructionTelemetry, + /// A small set of representative skipped vertices recorded during batch construction. /// /// This is intended for debugging/reproduction and is capped (currently the first 8 skips). @@ -1114,6 +1293,7 @@ impl ConstructionStatistics { .cells_removed_total .saturating_add(other.cells_removed_total); self.cells_removed_max = self.cells_removed_max.max(other.cells_removed_max); + self.telemetry.merge_from(&other.telemetry); for sample in &other.skip_samples { if self.skip_samples.len() >= Self::MAX_SKIP_SAMPLES { @@ -2639,6 +2819,7 @@ where dedup_policy, initial_simplex, retry_policy, + batch_repair_policy, use_global_repair_fallback, } = options; @@ -2667,6 +2848,7 @@ where attempts, base_seed, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ); } @@ -2685,6 +2867,7 @@ where attempts, base_seed, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ); } @@ -2696,6 +2879,7 @@ where vertices, topology_guarantee, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) }; @@ -2740,6 +2924,7 @@ where dedup_policy, initial_simplex, retry_policy, + batch_repair_policy, use_global_repair_fallback, } = options; @@ -2774,6 +2959,7 @@ where attempts, base_seed, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ); } @@ -2792,6 +2978,7 @@ where attempts, base_seed, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ); } @@ -2803,6 +2990,7 @@ where vertices, topology_guarantee, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) }; @@ -2963,6 +3151,10 @@ where clippy::too_many_lines, reason = "construction retry flow keeps seed selection, validation, and diagnostics together" )] + #[expect( + clippy::too_many_arguments, + reason = "private construction retry helper threads orthogonal batch knobs explicitly" + )] fn build_with_shuffled_retries( kernel: &K, vertices: &[Vertex], @@ -2970,6 +3162,7 @@ where attempts: NonZeroUsize, base_seed: Option, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result { let base_seed = base_seed.unwrap_or_else(|| Self::construction_shuffle_seed(vertices)); @@ -2996,6 +3189,7 @@ where 0_u64, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) { Ok(candidate) => match is_delaunay_property_only(&candidate.tri.tds) { @@ -3066,6 +3260,7 @@ where perturbation_seed, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) { Ok(candidate) => match is_delaunay_property_only(&candidate.tri.tds) { @@ -3142,6 +3337,10 @@ where clippy::result_large_err, reason = "Internal helper propagates public by-value construction-statistics error type" )] + #[expect( + clippy::too_many_arguments, + reason = "statistics retry helper mirrors the non-statistics construction path" + )] fn build_with_shuffled_retries_with_construction_statistics( kernel: &K, vertices: &[Vertex], @@ -3149,6 +3348,7 @@ where attempts: NonZeroUsize, base_seed: Option, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result<(Self, ConstructionStatistics), DelaunayTriangulationConstructionErrorWithStatistics> { @@ -3180,6 +3380,7 @@ where 0_u64, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) { Ok((candidate, stats)) => match is_delaunay_property_only(&candidate.tri.tds) { @@ -3276,6 +3477,7 @@ where perturbation_seed, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) { Ok((candidate, stats)) => match is_delaunay_property_only(&candidate.tri.tds) { @@ -3387,6 +3589,7 @@ where vertices: &[Vertex], topology_guarantee: TopologyGuarantee, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result { let dt = Self::build_with_kernel_inner_seeded( @@ -3396,6 +3599,7 @@ where 0, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, )?; @@ -3427,6 +3631,7 @@ where vertices: &[Vertex], topology_guarantee: TopologyGuarantee, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result<(Self, ConstructionStatistics), DelaunayTriangulationConstructionErrorWithStatistics> { @@ -3437,6 +3642,7 @@ where 0, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, )?; @@ -3469,6 +3675,10 @@ where clippy::result_large_err, reason = "Internal helper propagates public by-value construction-statistics error type" )] + #[expect( + clippy::too_many_arguments, + reason = "seeded construction helper carries retry, repair, and validation knobs" + )] fn build_with_kernel_inner_seeded_with_construction_statistics( kernel: K, vertices: &[Vertex], @@ -3476,6 +3686,7 @@ where perturbation_seed: u64, run_final_repair: bool, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result<(Self, ConstructionStatistics), DelaunayTriangulationConstructionErrorWithStatistics> { @@ -3540,12 +3751,11 @@ where // Disable maybe_repair_after_insertion during bulk construction: its full pipeline // (multi-pass repair + topology validation + heuristic rebuild) is too expensive - // per insertion. Instead, insert_remaining_vertices_seeded calls - // repair_delaunay_local_single_pass directly after each insertion (no topology - // check, no heuristic rebuild, soft-fail on non-convergence for D≥4). Soft-failed - // insertions (D≥4 only) record their adjacent cells in soft_fail_seeds, which is - // used as the seed for the final seeded repair in finalize_bulk_construction. If - // no soft-fails occurred the seed is empty and finalize skips the repair entirely. + // per insertion. Instead, insert_remaining_vertices_seeded accumulates the local + // frontier touched by successful insertions and calls repair_delaunay_local_single_pass + // at the requested cadence (no topology check, no heuristic rebuild, soft-fail on + // non-convergence for D≥4). Soft-failed repair frontiers are retained for the final + // seeded repair in finalize_bulk_construction. let original_repair_policy = dt.insertion_state.delaunay_repair_policy; dt.insertion_state.delaunay_repair_policy = DelaunayRepairPolicy::Never; dt.insertion_state.use_global_repair_fallback = use_global_repair_fallback; @@ -3560,11 +3770,14 @@ where } let mut soft_fail_seeds: Vec = Vec::new(); + let mut pending_repair_seeds: Vec = Vec::new(); if let Err(error) = dt.insert_remaining_vertices_seeded( vertices, perturbation_seed, grid_cell_size, + batch_repair_policy, Some(&mut stats), + &mut pending_repair_seeds, &mut soft_fail_seeds, ) { return Err(DelaunayTriangulationConstructionErrorWithStatistics { @@ -3577,6 +3790,8 @@ where original_validation_policy, original_repair_policy, run_final_repair, + batch_repair_policy, + &pending_repair_seeds, &soft_fail_seeds, ) { return Err(DelaunayTriangulationConstructionErrorWithStatistics { @@ -3590,6 +3805,10 @@ where /// Implements the non-statistics seeded construction core for callers that /// only need the triangulation. + #[expect( + clippy::too_many_arguments, + reason = "seeded construction helper carries retry, repair, and validation knobs" + )] fn build_with_kernel_inner_seeded( kernel: K, vertices: &[Vertex], @@ -3597,6 +3816,7 @@ where perturbation_seed: u64, run_final_repair: bool, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result { if vertices.len() < D + 1 { @@ -3651,17 +3871,22 @@ where dt.insertion_state.delaunay_repair_policy = DelaunayRepairPolicy::Never; dt.insertion_state.use_global_repair_fallback = use_global_repair_fallback; let mut soft_fail_seeds: Vec = Vec::new(); + let mut pending_repair_seeds: Vec = Vec::new(); dt.insert_remaining_vertices_seeded( vertices, perturbation_seed, grid_cell_size, + batch_repair_policy, None, + &mut pending_repair_seeds, &mut soft_fail_seeds, )?; dt.finalize_bulk_construction( original_validation_policy, original_repair_policy, run_final_repair, + batch_repair_policy, + &pending_repair_seeds, &soft_fail_seeds, )?; @@ -3855,18 +4080,196 @@ where } } + fn extend_local_repair_seed_cells( + &self, + vertex_key: VertexKey, + extra_seed_cells: &[CellKey], + pending_seed_cells: &mut Vec, + pending_seen: &mut FastHashSet, + ) { + for cell_key in self.tri.adjacent_cells(vertex_key) { + if pending_seen.insert(cell_key) { + pending_seed_cells.push(cell_key); + } + } + + for &cell_key in extra_seed_cells { + if self.tri.tds.contains_cell(cell_key) && pending_seen.insert(cell_key) { + pending_seed_cells.push(cell_key); + } + } + } + + fn retain_live_local_repair_seed_cells( + &self, + seed_cells: &mut Vec, + seen: &mut FastHashSet, + ) { + seen.clear(); + seed_cells + .retain(|cell_key| self.tri.tds.contains_cell(*cell_key) && seen.insert(*cell_key)); + } + + fn clear_local_repair_seed_cells( + seed_cells: &mut Vec, + seen: &mut FastHashSet, + ) { + seed_cells.clear(); + seen.clear(); + } + + #[expect( + clippy::too_many_lines, + reason = "local repair handling keeps success, fallback, and soft-fail paths together" + )] + fn repair_pending_local_seed_cells( + &mut self, + index: usize, + pending_seed_cells: &mut Vec, + pending_seen: &mut FastHashSet, + last_escalation_idx: &mut Option, + soft_fail_seeds: &mut Vec, + ) -> Result<(), DelaunayTriangulationConstructionError> { + self.retain_live_local_repair_seed_cells(pending_seed_cells, pending_seen); + if pending_seed_cells.is_empty() { + return Ok(()); + } + + let max_flips = local_repair_flip_budget::(pending_seed_cells.len()); + let trace_repair = batch_repair_trace_enabled(); + let repair_started = trace_repair.then(Instant::now); + if trace_repair { + tracing::debug!( + idx = index, + seed_cells = pending_seed_cells.len(), + max_flips, + "bulk batch repair: starting local repair" + ); + } + + let repair_result = { + self.invalidate_repair_caches(); + let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); + repair_delaunay_local_single_pass(tds, kernel, pending_seed_cells, max_flips) + }; + #[cfg(test)] + let repair_result = if test_hooks::force_repair_nonconvergent_enabled() { + Err(test_hooks::synthetic_nonconvergent_error()) + } else { + repair_result + }; + + match repair_result { + Ok(stats) => { + if trace_repair { + tracing::debug!( + idx = index, + seed_cells = pending_seed_cells.len(), + flips = stats.flips_performed, + checked = stats.facets_checked, + max_queue = stats.max_queue_len, + elapsed = ?repair_started.map(|started| started.elapsed()), + "bulk batch repair: local repair succeeded" + ); + } + if stats.flips_performed > 0 { + self.canonicalize_after_bulk_repair()?; + } + Self::clear_local_repair_seed_cells(pending_seed_cells, pending_seen); + } + Err(repair_err) => { + if trace_repair { + tracing::debug!( + idx = index, + seed_cells = pending_seed_cells.len(), + error = %repair_err, + elapsed = ?repair_started.map(|started| started.elapsed()), + "bulk batch repair: local repair failed" + ); + } + if D < 4 { + let topology = self.tri.topology_guarantee(); + self.invalidate_repair_caches(); + Self::try_d_lt4_global_repair_fallback( + &mut self.tri.tds, + &self.tri.kernel, + topology, + self.insertion_state.use_global_repair_fallback, + index, + &repair_err, + )?; + self.canonicalize_after_bulk_repair()?; + Self::clear_local_repair_seed_cells(pending_seed_cells, pending_seen); + return Ok(()); + } + + if !Self::can_soft_fail(&repair_err) { + return Err(Self::map_hard_repair_error(index, &repair_err)); + } + let outcome = self.try_local_repair_escalation_d_ge_4( + index, + max_flips, + last_escalation_idx, + &repair_err, + )?; + match outcome { + LocalRepairEscalationOutcome::Succeeded { stats } => { + tracing::debug!( + idx = index, + flips = stats.flips_performed, + max_queue = stats.max_queue_len, + "bulk D≥4: escalation closed the non-convergence; continuing" + ); + } + LocalRepairEscalationOutcome::Skipped { reason } => { + tracing::debug!( + idx = index, + error = %repair_err, + escalation_outcome = "skipped", + skip_reason = ?reason, + "bulk D≥4: batch repair non-convergent; continuing \ + (both_positive_artifact handled)" + ); + self.canonicalize_after_bulk_repair()?; + soft_fail_seeds.extend(pending_seed_cells.iter().copied()); + } + LocalRepairEscalationOutcome::FailedAlso { escalation_error } => { + tracing::debug!( + idx = index, + error = %repair_err, + escalation_outcome = "failed_also", + escalation_error = %escalation_error, + "bulk D≥4: batch repair non-convergent; continuing \ + (both_positive_artifact handled)" + ); + self.canonicalize_after_bulk_repair()?; + soft_fail_seeds.extend(pending_seed_cells.iter().copied()); + } + } + Self::clear_local_repair_seed_cells(pending_seed_cells, pending_seen); + } + } + Ok(()) + } + /// Inserts the non-simplex vertices under a fixed perturbation seed so bulk /// construction retries are reproducible. #[expect( clippy::too_many_lines, reason = "seeded insertion loop keeps cache repair and retry diagnostics in one flow" )] + #[expect( + clippy::too_many_arguments, + reason = "seeded insertion loop needs batch repair and construction-statistics state" + )] fn insert_remaining_vertices_seeded( &mut self, vertices: &[Vertex], perturbation_seed: u64, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, construction_stats: Option<&mut ConstructionStatistics>, + pending_repair_seeds: &mut Vec, soft_fail_seeds: &mut Vec, ) -> Result<(), DelaunayTriangulationConstructionError> { let mut grid_index = grid_cell_size.map(HashGridIndex::new); @@ -3906,6 +4309,8 @@ where // used for `LOCAL_REPAIR_ESCALATION_MIN_GAP` rate limiting across both // stats-enabled and stats-disabled arms. let mut last_escalation_idx: Option = None; + let mut pending_repair_seen: FastHashSet = + pending_repair_seeds.iter().copied().collect(); match construction_stats { None => { @@ -3971,154 +4376,37 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - // Per-insertion local Delaunay repair: seeded from the star of - // the inserted vertex with a seed-proportional flip budget. - // - // For D<4: the flip graph is proven convergent (Lawson 1977 for - // D=2, Rajan 1991/Joe 1991 for D=3). On cycling (FP noise near - // co-spherical configurations), roll back the insertion and retry - // with perturbation to break the co-sphericity. - // - // For D≥4: Bowyer-Watson with the fast kernel can produce - // non-Delaunay facets when the conflict region is detected - // imprecisely (co-spherical configurations). A bounded - // per-insertion repair pass fixes these violations. If repair - // does not converge (e.g. co-spherical cycling suppressed by - // both_positive_artifact), the soft-fail path lets construction - // continue; the final is_valid() check validates the result. + // Cadenced local Delaunay repair: accumulate the local frontier + // touched by each successful insertion, then repair the whole + // frontier when the policy fires. This keeps EveryN semantics + // local to the last N insertions rather than repairing only the + // final insertion in the batch. let topology = self.tri.topology_guarantee(); - if D >= 2 + if batch_repair_policy != DelaunayRepairPolicy::Never && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - let seed_cells = - self.collect_local_repair_seed_cells(v_key, &repair_seed_cells); - if !seed_cells.is_empty() { - let max_flips = local_repair_flip_budget::(seed_cells.len()); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass( - tds, - kernel, - &seed_cells, - max_flips, - ) - }; - #[cfg(test)] - let repair_result = - if test_hooks::force_repair_nonconvergent_enabled() { - Err(test_hooks::synthetic_nonconvergent_error()) - } else { - repair_result - }; - match repair_result { - Ok(stats) => { - if stats.flips_performed > 0 { - self.canonicalize_after_bulk_repair()?; - } - } - Err(repair_err) => { - if D < 4 { - self.invalidate_repair_caches(); - Self::try_d_lt4_global_repair_fallback( - &mut self.tri.tds, - &self.tri.kernel, - topology, - self.insertion_state.use_global_repair_fallback, - index, - &repair_err, - )?; - self.canonicalize_after_bulk_repair()?; - log_bulk_progress_if_due( - BatchProgressSample { - processed: offset + 1, - inserted: inserted_vertices, - skipped: skipped_vertices, - cell_count: self.tri.tds.number_of_cells(), - perturbation_seed, - }, - &mut batch_progress, - ); - continue; - } - // D≥4: try one escalation with a 4× budget and the full - // TDS as seed set before accepting the soft-fail. The - // escalation is rate-limited so healthy runs do not pay - // for it on every insertion. - if !Self::can_soft_fail(&repair_err) { - return Err(Self::map_hard_repair_error( - index, - &repair_err, - )); - } - let outcome = self.try_local_repair_escalation_d_ge_4( - index, - max_flips, - &mut last_escalation_idx, - &repair_err, - )?; - match outcome { - LocalRepairEscalationOutcome::Succeeded { - stats, - } => { - tracing::debug!( - idx = index, - flips = stats.flips_performed, - max_queue = stats.max_queue_len, - "bulk D≥4: escalation closed the \ - non-convergence; continuing" - ); - log_bulk_progress_if_due( - BatchProgressSample { - processed: offset + 1, - inserted: inserted_vertices, - skipped: skipped_vertices, - cell_count: self - .tri - .tds - .number_of_cells(), - perturbation_seed, - }, - &mut batch_progress, - ); - continue; - } - LocalRepairEscalationOutcome::Skipped { - reason, - } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "skipped", - skip_reason = ?reason, - "bulk D≥4: per-insertion repair \ - non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds - .extend(seed_cells.iter().copied()); - } - LocalRepairEscalationOutcome::FailedAlso { - escalation_error, - } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "failed_also", - escalation_error = %escalation_error, - "bulk D≥4: per-insertion repair \ - non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds - .extend(seed_cells.iter().copied()); - } - } - } - } + self.extend_local_repair_seed_cells( + v_key, + &repair_seed_cells, + pending_repair_seeds, + &mut pending_repair_seen, + ); + if matches!( + batch_repair_policy.decide( + inserted_vertices, + topology, + TopologicalOperation::FacetFlip, + ), + RepairDecision::Proceed + ) { + self.repair_pending_local_seed_cells( + index, + pending_repair_seeds, + &mut pending_repair_seen, + &mut last_escalation_idx, + soft_fail_seeds, + )?; } } log_bulk_progress_if_due( @@ -4223,7 +4511,12 @@ where let elapsed = started.map(|started| started.elapsed()); let insert_result = insert_result.map(|detail| { let repair_seed_cells = detail.repair_seed_cells; - (detail.outcome, detail.stats, repair_seed_cells) + ( + detail.outcome, + detail.stats, + repair_seed_cells, + detail.telemetry, + ) }); match insert_result { Ok(( @@ -4233,6 +4526,7 @@ where }, stats, repair_seed_cells, + telemetry, )) => { inserted_vertices = inserted_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { @@ -4245,6 +4539,7 @@ where ); } construction_stats.record_insertion(&stats); + construction_stats.telemetry.record_insertion(&telemetry); // Cache hint for faster subsequent insertions. self.insertion_state.last_inserted_cell = hint; @@ -4252,141 +4547,34 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - // Per-insertion local repair: see the non-stats branch + // Cadenced local repair: see the non-stats branch // comment for full details. let topology = self.tri.topology_guarantee(); - if D >= 2 + if batch_repair_policy != DelaunayRepairPolicy::Never && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - let seed_cells = - self.collect_local_repair_seed_cells(v_key, &repair_seed_cells); - if !seed_cells.is_empty() { - let max_flips = local_repair_flip_budget::(seed_cells.len()); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass( - tds, - kernel, - &seed_cells, - max_flips, - ) - }; - #[cfg(test)] - let repair_result = - if test_hooks::force_repair_nonconvergent_enabled() { - Err(test_hooks::synthetic_nonconvergent_error()) - } else { - repair_result - }; - match repair_result { - Ok(stats) => { - if stats.flips_performed > 0 { - self.canonicalize_after_bulk_repair()?; - } - } - Err(repair_err) => { - if D < 4 { - self.invalidate_repair_caches(); - Self::try_d_lt4_global_repair_fallback( - &mut self.tri.tds, - &self.tri.kernel, - topology, - self.insertion_state.use_global_repair_fallback, - index, - &repair_err, - )?; - self.canonicalize_after_bulk_repair()?; - log_bulk_progress_if_due( - BatchProgressSample { - processed: offset + 1, - inserted: inserted_vertices, - skipped: skipped_vertices, - cell_count: self.tri.tds.number_of_cells(), - perturbation_seed, - }, - &mut batch_progress, - ); - continue; - } - // D≥4: try one escalation with a 4× budget and the full - // TDS as seed set before accepting the soft-fail. The - // escalation is rate-limited so healthy runs do not pay - // for it on every insertion. - if !Self::can_soft_fail(&repair_err) { - return Err(Self::map_hard_repair_error( - index, - &repair_err, - )); - } - let outcome = self.try_local_repair_escalation_d_ge_4( - index, - max_flips, - &mut last_escalation_idx, - &repair_err, - )?; - match outcome { - LocalRepairEscalationOutcome::Succeeded { - stats, - } => { - tracing::debug!( - idx = index, - flips = stats.flips_performed, - max_queue = stats.max_queue_len, - "bulk D≥4: escalation closed the \ - non-convergence; continuing" - ); - log_bulk_progress_if_due( - BatchProgressSample { - processed: offset + 1, - inserted: inserted_vertices, - skipped: skipped_vertices, - cell_count: self - .tri - .tds - .number_of_cells(), - perturbation_seed, - }, - &mut batch_progress, - ); - continue; - } - LocalRepairEscalationOutcome::Skipped { - reason, - } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "skipped", - skip_reason = ?reason, - "bulk D≥4: per-insertion repair \ - non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds - .extend(seed_cells.iter().copied()); - } - LocalRepairEscalationOutcome::FailedAlso { - escalation_error, - } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "failed_also", - escalation_error = %escalation_error, - "bulk D≥4: per-insertion repair \ - non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds - .extend(seed_cells.iter().copied()); - } - } - } - } + self.extend_local_repair_seed_cells( + v_key, + &repair_seed_cells, + pending_repair_seeds, + &mut pending_repair_seen, + ); + if matches!( + batch_repair_policy.decide( + inserted_vertices, + topology, + TopologicalOperation::FacetFlip, + ), + RepairDecision::Proceed + ) { + self.repair_pending_local_seed_cells( + index, + pending_repair_seeds, + &mut pending_repair_seen, + &mut last_escalation_idx, + soft_fail_seeds, + )?; } } log_bulk_progress_if_due( @@ -4400,7 +4588,12 @@ where &mut batch_progress, ); } - Ok((InsertionOutcome::Skipped { error }, stats, _repair_seed_cells)) => { + Ok(( + InsertionOutcome::Skipped { error }, + stats, + _repair_seed_cells, + telemetry, + )) => { skipped_vertices = skipped_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { tracing::debug!( @@ -4413,6 +4606,7 @@ where ); } construction_stats.record_insertion(&stats); + construction_stats.telemetry.record_insertion(&telemetry); // Keep the first few skip samples so we have concrete reproduction anchors. let (coords, coords_available) = vertex_coords_f64(vertex) @@ -4480,76 +4674,56 @@ where original_validation_policy: ValidationPolicy, original_repair_policy: DelaunayRepairPolicy, run_final_repair: bool, + batch_repair_policy: DelaunayRepairPolicy, + pending_repair_seeds: &[CellKey], soft_fail_seeds: &[CellKey], ) -> Result<(), DelaunayTriangulationConstructionError> { // Restore policies after batch construction. self.tri.validation_policy = original_validation_policy; self.insertion_state.delaunay_repair_policy = original_repair_policy; - let topology = self.tri.topology_guarantee(); - if run_final_repair && self.should_run_delaunay_repair_for(topology, 0) { - // For D≥4: always run a global repair seeded from ALL cells. - // BW with the fast kernel can produce non-Delaunay facets anywhere, - // not only in the star of soft-failed insertions. A small fixed - // budget ensures we fail fast on cycling rather than spending minutes. - // Non-convergence is a soft-fail; correctness is validated by - // is_delaunay_property_only() in build_with_shuffled_retries. - // - // For D<4: repair is proven convergent; per-insertion repair now - // falls back to global repair_delaunay_with_flips_k2_k3 on - // local non-convergence, so soft_fail_seeds is typically empty - // for D<4. The seeded path below is kept for completeness. - if D >= 4 { - let cell_count = self.tri.tds.number_of_cells(); - if cell_count > 0 { - let all_cells: Vec = self.tri.tds.cell_keys().collect(); - tracing::debug!( - cell_count, - "post-construction: starting global D≥4 finalize repair" - ); - let repair_started = Instant::now(); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass(tds, kernel, &all_cells, 512).map(|_| ()) - }; - tracing::debug!( - elapsed = ?repair_started.elapsed(), - success = repair_result.is_ok(), - "post-construction: D≥4 finalize repair completed (soft-fail)" - ); - // Always soft-fail: is_delaunay_property_only() validates correctness. - } - } else if !soft_fail_seeds.is_empty() { - // D<4 seeded repair (unused in practice; kept for completeness). - tracing::debug!( - seed_count = soft_fail_seeds.len(), - "post-construction: starting seeded D<4 finalize repair" - ); - let repair_started = Instant::now(); - let max_flips = (soft_fail_seeds.len() * (D + 1) * 16).max(512); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass(tds, kernel, soft_fail_seeds, max_flips) - .map(|_| ()) - }; - let repair_outcome: Result<(), DelaunayTriangulationConstructionError> = - match repair_result { - Ok(()) => Ok(()), - Err(e) => Err(TriangulationConstructionError::GeometricDegeneracy { - message: format!("Delaunay repair failed after construction: {e}"), - } - .into()), - }; - tracing::debug!( - elapsed = ?repair_started.elapsed(), - success = repair_outcome.is_ok(), - "post-construction: D<4 finalize repair completed" - ); - repair_outcome?; + let has_cells = self.tri.tds.number_of_cells() > 0; + let mut completion_seed_cells = Vec::new(); + let mut completion_seen = FastHashSet::default(); + for &cell_key in pending_repair_seeds.iter().chain(soft_fail_seeds.iter()) { + if self.tri.tds.contains_cell(cell_key) && completion_seen.insert(cell_key) { + completion_seed_cells.push(cell_key); } } + if run_final_repair + && has_cells + && batch_repair_policy != DelaunayRepairPolicy::Never + && !completion_seed_cells.is_empty() + { + let seed_count = completion_seed_cells.len(); + let max_flips = local_repair_flip_budget::(seed_count); + tracing::debug!( + seed_count, + max_flips, + "post-construction: starting seeded completion Delaunay repair" + ); + let repair_started = Instant::now(); + let repair_result = { + self.invalidate_repair_caches(); + let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); + repair_delaunay_local_single_pass(tds, kernel, &completion_seed_cells, max_flips) + .map(|_| ()) + }; + let repair_outcome: Result<(), DelaunayTriangulationConstructionError> = + match repair_result { + Ok(()) => Ok(()), + Err(e) => Err(TriangulationConstructionError::GeometricDegeneracy { + message: format!("Delaunay repair failed after construction: {e}"), + } + .into()), + }; + tracing::debug!( + elapsed = ?repair_started.elapsed(), + success = repair_outcome.is_ok(), + "post-construction: seeded completion Delaunay repair finished" + ); + repair_outcome?; + } // Flip-based repair calls normalize_coherent_orientation() which makes all cells // combinatorially coherent but can leave the global sign negative. Re-canonicalize @@ -4558,6 +4732,7 @@ where .normalize_and_promote_positive_orientation() .map_err(Self::map_orientation_canonicalization_error)?; + let topology = self.tri.topology_guarantee(); if topology.requires_vertex_links_at_completion() { tracing::debug!("post-construction: starting topology validation (finalize)"); let validation_started = Instant::now(); @@ -7479,7 +7654,7 @@ mod tests { CavityFillingError, HullExtensionReason, NeighborWiringError, repair_neighbor_pointers, }; use crate::core::algorithms::locate::{ConflictError, LocateError}; - use crate::core::operations::InsertionResult; + use crate::core::operations::{InsertionResult, InsertionTelemetry}; use crate::core::tds::{EntityKind, GeometricError, TriangulationConstructionState}; use crate::core::vertex::VertexBuilder; use crate::geometry::kernel::{AdaptiveKernel, FastKernel, RobustKernel}; @@ -7783,10 +7958,15 @@ mod tests { let opts = ConstructionOptions::default() .with_insertion_order(InsertionOrderStrategy::Input) .with_dedup_policy(DedupPolicy::Exact) + .with_batch_repair_policy(DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap())) .with_retry_policy(RetryPolicy::Disabled); assert_eq!(opts.insertion_order(), InsertionOrderStrategy::Input); assert_eq!(opts.dedup_policy(), DedupPolicy::Exact); + assert_eq!( + opts.batch_repair_policy(), + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()) + ); assert_eq!(opts.retry_policy(), RetryPolicy::Disabled); } @@ -8192,6 +8372,48 @@ mod tests { assert!(matches!(stats.result, InsertionResult::Inserted)); } + #[test] + fn test_construction_statistics_record_insertion_tracks_telemetry() { + init_tracing(); + + let mut summary = ConstructionStatistics::default(); + let telemetry = InsertionTelemetry { + locate_calls: 2, + locate_walk_steps_total: 9, + locate_walk_steps_max: 7, + locate_hint_uses: 1, + locate_scan_fallbacks: 1, + located_inside: 1, + located_outside: 1, + conflict_region_calls: 1, + conflict_region_cells_total: 4, + conflict_region_cells_max: 4, + global_conflict_scans: 1, + global_conflict_cells_scanned: 12, + global_conflict_cells_found_total: 3, + global_conflict_cells_found_max: 3, + global_conflict_scan_nanos: 250_000, + ..InsertionTelemetry::default() + }; + + summary.telemetry.record_insertion(&telemetry); + + assert!(summary.telemetry.has_data()); + assert_eq!(summary.telemetry.locate_calls, 2); + assert_eq!(summary.telemetry.locate_walk_steps_total, 9); + assert_eq!(summary.telemetry.locate_walk_steps_max, 7); + assert_eq!(summary.telemetry.locate_hint_uses, 1); + assert_eq!(summary.telemetry.locate_scan_fallbacks, 1); + assert_eq!(summary.telemetry.located_inside, 1); + assert_eq!(summary.telemetry.located_outside, 1); + assert_eq!(summary.telemetry.conflict_region_calls, 1); + assert_eq!(summary.telemetry.conflict_region_cells_total, 4); + assert_eq!(summary.telemetry.global_conflict_scans, 1); + assert_eq!(summary.telemetry.global_conflict_cells_scanned, 12); + assert_eq!(summary.telemetry.global_conflict_cells_found_total, 3); + assert_eq!(summary.telemetry.global_conflict_scan_nanos, 250_000); + } + #[test] fn test_construction_statistics_record_insertion_tracks_skipped_variants() { init_tracing(); diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index fd45e878..ac796cee 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -31,7 +31,12 @@ //! # - "new" (default): build via DelaunayTriangulation::new() which applies Hilbert ordering //! # - "incremental": manual insert loop (debug/profiling) //! DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE=new \ -//! # Debug mode: "cadenced" (default, repair/validate on a cadence) or "strict" (per-insertion) +//! # Initial simplex strategy for batch construction: "first" (default) or "balanced" +//! DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX=balanced \ +//! # Debug mode: +//! # - "cadenced" (default): PLManifold, ridge-link validation during insertion, +//! # vertex-link validation at completion +//! # - "strict": PLManifoldStrict, vertex-link validation after every insertion //! DELAUNAY_LARGE_DEBUG_DEBUG_MODE=cadenced \ //! # Deterministically shuffle insertion order (incremental mode only) //! DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED=123 \ @@ -39,12 +44,16 @@ //! DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY=1000 \ //! # (Optional) validate topology every N insertions once cells exist (incremental mode only; can be expensive) //! DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY=2000 \ -//! # Allow skipped vertices (otherwise the test fails if any are skipped) +//! # Maximum skipped-vertex percentage before the run fails (default: 5.0) +//! DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT=5.0 \ +//! # Allow any number of skipped vertices (bypasses DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT) //! DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ //! # Skip the final flip-based repair pass (faster, but may leave Delaunay violations) //! DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR=1 \ -//! # Run bounded incremental flip repair every N successful insertions (incremental mode only; 0 disables; default: 128) -//! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=128 \ +//! # Run bounded flip repair every N successful insertions (0 disables; default: 4) +//! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=4 \ +//! # Optional: trace cadenced local-repair seed counts, flips, queues, and elapsed time +//! DELAUNAY_BATCH_REPAIR_TRACE=1 \ //! # Hard wall-clock cap in seconds before the harness aborts (0 = no cap; default: 600) //! DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=600 \ //! # Optional: emit periodic batch-construction summaries for new()/Hilbert runs @@ -72,8 +81,8 @@ use delaunay::geometry::util::{ use delaunay::prelude::tds::{InvariantKind, TriangulationValidationReport}; use delaunay::prelude::triangulation::*; use delaunay::triangulation::delaunay::{ - ConstructionOptions, ConstructionStatistics, DelaunayRepairHeuristicConfig, - DelaunayTriangulationConstructionErrorWithStatistics, + ConstructionOptions, ConstructionStatistics, ConstructionTelemetry, + DelaunayRepairHeuristicConfig, DelaunayTriangulationConstructionErrorWithStatistics, }; use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; use std::env; @@ -146,6 +155,8 @@ struct InsertionSummary { cells_removed_total: usize, cells_removed_max: usize, + telemetry: ConstructionTelemetry, + skip_samples: Vec>, } @@ -240,6 +251,7 @@ impl From for InsertionSummary { used_perturbation: stats.used_perturbation, cells_removed_total: stats.cells_removed_total, cells_removed_max: stats.cells_removed_max, + telemetry: stats.telemetry, skip_samples, } } @@ -288,6 +300,7 @@ fn init_tracing() { "DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET", "DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY", "DELAUNAY_BULK_PROGRESS_EVERY", + "DELAUNAY_BATCH_REPAIR_TRACE", "DELAUNAY_DEBUG_SHUFFLE", ]; let default_filter = if debug_env_vars @@ -330,6 +343,39 @@ impl ConstructionMode { } } +const fn initial_simplex_strategy_name(strategy: InitialSimplexStrategy) -> &'static str { + match strategy { + InitialSimplexStrategy::First => "first", + InitialSimplexStrategy::Balanced => "balanced", + _ => "unknown", + } +} + +fn initial_simplex_strategy_from_name(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("first") { + return Some(InitialSimplexStrategy::First); + } + + if raw.eq_ignore_ascii_case("balanced") { + return Some(InitialSimplexStrategy::Balanced); + } + + None +} + +fn initial_simplex_strategy_from_env() -> InitialSimplexStrategy { + let Ok(raw) = env::var("DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX") else { + return InitialSimplexStrategy::First; + }; + + initial_simplex_strategy_from_name(&raw).unwrap_or_else(|| { + panic!( + "invalid DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX={raw:?} (expected 'first' or 'balanced')" + ) + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DebugMode { /// Faster default: repair/check in cadence, with suspicion-driven automatic validation. @@ -347,6 +393,23 @@ impl DebugMode { } } +/// Selects the topology guarantee that matches the requested debug intensity. +const fn topology_for_debug_mode(debug_mode: DebugMode) -> TopologyGuarantee { + match debug_mode { + DebugMode::Cadenced => TopologyGuarantee::PLManifold, + DebugMode::Strict => TopologyGuarantee::PLManifoldStrict, + } +} + +/// Converts the repair cadence knob into the policy shared by batch and incremental modes. +const fn repair_policy_from_repair_every(repair_every: usize) -> DelaunayRepairPolicy { + match NonZeroUsize::new(repair_every) { + None => DelaunayRepairPolicy::Never, + Some(n) if n.get() == 1 => DelaunayRepairPolicy::EveryInsertion, + Some(n) => DelaunayRepairPolicy::EveryN(n), + } +} + impl PointDistribution { const fn name(self) -> &'static str { match self { @@ -356,6 +419,29 @@ impl PointDistribution { } } +/// Reads the skipped-vertex percentage budget for large-scale debug runs. +fn max_skip_pct_from_env() -> f64 { + let max_skip_pct = env_f64("DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT").unwrap_or(5.0); + assert!( + max_skip_pct.is_finite() && max_skip_pct >= 0.0, + "invalid DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT={max_skip_pct:?} (expected finite non-negative percentage)" + ); + max_skip_pct +} + +/// Computes the skipped-vertex percentage for budget checks and reporting. +fn skip_percentage(skipped: usize, total: usize) -> f64 { + if total == 0 { + return 0.0; + } + + let skipped = safe_usize_to_scalar::(skipped) + .expect("skipped-vertex count should fit in f64 for debug reporting"); + let total = safe_usize_to_scalar::(total) + .expect("point count should fit in f64 for debug reporting"); + (skipped / total) * 100.0 +} + fn env_f64(name: &str) -> Option { let Ok(raw) = env::var(name) else { return None; @@ -444,6 +530,8 @@ enum DebugOutcome { SkippedVertices { skipped: usize, total: usize, + skip_pct: f64, + max_skip_pct: f64, }, RepairNonConvergence { error: String, @@ -461,8 +549,16 @@ impl fmt::Display for DebugOutcome { Self::ConstructionFailure { error } => { write!(f, "ConstructionFailure | {error}") } - Self::SkippedVertices { skipped, total } => { - write!(f, "SkippedVertices | {skipped}/{total} skipped") + Self::SkippedVertices { + skipped, + total, + skip_pct, + max_skip_pct, + } => { + write!( + f, + "SkippedVertices | {skipped}/{total} skipped ({skip_pct:.2}%, max {max_skip_pct:.2}%)" + ) } Self::RepairNonConvergence { error } => { write!(f, "RepairNonConvergence | {error}") @@ -514,6 +610,84 @@ fn print_validation_report(report: &TriangulationValidationReport) { } } +fn usize_to_u128(value: usize) -> u128 { + u128::try_from(value).expect("usize should always fit in u128") +} + +fn format_ratio_2(numerator: usize, denominator: usize) -> String { + format_ratio_2_u128(usize_to_u128(numerator), usize_to_u128(denominator)) +} + +fn format_ratio_2_u128(numerator: u128, denominator: u128) -> String { + if denominator == 0 { + return "0.00".to_string(); + } + + let scaled = numerator.saturating_mul(100) / denominator; + format!("{}.{:02}", scaled / 100, scaled % 100) +} + +fn format_nanos_as_ms(nanos: u128) -> String { + let micros = nanos / 1_000; + format!("{}.{:03}", micros / 1_000, micros % 1_000) +} + +fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { + if !telemetry.has_data() { + return; + } + + println!(); + println!(" insertion telemetry:"); + println!( + " locate: calls={} walk_steps_total={} avg_walk={} max_walk={} hint_uses={} scan_fallbacks={}", + telemetry.locate_calls, + telemetry.locate_walk_steps_total, + format_ratio_2(telemetry.locate_walk_steps_total, telemetry.locate_calls), + telemetry.locate_walk_steps_max, + telemetry.locate_hint_uses, + telemetry.locate_scan_fallbacks + ); + println!( + " locate_results: inside={} outside={} boundary={}", + telemetry.located_inside, telemetry.located_outside, telemetry.located_on_boundary + ); + + if telemetry.conflict_region_calls > 0 { + println!( + " conflict_regions: calls={} cells_total={} avg_cells={} max_cells={}", + telemetry.conflict_region_calls, + telemetry.conflict_region_cells_total, + format_ratio_2( + telemetry.conflict_region_cells_total, + telemetry.conflict_region_calls, + ), + telemetry.conflict_region_cells_max + ); + } + + if telemetry.global_conflict_scans > 0 { + let scans = usize_to_u128(telemetry.global_conflict_scans); + println!( + " global_conflict_scans: scans={} cells_scanned_total={} avg_cells_scanned={} cells_found_total={} avg_cells_found={} max_cells_found={} total_ms={} avg_ms={}", + telemetry.global_conflict_scans, + telemetry.global_conflict_cells_scanned, + format_ratio_2( + telemetry.global_conflict_cells_scanned, + telemetry.global_conflict_scans, + ), + telemetry.global_conflict_cells_found_total, + format_ratio_2( + telemetry.global_conflict_cells_found_total, + telemetry.global_conflict_scans, + ), + telemetry.global_conflict_cells_found_max, + format_nanos_as_ms(telemetry.global_conflict_scan_nanos), + format_nanos_as_ms(telemetry.global_conflict_scan_nanos / scans) + ); + } +} + fn print_insertion_summary(summary: &InsertionSummary, elapsed: Duration) { println!("Insertion summary:"); println!(" inserted: {}", summary.inserted); @@ -539,6 +713,8 @@ fn print_insertion_summary(summary: &InsertionSummary, elapse } } + print_construction_telemetry(&summary.telemetry); + if !summary.skip_samples.is_empty() { println!(); println!(" skip_samples (first {}):", summary.skip_samples.len()); @@ -587,7 +763,9 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz let box_half_width = env_f64("DELAUNAY_LARGE_DEBUG_BOX_HALF_WIDTH").unwrap_or(100.0); let mode = construction_mode_from_env(); + let initial_simplex_strategy = initial_simplex_strategy_from_env(); let debug_mode = debug_mode_from_env(); + let topology_guarantee = topology_for_debug_mode(debug_mode); let shuffle_seed = env_u64("DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED"); let progress_every = env_usize("DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY") @@ -595,13 +773,15 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz .max(1); let allow_skips = env_flag("DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS"); + let max_skip_pct = max_skip_pct_from_env(); let skip_final_repair = env_flag("DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR"); // Delaunay repair scheduling // - 0 disables incremental repair // - 1 runs repair after every insertion // - N>1 runs repair after every N successful insertions - let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(128); + let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(4); + let repair_policy = repair_policy_from_repair_every(repair_every); let repair_max_flips = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_MAX_FLIPS"); let validate_every = env_usize("DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY").or_else(|| { if matches!(debug_mode, DebugMode::Cadenced) { @@ -628,13 +808,20 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz PointDistribution::Box => println!(" box_half_width:{box_half_width}"), } println!(" construction_mode: {}", mode.name()); + println!( + " initial_simplex: {}", + initial_simplex_strategy_name(initial_simplex_strategy) + ); println!(" debug_mode: {}", debug_mode.name()); + println!(" topology_guarantee: {topology_guarantee:?}"); println!(" shuffle_seed: {shuffle_seed:?}"); println!(" progress_every:{progress_every}"); println!(" validate_every:{validate_every:?}"); println!(" allow_skips: {allow_skips}"); + println!(" max_skip_pct: {max_skip_pct}"); println!(" skip_final_repair: {skip_final_repair}"); println!(" repair_every: {repair_every}"); + println!(" repair_policy: {repair_policy:?}"); println!(" repair_max_flips: {repair_max_flips:?}"); if max_runtime_secs > 0 { println!(" max_runtime_secs: {max_runtime_secs}"); @@ -676,17 +863,17 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz ConstructionMode::New => { // `DelaunayTriangulation::new()` applies Hilbert ordering during batch construction. // Use the statistics-returning variant so we can report aggregate insertion telemetry. - // - // Use PLManifoldStrict during batch construction to ensure vertex-link invariants are - // maintained on each insertion. let kernel = RobustKernel::::new(); println!("Starting batch construction (new)..."); let t_batch = Instant::now(); + let options = ConstructionOptions::default() + .with_initial_simplex_strategy(initial_simplex_strategy) + .with_batch_repair_policy(repair_policy); match DelaunayTriangulation::with_options_and_statistics( &kernel, &vertices, - TopologyGuarantee::PLManifoldStrict, - ConstructionOptions::default(), + topology_guarantee, + options, ) { Ok((dt, stats)) => { let summary: InsertionSummary = stats.into(); @@ -719,15 +906,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz println!("Shuffled insertion order with seed {shuffle_seed}"); } - let (topology_guarantee, validation_policy) = match debug_mode { - DebugMode::Cadenced => { - (TopologyGuarantee::PLManifold, ValidationPolicy::OnSuspicion) - } - DebugMode::Strict => ( - TopologyGuarantee::PLManifoldStrict, - ValidationPolicy::Always, - ), - }; + let validation_policy = topology_guarantee.default_validation_policy(); let mut dt: DelaunayTriangulation<_, (), (), D> = DelaunayTriangulation::with_empty_kernel_and_topology_guarantee( @@ -739,11 +918,6 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz // - Enable bounded incremental repair (local flip queue) every N successful insertions. // - Keep global Delaunay checks off during insertion; the harness can optionally run a final // global repair pass at the end. - let repair_policy = match NonZeroUsize::new(repair_every) { - None => DelaunayRepairPolicy::Never, - Some(n) if n.get() == 1 => DelaunayRepairPolicy::EveryInsertion, - Some(n) => DelaunayRepairPolicy::EveryN(n), - }; dt.set_delaunay_repair_policy(repair_policy); dt.set_delaunay_check_policy(DelaunayCheckPolicy::EndOnly); @@ -861,10 +1035,13 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz dt.dim() ); - if !allow_skips && skipped_total > 0 { + let skipped_pct = skip_percentage(skipped_total, n_points); + if !allow_skips && skipped_pct > max_skip_pct { let outcome = DebugOutcome::SkippedVertices { skipped: skipped_total, total: n_points, + skip_pct: skipped_pct, + max_skip_pct, }; print_abort_summary::(&outcome, seed, n_points, "skip check"); return outcome; @@ -988,6 +1165,66 @@ fn test_write_timeout_abort_message_propagates_error() { } } +#[test] +fn test_topology_for_debug_mode_uses_ridge_links_by_default() { + assert_eq!( + topology_for_debug_mode(DebugMode::Cadenced), + TopologyGuarantee::PLManifold + ); + assert_eq!( + topology_for_debug_mode(DebugMode::Strict), + TopologyGuarantee::PLManifoldStrict + ); +} + +#[test] +fn test_initial_simplex_strategy_from_name_maps_ab_switch() { + assert_eq!( + initial_simplex_strategy_from_name(""), + Some(InitialSimplexStrategy::First) + ); + assert_eq!( + initial_simplex_strategy_from_name("first"), + Some(InitialSimplexStrategy::First) + ); + assert_eq!( + initial_simplex_strategy_from_name("BALANCED"), + Some(InitialSimplexStrategy::Balanced) + ); + assert_eq!(initial_simplex_strategy_from_name("unknown"), None); + assert_eq!( + initial_simplex_strategy_name(InitialSimplexStrategy::Balanced), + "balanced" + ); +} + +#[test] +fn test_skip_percentage_reports_ratio() { + assert!((skip_percentage(0, 100) - 0.0).abs() < f64::EPSILON); + assert!((skip_percentage(4, 400) - 1.0).abs() < f64::EPSILON); + assert!((skip_percentage(12, 100) - 12.0).abs() < f64::EPSILON); +} + +#[test] +fn test_repair_policy_from_repair_every_maps_cadence() { + assert_eq!( + repair_policy_from_repair_every(0), + DelaunayRepairPolicy::Never + ); + assert_eq!( + repair_policy_from_repair_every(1), + DelaunayRepairPolicy::EveryInsertion + ); + assert_eq!( + repair_policy_from_repair_every(2), + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap()) + ); + assert_eq!( + repair_policy_from_repair_every(4), + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()) + ); +} + /// Regression test for issue #228: 3D 1000-point flip-repair non-convergence. /// /// Before the fix, `AdaptiveKernel`'s exact+SoS predicates were overridden by From d06e49881f8e645979ad47272139efe7f8bda079 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 9 May 2026 12:28:04 -0700 Subject: [PATCH 02/15] perf(triangulation): keep bulk repair seeding local - Return live insertion-created cells as repair seeds so batch construction can accumulate local frontiers without scanning vertex stars globally. - Move repair seed frontier helpers into triangulation locality utilities and defer broad repair fallback to final construction repair. - Extend construction telemetry and large-scale debug output with seed accumulation timing. --- src/core/operations.rs | 27 +- src/core/triangulation.rs | 168 ++++- src/lib.rs | 1 + src/triangulation/delaunay.rs | 1160 ++++++++++++++++----------------- src/triangulation/locality.rs | 142 ++++ tests/large_scale_debug.rs | 170 ++++- 6 files changed, 1048 insertions(+), 620 deletions(-) create mode 100644 src/triangulation/locality.rs diff --git a/src/core/operations.rs b/src/core/operations.rs index 4d190dbf..b98b9025 100644 --- a/src/core/operations.rs +++ b/src/core/operations.rs @@ -260,6 +260,31 @@ pub(crate) struct InsertionTelemetry { pub conflict_region_cells_total: usize, /// Maximum number of cells in a single local conflict region. pub conflict_region_cells_max: usize, + /// Wall-clock nanoseconds spent computing local conflict regions. + pub conflict_region_nanos: u64, + /// Maximum wall-clock nanoseconds spent computing one local conflict region. + pub conflict_region_nanos_max: u64, + + /// Number of cavity insertion attempts observed by insertion. + pub cavity_insertion_calls: usize, + /// Wall-clock nanoseconds spent filling cavities and wiring neighbors. + pub cavity_insertion_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one cavity insertion attempt. + pub cavity_insertion_nanos_max: u64, + + /// Number of hull extension attempts observed by insertion. + pub hull_extension_calls: usize, + /// Wall-clock nanoseconds spent extending the convex hull. + pub hull_extension_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one hull extension attempt. + pub hull_extension_nanos_max: u64, + + /// Number of post-insertion topology validations observed by insertion. + pub topology_validation_calls: usize, + /// Wall-clock nanoseconds spent in post-insertion topology validation. + pub topology_validation_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one post-insertion validation. + pub topology_validation_nanos_max: u64, /// Number of global exterior-point conflict scans. pub global_conflict_scans: usize, @@ -270,7 +295,7 @@ pub(crate) struct InsertionTelemetry { /// Maximum cells found by a single global exterior-point conflict scan. pub global_conflict_cells_found_max: usize, /// Wall-clock nanoseconds spent in global exterior-point conflict scans. - pub global_conflict_scan_nanos: u128, + pub global_conflict_scan_nanos: u64, } /// Ephemeral insertion state used by Delaunay triangulations. diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index e7e1f9dc..867e7298 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -113,11 +113,13 @@ use crate::core::algorithms::incremental_insertion::{ external_facets_for_boundary, fill_cavity, repair_neighbor_pointers, repair_neighbor_pointers_local, wire_cavity_neighbors, }; +#[cfg(debug_assertions)] +use crate::core::algorithms::locate::locate; #[cfg(feature = "diagnostics")] use crate::core::algorithms::locate::verify_conflict_region_completeness; use crate::core::algorithms::locate::{ ConflictError, LocateError, LocateResult, LocateStats, extract_cavity_boundary, - find_conflict_region, locate, locate_by_scan, locate_with_stats, + find_conflict_region, locate_by_scan, locate_with_stats, }; use crate::core::cell::{Cell, CellValidationError}; use crate::core::collections::spatial_hash_grid::HashGridIndex; @@ -164,7 +166,7 @@ use std::sync::{ OnceLock, atomic::{AtomicBool, AtomicU64, Ordering}, }; -use std::time::Instant; +use std::time::{Duration, Instant}; use thiserror::Error; use uuid::Uuid; @@ -836,10 +838,11 @@ struct TryInsertImplOk { cells_removed: usize, /// Suspicion flags observed during the insertion attempt. suspicion: SuspicionFlags, - /// Cells touched while shaping the cavity that should seed follow-up local repair. + /// Cells touched by insertion that should seed follow-up local repair. /// - /// This retains cells that were shrunk out of the final conflict region so higher - /// layers can still revisit them if the insertion leaves a nearby Delaunay violation. + /// This includes live cells created by the insertion plus cells that were shrunk + /// out of the final conflict region so higher layers can revisit nearby + /// Delaunay violations without rediscovering the inserted vertex star globally. repair_seed_cells: CellKeyBuffer, } @@ -880,7 +883,7 @@ pub(crate) struct DetailedInsertionResult { pub stats: InsertionStatistics, /// Internal path telemetry collected while attempting the insertion. pub telemetry: InsertionTelemetry, - /// Extra cells that should widen the caller's local repair seed set. + /// Local cells that should seed the caller's Delaunay repair set. pub repair_seed_cells: CellKeyBuffer, } @@ -4247,7 +4250,13 @@ where return Ok(insert_ok); } - if let Err(validation_err) = self.validate_after_insertion(insert_ok.suspicion) { + let validation_started = Instant::now(); + let validation_result = self.validate_after_insertion(insert_ok.suspicion); + Self::record_topology_validation_telemetry( + telemetry, + Self::duration_nanos_saturating(validation_started.elapsed()), + ); + if let Err(validation_err) = validation_result { // Roll back to snapshot and attempt a star-split fallback for interior points. self.tds = tds_snapshot.clone(); return self.try_star_split_fallback_after_topology_failure( @@ -4297,9 +4306,13 @@ where fallback_ok.suspicion.perturbation_used = true; } - if let Err(fallback_validation_err) = - self.validate_after_insertion(fallback_ok.suspicion) - { + let validation_started = Instant::now(); + let validation_result = self.validate_after_insertion(fallback_ok.suspicion); + Self::record_topology_validation_telemetry( + telemetry, + Self::duration_nanos_saturating(validation_started.elapsed()), + ); + if let Err(fallback_validation_err) = validation_result { return Err(Self::invariant_error_to_insertion_error( fallback_validation_err, )); @@ -5155,6 +5168,14 @@ where // Connectedness guard (STRUCTURAL SAFETY, NOT Level 3 validation) self.validate_connectedness(&new_cells)?; + // Seed follow-up Delaunay repair from the local insertion product. Higher layers + // use these cells to avoid rediscovering the inserted vertex star with a global scan. + for &cell_key in &new_cells { + if self.tds.contains_cell(cell_key) && seen_repair_seed_cells.insert(cell_key) { + repair_seed_cells.push(cell_key); + } + } + // Return hint for next insertion Ok((hint, total_removed, repair_seed_cells)) } @@ -5252,12 +5273,52 @@ where telemetry.conflict_region_cells_max = telemetry.conflict_region_cells_max.max(cells); } + #[inline] + fn record_conflict_region_timing(telemetry: &mut InsertionTelemetry, elapsed_nanos: u64) { + telemetry.conflict_region_nanos = telemetry + .conflict_region_nanos + .saturating_add(elapsed_nanos); + telemetry.conflict_region_nanos_max = + telemetry.conflict_region_nanos_max.max(elapsed_nanos); + } + + #[inline] + fn record_cavity_insertion_telemetry(telemetry: &mut InsertionTelemetry, elapsed_nanos: u64) { + telemetry.cavity_insertion_calls = telemetry.cavity_insertion_calls.saturating_add(1); + telemetry.cavity_insertion_nanos = telemetry + .cavity_insertion_nanos + .saturating_add(elapsed_nanos); + telemetry.cavity_insertion_nanos_max = + telemetry.cavity_insertion_nanos_max.max(elapsed_nanos); + } + + #[inline] + fn record_hull_extension_telemetry(telemetry: &mut InsertionTelemetry, elapsed_nanos: u64) { + telemetry.hull_extension_calls = telemetry.hull_extension_calls.saturating_add(1); + telemetry.hull_extension_nanos = + telemetry.hull_extension_nanos.saturating_add(elapsed_nanos); + telemetry.hull_extension_nanos_max = telemetry.hull_extension_nanos_max.max(elapsed_nanos); + } + + #[inline] + fn record_topology_validation_telemetry( + telemetry: &mut InsertionTelemetry, + elapsed_nanos: u64, + ) { + telemetry.topology_validation_calls = telemetry.topology_validation_calls.saturating_add(1); + telemetry.topology_validation_nanos = telemetry + .topology_validation_nanos + .saturating_add(elapsed_nanos); + telemetry.topology_validation_nanos_max = + telemetry.topology_validation_nanos_max.max(elapsed_nanos); + } + #[inline] fn record_global_conflict_scan_telemetry( telemetry: &mut InsertionTelemetry, cells_scanned: usize, cells_found: usize, - elapsed_nanos: u128, + elapsed_nanos: u64, ) { telemetry.global_conflict_scans = telemetry.global_conflict_scans.saturating_add(1); telemetry.global_conflict_cells_scanned = telemetry @@ -5273,6 +5334,11 @@ where .saturating_add(elapsed_nanos); } + #[inline] + fn duration_nanos_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) + } + /// Internal implementation of insert without retry logic. /// Returns the result and the number of cells removed during repair. /// @@ -5387,8 +5453,13 @@ where // // Fallback: treat the containing cell as the conflict region, effectively performing // a star-split of that cell to keep the simplicial complex connected. + let conflict_started = Instant::now(); let computed = find_conflict_region(&self.tds, &self.kernel, &point, start_cell)?; Self::record_conflict_region_telemetry(telemetry, computed.len()); + Self::record_conflict_region_timing( + telemetry, + Self::duration_nanos_saturating(conflict_started.elapsed()), + ); #[cfg(feature = "diagnostics")] if std::env::var_os("DELAUNAY_DEBUG_CONFLICT_VERIFY").is_some() { @@ -5461,11 +5532,13 @@ where let cells_scanned = self.tds.number_of_cells(); let global_scan_started = Instant::now(); let computed = self.find_conflict_region_global(&point)?; + let elapsed_nanos = + u64::try_from(global_scan_started.elapsed().as_nanos()).unwrap_or(u64::MAX); Self::record_global_conflict_scan_telemetry( telemetry, cells_scanned, computed.len(), - global_scan_started.elapsed().as_nanos(), + elapsed_nanos, ); if computed.is_empty() { #[cfg(debug_assertions)] @@ -5537,13 +5610,19 @@ where conflict_cells, } => { let conflict_cells = conflict_cells.into_owned(); - let (hint, total_removed, repair_seed_cells) = self.insert_with_conflict_region( + let cavity_started = Instant::now(); + let insertion_result = self.insert_with_conflict_region( v_key, &point, conflict_cells, Some(start_cell), &mut suspicion, - )?; + ); + Self::record_cavity_insertion_telemetry( + telemetry, + Self::duration_nanos_saturating(cavity_started.elapsed()), + ); + let (hint, total_removed, repair_seed_cells) = insertion_result?; Ok(TryInsertImplOk { inserted: (v_key, hint), cells_removed: total_removed, @@ -5560,6 +5639,7 @@ where tracing::debug!( "Outside insertion attempting cavity insertion with conflict region size {conflict_len}" ); + let cavity_started = Instant::now(); let result = self.insert_with_conflict_region( v_key, &point, @@ -5567,6 +5647,10 @@ where None, &mut suspicion, ); + Self::record_cavity_insertion_telemetry( + telemetry, + Self::duration_nanos_saturating(cavity_started.elapsed()), + ); match result { Ok((hint, total_removed, repair_seed_cells)) => { return Ok(TryInsertImplOk { @@ -5624,7 +5708,13 @@ where "Outside insertion: proceeding to hull extension" ); } - let new_cells = match extend_hull(&mut self.tds, &self.kernel, v_key, &point) { + let hull_started = Instant::now(); + let hull_result = extend_hull(&mut self.tds, &self.kernel, v_key, &point); + Self::record_hull_extension_telemetry( + telemetry, + Self::duration_nanos_saturating(hull_started.elapsed()), + ); + let new_cells = match hull_result { Ok(cells) => cells, Err(err) => { let retry_inside = matches!( @@ -5636,6 +5726,26 @@ where if retry_inside { let fallback_location = locate_by_scan(&self.tds, &self.kernel, &point)?; + // This retry starts as a scan, so account for the fallback + // explicitly and let the common recorder handle the outcome. + telemetry.locate_scan_fallbacks = + telemetry.locate_scan_fallbacks.saturating_add(1); + let scan_start_cell = self + .tds + .cell_keys() + .next() + .ok_or(LocateError::EmptyTriangulation)?; + let scan_stats = LocateStats { + start_cell: scan_start_cell, + used_hint: false, + walk_steps: 0, + fallback: None, + }; + Self::record_locate_telemetry( + telemetry, + fallback_location, + &scan_stats, + ); if let LocateResult::InsideCell(start_cell) = fallback_location { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { @@ -5648,14 +5758,19 @@ where suspicion.fallback_star_split = true; let mut star_conflict = CellKeyBuffer::new(); star_conflict.push(start_cell); - let (hint, total_removed, repair_seed_cells) = self - .insert_with_conflict_region( - v_key, - &point, - star_conflict, - Some(start_cell), - &mut suspicion, - )?; + let cavity_started = Instant::now(); + let insertion_result = self.insert_with_conflict_region( + v_key, + &point, + star_conflict, + Some(start_cell), + &mut suspicion, + ); + Self::record_cavity_insertion_telemetry( + telemetry, + Self::duration_nanos_saturating(cavity_started.elapsed()), + ); + let (hint, total_removed, repair_seed_cells) = insertion_result?; return Ok(TryInsertImplOk { inserted: (v_key, hint), cells_removed: total_removed, @@ -5866,11 +5981,16 @@ where self.validate_connectedness(&new_cells)?; // Return vertex key and hint for next insertion + let repair_seed_cells: CellKeyBuffer = new_cells + .iter() + .copied() + .filter(|&cell_key| self.tds.contains_cell(cell_key)) + .collect(); Ok(TryInsertImplOk { inserted: (v_key, hint), cells_removed: total_removed, suspicion, - repair_seed_cells: CellKeyBuffer::new(), + repair_seed_cells, }) } } diff --git a/src/lib.rs b/src/lib.rs index 5c0d5c14..074f5653 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -795,6 +795,7 @@ pub mod triangulation { pub mod delaunayize; /// Triangulation editing operations (bistellar flips). pub mod flips; + pub(crate) mod locality; // Re-export commonly used triangulation types for discoverability. pub use crate::core::triangulation::Triangulation; diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 48773608..f73afb26 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -20,8 +20,8 @@ use crate::core::collections::{CellKeyBuffer, FastHashMap, FastHashSet, FastHash use crate::core::edge::EdgeKey; use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter}; use crate::core::operations::{ - DelaunayInsertionState, InsertionOutcome, InsertionStatistics, InsertionTelemetry, - RepairDecision, TopologicalOperation, + DelaunayInsertionState, InsertionOutcome, InsertionResult, InsertionStatistics, + InsertionTelemetry, RepairDecision, TopologicalOperation, }; use crate::core::tds::{ CellKey, InvariantError, InvariantKind, InvariantViolation, Tds, TdsConstructionError, @@ -43,6 +43,9 @@ use crate::geometry::util::safe_usize_to_scalar; use crate::topology::manifold::{ManifoldError, validate_ridge_links_for_cells}; use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; use crate::triangulation::builder::DelaunayTriangulationBuilder; +use crate::triangulation::locality::{ + accumulate_live_cell_seeds, clear_cell_seed_set, retain_live_cell_seeds, +}; use core::{cmp::Ordering, fmt}; use num_traits::{NumCast, ToPrimitive, Zero}; use rand::SeedableRng; @@ -52,7 +55,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::env; use std::hash::{Hash, Hasher}; use std::num::NonZeroUsize; -use std::time::Instant; +use std::time::{Duration, Instant}; use thiserror::Error; use uuid::Uuid; @@ -69,27 +72,19 @@ const HEURISTIC_REBUILD_ATTEMPTS: usize = 6; // `FLOOR`. Two regimes so that D≥4's higher queue demand does not force a // global budget increase. // -// The D≥4 constants are sized from the measured `max_queue` distribution on -// the 500-point 4D seeded repro (seed `0xD225B8A07E274AE6`, ball radius 100) +// The D≥4 constants are sized from the measured `max_queue` distribution on the +// 500-point 4D seeded repro (seed `0xD225B8A07E274AE6`, ball radius 100) // captured in `docs/archive/issue_204_investigation.md`: // // max_queue samples min=91 p50=207 p90=281 p95=312 p99=409 max=416 // // `FACTOR = 12` with `FLOOR = 96` yields a typical 300-flip budget (5-cell seed -// set), covering p50–p90 and brushing p95. The p95–p99 tail is intentionally -// left to the escalation pass (see `LOCAL_REPAIR_ESCALATION_*`) rather than -// paid for on every insertion. +// set), covering p50–p90 and brushing p95. The p95–p99 tail is deferred to the +// final completion repair rather than paid for during every cadenced repair. pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4: usize = 12; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4: usize = 96; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4: usize = 4; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4: usize = 16; - -// Escalation tunables for D≥4. When the base local repair hits its budget, -// the soft-fail path reruns the repair once with `BASE_BUDGET * ESCALATION_FACTOR` -// and the full TDS as seed set before giving up. The escalation is rate-limited -// so every insertion does not pay for a near-global flip pass. -pub(crate) const LOCAL_REPAIR_ESCALATION_BUDGET_FACTOR_D_GE_4: usize = 4; -pub(crate) const LOCAL_REPAIR_ESCALATION_MIN_GAP: usize = 8; const RIDGE_LINK_REPAIR_VALIDATION_MESSAGE: &str = "Topology invalid after Delaunay repair"; fn ridge_link_repair_validation_error(err: ManifoldError) -> InsertionError { @@ -102,63 +97,6 @@ fn ridge_link_repair_validation_error(err: ManifoldError) -> InsertionError { } } -/// Outcome of a per-insertion D≥4 local-repair escalation attempt. -/// -/// Three orthogonal cases so the caller and any telemetry downstream can match -/// on the outcome without string parsing: -/// -/// - [`Skipped`](Self::Skipped) — the escalation did not run. The caller -/// should fall through to the soft-fail path using the original -/// [`DelaunayRepairError`] that triggered escalation. -/// - [`Succeeded`](Self::Succeeded) — the escalation converged. The caller -/// has already canonicalized the triangulation and should continue to the -/// next insertion. -/// - [`FailedAlso`](Self::FailedAlso) — the escalation ran but also hit its -/// budget or postcondition. The typed `DelaunayRepairError` is preserved so -/// downstream diagnostics can correlate it with the original error; the -/// caller should fall through to the soft-fail path. -/// -/// [`DelaunayRepairError`]: crate::core::algorithms::flips::DelaunayRepairError -#[derive(Clone, Debug)] -enum LocalRepairEscalationOutcome { - /// The escalation was not attempted. - Skipped { - /// Why the escalation was skipped. - reason: EscalationSkipReason, - }, - /// The escalation ran and successfully converged. - Succeeded { - /// Repair diagnostics from the successful escalation attempt. - stats: DelaunayRepairStats, - }, - /// The escalation ran but also failed to converge or satisfy its - /// postcondition. - FailedAlso { - /// Typed repair error produced by the escalation attempt. Preserved - /// by value so callers can match on the variant instead of parsing - /// the display form. - escalation_error: DelaunayRepairError, - }, -} - -/// Why a [`LocalRepairEscalationOutcome::Skipped`] escalation attempt did not -/// run. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum EscalationSkipReason { - /// The previous escalation ran within the `min_gap` insertion window, so - /// this attempt was rate-limited. - RateLimited { - /// Insertion index of the previous escalation. - last_escalation_idx: usize, - /// Configured minimum gap between escalations. - min_gap: usize, - }, - /// The triangulation had no cells to seed repair with. This is an edge - /// case for early insertions where the initial simplex has not been - /// committed; escalation there has nothing to escalate against. - EmptyTds, -} - /// Returns true when a repair error represents input geometry or predicate /// instability that shuffled construction may be able to resolve. const fn is_geometric_repair_error(repair_err: &DelaunayRepairError) -> bool { @@ -226,6 +164,7 @@ mod test_hooks { thread_local! { static FORCE_HEURISTIC_REBUILD: Cell = const { Cell::new(false) }; static FORCE_REPAIR_NONCONVERGENT: Cell = const { Cell::new(false) }; + static BATCH_LOCAL_REPAIR_CALLS: Cell = const { Cell::new(0) }; } pub(super) fn force_heuristic_rebuild_enabled() -> bool { @@ -260,6 +199,18 @@ mod test_hooks { FORCE_REPAIR_NONCONVERGENT.with(|flag| flag.set(prior)); } + pub(super) fn reset_batch_local_repair_calls() { + BATCH_LOCAL_REPAIR_CALLS.with(|calls| calls.set(0)); + } + + pub(super) fn batch_local_repair_calls() -> usize { + BATCH_LOCAL_REPAIR_CALLS.with(Cell::get) + } + + pub(super) fn record_batch_local_repair_call() { + BATCH_LOCAL_REPAIR_CALLS.with(|calls| calls.set(calls.get().saturating_add(1))); + } + pub(super) fn synthetic_nonconvergent_error() -> DelaunayRepairError { DelaunayRepairError::NonConvergent { max_flips: 0, @@ -889,10 +840,11 @@ pub struct ConstructionOptions { initial_simplex: InitialSimplexStrategy, retry_policy: RetryPolicy, batch_repair_policy: DelaunayRepairPolicy, - /// When `true` (default), D<4 per-insertion repair falls back to a global - /// `repair_delaunay_with_flips_k2_k3` pass when the bounded local pass - /// cycles. Set to `false` for constructions where global repair could - /// disrupt the triangulation topology (e.g. periodic image-point builds). + /// When `true` (default), final bulk repair can fall back to a global + /// `repair_delaunay_with_flips_k2_k3` pass before acceptance when the + /// seeded completion pass cycles. Set to `false` for constructions where + /// global repair could disrupt the triangulation topology (e.g. periodic + /// image-point builds). pub(crate) use_global_repair_fallback: bool, } @@ -999,6 +951,13 @@ impl ConstructionOptions { #[derive(Debug, Default, Clone)] #[non_exhaustive] pub struct ConstructionTelemetry { + /// Number of transactional insertion calls with wall-clock timing. + pub insertion_wall_time_calls: usize, + /// Wall-clock nanoseconds spent in transactional insertion calls. + pub insertion_wall_time_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one transactional insertion call. + pub insertion_wall_time_nanos_max: u64, + /// Number of point-location calls performed during construction. pub locate_calls: usize, /// Total facet-walk steps across all point-location calls. @@ -1022,6 +981,49 @@ pub struct ConstructionTelemetry { pub conflict_region_cells_total: usize, /// Maximum number of cells in a single local conflict region. pub conflict_region_cells_max: usize, + /// Wall-clock nanoseconds spent computing local conflict regions. + pub conflict_region_nanos: u64, + /// Maximum wall-clock nanoseconds spent computing one local conflict region. + pub conflict_region_nanos_max: u64, + + /// Number of cavity insertion attempts observed during construction. + pub cavity_insertion_calls: usize, + /// Wall-clock nanoseconds spent filling cavities and wiring neighbors. + pub cavity_insertion_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one cavity insertion attempt. + pub cavity_insertion_nanos_max: u64, + + /// Number of hull extension attempts observed during construction. + pub hull_extension_calls: usize, + /// Wall-clock nanoseconds spent extending the convex hull. + pub hull_extension_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one hull extension attempt. + pub hull_extension_nanos_max: u64, + + /// Number of post-insertion topology validations observed during construction. + pub topology_validation_calls: usize, + /// Wall-clock nanoseconds spent in post-insertion topology validation. + pub topology_validation_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one post-insertion validation. + pub topology_validation_nanos_max: u64, + + /// Number of cadenced local Delaunay repair calls during batch construction. + pub local_repair_calls: usize, + /// Wall-clock nanoseconds spent in cadenced local Delaunay repair. + pub local_repair_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one cadenced local repair call. + pub local_repair_nanos_max: u64, + + /// Number of bulk local-repair seed accumulation calls. + pub repair_seed_accumulation_calls: usize, + /// Wall-clock nanoseconds spent accumulating bulk local-repair seeds. + pub repair_seed_accumulation_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one bulk seed accumulation call. + pub repair_seed_accumulation_nanos_max: u64, + /// Total live seed cells added to pending bulk local-repair frontiers. + pub repair_seed_cells_added_total: usize, + /// Maximum live seed cells added by one bulk seed accumulation call. + pub repair_seed_cells_added_max: usize, /// Number of global exterior-point conflict scans. pub global_conflict_scans: usize, @@ -1032,14 +1034,56 @@ pub struct ConstructionTelemetry { /// Maximum cells found by a single global exterior-point conflict scan. pub global_conflict_cells_found_max: usize, /// Wall-clock nanoseconds spent in global exterior-point conflict scans. - pub global_conflict_scan_nanos: u128, + pub global_conflict_scan_nanos: u64, } impl ConstructionTelemetry { /// Returns true when any insertion-path telemetry was recorded. #[must_use] pub const fn has_data(&self) -> bool { - self.locate_calls > 0 || self.conflict_region_calls > 0 || self.global_conflict_scans > 0 + self.insertion_wall_time_calls > 0 + || self.insertion_wall_time_nanos > 0 + || self.locate_calls > 0 + || self.conflict_region_calls > 0 + || self.cavity_insertion_calls > 0 + || self.hull_extension_calls > 0 + || self.topology_validation_calls > 0 + || self.local_repair_calls > 0 + || self.repair_seed_accumulation_calls > 0 + || self.global_conflict_scans > 0 + } + + /// Records the wall-clock duration of one transactional insertion call. + pub(crate) fn record_insertion_timing(&mut self, elapsed_nanos: u64) { + self.insertion_wall_time_calls = self.insertion_wall_time_calls.saturating_add(1); + self.insertion_wall_time_nanos = + self.insertion_wall_time_nanos.saturating_add(elapsed_nanos); + self.insertion_wall_time_nanos_max = self.insertion_wall_time_nanos_max.max(elapsed_nanos); + } + + /// Records the wall-clock duration of one cadenced local repair call. + pub(crate) fn record_local_repair_timing(&mut self, elapsed_nanos: u64) { + self.local_repair_calls = self.local_repair_calls.saturating_add(1); + self.local_repair_nanos = self.local_repair_nanos.saturating_add(elapsed_nanos); + self.local_repair_nanos_max = self.local_repair_nanos_max.max(elapsed_nanos); + } + + /// Records one bulk local-repair seed accumulation step. + pub(crate) fn record_repair_seed_accumulation( + &mut self, + elapsed_nanos: u64, + cells_added: usize, + ) { + self.repair_seed_accumulation_calls = self.repair_seed_accumulation_calls.saturating_add(1); + self.repair_seed_accumulation_nanos = self + .repair_seed_accumulation_nanos + .saturating_add(elapsed_nanos); + self.repair_seed_accumulation_nanos_max = + self.repair_seed_accumulation_nanos_max.max(elapsed_nanos); + self.repair_seed_cells_added_total = self + .repair_seed_cells_added_total + .saturating_add(cells_added); + self.repair_seed_cells_added_max = self.repair_seed_cells_added_max.max(cells_added); } /// Adds one insertion's telemetry into this construction summary. @@ -1074,6 +1118,42 @@ impl ConstructionTelemetry { self.conflict_region_cells_max = self .conflict_region_cells_max .max(telemetry.conflict_region_cells_max); + self.conflict_region_nanos = self + .conflict_region_nanos + .saturating_add(telemetry.conflict_region_nanos); + self.conflict_region_nanos_max = self + .conflict_region_nanos_max + .max(telemetry.conflict_region_nanos_max); + + self.cavity_insertion_calls = self + .cavity_insertion_calls + .saturating_add(telemetry.cavity_insertion_calls); + self.cavity_insertion_nanos = self + .cavity_insertion_nanos + .saturating_add(telemetry.cavity_insertion_nanos); + self.cavity_insertion_nanos_max = self + .cavity_insertion_nanos_max + .max(telemetry.cavity_insertion_nanos_max); + + self.hull_extension_calls = self + .hull_extension_calls + .saturating_add(telemetry.hull_extension_calls); + self.hull_extension_nanos = self + .hull_extension_nanos + .saturating_add(telemetry.hull_extension_nanos); + self.hull_extension_nanos_max = self + .hull_extension_nanos_max + .max(telemetry.hull_extension_nanos_max); + + self.topology_validation_calls = self + .topology_validation_calls + .saturating_add(telemetry.topology_validation_calls); + self.topology_validation_nanos = self + .topology_validation_nanos + .saturating_add(telemetry.topology_validation_nanos); + self.topology_validation_nanos_max = self + .topology_validation_nanos_max + .max(telemetry.topology_validation_nanos_max); self.global_conflict_scans = self .global_conflict_scans @@ -1094,6 +1174,16 @@ impl ConstructionTelemetry { /// Merges another construction telemetry summary into this one. fn merge_from(&mut self, other: &Self) { + self.insertion_wall_time_nanos = self + .insertion_wall_time_nanos + .saturating_add(other.insertion_wall_time_nanos); + self.insertion_wall_time_calls = self + .insertion_wall_time_calls + .saturating_add(other.insertion_wall_time_calls); + self.insertion_wall_time_nanos_max = self + .insertion_wall_time_nanos_max + .max(other.insertion_wall_time_nanos_max); + self.locate_calls = self.locate_calls.saturating_add(other.locate_calls); self.locate_walk_steps_total = self .locate_walk_steps_total @@ -1118,6 +1208,54 @@ impl ConstructionTelemetry { self.conflict_region_cells_max = self .conflict_region_cells_max .max(other.conflict_region_cells_max); + self.conflict_region_nanos = self + .conflict_region_nanos + .saturating_add(other.conflict_region_nanos); + self.conflict_region_nanos_max = self + .conflict_region_nanos_max + .max(other.conflict_region_nanos_max); + + self.cavity_insertion_calls = self + .cavity_insertion_calls + .saturating_add(other.cavity_insertion_calls); + self.cavity_insertion_nanos = self + .cavity_insertion_nanos + .saturating_add(other.cavity_insertion_nanos); + self.cavity_insertion_nanos_max = self + .cavity_insertion_nanos_max + .max(other.cavity_insertion_nanos_max); + + self.hull_extension_calls = self + .hull_extension_calls + .saturating_add(other.hull_extension_calls); + self.hull_extension_nanos = self + .hull_extension_nanos + .saturating_add(other.hull_extension_nanos); + self.hull_extension_nanos_max = self + .hull_extension_nanos_max + .max(other.hull_extension_nanos_max); + + self.topology_validation_calls = self + .topology_validation_calls + .saturating_add(other.topology_validation_calls); + self.topology_validation_nanos = self + .topology_validation_nanos + .saturating_add(other.topology_validation_nanos); + self.topology_validation_nanos_max = self + .topology_validation_nanos_max + .max(other.topology_validation_nanos_max); + + self.local_repair_calls = self + .local_repair_calls + .saturating_add(other.local_repair_calls); + self.local_repair_nanos = self + .local_repair_nanos + .saturating_add(other.local_repair_nanos); + self.local_repair_nanos_max = self + .local_repair_nanos_max + .max(other.local_repair_nanos_max); + + self.merge_repair_seed_accumulation_from(other); self.global_conflict_scans = self .global_conflict_scans @@ -1135,6 +1273,24 @@ impl ConstructionTelemetry { .global_conflict_scan_nanos .saturating_add(other.global_conflict_scan_nanos); } + + fn merge_repair_seed_accumulation_from(&mut self, other: &Self) { + self.repair_seed_accumulation_calls = self + .repair_seed_accumulation_calls + .saturating_add(other.repair_seed_accumulation_calls); + self.repair_seed_accumulation_nanos = self + .repair_seed_accumulation_nanos + .saturating_add(other.repair_seed_accumulation_nanos); + self.repair_seed_accumulation_nanos_max = self + .repair_seed_accumulation_nanos_max + .max(other.repair_seed_accumulation_nanos_max); + self.repair_seed_cells_added_total = self + .repair_seed_cells_added_total + .saturating_add(other.repair_seed_cells_added_total); + self.repair_seed_cells_added_max = self + .repair_seed_cells_added_max + .max(other.repair_seed_cells_added_max); + } } /// Aggregate statistics collected during batch construction. @@ -1169,6 +1325,12 @@ pub struct ConstructionStatistics { /// Aggregate insertion-path telemetry. pub telemetry: ConstructionTelemetry, + /// Slowest transactional insertions observed during batch construction. + /// + /// This is intended for diagnosing scaling pathologies and is capped + /// (currently the top 8 by insertion wall time). + pub slow_insertions: Vec, + /// A small set of representative skipped vertices recorded during batch construction. /// /// This is intended for debugging/reproduction and is capped (currently the first 8 skips). @@ -1199,6 +1361,40 @@ pub struct ConstructionSkipSample { pub error: String, } +/// A slow transactional insertion sample captured during batch construction. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ConstructionSlowInsertionSample { + /// Index in the construction insertion order (after preprocessing and ordering). + pub index: usize, + /// UUID of the inserted or skipped vertex. + pub uuid: Uuid, + /// Number of insertion attempts for this vertex. + pub attempts: usize, + /// Final insertion result for this vertex. + pub result: InsertionResult, + /// Wall-clock nanoseconds spent in the transactional insertion call. + pub elapsed_nanos: u64, + /// Cell count immediately after the insertion attempt. + pub cells_after: usize, + /// Point-location calls performed by this insertion. + pub locate_calls: usize, + /// Facet-walk steps performed by this insertion. + pub locate_walk_steps_total: usize, + /// Local conflict-region calls performed by this insertion. + pub conflict_region_calls: usize, + /// Local conflict-region cells observed by this insertion. + pub conflict_region_cells_total: usize, + /// Cavity insertion calls performed by this insertion. + pub cavity_insertion_calls: usize, + /// Global exterior conflict scans performed by this insertion. + pub global_conflict_scans: usize, + /// Hull extension calls performed by this insertion. + pub hull_extension_calls: usize, + /// Post-insertion topology validations performed by this insertion. + pub topology_validation_calls: usize, +} + /// Construction error that also carries aggregate statistics collected up to the failure point. /// /// Returned by statistics constructors such as @@ -1245,6 +1441,7 @@ impl ConstructionStatistics { } const MAX_SKIP_SAMPLES: usize = 8; + const MAX_SLOW_INSERTION_SAMPLES: usize = 8; /// Record a single insertion attempt (inserted or skipped). pub fn record_insertion(&mut self, stats: &InsertionStatistics) { @@ -1266,6 +1463,22 @@ impl ConstructionStatistics { } } + /// Record a slow insertion sample, preserving the top samples by elapsed time. + pub fn record_slow_insertion_sample(&mut self, sample: ConstructionSlowInsertionSample) { + let insert_at = self + .slow_insertions + .iter() + .position(|existing| sample.elapsed_nanos > existing.elapsed_nanos) + .unwrap_or(self.slow_insertions.len()); + if insert_at < Self::MAX_SLOW_INSERTION_SAMPLES { + self.slow_insertions.insert(insert_at, sample); + self.slow_insertions + .truncate(Self::MAX_SLOW_INSERTION_SAMPLES); + } else if self.slow_insertions.len() < Self::MAX_SLOW_INSERTION_SAMPLES { + self.slow_insertions.push(sample); + } + } + /// Merges attempt-level statistics from another construction pass. fn merge_from(&mut self, other: &Self) { self.inserted = self.inserted.saturating_add(other.inserted); @@ -1301,6 +1514,10 @@ impl ConstructionStatistics { } self.skip_samples.push(sample.clone()); } + + for sample in &other.slow_insertions { + self.record_slow_insertion_sample(sample.clone()); + } } /// Total number of skipped vertices. @@ -1880,6 +2097,12 @@ fn construction_retry_trace_enabled() -> bool { || env::var_os("DELAUNAY_INSERT_TRACE").is_some() } +/// Converts a measured duration to nanoseconds while saturating pathological +/// values that exceed the public telemetry counter width. +fn duration_nanos_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) +} + #[derive(Clone, Copy, Debug)] /// Snapshot of one batch-construction progress sample. struct BatchProgressSample { @@ -3893,55 +4116,6 @@ where Ok(dt) } - /// Handle D<4 local repair non-convergence by falling back to global repair or - /// hard-failing to trigger shuffle retry. - /// - /// Returns `Ok(())` if global repair succeeded (caller should `continue` the - /// insertion loop). Returns `Err(...)` if the caller should propagate the - /// construction error. - fn try_d_lt4_global_repair_fallback( - tds: &mut Tds, - kernel: &K, - topology: TopologyGuarantee, - use_global_repair_fallback: bool, - index: usize, - repair_err: &DelaunayRepairError, - ) -> Result<(), DelaunayTriangulationConstructionError> { - if use_global_repair_fallback { - tracing::debug!( - error = %repair_err, - idx = index, - "bulk D<4: local repair cycling; falling back to global repair" - ); - let global_result = repair_delaunay_with_flips_k2_k3(tds, kernel, None, topology, None); - if let Err(global_err) = global_result { - tracing::debug!( - error = %global_err, - idx = index, - "bulk D<4: global repair also failed; aborting this vertex ordering" - ); - return Err(TriangulationConstructionError::GeometricDegeneracy { - message: format!( - "per-insertion Delaunay repair failed at index {index}: local error: {repair_err}; global fallback: {global_err}" - ), - } - .into()); - } - return Ok(()); - } - // Global repair disabled (e.g. periodic build): hard-fail to trigger - // shuffle retry with a different vertex ordering. - tracing::debug!( - error = %repair_err, - idx = index, - "bulk D<4: local repair cycling (global fallback disabled); aborting" - ); - Err(TriangulationConstructionError::GeometricDegeneracy { - message: format!("per-insertion Delaunay repair failed at index {index}: {repair_err}"), - } - .into()) - } - /// Restores positive geometric orientation after bulk repair calls the /// low-level TDS flip routine directly. fn canonicalize_after_bulk_repair( @@ -3960,100 +4134,6 @@ where Ok(()) } - /// Attempt one D≥4 local-repair escalation before the soft-fail path - /// continues. - /// - /// Reruns `repair_delaunay_local_single_pass` with - /// `base_budget * LOCAL_REPAIR_ESCALATION_BUDGET_FACTOR_D_GE_4` and the - /// full TDS as seed set. Rate-limited by `LOCAL_REPAIR_ESCALATION_MIN_GAP` - /// so only every Nth insertion pays the (near-global) flip pass cost. - /// - /// Returns a typed [`LocalRepairEscalationOutcome`] so the caller can - /// distinguish `Skipped { reason }` (rate-limited or empty TDS) from - /// `Succeeded { stats }` (caller has already canonicalized and should - /// continue normally) from `FailedAlso { escalation_error }` (the - /// escalation ran but also hit its budget; the caller should fall through - /// to the soft-fail path, and the typed `DelaunayRepairError` is - /// preserved for downstream diagnostics). `Err(...)` is reserved for - /// hard errors the bulk loop must propagate. - fn try_local_repair_escalation_d_ge_4( - &mut self, - index: usize, - base_budget: usize, - last_escalation_idx: &mut Option, - original_err: &DelaunayRepairError, - ) -> Result { - // Rate-limit: only escalate if we have not escalated within the last - // LOCAL_REPAIR_ESCALATION_MIN_GAP insertions. This keeps healthy runs - // from paying the near-global flip pass on every insertion while still - // catching pathological clusters of consecutive soft-fails. - if let Some(last_idx) = *last_escalation_idx - && index.saturating_sub(last_idx) < LOCAL_REPAIR_ESCALATION_MIN_GAP - { - return Ok(LocalRepairEscalationOutcome::Skipped { - reason: EscalationSkipReason::RateLimited { - last_escalation_idx: last_idx, - min_gap: LOCAL_REPAIR_ESCALATION_MIN_GAP, - }, - }); - } - - // Escalation seed set: use every current cell key. This gives the - // repair the broadest possible view of the local backlog without - // switching to a different repair entry point. - let full_seeds: Vec = self.tri.tds.cell_keys().collect(); - if full_seeds.is_empty() { - return Ok(LocalRepairEscalationOutcome::Skipped { - reason: EscalationSkipReason::EmptyTds, - }); - } - let escalated_budget = - base_budget.saturating_mul(LOCAL_REPAIR_ESCALATION_BUDGET_FACTOR_D_GE_4); - - tracing::debug!( - idx = index, - seed_cells = full_seeds.len(), - base_budget, - escalated_budget, - original_error = %original_err, - "bulk D≥4: escalating local repair with full-TDS seed set" - ); - - let escalation_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass(tds, kernel, &full_seeds, escalated_budget) - }; - - *last_escalation_idx = Some(index); - - match escalation_result { - Ok(stats) => { - tracing::debug!( - idx = index, - flips = stats.flips_performed, - max_queue = stats.max_queue_len, - "bulk D≥4: escalation succeeded" - ); - self.canonicalize_after_bulk_repair()?; - Ok(LocalRepairEscalationOutcome::Succeeded { stats }) - } - Err(escalation_err) => { - if !Self::can_soft_fail(&escalation_err) { - return Err(Self::map_hard_repair_error(index, &escalation_err)); - } - tracing::debug!( - idx = index, - error = %escalation_err, - "bulk D≥4: escalation also non-convergent; falling through to soft-fail" - ); - Ok(LocalRepairEscalationOutcome::FailedAlso { - escalation_error: escalation_err, - }) - } - } - } - /// Identifies D≥4 local-repair failures that can safely try escalation and /// then enter the bounded soft-fail path. const fn can_soft_fail(repair_err: &DelaunayRepairError) -> bool { @@ -4080,64 +4160,25 @@ where } } - fn extend_local_repair_seed_cells( - &self, - vertex_key: VertexKey, - extra_seed_cells: &[CellKey], - pending_seed_cells: &mut Vec, - pending_seen: &mut FastHashSet, - ) { - for cell_key in self.tri.adjacent_cells(vertex_key) { - if pending_seen.insert(cell_key) { - pending_seed_cells.push(cell_key); - } - } - - for &cell_key in extra_seed_cells { - if self.tri.tds.contains_cell(cell_key) && pending_seen.insert(cell_key) { - pending_seed_cells.push(cell_key); - } - } - } - - fn retain_live_local_repair_seed_cells( - &self, - seed_cells: &mut Vec, - seen: &mut FastHashSet, - ) { - seen.clear(); - seed_cells - .retain(|cell_key| self.tri.tds.contains_cell(*cell_key) && seen.insert(*cell_key)); - } - - fn clear_local_repair_seed_cells( - seed_cells: &mut Vec, - seen: &mut FastHashSet, - ) { - seed_cells.clear(); - seen.clear(); - } - - #[expect( - clippy::too_many_lines, - reason = "local repair handling keeps success, fallback, and soft-fail paths together" - )] fn repair_pending_local_seed_cells( &mut self, index: usize, pending_seed_cells: &mut Vec, pending_seen: &mut FastHashSet, - last_escalation_idx: &mut Option, soft_fail_seeds: &mut Vec, + construction_telemetry: Option<&mut ConstructionTelemetry>, ) -> Result<(), DelaunayTriangulationConstructionError> { - self.retain_live_local_repair_seed_cells(pending_seed_cells, pending_seen); + retain_live_cell_seeds(&self.tri.tds, pending_seed_cells, pending_seen); if pending_seed_cells.is_empty() { return Ok(()); } + #[cfg(test)] + test_hooks::record_batch_local_repair_call(); + let max_flips = local_repair_flip_budget::(pending_seed_cells.len()); let trace_repair = batch_repair_trace_enabled(); - let repair_started = trace_repair.then(Instant::now); + let repair_started = Instant::now(); if trace_repair { tracing::debug!( idx = index, @@ -4158,6 +4199,10 @@ where } else { repair_result }; + let repair_elapsed = repair_started.elapsed(); + if let Some(telemetry) = construction_telemetry { + telemetry.record_local_repair_timing(duration_nanos_saturating(repair_elapsed)); + } match repair_result { Ok(stats) => { @@ -4168,14 +4213,14 @@ where flips = stats.flips_performed, checked = stats.facets_checked, max_queue = stats.max_queue_len, - elapsed = ?repair_started.map(|started| started.elapsed()), + elapsed = ?repair_elapsed, "bulk batch repair: local repair succeeded" ); } if stats.flips_performed > 0 { self.canonicalize_after_bulk_repair()?; } - Self::clear_local_repair_seed_cells(pending_seed_cells, pending_seen); + clear_cell_seed_set(pending_seed_cells, pending_seen); } Err(repair_err) => { if trace_repair { @@ -4183,70 +4228,22 @@ where idx = index, seed_cells = pending_seed_cells.len(), error = %repair_err, - elapsed = ?repair_started.map(|started| started.elapsed()), + elapsed = ?repair_elapsed, "bulk batch repair: local repair failed" ); } - if D < 4 { - let topology = self.tri.topology_guarantee(); - self.invalidate_repair_caches(); - Self::try_d_lt4_global_repair_fallback( - &mut self.tri.tds, - &self.tri.kernel, - topology, - self.insertion_state.use_global_repair_fallback, - index, - &repair_err, - )?; - self.canonicalize_after_bulk_repair()?; - Self::clear_local_repair_seed_cells(pending_seed_cells, pending_seen); - return Ok(()); - } - if !Self::can_soft_fail(&repair_err) { return Err(Self::map_hard_repair_error(index, &repair_err)); } - let outcome = self.try_local_repair_escalation_d_ge_4( - index, - max_flips, - last_escalation_idx, - &repair_err, - )?; - match outcome { - LocalRepairEscalationOutcome::Succeeded { stats } => { - tracing::debug!( - idx = index, - flips = stats.flips_performed, - max_queue = stats.max_queue_len, - "bulk D≥4: escalation closed the non-convergence; continuing" - ); - } - LocalRepairEscalationOutcome::Skipped { reason } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "skipped", - skip_reason = ?reason, - "bulk D≥4: batch repair non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds.extend(pending_seed_cells.iter().copied()); - } - LocalRepairEscalationOutcome::FailedAlso { escalation_error } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "failed_also", - escalation_error = %escalation_error, - "bulk D≥4: batch repair non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds.extend(pending_seed_cells.iter().copied()); - } - } - Self::clear_local_repair_seed_cells(pending_seed_cells, pending_seen); + tracing::debug!( + idx = index, + error = %repair_err, + seed_cells = pending_seed_cells.len(), + "bulk batch repair: local repair soft-failed; deferring seeds to final repair" + ); + self.canonicalize_after_bulk_repair()?; + soft_fail_seeds.extend(pending_seed_cells.iter().copied()); + clear_cell_seed_set(pending_seed_cells, pending_seen); } } Ok(()) @@ -4305,10 +4302,6 @@ where // progress line reads `processed=N/total inserted=I skipped=S` coherently. let mut inserted_vertices = 0usize; let mut skipped_vertices = 0usize; - // Last insertion index at which the D≥4 local-repair escalation ran, - // used for `LOCAL_REPAIR_ESCALATION_MIN_GAP` rate limiting across both - // stats-enabled and stats-disabled arms. - let mut last_escalation_idx: Option = None; let mut pending_repair_seen: FastHashSet = pending_repair_seeds.iter().copied().collect(); @@ -4360,7 +4353,7 @@ where match insert_result { Ok(( InsertionOutcome::Inserted { - vertex_key: v_key, + vertex_key: _, hint, }, _stats, @@ -4386,8 +4379,8 @@ where && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - self.extend_local_repair_seed_cells( - v_key, + accumulate_live_cell_seeds( + &self.tri.tds, &repair_seed_cells, pending_repair_seeds, &mut pending_repair_seen, @@ -4404,8 +4397,8 @@ where index, pending_repair_seeds, &mut pending_repair_seen, - &mut last_escalation_idx, soft_fail_seeds, + None, )?; } } @@ -4483,7 +4476,7 @@ where tracing::debug!(index, %uuid, coords = ?coords, "[bulk] start"); } - let started = trace_insertion.then(Instant::now); + let started = Instant::now(); let mut insert = || { // Keep the stats and non-stats branches aligned so bulk-index-based // tracing behaves the same regardless of whether the caller records @@ -4508,7 +4501,8 @@ where } else { insert() }; - let elapsed = started.map(|started| started.elapsed()); + let elapsed = started.elapsed(); + let elapsed_nanos = duration_nanos_saturating(elapsed); let insert_result = insert_result.map(|detail| { let repair_seed_cells = detail.repair_seed_cells; ( @@ -4521,7 +4515,7 @@ where match insert_result { Ok(( InsertionOutcome::Inserted { - vertex_key: v_key, + vertex_key: _, hint, }, stats, @@ -4529,7 +4523,7 @@ where telemetry, )) => { inserted_vertices = inserted_vertices.saturating_add(1); - if trace_insertion && let Some(elapsed) = elapsed { + if trace_insertion { tracing::debug!( index, %uuid, @@ -4540,6 +4534,28 @@ where } construction_stats.record_insertion(&stats); construction_stats.telemetry.record_insertion(&telemetry); + construction_stats + .telemetry + .record_insertion_timing(elapsed_nanos); + construction_stats.record_slow_insertion_sample( + ConstructionSlowInsertionSample { + index, + uuid, + attempts: stats.attempts, + result: stats.result, + elapsed_nanos, + cells_after: self.tri.tds.number_of_cells(), + locate_calls: telemetry.locate_calls, + locate_walk_steps_total: telemetry.locate_walk_steps_total, + conflict_region_calls: telemetry.conflict_region_calls, + conflict_region_cells_total: telemetry + .conflict_region_cells_total, + cavity_insertion_calls: telemetry.cavity_insertion_calls, + global_conflict_scans: telemetry.global_conflict_scans, + hull_extension_calls: telemetry.hull_extension_calls, + topology_validation_calls: telemetry.topology_validation_calls, + }, + ); // Cache hint for faster subsequent insertions. self.insertion_state.last_inserted_cell = hint; @@ -4554,12 +4570,19 @@ where && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - self.extend_local_repair_seed_cells( - v_key, + let seed_started = Instant::now(); + let seed_cells_added = accumulate_live_cell_seeds( + &self.tri.tds, &repair_seed_cells, pending_repair_seeds, &mut pending_repair_seen, ); + construction_stats + .telemetry + .record_repair_seed_accumulation( + duration_nanos_saturating(seed_started.elapsed()), + seed_cells_added, + ); if matches!( batch_repair_policy.decide( inserted_vertices, @@ -4572,8 +4595,8 @@ where index, pending_repair_seeds, &mut pending_repair_seen, - &mut last_escalation_idx, soft_fail_seeds, + Some(&mut construction_stats.telemetry), )?; } } @@ -4595,7 +4618,7 @@ where telemetry, )) => { skipped_vertices = skipped_vertices.saturating_add(1); - if trace_insertion && let Some(elapsed) = elapsed { + if trace_insertion { tracing::debug!( index, %uuid, @@ -4607,6 +4630,28 @@ where } construction_stats.record_insertion(&stats); construction_stats.telemetry.record_insertion(&telemetry); + construction_stats + .telemetry + .record_insertion_timing(elapsed_nanos); + construction_stats.record_slow_insertion_sample( + ConstructionSlowInsertionSample { + index, + uuid, + attempts: stats.attempts, + result: stats.result, + elapsed_nanos, + cells_after: self.tri.tds.number_of_cells(), + locate_calls: telemetry.locate_calls, + locate_walk_steps_total: telemetry.locate_walk_steps_total, + conflict_region_calls: telemetry.conflict_region_calls, + conflict_region_cells_total: telemetry + .conflict_region_cells_total, + cavity_insertion_calls: telemetry.cavity_insertion_calls, + global_conflict_scans: telemetry.global_conflict_scans, + hull_extension_calls: telemetry.hull_extension_calls, + topology_validation_calls: telemetry.topology_validation_calls, + }, + ); // Keep the first few skip samples so we have concrete reproduction anchors. let (coords, coords_available) = vertex_coords_f64(vertex) @@ -4644,7 +4689,7 @@ where ); } Err(e) => { - if trace_insertion && let Some(elapsed) = elapsed { + if trace_insertion { tracing::debug!( index, %uuid, @@ -4695,34 +4740,7 @@ where && batch_repair_policy != DelaunayRepairPolicy::Never && !completion_seed_cells.is_empty() { - let seed_count = completion_seed_cells.len(); - let max_flips = local_repair_flip_budget::(seed_count); - tracing::debug!( - seed_count, - max_flips, - "post-construction: starting seeded completion Delaunay repair" - ); - let repair_started = Instant::now(); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass(tds, kernel, &completion_seed_cells, max_flips) - .map(|_| ()) - }; - let repair_outcome: Result<(), DelaunayTriangulationConstructionError> = - match repair_result { - Ok(()) => Ok(()), - Err(e) => Err(TriangulationConstructionError::GeometricDegeneracy { - message: format!("Delaunay repair failed after construction: {e}"), - } - .into()), - }; - tracing::debug!( - elapsed = ?repair_started.elapsed(), - success = repair_outcome.is_ok(), - "post-construction: seeded completion Delaunay repair finished" - ); - repair_outcome?; + self.run_seeded_completion_repair(&completion_seed_cells)?; } // Flip-based repair calls normalize_coherent_orientation() which makes all cells @@ -4754,6 +4772,78 @@ where Ok(()) } + fn run_seeded_completion_repair( + &mut self, + completion_seed_cells: &[CellKey], + ) -> Result<(), DelaunayTriangulationConstructionError> { + let seed_count = completion_seed_cells.len(); + let max_flips = local_repair_flip_budget::(seed_count); + tracing::debug!( + seed_count, + max_flips, + "post-construction: starting seeded completion Delaunay repair" + ); + let repair_started = Instant::now(); + let repair_result = { + self.invalidate_repair_caches(); + let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); + repair_delaunay_local_single_pass(tds, kernel, completion_seed_cells, max_flips) + .map(|_| ()) + }; + let repair_outcome = match repair_result { + Ok(()) => Ok(()), + Err(error) => self.try_final_global_repair_after_seeded_failure(&error), + }; + tracing::debug!( + elapsed = ?repair_started.elapsed(), + success = repair_outcome.is_ok(), + "post-construction: seeded completion Delaunay repair finished" + ); + repair_outcome + } + + fn try_final_global_repair_after_seeded_failure( + &mut self, + seeded_error: &DelaunayRepairError, + ) -> Result<(), DelaunayTriangulationConstructionError> { + if !self.insertion_state.use_global_repair_fallback || !Self::can_soft_fail(seeded_error) { + let message = format!("Delaunay repair failed after construction: {seeded_error}"); + return Err(Self::map_completion_repair_error(message, seeded_error)); + } + + tracing::debug!( + error = %seeded_error, + "post-construction: seeded completion repair soft-failed; trying final global repair" + ); + self.invalidate_repair_caches(); + let topology = self.tri.topology_guarantee(); + let global_result = { + let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); + repair_delaunay_with_flips_k2_k3(tds, kernel, None, topology, None) + }; + match global_result { + Ok(_) => Ok(()), + Err(global_error) => { + let message = format!( + "Delaunay repair failed after construction: seeded local error: \ + {seeded_error}; global fallback: {global_error}" + ); + Err(Self::map_completion_repair_error(message, &global_error)) + } + } + } + + fn map_completion_repair_error( + message: String, + repair_error: &DelaunayRepairError, + ) -> DelaunayTriangulationConstructionError { + if is_geometric_repair_error(repair_error) { + TriangulationConstructionError::GeometricDegeneracy { message }.into() + } else { + TriangulationConstructionError::InternalInconsistency { message }.into() + } + } + /// Map an [`InsertionError`] from post-construction orientation canonicalization /// into a [`TriangulationConstructionError`]. /// @@ -7846,78 +7936,6 @@ mod tests { assert!(!seeds.contains(&stale_cell)); } - #[test] - fn test_local_repair_escalation_outcome_variants_are_orthogonal() { - // Skipped / Succeeded / FailedAlso must each match a distinct typed - // pattern so callers can decide "continue" vs "fall through" without - // string parsing. This locks in the typed-error contract added with - // Fix 2 of the #204 plan. - let skipped_rate_limited = LocalRepairEscalationOutcome::Skipped { - reason: EscalationSkipReason::RateLimited { - last_escalation_idx: 7, - min_gap: LOCAL_REPAIR_ESCALATION_MIN_GAP, - }, - }; - let skipped_empty = LocalRepairEscalationOutcome::Skipped { - reason: EscalationSkipReason::EmptyTds, - }; - let succeeded = LocalRepairEscalationOutcome::Succeeded { - stats: DelaunayRepairStats::default(), - }; - let failed_also = LocalRepairEscalationOutcome::FailedAlso { - escalation_error: DelaunayRepairError::PostconditionFailed { - message: "unit test escalation failure".to_string(), - }, - }; - - // Each variant matches its own pattern and only its own pattern. - assert!(matches!( - skipped_rate_limited, - LocalRepairEscalationOutcome::Skipped { .. } - )); - assert!(matches!( - skipped_empty, - LocalRepairEscalationOutcome::Skipped { .. } - )); - assert!(matches!( - succeeded, - LocalRepairEscalationOutcome::Succeeded { .. } - )); - assert!(matches!( - failed_also, - LocalRepairEscalationOutcome::FailedAlso { .. } - )); - - // Skip reasons are themselves orthogonal: RateLimited carries the - // index/gap pair; EmptyTds is fieldless. PartialEq makes the - // distinction explicit so future code can `assert_eq!` on it. - let LocalRepairEscalationOutcome::Skipped { reason } = skipped_rate_limited else { - panic!("skipped_rate_limited should match Skipped"); - }; - assert_eq!( - reason, - EscalationSkipReason::RateLimited { - last_escalation_idx: 7, - min_gap: LOCAL_REPAIR_ESCALATION_MIN_GAP, - }, - ); - assert_ne!(reason, EscalationSkipReason::EmptyTds); - - // FailedAlso preserves the typed `DelaunayRepairError` by value (no - // boxing, no stringification) so downstream diagnostics can pattern- - // match the variant chain. - let LocalRepairEscalationOutcome::FailedAlso { - escalation_error: err, - } = failed_also - else { - panic!("failed_also should match FailedAlso"); - }; - assert!(matches!( - err, - DelaunayRepairError::PostconditionFailed { .. } - )); - } - struct ForceHeuristicRebuildGuard { prior: bool, } @@ -8388,6 +8406,17 @@ mod tests { conflict_region_calls: 1, conflict_region_cells_total: 4, conflict_region_cells_max: 4, + conflict_region_nanos: 125_000, + conflict_region_nanos_max: 125_000, + cavity_insertion_calls: 1, + cavity_insertion_nanos: 375_000, + cavity_insertion_nanos_max: 375_000, + hull_extension_calls: 1, + hull_extension_nanos: 500_000, + hull_extension_nanos_max: 500_000, + topology_validation_calls: 1, + topology_validation_nanos: 625_000, + topology_validation_nanos_max: 625_000, global_conflict_scans: 1, global_conflict_cells_scanned: 12, global_conflict_cells_found_total: 3, @@ -8397,8 +8426,16 @@ mod tests { }; summary.telemetry.record_insertion(&telemetry); + summary.telemetry.record_insertion_timing(1_000_000); + summary.telemetry.record_local_repair_timing(2_000_000); + summary + .telemetry + .record_repair_seed_accumulation(500_000, 7); assert!(summary.telemetry.has_data()); + assert_eq!(summary.telemetry.insertion_wall_time_calls, 1); + assert_eq!(summary.telemetry.insertion_wall_time_nanos, 1_000_000); + assert_eq!(summary.telemetry.insertion_wall_time_nanos_max, 1_000_000); assert_eq!(summary.telemetry.locate_calls, 2); assert_eq!(summary.telemetry.locate_walk_steps_total, 9); assert_eq!(summary.telemetry.locate_walk_steps_max, 7); @@ -8408,6 +8445,20 @@ mod tests { assert_eq!(summary.telemetry.located_outside, 1); assert_eq!(summary.telemetry.conflict_region_calls, 1); assert_eq!(summary.telemetry.conflict_region_cells_total, 4); + assert_eq!(summary.telemetry.conflict_region_nanos, 125_000); + assert_eq!(summary.telemetry.conflict_region_nanos_max, 125_000); + assert_eq!(summary.telemetry.cavity_insertion_calls, 1); + assert_eq!(summary.telemetry.cavity_insertion_nanos, 375_000); + assert_eq!(summary.telemetry.hull_extension_calls, 1); + assert_eq!(summary.telemetry.hull_extension_nanos, 500_000); + assert_eq!(summary.telemetry.topology_validation_calls, 1); + assert_eq!(summary.telemetry.topology_validation_nanos, 625_000); + assert_eq!(summary.telemetry.local_repair_calls, 1); + assert_eq!(summary.telemetry.local_repair_nanos, 2_000_000); + assert_eq!(summary.telemetry.repair_seed_accumulation_calls, 1); + assert_eq!(summary.telemetry.repair_seed_accumulation_nanos, 500_000); + assert_eq!(summary.telemetry.repair_seed_cells_added_total, 7); + assert_eq!(summary.telemetry.repair_seed_cells_added_max, 7); assert_eq!(summary.telemetry.global_conflict_scans, 1); assert_eq!(summary.telemetry.global_conflict_cells_scanned, 12); assert_eq!(summary.telemetry.global_conflict_cells_found_total, 3); @@ -8479,6 +8530,44 @@ mod tests { ); } + #[test] + fn test_construction_statistics_records_slowest_insertion_samples() { + init_tracing(); + + let mut summary = ConstructionStatistics::default(); + for index in 0..10 { + let sample_index_u32 = u32::try_from(index).unwrap(); + summary.record_slow_insertion_sample(ConstructionSlowInsertionSample { + index, + uuid: Uuid::from_u128( + >::from(sample_index_u32) + 1, + ), + attempts: 1, + result: InsertionResult::Inserted, + elapsed_nanos: >::from(sample_index_u32) * 1_000, + cells_after: index, + locate_calls: 1, + locate_walk_steps_total: index, + conflict_region_calls: 1, + conflict_region_cells_total: index, + cavity_insertion_calls: 1, + global_conflict_scans: 0, + hull_extension_calls: 0, + topology_validation_calls: 1, + }); + } + + assert_eq!(summary.slow_insertions.len(), 8); + assert_eq!(summary.slow_insertions.first().map(|s| s.index), Some(9)); + assert_eq!(summary.slow_insertions.last().map(|s| s.index), Some(2)); + assert!( + summary + .slow_insertions + .windows(2) + .all(|pair| pair[0].elapsed_nanos >= pair[1].elapsed_nanos) + ); + } + #[test] fn test_select_balanced_simplex_indices_insufficient_vertices() { init_tracing(); @@ -10746,6 +10835,46 @@ mod tests { assert!(dt.validate().is_ok()); } + /// Exercises the `EveryN` cadence through the full bulk path: vertices + /// accumulate `pending_repair_seeds`, trigger cadenced local repair, and + /// then complete through `finalize_bulk_construction`. + #[test] + fn test_batch_4d_every_n_repair_cadence_runs_with_pending_seeds() { + init_tracing(); + let vertices = vec![ + vertex!([0.0, 0.0, 0.0, 0.0]), + vertex!([1.0, 0.0, 0.0, 0.0]), + 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.2, 0.2, 0.2, 0.2]), + vertex!([0.35, 0.25, 0.15, 0.3]), + ]; + + test_hooks::reset_batch_local_repair_calls(); + let _guard = ForceRepairNonconvergentGuard::enable(); + let kernel = RobustKernel::::new(); + let options = ConstructionOptions::default() + .with_batch_repair_policy(DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap())); + let (dt, stats) = + DelaunayTriangulation::, (), (), 4>::with_options_and_statistics( + &kernel, + &vertices, + TopologyGuarantee::DEFAULT, + options, + ) + .expect("EveryN batch repair should soft-fail forced local non-convergence and finish"); + + assert_eq!(dt.number_of_vertices(), vertices.len()); + assert_eq!(stats.inserted, vertices.len()); + assert_eq!( + test_hooks::batch_local_repair_calls(), + 1, + "EveryN(2) should run one cadenced repair before finalize_bulk_construction" + ); + assert!(dt.validate().is_ok()); + } + #[test] fn test_repair_soft_fail_classification() { let nonconvergent = test_hooks::synthetic_nonconvergent_error(); @@ -10845,151 +10974,6 @@ mod tests { ); } - // ========================================================================= - // Tests for try_d_lt4_global_repair_fallback - // ========================================================================= - - /// When `use_global_repair_fallback` is false the helper should return an error - /// immediately without attempting global repair. - #[test] - fn test_try_d_lt4_global_repair_fallback_disabled_returns_error() { - init_tracing(); - - 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 mut dt: DelaunayTriangulation, (), (), 3> = - DelaunayTriangulation::new(&vertices).unwrap(); - - let repair_err = DelaunayRepairError::NonConvergent { - max_flips: 16, - diagnostics: Box::new(DelaunayRepairDiagnostics { - facets_checked: 0, - flips_performed: 0, - max_queue_len: 0, - ambiguous_predicates: 0, - ambiguous_predicate_samples: Vec::new(), - predicate_failures: 0, - cycle_detections: 0, - cycle_signature_samples: Vec::new(), - attempt: 1, - queue_order: RepairQueueOrder::Fifo, - }), - }; - - let result = - DelaunayTriangulation::, (), (), 3>::try_d_lt4_global_repair_fallback( - &mut dt.tri.tds, - &dt.tri.kernel, - TopologyGuarantee::PLManifold, - false, // disabled - 5, - &repair_err, - ); - - assert!(result.is_err()); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("per-insertion Delaunay repair failed at index 5"), - "error should mention the index: {err_msg}" - ); - } - - /// When `use_global_repair_fallback` is true and the TDS is already valid, - /// global repair succeeds and the helper returns `Ok(())`. - #[test] - fn test_try_d_lt4_global_repair_fallback_enabled_succeeds_on_valid_tds() { - init_tracing(); - - 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]), - vertex!([0.3, 0.3, 0.3]), - ]; - let mut dt: DelaunayTriangulation, (), (), 3> = - DelaunayTriangulation::new(&vertices).unwrap(); - - let repair_err = DelaunayRepairError::NonConvergent { - max_flips: 16, - diagnostics: Box::new(DelaunayRepairDiagnostics { - facets_checked: 0, - flips_performed: 0, - max_queue_len: 0, - ambiguous_predicates: 0, - ambiguous_predicate_samples: Vec::new(), - predicate_failures: 0, - cycle_detections: 0, - cycle_signature_samples: Vec::new(), - attempt: 1, - queue_order: RepairQueueOrder::Fifo, - }), - }; - - // TDS is valid, so global repair should succeed (nothing to fix). - let result = - DelaunayTriangulation::, (), (), 3>::try_d_lt4_global_repair_fallback( - &mut dt.tri.tds, - &dt.tri.kernel, - TopologyGuarantee::PLManifold, - true, // enabled - 5, - &repair_err, - ); - - assert!( - result.is_ok(), - "global repair on valid TDS should succeed: {:?}", - result.err() - ); - } - - /// Verify the error message includes both local and global error details when - /// global repair also fails. - #[test] - fn test_try_d_lt4_global_repair_fallback_error_includes_both_messages() { - init_tracing(); - - // Build a 1D triangulation — repair_delaunay_with_flips_k2_k3 returns - // UnsupportedDimension for D<2, guaranteeing the global repair fails. - let vertices = vec![vertex!([0.0]), vertex!([1.0])]; - let mut dt: DelaunayTriangulation, (), (), 1> = - DelaunayTriangulation::new(&vertices).unwrap(); - - let repair_err = DelaunayRepairError::PostconditionFailed { - message: "synthetic local error".to_string(), - }; - - let result = - DelaunayTriangulation::, (), (), 1>::try_d_lt4_global_repair_fallback( - &mut dt.tri.tds, - &dt.tri.kernel, - TopologyGuarantee::PLManifold, - true, // enabled — but global repair will fail (D=1) - 7, - &repair_err, - ); - - assert!(result.is_err()); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("local error:"), - "error should contain local error detail: {err_msg}" - ); - assert!( - err_msg.contains("global fallback:"), - "error should contain global fallback detail: {err_msg}" - ); - assert!( - err_msg.contains("index 7"), - "error should contain the index: {err_msg}" - ); - } - #[test] fn test_map_orientation_canonicalization_error_topology_validation_is_internal() { let error = InsertionError::TopologyValidation(TdsError::InconsistentDataStructure { diff --git a/src/triangulation/locality.rs b/src/triangulation/locality.rs new file mode 100644 index 00000000..dc5cc0a6 --- /dev/null +++ b/src/triangulation/locality.rs @@ -0,0 +1,142 @@ +//! Locality helpers for triangulation construction and repair. +//! +//! These utilities sit at the boundary between spatial locality and topological +//! locality: callers may use Hilbert ordering or point-location hints to find a +//! nearby insertion site, then pass the concrete cell keys touched by the TDS +//! mutation here to build bounded repair frontiers. + +#![forbid(unsafe_code)] + +use crate::core::collections::FastHashSet; +use crate::core::tds::{CellKey, Tds}; +use crate::core::traits::data_type::DataType; + +/// Adds live, deduplicated candidate cells to a pending local repair frontier. +/// +/// Returns the number of cells newly appended to `pending_seed_cells`. +pub fn accumulate_live_cell_seeds( + tds: &Tds, + candidate_seed_cells: &[CellKey], + pending_seed_cells: &mut Vec, + pending_seen: &mut FastHashSet, +) -> usize +where + U: DataType, + V: DataType, +{ + let mut added = 0usize; + for &cell_key in candidate_seed_cells { + if tds.contains_cell(cell_key) && pending_seen.insert(cell_key) { + pending_seed_cells.push(cell_key); + added = added.saturating_add(1); + } + } + added +} + +/// Retains only live, deduplicated cells in a pending local repair frontier. +pub fn retain_live_cell_seeds( + tds: &Tds, + seed_cells: &mut Vec, + seen: &mut FastHashSet, +) where + U: DataType, + V: DataType, +{ + seen.clear(); + seed_cells.retain(|cell_key| tds.contains_cell(*cell_key) && seen.insert(*cell_key)); +} + +/// Clears a local repair frontier and its deduplication set together. +pub fn clear_cell_seed_set(seed_cells: &mut Vec, seen: &mut FastHashSet) { + seed_cells.clear(); + seen.clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::triangulation::delaunay::DelaunayTriangulation; + use crate::vertex; + use slotmap::KeyData; + + #[test] + fn accumulate_live_cell_seeds_dedupes_and_ignores_stale() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + vertex!([1.0, 1.0]), + vertex!([0.5, 0.5]), + ]; + let dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let all_cells: Vec = dt.cells().map(|(cell_key, _)| cell_key).collect(); + assert!( + all_cells.len() >= 2, + "fixture should produce multiple cells for seed accumulation" + ); + + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let mut pending_seed_cells = vec![all_cells[0]]; + let mut pending_seen: FastHashSet = pending_seed_cells.iter().copied().collect(); + let added = accumulate_live_cell_seeds( + dt.tds(), + &[all_cells[0], all_cells[1], all_cells[1], stale_cell], + &mut pending_seed_cells, + &mut pending_seen, + ); + + assert_eq!(added, 1); + assert_eq!(pending_seed_cells, vec![all_cells[0], all_cells[1]]); + assert!(!pending_seed_cells.contains(&stale_cell)); + + let added_again = accumulate_live_cell_seeds( + dt.tds(), + &[all_cells[1]], + &mut pending_seed_cells, + &mut pending_seen, + ); + assert_eq!(added_again, 0); + assert_eq!(pending_seed_cells, vec![all_cells[0], all_cells[1]]); + } + + #[test] + fn retain_live_cell_seeds_filters_stale_and_dedupes() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + vertex!([1.0, 1.0]), + vertex!([0.5, 0.5]), + ]; + let dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let all_cells: Vec = dt.cells().map(|(cell_key, _)| cell_key).collect(); + assert!( + all_cells.len() >= 2, + "fixture should produce multiple cells for seed retention" + ); + + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let mut seed_cells = vec![all_cells[0], stale_cell, all_cells[1], all_cells[0]]; + let mut seen = FastHashSet::default(); + retain_live_cell_seeds(dt.tds(), &mut seed_cells, &mut seen); + + assert_eq!(seed_cells, vec![all_cells[0], all_cells[1]]); + assert_eq!(seen.len(), 2); + } + + #[test] + fn clear_cell_seed_set_clears_both_collections() { + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let mut seed_cells = vec![stale_cell]; + let mut seen = FastHashSet::default(); + seen.insert(stale_cell); + + clear_cell_seed_set(&mut seed_cells, &mut seen); + + assert!(seed_cells.is_empty()); + assert!(seen.is_empty()); + } +} diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index ac796cee..085af2dc 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -73,6 +73,7 @@ #![forbid(unsafe_code)] +use delaunay::core::operations::InsertionResult; use delaunay::core::triangulation::TopologyGuarantee; use delaunay::geometry::kernel::RobustKernel; use delaunay::geometry::util::{ @@ -140,6 +141,24 @@ struct SkipSample { error: String, } +#[derive(Debug, Clone)] +struct SlowInsertionSample { + index: usize, + uuid: uuid::Uuid, + attempts: usize, + result: InsertionResult, + elapsed_nanos: u64, + cells_after: usize, + locate_calls: usize, + locate_walk_steps_total: usize, + conflict_region_calls: usize, + conflict_region_cells_total: usize, + cavity_insertion_calls: usize, + global_conflict_scans: usize, + hull_extension_calls: usize, + topology_validation_calls: usize, +} + #[derive(Debug, Default, Clone)] struct InsertionSummary { inserted: usize, @@ -157,6 +176,7 @@ struct InsertionSummary { telemetry: ConstructionTelemetry, + slow_insertions: Vec, skip_samples: Vec>, } @@ -210,6 +230,26 @@ impl InsertionSummary { impl From for InsertionSummary { fn from(stats: ConstructionStatistics) -> Self { + let slow_insertions = stats + .slow_insertions + .iter() + .map(|sample| SlowInsertionSample { + index: sample.index, + uuid: sample.uuid, + attempts: sample.attempts, + result: sample.result, + elapsed_nanos: sample.elapsed_nanos, + cells_after: sample.cells_after, + locate_calls: sample.locate_calls, + locate_walk_steps_total: sample.locate_walk_steps_total, + conflict_region_calls: sample.conflict_region_calls, + conflict_region_cells_total: sample.conflict_region_cells_total, + cavity_insertion_calls: sample.cavity_insertion_calls, + global_conflict_scans: sample.global_conflict_scans, + hull_extension_calls: sample.hull_extension_calls, + topology_validation_calls: sample.topology_validation_calls, + }) + .collect(); let skip_samples: Vec> = stats .skip_samples .iter() @@ -252,6 +292,7 @@ impl From for InsertionSummary { cells_removed_total: stats.cells_removed_total, cells_removed_max: stats.cells_removed_max, telemetry: stats.telemetry, + slow_insertions, skip_samples, } } @@ -343,11 +384,14 @@ impl ConstructionMode { } } -const fn initial_simplex_strategy_name(strategy: InitialSimplexStrategy) -> &'static str { +fn initial_simplex_strategy_name(strategy: InitialSimplexStrategy) -> &'static str { match strategy { InitialSimplexStrategy::First => "first", InitialSimplexStrategy::Balanced => "balanced", - _ => "unknown", + _ => { + tracing::debug!(?strategy, "unknown initial simplex strategy"); + "unknown" + } } } @@ -627,11 +671,56 @@ fn format_ratio_2_u128(numerator: u128, denominator: u128) -> String { format!("{}.{:02}", scaled / 100, scaled % 100) } -fn format_nanos_as_ms(nanos: u128) -> String { - let micros = nanos / 1_000; +fn format_nanos_as_ms(nanos: u64) -> String { + let micros = u128::from(nanos) / 1_000; format!("{}.{:03}", micros / 1_000, micros % 1_000) } +fn format_avg_nanos_as_ms(total_nanos: u64, count: usize) -> String { + if count == 0 { + return "0.000".to_string(); + } + + let count = u64::try_from(count).expect("sample count should fit in u64 for debug reporting"); + format_nanos_as_ms(total_nanos / count) +} + +fn print_timing_summary(label: &str, calls: usize, total_nanos: u64, max_nanos: u64) { + if calls == 0 { + return; + } + + println!( + " {label}: calls={calls} total_ms={} avg_ms={} max_ms={}", + format_nanos_as_ms(total_nanos), + format_avg_nanos_as_ms(total_nanos, calls), + format_nanos_as_ms(max_nanos) + ); +} + +fn print_repair_seed_accumulation_telemetry(telemetry: &ConstructionTelemetry) { + if telemetry.repair_seed_accumulation_calls == 0 { + return; + } + + println!( + " repair_seed_accumulation: calls={} cells_added_total={} avg_cells_added={} max_cells_added={} total_ms={} avg_ms={} max_ms={}", + telemetry.repair_seed_accumulation_calls, + telemetry.repair_seed_cells_added_total, + format_ratio_2( + telemetry.repair_seed_cells_added_total, + telemetry.repair_seed_accumulation_calls, + ), + telemetry.repair_seed_cells_added_max, + format_nanos_as_ms(telemetry.repair_seed_accumulation_nanos), + format_avg_nanos_as_ms( + telemetry.repair_seed_accumulation_nanos, + telemetry.repair_seed_accumulation_calls, + ), + format_nanos_as_ms(telemetry.repair_seed_accumulation_nanos_max) + ); +} + fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { if !telemetry.has_data() { return; @@ -639,6 +728,12 @@ fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { println!(); println!(" insertion telemetry:"); + print_timing_summary( + "insertion_wall", + telemetry.insertion_wall_time_calls, + telemetry.insertion_wall_time_nanos, + telemetry.insertion_wall_time_nanos_max, + ); println!( " locate: calls={} walk_steps_total={} avg_walk={} max_walk={} hint_uses={} scan_fallbacks={}", telemetry.locate_calls, @@ -655,19 +750,52 @@ fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { if telemetry.conflict_region_calls > 0 { println!( - " conflict_regions: calls={} cells_total={} avg_cells={} max_cells={}", + " conflict_regions: calls={} cells_total={} avg_cells={} max_cells={} total_ms={} avg_ms={} max_ms={}", telemetry.conflict_region_calls, telemetry.conflict_region_cells_total, format_ratio_2( telemetry.conflict_region_cells_total, telemetry.conflict_region_calls, ), - telemetry.conflict_region_cells_max + telemetry.conflict_region_cells_max, + format_nanos_as_ms(telemetry.conflict_region_nanos), + format_avg_nanos_as_ms( + telemetry.conflict_region_nanos, + telemetry.conflict_region_calls, + ), + format_nanos_as_ms(telemetry.conflict_region_nanos_max) ); } + print_timing_summary( + "cavity_insertions", + telemetry.cavity_insertion_calls, + telemetry.cavity_insertion_nanos, + telemetry.cavity_insertion_nanos_max, + ); + print_timing_summary( + "hull_extensions", + telemetry.hull_extension_calls, + telemetry.hull_extension_nanos, + telemetry.hull_extension_nanos_max, + ); + print_timing_summary( + "topology_validations", + telemetry.topology_validation_calls, + telemetry.topology_validation_nanos, + telemetry.topology_validation_nanos_max, + ); + print_timing_summary( + "local_repairs", + telemetry.local_repair_calls, + telemetry.local_repair_nanos, + telemetry.local_repair_nanos_max, + ); + print_repair_seed_accumulation_telemetry(telemetry); + if telemetry.global_conflict_scans > 0 { - let scans = usize_to_u128(telemetry.global_conflict_scans); + let scans = u64::try_from(telemetry.global_conflict_scans) + .expect("scan count should fit in u64 for debug reporting"); println!( " global_conflict_scans: scans={} cells_scanned_total={} avg_cells_scanned={} cells_found_total={} avg_cells_found={} max_cells_found={} total_ms={} avg_ms={}", telemetry.global_conflict_scans, @@ -715,6 +843,33 @@ fn print_insertion_summary(summary: &InsertionSummary, elapse print_construction_telemetry(&summary.telemetry); + if !summary.slow_insertions.is_empty() { + println!(); + println!( + " slow_insertions (top {} by transactional insertion wall time):", + summary.slow_insertions.len() + ); + for s in &summary.slow_insertions { + println!( + " idx={} uuid={} attempts={} result={:?} elapsed_ms={} cells_after={} locate_calls={} walk_steps={} conflict_calls={} conflict_cells={} cavity_calls={} global_scans={} hull_calls={} validation_calls={}", + s.index, + s.uuid, + s.attempts, + s.result, + format_nanos_as_ms(s.elapsed_nanos), + s.cells_after, + s.locate_calls, + s.locate_walk_steps_total, + s.conflict_region_calls, + s.conflict_region_cells_total, + s.cavity_insertion_calls, + s.global_conflict_scans, + s.hull_extension_calls, + s.topology_validation_calls + ); + } + } + if !summary.skip_samples.is_empty() { println!(); println!(" skip_samples (first {}):", summary.skip_samples.len()); @@ -1201,6 +1356,7 @@ fn test_initial_simplex_strategy_from_name_maps_ab_switch() { #[test] fn test_skip_percentage_reports_ratio() { assert!((skip_percentage(0, 100) - 0.0).abs() < f64::EPSILON); + assert!((skip_percentage(0, 0) - 0.0).abs() < f64::EPSILON); assert!((skip_percentage(4, 400) - 1.0).abs() < f64::EPSILON); assert!((skip_percentage(12, 100) - 12.0).abs() < f64::EPSILON); } From cbb06bd1ab283a431e8d5c139b3053d39a156131 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 9 May 2026 13:52:43 -0700 Subject: [PATCH 03/15] perf(triangulation): keep exterior repair seeding local - Preserve the terminal locate cell so exterior insertion can seed nearby repair work without scanning the full triangulation. - Skip global conflict-region scans during exterior hull extension and defer broad Delaunay cleanup to cadenced and final repair. - Move local repair seed bookkeeping helpers into triangulation locality utilities. --- src/core/algorithms/locate.rs | 59 +++++++- src/core/triangulation.rs | 277 ++++++++++++++-------------------- src/triangulation/locality.rs | 222 ++++++++++++++++++++++++++- 3 files changed, 391 insertions(+), 167 deletions(-) diff --git a/src/core/algorithms/locate.rs b/src/core/algorithms/locate.rs index fbc4378e..8bc5bc27 100644 --- a/src/core/algorithms/locate.rs +++ b/src/core/algorithms/locate.rs @@ -661,6 +661,21 @@ impl LocateStats { } } +/// Internal locate result that also records the final cell reached by the walk. +/// +/// Exterior insertion uses `terminal_cell` as a local conflict-region seed so it +/// can avoid a full triangulation scan while still repairing cells near the hull +/// facet where point location exited the triangulation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LocateTrace { + /// Public location classification. + pub(crate) result: LocateResult, + /// Public locate diagnostics. + pub(crate) stats: LocateStats, + /// Last cell visited by the facet walk before returning or falling back. + pub(crate) terminal_cell: CellKey, +} + /// Locate a point in the triangulation using facet walking (correctness-first). /// /// This function attempts a fast facet-walking traversal starting from `hint` (when provided). @@ -802,6 +817,26 @@ pub fn locate_with_stats( point: &Point, hint: Option, ) -> Result<(LocateResult, LocateStats), LocateError> +where + K: Kernel, + U: DataType, + V: DataType, +{ + let trace = locate_with_trace(tds, kernel, point, hint)?; + Ok((trace.result, trace.stats)) +} + +/// Locate a point and keep the final walked cell for local exterior repair. +/// +/// This mirrors [`locate_with_stats`] but also exposes the last facet-walk cell +/// before the algorithm concluded. For [`LocateResult::Outside`] without a scan +/// fallback, that cell is adjacent to the hull facet crossed by the query point. +pub(crate) fn locate_with_trace( + tds: &Tds, + kernel: &K, + point: &Point, + hint: Option, +) -> Result where K: Kernel, U: DataType, @@ -842,7 +877,11 @@ where steps: stats.walk_steps, }); let result = locate_by_scan(tds, kernel, point)?; - return Ok((result, stats)); + return Ok(LocateTrace { + result, + stats, + terminal_cell: current_cell, + }); } let cell = tds.cell(current_cell).ok_or(LocateError::InvalidCell { @@ -863,12 +902,20 @@ where found_outside_facet = true; break; } - return Ok((LocateResult::Outside, stats)); + return Ok(LocateTrace { + result: LocateResult::Outside, + stats, + terminal_cell: current_cell, + }); } } if !found_outside_facet { - return Ok((LocateResult::InsideCell(current_cell), stats)); + return Ok(LocateTrace { + result: LocateResult::InsideCell(current_cell), + stats, + terminal_cell: current_cell, + }); } } @@ -877,7 +924,11 @@ where steps: stats.walk_steps, }); let result = locate_by_scan(tds, kernel, point)?; - Ok((result, stats)) + Ok(LocateTrace { + result, + stats, + terminal_cell: current_cell, + }) } pub(crate) fn locate_by_scan( diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 867e7298..cc86db83 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -119,7 +119,7 @@ use crate::core::algorithms::locate::locate; use crate::core::algorithms::locate::verify_conflict_region_completeness; use crate::core::algorithms::locate::{ ConflictError, LocateError, LocateResult, LocateStats, extract_cavity_boundary, - find_conflict_region, locate_by_scan, locate_with_stats, + find_conflict_region, locate_by_scan, locate_with_stats, locate_with_trace, }; use crate::core::cell::{Cell, CellValidationError}; use crate::core::collections::spatial_hash_grid::HashGridIndex; @@ -129,7 +129,9 @@ use crate::core::collections::{ fast_hash_map_with_capacity, fast_hash_set_with_capacity, }; use crate::core::edge::EdgeKey; -use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter, FacetHandle, facet_key_from_vertices}; +#[cfg(test)] +use crate::core::facet::facet_key_from_vertices; +use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter, FacetHandle}; use crate::core::operations::{ InsertionOutcome, InsertionResult, InsertionStatistics, InsertionTelemetry, SuspicionFlags, }; @@ -138,6 +140,7 @@ use crate::core::tds::{ TdsConstructionError, TdsError, TriangulationValidationReport, VertexKey, }; use crate::core::traits::data_type::DataType; +#[cfg(test)] use crate::core::util::canonical_points::sorted_cell_points; use crate::core::vertex::Vertex; use crate::geometry::kernel::Kernel; @@ -156,6 +159,10 @@ use crate::topology::manifold::{ use crate::topology::traits::global_topology_model::GlobalTopologyModel; use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; use crate::triangulation::delaunay::DelaunayTriangulationValidationError; +use crate::triangulation::locality::{ + append_live_unique_cell_seeds, collect_local_exterior_conflict_seed_cells, + replace_cells_and_record_removed, retain_cells_and_record_removed, +}; use core::ops::Div; use num_traits::{NumCast, One, Zero}; use std::borrow::Cow; @@ -370,34 +377,6 @@ fn log_cavity_reduction_event( ); } -fn retain_conflict_cells_and_record_removed( - conflict_cells: &mut CellKeyBuffer, - repair_seed_cells: &mut CellKeyBuffer, - mut keep_cell: impl FnMut(CellKey) -> bool, -) { - conflict_cells.retain(|cell_key| { - let keep = keep_cell(*cell_key); - if !keep { - repair_seed_cells.push(*cell_key); - } - keep - }); -} - -fn replace_conflict_cells_and_record_removed( - conflict_cells: &mut CellKeyBuffer, - repair_seed_cells: &mut CellKeyBuffer, - replacement: CellKeyBuffer, -) { - let replacement_set: FastHashSet = replacement.iter().copied().collect(); - for &cell_key in conflict_cells.iter() { - if !replacement_set.contains(&cell_key) { - repair_seed_cells.push(cell_key); - } - } - *conflict_cells = replacement; -} - #[expect( clippy::too_many_arguments, reason = "Diagnostic helper keeps retryable skip instrumentation centralized" @@ -871,6 +850,7 @@ enum InsertionSite<'a> { }, Exterior { conflict_cells: Option>, + repair_seed_cells: CellKeyBuffer, }, } @@ -4467,8 +4447,12 @@ where /// Find all conflict cells by scanning the entire triangulation. /// - /// This is used for exterior points where the standard BFS conflict-region - /// search lacks a guaranteed seed cell inside the conflict region. + /// Test-only global conflict scanner for malformed-cell error coverage. + /// + /// Exterior production insertion deliberately avoids this path: hull + /// extension is the local topological mutation, and Delaunay violations are + /// left to the cadenced or final repair layers. + #[cfg(test)] fn find_conflict_region_global( &self, point: &Point, @@ -4552,6 +4536,7 @@ where } /// Returns true if any conflict cell has a facet on the hull boundary. + #[cfg(test)] fn conflict_region_touches_boundary( &self, conflict_cells: &CellKeyBuffer, @@ -4718,7 +4703,7 @@ where saw_ridge_fan_shrink = true; let remove_set: FastHashSet = extra_cells.iter().copied().collect(); - retain_conflict_cells_and_record_removed( + retain_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, |cell_key| !remove_set.contains(&cell_key), @@ -4793,7 +4778,7 @@ where ); let remove_set: FastHashSet = disconnected_cells.iter().copied().collect(); - retain_conflict_cells_and_record_removed( + retain_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, |cell_key| !remove_set.contains(&cell_key), @@ -4826,7 +4811,7 @@ where || format!("open_boundary_shrink open_cell={open_cell:?}"), ); let open = *open_cell; - retain_conflict_cells_and_record_removed( + retain_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, |cell_key| cell_key != open, @@ -4907,7 +4892,7 @@ where let mut replacement = CellKeyBuffer::new(); replacement.push(start_cell); - replace_conflict_cells_and_record_removed( + replace_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, replacement, @@ -4944,7 +4929,7 @@ where let mut replacement = CellKeyBuffer::new(); replacement.push(start_cell); - replace_conflict_cells_and_record_removed( + replace_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, replacement, @@ -5170,11 +5155,7 @@ where // Seed follow-up Delaunay repair from the local insertion product. Higher layers // use these cells to avoid rediscovering the inserted vertex star with a global scan. - for &cell_key in &new_cells { - if self.tds.contains_cell(cell_key) && seen_repair_seed_cells.insert(cell_key) { - repair_seed_cells.push(cell_key); - } - } + append_live_unique_cell_seeds(&self.tds, &new_cells, &mut repair_seed_cells); // Return hint for next insertion Ok((hint, total_removed, repair_seed_cells)) @@ -5313,27 +5294,6 @@ where telemetry.topology_validation_nanos_max.max(elapsed_nanos); } - #[inline] - fn record_global_conflict_scan_telemetry( - telemetry: &mut InsertionTelemetry, - cells_scanned: usize, - cells_found: usize, - elapsed_nanos: u64, - ) { - telemetry.global_conflict_scans = telemetry.global_conflict_scans.saturating_add(1); - telemetry.global_conflict_cells_scanned = telemetry - .global_conflict_cells_scanned - .saturating_add(cells_scanned); - telemetry.global_conflict_cells_found_total = telemetry - .global_conflict_cells_found_total - .saturating_add(cells_found); - telemetry.global_conflict_cells_found_max = - telemetry.global_conflict_cells_found_max.max(cells_found); - telemetry.global_conflict_scan_nanos = telemetry - .global_conflict_scan_nanos - .saturating_add(elapsed_nanos); - } - #[inline] fn duration_nanos_saturating(duration: Duration) -> u64 { u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) @@ -5418,7 +5378,9 @@ where // // `locate()` delegates to `locate_with_stats()`, so collecting the stats here keeps // the same point-location algorithm while making release-mode batch diagnostics useful. - let (location, locate_stats) = locate_with_stats(&self.tds, &self.kernel, &point, hint)?; + let locate_trace = locate_with_trace(&self.tds, &self.kernel, &point, hint)?; + let location = locate_trace.result; + let locate_stats = locate_trace.stats; Self::record_locate_telemetry(telemetry, location, &locate_stats); #[cfg(debug_assertions)] @@ -5513,63 +5475,42 @@ where } } (LocateResult::Outside, None) => { - // 2D exterior insertions skip the global conflict-region scan and go straight to - // hull extension, which is cheaper and more reliable in 2D. For D>2 we attempt - // cavity insertion first using a global conflict scan. - if D == 2 { - #[cfg(debug_assertions)] + // Exterior insertion is the hull-extension case. Avoid the old + // full-TDS conflict scan here; it was O(number_of_cells) per + // exterior point, often only to rediscover that the hull path + // was required anyway. Cadenced and final Delaunay repair own + // any local empty-circumsphere cleanup after the hull mutation. + #[cfg(debug_assertions)] + if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { tracing::debug!( - "Outside insertion in 2D: skipping global conflict-region scan; using hull extension" + "Outside insertion: skipping global conflict-region scan; using hull extension" ); - InsertionSite::Exterior { - conflict_cells: None, - } - } else { - #[cfg(debug_assertions)] - if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { - tracing::debug!("Outside insertion: starting global conflict-region scan"); - } - let cells_scanned = self.tds.number_of_cells(); - let global_scan_started = Instant::now(); - let computed = self.find_conflict_region_global(&point)?; - let elapsed_nanos = - u64::try_from(global_scan_started.elapsed().as_nanos()).unwrap_or(u64::MAX); - Self::record_global_conflict_scan_telemetry( + } + let repair_seed_cells = if !locate_stats.fell_back_to_scan() + && self.tds.contains_cell(locate_trace.terminal_cell) + { + let conflict_started = Instant::now(); + let local_seed_cells = collect_local_exterior_conflict_seed_cells( + &self.tds, + &self.kernel, + &point, + locate_trace.terminal_cell, + )?; + Self::record_conflict_region_telemetry( telemetry, - cells_scanned, - computed.len(), - elapsed_nanos, + local_seed_cells.conflict_cells_found, ); - if computed.is_empty() { - #[cfg(debug_assertions)] - if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { - tracing::debug!( - "Outside insertion: global conflict region empty; will use hull extension" - ); - } - InsertionSite::Exterior { - conflict_cells: None, - } - } else if self.conflict_region_touches_boundary(&computed)? { - #[cfg(debug_assertions)] - tracing::debug!( - "Outside insertion (D={D}) conflict region touches hull; skipping cavity insertion" - ); - // Avoid cavity insertion when the conflict region touches the hull. - // These mixed boundaries can yield ridge-link singularities in higher dimensions. - InsertionSite::Exterior { - conflict_cells: None, - } - } else { - #[cfg(debug_assertions)] - tracing::debug!( - "Outside insertion (D={D}) using global conflict region with {} cells", - computed.len() - ); - InsertionSite::Exterior { - conflict_cells: Some(Cow::Owned(computed)), - } - } + Self::record_conflict_region_timing( + telemetry, + Self::duration_nanos_saturating(conflict_started.elapsed()), + ); + local_seed_cells.seed_cells + } else { + CellKeyBuffer::new() + }; + InsertionSite::Exterior { + conflict_cells: None, + repair_seed_cells, } } (LocateResult::Outside, Some(cells)) => { @@ -5583,6 +5524,7 @@ where } InsertionSite::Exterior { conflict_cells: None, + repair_seed_cells: CellKeyBuffer::new(), } } else { #[cfg(debug_assertions)] @@ -5594,6 +5536,7 @@ where } InsertionSite::Exterior { conflict_cells: Some(Cow::Borrowed(cells)), + repair_seed_cells: cells.iter().copied().collect(), } } } @@ -5630,7 +5573,10 @@ where repair_seed_cells, }) } - InsertionSite::Exterior { conflict_cells } => { + InsertionSite::Exterior { + conflict_cells, + repair_seed_cells: exterior_repair_seed_cells, + } => { if let Some(conflict_cells) = conflict_cells { let conflict_cells = conflict_cells.into_owned(); #[cfg(debug_assertions)] @@ -5981,11 +5927,13 @@ where self.validate_connectedness(&new_cells)?; // Return vertex key and hint for next insertion - let repair_seed_cells: CellKeyBuffer = new_cells - .iter() - .copied() - .filter(|&cell_key| self.tds.contains_cell(cell_key)) - .collect(); + let mut repair_seed_cells = CellKeyBuffer::new(); + append_live_unique_cell_seeds(&self.tds, &new_cells, &mut repair_seed_cells); + append_live_unique_cell_seeds( + &self.tds, + &exterior_repair_seed_cells, + &mut repair_seed_cells, + ); Ok(TryInsertImplOk { inserted: (v_key, hint), cells_removed: total_removed, @@ -6852,45 +6800,6 @@ mod tests { assert!(cavity_conflict_error_summary(&internal).contains("internal_inconsistency site=")); } - #[test] - fn test_cavity_reduction_cell_bookkeeping_records_removed_cells() { - let a = CellKey::from(KeyData::from_ffi(31)); - let b = CellKey::from(KeyData::from_ffi(32)); - let c = CellKey::from(KeyData::from_ffi(33)); - let d = CellKey::from(KeyData::from_ffi(34)); - - let mut conflict_cells: CellKeyBuffer = [a, b, c].into_iter().collect(); - let mut repair_seed_cells = CellKeyBuffer::new(); - retain_conflict_cells_and_record_removed( - &mut conflict_cells, - &mut repair_seed_cells, - |ck| ck != b, - ); - assert_eq!( - conflict_cells.iter().copied().collect::>(), - vec![a, c] - ); - assert_eq!( - repair_seed_cells.iter().copied().collect::>(), - vec![b] - ); - - let replacement: CellKeyBuffer = [c, d].into_iter().collect(); - replace_conflict_cells_and_record_removed( - &mut conflict_cells, - &mut repair_seed_cells, - replacement, - ); - assert_eq!( - conflict_cells.iter().copied().collect::>(), - vec![c, d] - ); - assert_eq!( - repair_seed_cells.iter().copied().collect::>(), - vec![b, a] - ); - } - #[test] fn test_log_cavity_reduction_event_only_evaluates_when_enabled() { let mut conflict_cells = CellKeyBuffer::new(); @@ -9515,6 +9424,50 @@ mod tests { assert_eq!(tri.number_of_cells(), 1); } + #[test] + fn triangulation_exterior_insert_3d_uses_hull_extension_without_global_conflict_scan() { + let mut tri: Triangulation, (), (), 3> = + Triangulation::new_empty(FastKernel::new()); + + for coords in [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] { + tri.insert_with_statistics(vertex!(coords), None, None) + .unwrap(); + } + + let hint = tri.cells().next().map(|(cell_key, _)| cell_key); + let detail = tri + .insert_with_statistics_seeded_indexed_detailed( + vertex!([2.0, 2.0, 2.0]), + None, + hint, + 0, + None, + None, + ) + .unwrap(); + + assert!(matches!( + detail.outcome, + InsertionOutcome::Inserted { hint: Some(_), .. } + )); + assert_eq!(detail.telemetry.global_conflict_scans, 0); + assert_eq!(detail.telemetry.global_conflict_cells_scanned, 0); + assert_eq!(detail.telemetry.global_conflict_cells_found_total, 0); + assert_eq!(detail.telemetry.global_conflict_scan_nanos, 0); + assert_eq!(detail.telemetry.cavity_insertion_calls, 0); + assert_eq!(detail.telemetry.hull_extension_calls, 1); + assert!( + !detail.repair_seed_cells.is_empty(), + "hull extension should return local repair seeds" + ); + assert!(tri.is_valid().is_ok()); + } + #[test] fn triangulation_insert_with_statistics_hint_usage_4d() { let mut tri: Triangulation, (), (), 4> = diff --git a/src/triangulation/locality.rs b/src/triangulation/locality.rs index dc5cc0a6..6ae00e5a 100644 --- a/src/triangulation/locality.rs +++ b/src/triangulation/locality.rs @@ -7,9 +7,20 @@ #![forbid(unsafe_code)] -use crate::core::collections::FastHashSet; +use crate::core::algorithms::locate::{ConflictError, find_conflict_region}; +use crate::core::collections::{CellKeyBuffer, FastHashSet}; use crate::core::tds::{CellKey, Tds}; use crate::core::traits::data_type::DataType; +use crate::geometry::kernel::Kernel; +use crate::geometry::point::Point; + +/// Local conflict-seed collection result for exterior insertion repair. +pub struct LocalConflictSeedCells { + /// Live cells that should seed local Delaunay repair. + pub seed_cells: CellKeyBuffer, + /// Number of cells returned by the local conflict-region search before any fallback seed. + pub conflict_cells_found: usize, +} /// Adds live, deduplicated candidate cells to a pending local repair frontier. /// @@ -34,6 +45,28 @@ where added } +/// Adds live, deduplicated candidate cells to a compact repair seed buffer. +/// +/// Returns the number of cells newly appended to `seed_cells`. +pub fn append_live_unique_cell_seeds( + tds: &Tds, + candidate_seed_cells: &[CellKey], + seed_cells: &mut CellKeyBuffer, +) -> usize +where + U: DataType, + V: DataType, +{ + let mut added = 0usize; + for &cell_key in candidate_seed_cells { + if tds.contains_cell(cell_key) && !seed_cells.contains(&cell_key) { + seed_cells.push(cell_key); + added = added.saturating_add(1); + } + } + added +} + /// Retains only live, deduplicated cells in a pending local repair frontier. pub fn retain_live_cell_seeds( tds: &Tds, @@ -53,13 +86,98 @@ pub fn clear_cell_seed_set(seed_cells: &mut Vec, seen: &mut FastHashSet seen.clear(); } +/// Retains conflict cells and records removed cells as local repair seeds. +pub fn retain_cells_and_record_removed( + conflict_cells: &mut CellKeyBuffer, + repair_seed_cells: &mut CellKeyBuffer, + mut keep_cell: impl FnMut(CellKey) -> bool, +) { + conflict_cells.retain(|cell_key| { + let keep = keep_cell(*cell_key); + if !keep { + repair_seed_cells.push(*cell_key); + } + keep + }); +} + +/// Replaces conflict cells and records cells missing from the replacement. +pub fn replace_cells_and_record_removed( + conflict_cells: &mut CellKeyBuffer, + repair_seed_cells: &mut CellKeyBuffer, + replacement: CellKeyBuffer, +) { + let replacement_set: FastHashSet = replacement.iter().copied().collect(); + for &cell_key in conflict_cells.iter() { + if !replacement_set.contains(&cell_key) { + repair_seed_cells.push(cell_key); + } + } + *conflict_cells = replacement; +} + +/// Collects local repair seeds for an exterior insertion from the terminal walk cell. +/// +/// The terminal cell is adjacent to the hull facet crossed by point location, so a +/// BFS conflict search from it gives a bounded local frontier without scanning the +/// entire triangulation. If no circumsphere conflict is found, the terminal cell +/// itself is still a useful local seed. +pub fn collect_local_exterior_conflict_seed_cells( + tds: &Tds, + kernel: &K, + point: &Point, + terminal_cell: CellKey, +) -> Result +where + K: Kernel, + U: DataType, + V: DataType, +{ + let mut seed_cells = CellKeyBuffer::new(); + if !tds.contains_cell(terminal_cell) { + return Ok(LocalConflictSeedCells { + seed_cells, + conflict_cells_found: 0, + }); + } + + let computed = find_conflict_region(tds, kernel, point, terminal_cell)?; + let conflict_cells_found = computed.len(); + if computed.is_empty() { + seed_cells.push(terminal_cell); + } else { + seed_cells = computed; + } + + Ok(LocalConflictSeedCells { + seed_cells, + conflict_cells_found, + }) +} + #[cfg(test)] mod tests { use super::*; + use crate::core::triangulation::Triangulation; + use crate::geometry::kernel::FastKernel; + use crate::geometry::point::Point; + use crate::geometry::traits::coordinate::Coordinate; use crate::triangulation::delaunay::DelaunayTriangulation; use crate::vertex; use slotmap::KeyData; + fn simplex_triangulation_3d() -> Triangulation, (), (), 3> { + let vertices = [ + 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 tds = + Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); + Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds) + } + #[test] fn accumulate_live_cell_seeds_dedupes_and_ignores_stale() { let vertices = vec![ @@ -101,6 +219,39 @@ mod tests { assert_eq!(pending_seed_cells, vec![all_cells[0], all_cells[1]]); } + #[test] + fn append_live_unique_cell_seeds_dedupes_and_ignores_stale() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + vertex!([1.0, 1.0]), + vertex!([0.5, 0.5]), + ]; + let dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let all_cells: Vec = dt.cells().map(|(cell_key, _)| cell_key).collect(); + assert!( + all_cells.len() >= 2, + "fixture should produce multiple cells for compact seed accumulation" + ); + + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let mut seed_cells = CellKeyBuffer::new(); + seed_cells.push(all_cells[0]); + let added = append_live_unique_cell_seeds( + dt.tds(), + &[all_cells[0], all_cells[1], stale_cell, all_cells[1]], + &mut seed_cells, + ); + + assert_eq!(added, 1); + assert_eq!( + seed_cells.iter().copied().collect::>(), + vec![all_cells[0], all_cells[1],] + ); + } + #[test] fn retain_live_cell_seeds_filters_stale_and_dedupes() { let vertices = vec![ @@ -139,4 +290,73 @@ mod tests { assert!(seed_cells.is_empty()); assert!(seen.is_empty()); } + + #[test] + fn retain_and_replace_cells_record_removed_repair_seeds() { + let a = CellKey::from(KeyData::from_ffi(31)); + let b = CellKey::from(KeyData::from_ffi(32)); + let c = CellKey::from(KeyData::from_ffi(33)); + let d = CellKey::from(KeyData::from_ffi(34)); + + let mut conflict_cells: CellKeyBuffer = [a, b, c].into_iter().collect(); + let mut repair_seed_cells = CellKeyBuffer::new(); + retain_cells_and_record_removed(&mut conflict_cells, &mut repair_seed_cells, |ck| ck != b); + assert_eq!( + conflict_cells.iter().copied().collect::>(), + vec![a, c] + ); + assert_eq!( + repair_seed_cells.iter().copied().collect::>(), + vec![b] + ); + + let replacement: CellKeyBuffer = [c, d].into_iter().collect(); + replace_cells_and_record_removed(&mut conflict_cells, &mut repair_seed_cells, replacement); + assert_eq!( + conflict_cells.iter().copied().collect::>(), + vec![c, d] + ); + assert_eq!( + repair_seed_cells.iter().copied().collect::>(), + vec![b, a] + ); + } + + #[test] + fn collect_local_exterior_conflict_seed_cells_uses_terminal_seed_when_empty() { + let tri = simplex_triangulation_3d(); + let terminal_cell = tri.tds.cell_keys().next().unwrap(); + let result = collect_local_exterior_conflict_seed_cells( + &tri.tds, + &FastKernel::new(), + &Point::new([2.0, 2.0, 2.0]), + terminal_cell, + ) + .unwrap(); + + assert_eq!(result.conflict_cells_found, 0); + assert_eq!( + result.seed_cells.iter().copied().collect::>(), + vec![terminal_cell] + ); + } + + #[test] + fn collect_local_exterior_conflict_seed_cells_returns_local_conflicts() { + let tri = simplex_triangulation_3d(); + let terminal_cell = tri.tds.cell_keys().next().unwrap(); + let result = collect_local_exterior_conflict_seed_cells( + &tri.tds, + &FastKernel::new(), + &Point::new([0.5, 0.5, 0.5]), + terminal_cell, + ) + .unwrap(); + + assert_eq!(result.conflict_cells_found, 1); + assert_eq!( + result.seed_cells.iter().copied().collect::>(), + vec![terminal_cell] + ); + } } From 8726ad54bb728fe11a633630e41e05aff4ea2d9e Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 9 May 2026 21:34:48 -0700 Subject: [PATCH 04/15] perf(triangulation): localize construction repair cadence - Keep exterior repair seeding local when conflict buffers are empty, avoiding global conflict-region scans during insertion. - Gate topology-validation telemetry on actual validation work and add typed validation cadences for large-scale diagnostics. - Preserve typed Delaunay repair failures with construction repair phases and keep mutation repair gates independent of insertion cadence. - Add focused triangulation construction, repair, and validation preludes across docs, examples, benches, and tests. --- README.md | 18 +- benches/ci_performance_suite.rs | 6 +- benches/large_scale_performance.rs | 2 +- benches/profiling_suite.rs | 2 +- benches/topology_guarantee_construction.rs | 6 +- docs/api_design.md | 8 +- docs/code_organization.md | 11 +- docs/dev/rust.md | 5 +- docs/diagnostics.md | 2 +- docs/numerical_robustness_guide.md | 5 +- docs/topology.md | 2 +- docs/validation.md | 30 +- docs/workflows.md | 26 +- examples/delaunayize_repair.rs | 5 +- examples/diagnostics.rs | 7 +- examples/numerical_robustness.rs | 4 +- examples/pachner_roundtrip_4d.rs | 8 +- examples/topology_editing_2d_3d.rs | 7 +- examples/zero_allocation_iterator_demo.rs | 2 +- src/core/operations.rs | 9 + src/core/triangulation.rs | 217 +++++++++++--- src/lib.rs | 130 ++++++-- src/triangulation.rs | 54 ++++ src/triangulation/builder.rs | 103 +++---- src/triangulation/delaunay.rs | 332 ++++++++++++++------- src/triangulation/locality.rs | 9 +- src/triangulation/validation.rs | 129 ++++++++ tests/allocation_api.rs | 4 +- tests/check_perturbation_stats.rs | 6 +- tests/circumsphere_debug_tools.rs | 2 +- tests/conflict_region_verification.rs | 2 +- tests/dedup_batch_construction.rs | 6 +- tests/delaunay_edge_cases.rs | 5 +- tests/delaunay_incremental_insertion.rs | 4 +- tests/delaunay_public_api_coverage.rs | 6 +- tests/delaunay_repair_fallback.rs | 4 +- tests/euler_characteristic.rs | 5 +- tests/insert_with_statistics.rs | 5 +- tests/large_scale_debug.rs | 23 +- tests/prelude_exports.rs | 22 +- tests/proptest_delaunay_triangulation.rs | 7 +- tests/proptest_euler_characteristic.rs | 4 +- tests/proptest_flips.rs | 4 +- tests/proptest_orientation.rs | 6 +- tests/proptest_triangulation.rs | 4 +- tests/regressions.rs | 5 +- tests/serialization_vertex_preservation.rs | 4 +- 47 files changed, 941 insertions(+), 326 deletions(-) create mode 100644 src/triangulation.rs create mode 100644 src/triangulation/validation.rs diff --git a/README.md b/README.md index f5ea3e1d..216cb5a4 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,15 @@ Choose the smallest prelude that matches the task: | Task | Import | |---|---| -| Build, configure, insert, or remove vertices | `use delaunay::prelude::triangulation::*` | +| Construct/configure a Delaunay triangulation | `use delaunay::prelude::triangulation::construction::*` | | Read-only traversal, adjacency, convex hulls, and comparison helpers | `use delaunay::prelude::query::*` | | Points, kernels, predicates, and geometric measures | `use delaunay::prelude::geometry::*` | | Random points or triangulations for examples, tests, and benchmarks | `use delaunay::prelude::generators::*` | +| Low-level incremental insertion building blocks | `use delaunay::prelude::triangulation::insertion::*` | | Bistellar flips / Edit API | `use delaunay::prelude::triangulation::flips::*` | | Delaunay repair diagnostics and policies | `use delaunay::prelude::triangulation::repair::*` | | Delaunayize workflow | `use delaunay::prelude::triangulation::delaunayize::*` | +| Construction validation cadence/policy | `use delaunay::prelude::triangulation::validation::*` | | Hilbert ordering and quantization utilities | `use delaunay::prelude::ordering::*` | | Low-level TDS cells, facets, keys, and validation reports | `use delaunay::prelude::tds::*` | | Collection aliases and small buffers | `use delaunay::prelude::collections::*` | @@ -107,9 +109,11 @@ Choose the smallest prelude that matches the task: `use delaunay::prelude::*` remains available for quick experiments, but examples and benchmarks in this repository prefer focused preludes so imports document intent. +The broad `delaunay::prelude::triangulation::*` import is retained for compatibility, +but new docs and tests should prefer the narrow workflow preludes above. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; // Create a 4D Delaunay triangulation from a set of vertices (uses AdaptiveKernel by default). let vertices = vec![ @@ -138,7 +142,9 @@ assert!(dt.is_valid().is_ok()); For periodic boundary conditions, use `DelaunayTriangulationBuilder`: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulationBuilder, TopologyKind, vertex, +}; // Phase 1: Canonicalization (wraps coordinates into [0, 1)²) let vertices = vec![ @@ -196,7 +202,11 @@ The construction pipeline exposes deterministic controls for experiments and reg - Explicit topology/validation configuration via `TopologyGuarantee` and `ValidationPolicy` ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DedupPolicy, DelaunayTriangulationBuilder, InsertionOrderStrategy, + RetryPolicy, TopologyGuarantee, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0]), diff --git a/benches/ci_performance_suite.rs b/benches/ci_performance_suite.rs index b03ec1e6..4cdb4bb5 100644 --- a/benches/ci_performance_suite.rs +++ b/benches/ci_performance_suite.rs @@ -36,12 +36,12 @@ use delaunay::prelude::geometry::{ AdaptiveKernel, Coordinate, Point, RobustKernel, simplex_volume, }; use delaunay::prelude::query::ConvexHull; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, RetryPolicy, Vertex, +}; use delaunay::prelude::triangulation::flips::{ BistellarFlips, CellKey, EdgeKey, FacetHandle, RidgeHandle, TopologyGuarantee, TriangleHandle, }; -use delaunay::prelude::triangulation::{ - ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, RetryPolicy, Vertex, -}; use delaunay::vertex; use std::{env, hint::black_box, num::NonZeroUsize, sync::Once}; #[cfg(feature = "bench-logging")] diff --git a/benches/large_scale_performance.rs b/benches/large_scale_performance.rs index 94a85ccf..c83d14b4 100644 --- a/benches/large_scale_performance.rs +++ b/benches/large_scale_performance.rs @@ -79,7 +79,7 @@ use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main}; use delaunay::prelude::generators::generate_random_points_seeded; use delaunay::prelude::geometry::AdaptiveKernel; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ ConstructionOptions, DelaunayTriangulation, RetryPolicy, Vertex, }; use delaunay::vertex; diff --git a/benches/profiling_suite.rs b/benches/profiling_suite.rs index bee63b42..bb843eaa 100644 --- a/benches/profiling_suite.rs +++ b/benches/profiling_suite.rs @@ -64,7 +64,7 @@ use delaunay::prelude::generators::{ }; use delaunay::prelude::geometry::{Coordinate, safe_usize_to_scalar}; use delaunay::prelude::query::*; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ ConstructionOptions, DelaunayTriangulationBuilder, RetryPolicy, }; use delaunay::vertex; diff --git a/benches/topology_guarantee_construction.rs b/benches/topology_guarantee_construction.rs index c8bd9a47..4a74200a 100644 --- a/benches/topology_guarantee_construction.rs +++ b/benches/topology_guarantee_construction.rs @@ -14,9 +14,9 @@ use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use delaunay::prelude::generators::generate_random_points_seeded; -use delaunay::prelude::triangulation::{ - DelaunayRepairPolicy, DelaunayTriangulation, TopologyGuarantee, ValidationPolicy, -}; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, TopologyGuarantee}; +use delaunay::prelude::triangulation::repair::DelaunayRepairPolicy; +use delaunay::prelude::triangulation::validation::ValidationPolicy; use delaunay::vertex; use std::hint::black_box; use std::time::Duration; diff --git a/docs/api_design.md b/docs/api_design.md index 8a5de6ea..b02c5a09 100644 --- a/docs/api_design.md +++ b/docs/api_design.md @@ -60,7 +60,7 @@ The library provides two distinct APIs for different use cases: For most use cases, the simple constructor is sufficient: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; // Simple construction from vertices (Euclidean space, default options) let vertices = vec![ @@ -87,7 +87,7 @@ For advanced configuration (toroidal topology, custom validation policies, etc.) use `DelaunayTriangulationBuilder`: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; // Toroidal (periodic) triangulation in 2D let vertices = vec![ @@ -148,7 +148,7 @@ for topology guarantee and validation policy details. The Edit API is exposed through the `BistellarFlips` trait in `prelude::triangulation::flips`: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::flips::*; // Start with a valid triangulation @@ -257,7 +257,7 @@ After applying flips, you should: You can mix both APIs in the same workflow: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::flips::*; // 1. Build initial triangulation (Builder API) diff --git a/docs/code_organization.md b/docs/code_organization.md index 353e2c72..c0d22abd 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -220,7 +220,10 @@ delaunay/ │ │ ├── builder.rs │ │ ├── delaunay.rs │ │ ├── delaunayize.rs -│ │ └── flips.rs +│ │ ├── flips.rs +│ │ ├── locality.rs +│ │ └── validation.rs +│ ├── triangulation.rs │ └── lib.rs ├── tests/ │ ├── semgrep/ @@ -431,6 +434,12 @@ The `benchmark-utils` CLI provides integrated benchmark workflow functionality, - `delaunayize.rs` - End-to-end "repair then delaunayize" workflow (`delaunayize_by_flips`); bounded topology repair + flip-based Delaunay repair + optional fallback rebuild - `flips.rs` - High-level bistellar flip (Pachner move) trait and supporting public types; delegates to `core::algorithms::flips` +- `locality.rs` - Local seed/frontier helpers for Hilbert-local construction and repair +- `validation.rs` - Construction validation cadence and scheduling helpers + +**`src/triangulation.rs`** - Public facade for triangulation-facing workflows. +It keeps the module namespace stable while the implementation is split across +orthogonal files under `src/triangulation/`. **`src/topology/`** - Topology analysis and validation: diff --git a/docs/dev/rust.md b/docs/dev/rust.md index 1a85a050..bcb4eb40 100644 --- a/docs/dev/rust.md +++ b/docs/dev/rust.md @@ -563,9 +563,12 @@ Examples: ```text delaunay::prelude::triangulation +delaunay::prelude::triangulation::construction delaunay::prelude::triangulation::flips +delaunay::prelude::triangulation::insertion delaunay::prelude::triangulation::repair delaunay::prelude::triangulation::delaunayize +delaunay::prelude::triangulation::validation delaunay::prelude::query delaunay::prelude::algorithms delaunay::prelude::geometry @@ -600,7 +603,7 @@ Example: /// # Examples /// /// ```rust -/// # use delaunay::prelude::triangulation::*; +/// # use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// # fn main() -> Result<(), Box> { /// let mut triangulation = DelaunayTriangulation::<_, _, _, 2>::default(); /// let key = triangulation.insert_vertex([0.0, 0.0])?; diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 3850fa40..5f7859ae 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -43,7 +43,7 @@ Use `delaunay_violation_report` when you want data instead of only log output: ```rust use delaunay::prelude::diagnostics::delaunay_violation_report; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), diff --git a/docs/numerical_robustness_guide.md b/docs/numerical_robustness_guide.md index 00bef919..4bc05df5 100644 --- a/docs/numerical_robustness_guide.md +++ b/docs/numerical_robustness_guide.md @@ -88,7 +88,7 @@ The convenience constructors (`DelaunayTriangulation::new()`, `::empty()`, etc.) ```rust use delaunay::prelude::geometry::RobustKernel; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let kernel = RobustKernel::::new(); @@ -165,7 +165,8 @@ cases involve cavity/topology failures rather than predicate degeneracies. Use `insert_with_statistics()` to observe this behavior: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); diff --git a/docs/topology.md b/docs/topology.md index 0cc02809..21040258 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -177,7 +177,7 @@ Toroidal (periodic) triangulations are **fully implemented and functional**. You construct toroidal triangulations using `DelaunayTriangulationBuilder`: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; // 2D periodic triangulation let vertices = vec![ diff --git a/docs/validation.md b/docs/validation.md index 51d5908f..9fefa487 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -79,7 +79,10 @@ insertion deviates from the happy-path and trips internal **suspicion flags**, e ### Example: configuring validation policy ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -121,7 +124,10 @@ PL-manifoldness. You can trigger that final certification via `Triangulation::validate_at_completion()` (or `Triangulation::validate()`). ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -210,7 +216,10 @@ Validates basic data integrity of individual vertices and cells. ### Example ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let v = vertex!([0.0, 0.0, 0.0]); assert!(v.is_valid().is_ok()); @@ -263,7 +272,10 @@ Validates the combinatorial structure of the Triangulation Data Structure. ### Example ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -341,7 +353,10 @@ Validates that the triangulation forms a valid topological manifold. ### Example ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -404,7 +419,10 @@ Validates the geometric optimality of the triangulation. ### Example ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), diff --git a/docs/workflows.md b/docs/workflows.md index f13f5c6b..8b600b5d 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -14,7 +14,7 @@ For the theoretical background and rationale behind the invariants, see [`invari For most use cases, construction is a single call: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -39,7 +39,8 @@ Two knobs are commonly used for insertion-time safety vs performance: See [`validation.md`](validation.md) for details. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, TopologyGuarantee}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -76,7 +77,7 @@ The explicit repair methods (`repair_delaunay_with_flips`, `repair_delaunay_with [`numerical_robustness_guide.md`](numerical_robustness_guide.md) for kernel selection guidance. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayRepairPolicy, DelaunayTriangulation}; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -90,7 +91,7 @@ dt.set_delaunay_repair_policy(DelaunayRepairPolicy::Never); You can also run a global repair pass manually: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -137,8 +138,8 @@ If repair fails to converge within the flip budget, you get detections, etc.). ```rust +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::repair::DelaunayRepairError; -use delaunay::prelude::triangulation::*; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -178,7 +179,7 @@ You can provide explicit seeds for reproducibility; otherwise deterministic defa from the current vertex set. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::repair::DelaunayRepairHeuristicConfig; let vertices = vec![ @@ -205,7 +206,7 @@ Toroidal triangulations handle periodic boundary conditions. Use `DelaunayTriangulationBuilder` to construct them: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; // 2D periodic triangulation with unit square domain let vertices = vec![ @@ -243,7 +244,9 @@ Data is attached at construction time via `VertexBuilder::data()`, read via the and modified post-construction via `set_vertex_data` / `set_cell_data`. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulationBuilder, Vertex, vertex, +}; // Attach integer labels at construction time let vertices: [Vertex; 3] = [ @@ -280,7 +283,8 @@ If you need observability (or you want to handle skipped vertices explicitly), u `insert_with_statistics()`. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -311,7 +315,7 @@ possible and fan retriangulation otherwise, then runs flip-based Delaunay repair the operation rolls back to the pre-removal triangulation. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -354,7 +358,7 @@ After using flips, you typically: See [`api_design.md`](api_design.md) for the full Builder vs Edit API design. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::flips::*; let vertices = vec![ diff --git a/examples/delaunayize_repair.rs b/examples/delaunayize_repair.rs index 009f4c25..08f1b9b0 100644 --- a/examples/delaunayize_repair.rs +++ b/examples/delaunayize_repair.rs @@ -18,11 +18,10 @@ //! cargo run --example delaunayize_repair //! ``` +use delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError; use delaunay::prelude::triangulation::delaunayize::*; use delaunay::prelude::triangulation::flips::*; -use delaunay::prelude::triangulation::{ - DelaunayTriangulationConstructionError, DelaunayTriangulationValidationError, -}; +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; // For the generic print_outcome helper. use delaunay::prelude::DataType; diff --git a/examples/diagnostics.rs b/examples/diagnostics.rs index 38d40720..9f555579 100644 --- a/examples/diagnostics.rs +++ b/examples/diagnostics.rs @@ -17,11 +17,14 @@ use delaunay::prelude::diagnostics::{ #[cfg(feature = "diagnostics")] use delaunay::prelude::geometry::AdaptiveKernel; #[cfg(feature = "diagnostics")] -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, DelaunayTriangulationConstructionError, - DelaunayTriangulationValidationError, flips::*, }; #[cfg(feature = "diagnostics")] +use delaunay::prelude::triangulation::flips::*; +#[cfg(feature = "diagnostics")] +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; +#[cfg(feature = "diagnostics")] use delaunay::vertex; #[cfg(feature = "diagnostics")] diff --git a/examples/numerical_robustness.rs b/examples/numerical_robustness.rs index bb77048a..beb09c9a 100644 --- a/examples/numerical_robustness.rs +++ b/examples/numerical_robustness.rs @@ -9,10 +9,10 @@ use delaunay::prelude::geometry::{ AdaptiveKernel, CircumcenterError, Coordinate, CoordinateConversionError, FastKernel, Kernel, Point, RobustKernel, robust_insphere, robust_orientation, }; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, DelaunayTriangulationConstructionError, - DelaunayTriangulationValidationError, }; +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; use delaunay::vertex; #[derive(Debug, thiserror::Error)] diff --git a/examples/pachner_roundtrip_4d.rs b/examples/pachner_roundtrip_4d.rs index 0becc8eb..eb2ec39b 100644 --- a/examples/pachner_roundtrip_4d.rs +++ b/examples/pachner_roundtrip_4d.rs @@ -15,11 +15,11 @@ use ::uuid::Uuid; use delaunay::prelude::geometry::RobustKernel; -use delaunay::prelude::triangulation::flips::*; -use delaunay::prelude::triangulation::{ - ConstructionOptions, DelaunayTriangulationConstructionError, - DelaunayTriangulationValidationError, InsertionOrderStrategy, Vertex, +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayTriangulationConstructionError, InsertionOrderStrategy, Vertex, }; +use delaunay::prelude::triangulation::flips::*; +use delaunay::prelude::triangulation::repair::DelaunayTriangulationValidationError; use std::time::Instant; type Dt4 = DelaunayTriangulation, (), (), 4>; diff --git a/examples/topology_editing_2d_3d.rs b/examples/topology_editing_2d_3d.rs index 9db04a6b..b13684c2 100644 --- a/examples/topology_editing_2d_3d.rs +++ b/examples/topology_editing_2d_3d.rs @@ -26,8 +26,13 @@ use delaunay::prelude::geometry::{ CircumcenterError, Coordinate, Kernel, Point, circumcenter, hypot, }; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, + vertex, +}; use delaunay::prelude::triangulation::flips::*; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::insertion::InsertionError; +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; use delaunay::prelude::{TdsError, VertexKey}; type ExampleResult = Result; diff --git a/examples/zero_allocation_iterator_demo.rs b/examples/zero_allocation_iterator_demo.rs index 57bd4ced..c2ab1644 100644 --- a/examples/zero_allocation_iterator_demo.rs +++ b/examples/zero_allocation_iterator_demo.rs @@ -7,7 +7,7 @@ use delaunay::prelude::generators::generate_random_triangulation; use delaunay::prelude::tds::CellValidationError; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, DelaunayTriangulationConstructionError, }; use std::hint::black_box; diff --git a/src/core/operations.rs b/src/core/operations.rs index b98b9025..e23d2286 100644 --- a/src/core/operations.rs +++ b/src/core/operations.rs @@ -468,6 +468,15 @@ mod tests { DelaunayRepairPolicy::EveryInsertion.decide(1, TopologyGuarantee::PLManifold, op); assert!(matches!(decision, RepairDecision::Proceed)); + let decision = + DelaunayRepairPolicy::EveryInsertion.decide(0, TopologyGuarantee::PLManifold, op); + assert!(matches!( + decision, + RepairDecision::Skip { + reason: RepairSkipReason::PolicyDisabled + } + )); + let decision = DelaunayRepairPolicy::EveryInsertion.decide(1, TopologyGuarantee::Pseudomanifold, op); assert!(matches!(decision, RepairDecision::Proceed)); diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index cc86db83..4006a017 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -825,6 +825,12 @@ struct TryInsertImplOk { repair_seed_cells: CellKeyBuffer, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InsertionValidationWork { + FullValidation, + RequiredTopologyLinks, +} + /// Internal result from over-shared-facet repair, including the surviving frontier /// that should seed local neighbor-pointer repair. struct LocalFacetRepairOutcome { @@ -4120,9 +4126,12 @@ where Ok(()) } - fn validate_after_insertion(&self, suspicion: SuspicionFlags) -> Result<(), InvariantError> { + fn validation_after_insertion_work( + &self, + suspicion: SuspicionFlags, + ) -> Option { if self.tds.number_of_cells() == 0 { - return Ok(()); + return None; } let should_validate = self.validation_policy.should_validate(suspicion); @@ -4131,15 +4140,30 @@ where .topology_guarantee .requires_vertex_links_during_insertion(); - if !should_validate && !requires_link_checks { - return Ok(()); + if should_validate { + Some(InsertionValidationWork::FullValidation) + } else if requires_link_checks { + Some(InsertionValidationWork::RequiredTopologyLinks) + } else { + None } + } + + fn validation_after_insertion_will_run(&self, suspicion: SuspicionFlags) -> bool { + self.validation_after_insertion_work(suspicion).is_some() + } + + fn validate_after_insertion(&self, suspicion: SuspicionFlags) -> Result<(), InvariantError> { + let Some(work) = self.validation_after_insertion_work(suspicion) else { + return Ok(()); + }; self.log_validation_trigger_if_enabled(suspicion); - if should_validate { - self.is_valid() - } else { - self.validate_required_topology_links() + match work { + InsertionValidationWork::FullValidation => self.is_valid(), + InsertionValidationWork::RequiredTopologyLinks => { + self.validate_required_topology_links() + } } } @@ -4230,12 +4254,16 @@ where return Ok(insert_ok); } - let validation_started = Instant::now(); + let validation_started = self + .validation_after_insertion_will_run(insert_ok.suspicion) + .then(Instant::now); let validation_result = self.validate_after_insertion(insert_ok.suspicion); - Self::record_topology_validation_telemetry( - telemetry, - Self::duration_nanos_saturating(validation_started.elapsed()), - ); + if let Some(validation_started) = validation_started { + Self::record_topology_validation_telemetry( + telemetry, + Self::duration_nanos_saturating(validation_started.elapsed()), + ); + } if let Err(validation_err) = validation_result { // Roll back to snapshot and attempt a star-split fallback for interior points. self.tds = tds_snapshot.clone(); @@ -4286,12 +4314,16 @@ where fallback_ok.suspicion.perturbation_used = true; } - let validation_started = Instant::now(); + let validation_started = self + .validation_after_insertion_will_run(fallback_ok.suspicion) + .then(Instant::now); let validation_result = self.validate_after_insertion(fallback_ok.suspicion); - Self::record_topology_validation_telemetry( - telemetry, - Self::duration_nanos_saturating(validation_started.elapsed()), - ); + if let Some(validation_started) = validation_started { + Self::record_topology_validation_telemetry( + telemetry, + Self::duration_nanos_saturating(validation_started.elapsed()), + ); + } if let Err(fallback_validation_err) = validation_result { return Err(Self::invariant_error_to_insertion_error( fallback_validation_err, @@ -5299,6 +5331,32 @@ where u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) } + fn collect_exterior_repair_seed_cells( + &self, + point: &Point, + terminal_cell: CellKey, + locate_stats: &LocateStats, + telemetry: &mut InsertionTelemetry, + ) -> Result { + if locate_stats.fell_back_to_scan() || !self.tds.contains_cell(terminal_cell) { + return Ok(CellKeyBuffer::new()); + } + + let conflict_started = Instant::now(); + let local_seed_cells = collect_local_exterior_conflict_seed_cells( + &self.tds, + &self.kernel, + point, + terminal_cell, + )?; + Self::record_conflict_region_telemetry(telemetry, local_seed_cells.conflict_cells_found); + Self::record_conflict_region_timing( + telemetry, + Self::duration_nanos_saturating(conflict_started.elapsed()), + ); + Ok(local_seed_cells.seed_cells) + } + /// Internal implementation of insert without retry logic. /// Returns the result and the number of cells removed during repair. /// @@ -5486,35 +5544,18 @@ where "Outside insertion: skipping global conflict-region scan; using hull extension" ); } - let repair_seed_cells = if !locate_stats.fell_back_to_scan() - && self.tds.contains_cell(locate_trace.terminal_cell) - { - let conflict_started = Instant::now(); - let local_seed_cells = collect_local_exterior_conflict_seed_cells( - &self.tds, - &self.kernel, - &point, - locate_trace.terminal_cell, - )?; - Self::record_conflict_region_telemetry( - telemetry, - local_seed_cells.conflict_cells_found, - ); - Self::record_conflict_region_timing( - telemetry, - Self::duration_nanos_saturating(conflict_started.elapsed()), - ); - local_seed_cells.seed_cells - } else { - CellKeyBuffer::new() - }; + let repair_seed_cells = self.collect_exterior_repair_seed_cells( + &point, + locate_trace.terminal_cell, + &locate_stats, + telemetry, + )?; InsertionSite::Exterior { conflict_cells: None, repair_seed_cells, } } (LocateResult::Outside, Some(cells)) => { - Self::record_conflict_region_telemetry(telemetry, cells.len()); if cells.is_empty() { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { @@ -5522,11 +5563,18 @@ where "Outside insertion: caller provided empty conflict region; will use hull extension" ); } + let repair_seed_cells = self.collect_exterior_repair_seed_cells( + &point, + locate_trace.terminal_cell, + &locate_stats, + telemetry, + )?; InsertionSite::Exterior { conflict_cells: None, - repair_seed_cells: CellKeyBuffer::new(), + repair_seed_cells, } } else { + Self::record_conflict_region_telemetry(telemetry, cells.len()); #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { tracing::debug!( @@ -7272,6 +7320,20 @@ mod tests { .unwrap(); } + #[test] + fn test_validation_after_insertion_will_run_matches_policy_and_link_requirements() { + let tds = build_disconnected_two_triangles_tds_2d(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + + tri.set_validation_policy(ValidationPolicy::OnSuspicion); + tri.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); + assert!(!tri.validation_after_insertion_will_run(SuspicionFlags::default())); + + tri.set_topology_guarantee(TopologyGuarantee::PLManifold); + assert!(tri.validation_after_insertion_will_run(SuspicionFlags::default())); + } + #[test] fn test_select_locate_hint_from_hash_grid_returns_incident_cell() { let vertices = vec![ @@ -9468,6 +9530,77 @@ mod tests { assert!(tri.is_valid().is_ok()); } + #[test] + fn triangulation_exterior_insert_with_empty_conflicts_uses_local_repair_seeds() { + let mut tri: Triangulation, (), (), 3> = + Triangulation::new_empty(FastKernel::new()); + + for coords in [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] { + tri.insert_with_statistics(vertex!(coords), None, None) + .unwrap(); + } + + let hint = tri.cells().next().map(|(cell_key, _)| cell_key); + let empty_conflicts = CellKeyBuffer::new(); + let detail = tri + .insert_with_statistics_seeded_indexed_detailed( + vertex!([2.0, 2.0, 2.0]), + Some(&empty_conflicts), + hint, + 0, + None, + None, + ) + .unwrap(); + + assert!(matches!( + detail.outcome, + InsertionOutcome::Inserted { hint: Some(_), .. } + )); + assert_eq!(detail.telemetry.global_conflict_scans, 0); + assert_eq!(detail.telemetry.hull_extension_calls, 1); + assert!( + !detail.repair_seed_cells.is_empty(), + "empty caller conflicts should still use terminal-cell local repair seeds" + ); + assert!(tri.is_valid().is_ok()); + } + + #[test] + fn triangulation_policy_skipped_validation_does_not_increment_telemetry() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + ]; + let tds = + Triangulation::, (), (), 2>::build_initial_simplex(&vertices).unwrap(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + tri.set_validation_policy(ValidationPolicy::OnSuspicion); + tri.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); + + let hint = tri.cells().next().map(|(cell_key, _)| cell_key); + let detail = tri + .insert_with_statistics_seeded_indexed_detailed( + vertex!([0.25, 0.25]), + None, + hint, + 0, + None, + None, + ) + .unwrap(); + + assert!(matches!(detail.outcome, InsertionOutcome::Inserted { .. })); + assert_eq!(detail.telemetry.topology_validation_calls, 0); + } + #[test] fn triangulation_insert_with_statistics_hint_usage_4d() { let mut tri: Triangulation, (), (), 4> = diff --git a/src/lib.rs b/src/lib.rs index 074f5653..550155a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,7 +49,7 @@ //! //! | Task | Import | //! |---|---| -//! | Build a triangulation, insert/remove vertices | `use delaunay::prelude::triangulation::*` | +//! | Construct/configure a Delaunay triangulation | `use delaunay::prelude::triangulation::construction::*` | //! | Low-level incremental insertion building blocks | `use delaunay::prelude::triangulation::insertion::*` | //! | Read-only queries, traversal, convex hull | `use delaunay::prelude::query::*` | //! | Point location and conflict-region algorithms | `use delaunay::prelude::algorithms::*` | @@ -59,10 +59,12 @@ //! | Bistellar flips (Pachner moves) | `use delaunay::prelude::triangulation::flips::*` | //! | Delaunay repair and flip-based Level 4 validation | `use delaunay::prelude::triangulation::repair::*` | //! | Delaunayize workflow (repair + flip) | `use delaunay::prelude::triangulation::delaunayize::*` | +//! | Construction validation cadence/policy | `use delaunay::prelude::triangulation::validation::*` | //! | Topology validation, Euler characteristic | `use delaunay::prelude::topology::validation::*` | //! | Topological spaces and topology traits | `use delaunay::prelude::topology::spaces::*` | //! | Low-level TDS cells, facets, keys | `use delaunay::prelude::tds::*` | //! | Collection types (`FastHashMap`, etc.) | `use delaunay::prelude::collections::*` | +//! | Legacy broad triangulation import | `use delaunay::prelude::triangulation::*` | //! | Everything (kitchen sink) | `use delaunay::prelude::*` | //! //! ## Examples (contract-oriented) @@ -70,7 +72,10 @@ //! ### Validation hierarchy (Levels 1–4) //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; +//! use delaunay::prelude::triangulation::insertion::InsertionError; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -99,7 +104,11 @@ //! ### Topology guarantees and insertion-time validation (`TopologyGuarantee`, `ValidationPolicy`) //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, TopologyGuarantee, +//! vertex, +//! }; +//! use delaunay::prelude::triangulation::validation::ValidationPolicy; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -125,7 +134,10 @@ //! ### Transactional operations and duplicate rejection //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; +//! use delaunay::prelude::triangulation::insertion::InsertionError; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -227,7 +239,11 @@ //! This automatic pass only runs Level 3 (`Triangulation::is_valid()`). It does **not** run Level 4. //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulation, DelaunayTriangulationConstructionError, vertex, +//! }; +//! use delaunay::prelude::triangulation::insertion::InsertionError; +//! use delaunay::prelude::triangulation::validation::ValidationPolicy; //! //! # #[derive(Debug, thiserror::Error)] //! # enum ExampleError { @@ -281,7 +297,9 @@ //! you may want to validate the Delaunay property explicitly for near-degenerate inputs. //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -300,7 +318,9 @@ //! ``` //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -782,26 +802,7 @@ pub mod geometry { pub use util::*; } -/// Triangulation-facing APIs. -/// -/// This module groups public APIs that operate on triangulations, such as explicit -/// bistellar (Pachner) flip operations. -pub mod triangulation { - /// Fluent builder for [`DelaunayTriangulation`] with optional toroidal topology. - pub mod builder; - /// Delaunay triangulation layer with incremental insertion. - pub mod delaunay; - /// End-to-end "repair then delaunayize" workflow. - pub mod delaunayize; - /// Triangulation editing operations (bistellar flips). - pub mod flips; - pub(crate) mod locality; - - // Re-export commonly used triangulation types for discoverability. - pub use crate::core::triangulation::Triangulation; - pub use crate::triangulation::builder::DelaunayTriangulationBuilder; - pub use crate::triangulation::delaunay::DelaunayTriangulation; -} +pub mod triangulation; /// Topology analysis and validation for triangulated spaces. /// @@ -824,7 +825,9 @@ pub mod triangulation { /// # Example /// /// ```rust -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// DelaunayTriangulation, DelaunayTriangulationConstructionError, vertex, +/// }; /// use delaunay::prelude::topology::validation; /// /// # #[derive(Debug, thiserror::Error)] @@ -977,6 +980,57 @@ pub mod prelude { pub use crate::triangulation::builder::*; pub use crate::triangulation::delaunay::*; + /// Batch construction options, builders, and construction errors. + /// + /// This focused prelude is for callers configuring Delaunay construction + /// without importing the broader triangulation editing and repair + /// surface. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, + /// }; + /// + /// # fn main() -> Result<(), DelaunayTriangulationConstructionError> { + /// let vertices = vec![ + /// vertex!([0.0, 0.0]), + /// vertex!([1.0, 0.0]), + /// vertex!([0.0, 1.0]), + /// ]; + /// let triangulation = DelaunayTriangulationBuilder::new(&vertices) + /// .build::<()>()?; + /// + /// assert_eq!(triangulation.number_of_vertices(), 3); + /// # Ok(()) + /// # } + /// ``` + pub mod construction { + pub use crate::core::triangulation::{ + TopologyGuarantee, Triangulation, TriangulationConstructionError, + }; + pub use crate::core::vertex::{ + Vertex, VertexBuilder, VertexBuilderError, VertexValidationError, + }; + pub use crate::topology::traits::{ + GlobalTopology, TopologyKind, ToroidalConstructionMode, + }; + pub use crate::triangulation::builder::{ + DelaunayTriangulationBuilder, ExplicitConstructionError, + }; + pub use crate::triangulation::delaunay::{ + ConstructionOptions, ConstructionStatistics, ConstructionTelemetry, DedupPolicy, + DelaunayConstructionFailure, DelaunayConstructionRepairPhase, DelaunayRepairPolicy, + DelaunayTriangulation, DelaunayTriangulationConstructionError, + DelaunayTriangulationConstructionErrorWithStatistics, InitialSimplexStrategy, + InsertionOrderStrategy, RetryPolicy, + }; + + // Convenience macro (commonly used in docs/examples). + pub use crate::vertex; + } + /// Bistellar (Pachner) flips for explicit triangulation editing. pub mod flips { pub use crate::core::algorithms::flips::*; @@ -1028,7 +1082,8 @@ pub mod prelude { pub use crate::core::util::{DelaunayValidationError, find_delaunay_violations}; pub use crate::triangulation::delaunay::{ DelaunayCheckPolicy, DelaunayRepairHeuristicConfig, DelaunayRepairHeuristicSeeds, - DelaunayRepairOutcome, DelaunayRepairPolicy, DelaunayTriangulation, + DelaunayRepairOperation, DelaunayRepairOutcome, DelaunayRepairPolicy, + DelaunayTriangulation, DelaunayTriangulationValidationError, }; // Convenience macro (commonly used in docs/examples). @@ -1051,6 +1106,23 @@ pub mod prelude { pub use crate::vertex; } + /// Validation scheduling helpers for construction diagnostics. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::validation::ValidationCadence; + /// + /// let cadence = ValidationCadence::from_optional_every(Some(32)); + /// assert!(!cadence.should_validate(31)); + /// assert!(cadence.should_validate(32)); + /// ``` + pub mod validation { + pub use crate::core::triangulation::{TriangulationValidationError, ValidationPolicy}; + pub use crate::triangulation::delaunay::DelaunayTriangulationValidationError; + pub use crate::triangulation::validation::*; + } + pub use crate::core::algorithms::incremental_insertion::{ CavityFillingError, CavityRepairStage, HullExtensionReason, InsertionError, NeighborWiringError, diff --git a/src/triangulation.rs b/src/triangulation.rs new file mode 100644 index 00000000..857abe13 --- /dev/null +++ b/src/triangulation.rs @@ -0,0 +1,54 @@ +//! Triangulation-facing APIs. +//! +//! This module is the public facade for triangulation workflows. It deliberately +//! stays thin: +//! +//! - [`crate::core::triangulation`] owns the generic `Triangulation` container +//! and low-level mutation invariants. +//! - [`crate::triangulation`] owns higher-level construction, Delaunay repair, +//! validation scheduling, editing, and builder workflows. +//! - Submodules under this namespace keep those concerns separate while this +//! facade preserves the stable public import surface. +//! +//! # Examples +//! +//! ```rust +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; +//! +//! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { +//! let vertices = vec![ +//! vertex!([0.0, 0.0]), +//! vertex!([1.0, 0.0]), +//! vertex!([0.0, 1.0]), +//! ]; +//! let triangulation = DelaunayTriangulationBuilder::new(&vertices) +//! .build::<()>()?; +//! +//! assert_eq!(triangulation.number_of_vertices(), 3); +//! # Ok(()) +//! # } +//! ``` + +#![forbid(unsafe_code)] + +/// Fluent builder for Delaunay triangulations. +/// +/// See [`DelaunayTriangulation`](crate::triangulation::delaunay::DelaunayTriangulation) +/// for the constructed triangulation type. +pub mod builder; +/// Delaunay triangulation layer with incremental insertion. +pub mod delaunay; +/// End-to-end "repair then delaunayize" workflow. +pub mod delaunayize; +/// Triangulation editing operations (bistellar flips). +pub mod flips; +pub(crate) mod locality; +/// Validation scheduling helpers for triangulation diagnostics. +pub mod validation; + +// Re-export commonly used triangulation types for discoverability. +pub use crate::core::triangulation::Triangulation; +pub use crate::triangulation::builder::DelaunayTriangulationBuilder; +pub use crate::triangulation::delaunay::DelaunayTriangulation; diff --git a/src/triangulation/builder.rs b/src/triangulation/builder.rs index 1db0627e..6e97b393 100644 --- a/src/triangulation/builder.rs +++ b/src/triangulation/builder.rs @@ -32,10 +32,9 @@ //! ## Standard Euclidean construction //! //! ```rust -//! use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; -//! use delaunay::vertex; +//! use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; //! -//! # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { +//! # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { //! let vertices = vec![ //! vertex!([0.0, 0.0]), //! vertex!([1.0, 0.0]), @@ -53,10 +52,9 @@ //! ## Toroidal construction (Phase 1: canonicalization only) //! //! ```rust -//! use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; -//! use delaunay::vertex; +//! use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; //! -//! # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { +//! # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { //! // Vertices that fall outside [0, 1)² are wrapped before triangulation. //! let vertices = vec![ //! vertex!([0.2, 0.3]), @@ -81,10 +79,9 @@ //! //! ```rust,no_run //! use delaunay::prelude::geometry::RobustKernel; -//! use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; -//! use delaunay::vertex; +//! use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; //! -//! # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { +//! # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { //! let vertices = vec![ //! vertex!([0.1, 0.2]), //! vertex!([0.4, 0.7]), @@ -304,11 +301,10 @@ fn search_closed_2d_selection( /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ +/// use delaunay::prelude::triangulation::construction::{ /// DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, -/// ExplicitConstructionError, +/// ExplicitConstructionError, vertex, /// }; -/// use delaunay::vertex; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let cells = vec![vec![0, 1]]; // Wrong arity for 2D (needs 3 vertices) @@ -402,12 +398,11 @@ pub enum ExplicitConstructionError { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ -/// ConstructionOptions, DelaunayTriangulationBuilder, TopologyGuarantee, +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DelaunayTriangulationBuilder, TopologyGuarantee, vertex, /// }; -/// use delaunay::vertex; /// -/// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { +/// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), /// vertex!([1.0, 0.0, 0.0]), @@ -480,9 +475,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, Vertex, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), DelaunayTriangulationConstructionError> { /// // No vertex data (U = () inferred) /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let _dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; @@ -529,13 +526,12 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ + /// use delaunay::prelude::triangulation::construction::{ /// DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, - /// ExplicitConstructionError, + /// ExplicitConstructionError, vertex, /// }; - /// use delaunay::vertex; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0]), /// vertex!([1.0, 0.0]), @@ -598,14 +594,16 @@ where /// /// ```rust /// use delaunay::prelude::geometry::{Coordinate, Point}; - /// use delaunay::prelude::triangulation::{DelaunayTriangulationBuilder, Vertex, VertexBuilder}; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, Vertex, VertexBuilder, + /// }; /// /// # #[derive(Debug, thiserror::Error)] /// # enum ExampleError { /// # #[error(transparent)] - /// # Vertex(#[from] delaunay::prelude::triangulation::VertexBuilderError), + /// # Vertex(#[from] delaunay::prelude::triangulation::construction::VertexBuilderError), /// # #[error(transparent)] - /// # Construction(#[from] delaunay::prelude::triangulation::DelaunayTriangulationConstructionError), + /// # Construction(#[from] delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError), /// # } /// # fn main() -> Result<(), ExampleError> { /// let vertices: Vec> = vec![ @@ -649,14 +647,16 @@ where /// /// ```rust /// use delaunay::prelude::geometry::{Coordinate, Point}; - /// use delaunay::prelude::triangulation::{DelaunayTriangulationBuilder, Vertex, VertexBuilder}; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, Vertex, VertexBuilder, + /// }; /// /// # #[derive(Debug, thiserror::Error)] /// # enum ExampleError { /// # #[error(transparent)] - /// # Vertex(#[from] delaunay::prelude::triangulation::VertexBuilderError), + /// # Vertex(#[from] delaunay::prelude::triangulation::construction::VertexBuilderError), /// # #[error(transparent)] - /// # Construction(#[from] delaunay::prelude::triangulation::DelaunayTriangulationConstructionError), + /// # Construction(#[from] delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError), /// # } /// # fn main() -> Result<(), ExampleError> { /// // f32 vertices — new() is f64-only, so from_vertices is required here. @@ -701,10 +701,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.2, 0.3]), /// vertex!([0.8, 0.1]), @@ -751,10 +752,11 @@ where /// /// ```rust,no_run /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.1, 0.2]), /// vertex!([0.4, 0.7]), @@ -789,10 +791,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{DelaunayTriangulationBuilder, TopologyGuarantee}; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, TopologyGuarantee, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0]), /// vertex!([1.0, 0.0]), @@ -832,10 +835,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ - /// DelaunayTriangulationBuilder, GlobalTopology, ToroidalConstructionMode, + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, GlobalTopology, ToroidalConstructionMode, vertex, /// }; - /// use delaunay::vertex; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -866,12 +868,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ - /// ConstructionOptions, DelaunayTriangulationBuilder, InsertionOrderStrategy, + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DelaunayTriangulationBuilder, InsertionOrderStrategy, vertex, /// }; - /// use delaunay::vertex; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0]), /// vertex!([1.0, 0.0]), @@ -1054,10 +1055,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), /// vertex!([1.0, 0.0, 0.0]), @@ -1107,10 +1109,11 @@ where /// /// ```rust /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), /// vertex!([1.0, 0.0, 0.0]), diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index f73afb26..e792544b 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -258,8 +258,9 @@ impl Drop for HeuristicRebuildRecursionGuard { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::DelaunayTriangulationConstructionError; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// DelaunayTriangulation, DelaunayTriangulationConstructionError, vertex, +/// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -298,6 +299,30 @@ impl From for DelaunayTriangulationConstructionE } } +/// Construction phase that invoked flip-based Delaunay repair. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DelaunayConstructionRepairPhase { + /// Cadenced local repair after a successful bulk insertion. + BatchLocal { + /// Zero-based input index that triggered the repair cadence. + index: usize, + }, + /// Seeded or fallback repair during construction finalization. + Completion, +} + +impl fmt::Display for DelaunayConstructionRepairPhase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BatchLocal { index } => { + write!(f, "batch local repair at input index {index}") + } + Self::Completion => f.write_str("completion repair"), + } + } +} + /// Pattern-matchable summary of a lower-layer construction failure. /// /// This is the payload for @@ -318,11 +343,10 @@ impl From for DelaunayTriangulationConstructionE /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ +/// use delaunay::prelude::triangulation::construction::{ /// DelaunayConstructionFailure, DelaunayTriangulation, -/// DelaunayTriangulationConstructionError, +/// DelaunayTriangulationConstructionError, vertex, /// }; -/// use delaunay::vertex; /// /// let vertices = vec![vertex!([0.0, 0.0, 0.0])]; /// let err = DelaunayTriangulation::new(&vertices).unwrap_err(); @@ -376,6 +400,16 @@ pub enum DelaunayConstructionFailure { message: String, }, + /// Flip-based Delaunay repair failed during construction. + #[error("Delaunay repair failed during {phase}: {source}")] + DelaunayRepair { + /// Construction phase that invoked repair. + phase: DelaunayConstructionRepairPhase, + /// Underlying typed repair failure. + #[source] + source: Box, + }, + /// Duplicate coordinates were detected. #[error("duplicate coordinates detected: {coordinates}")] DuplicateCoordinates { @@ -544,8 +578,8 @@ impl fmt::Display for DelaunayRepairOperation { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::DelaunayTriangulationValidationError; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; +/// use delaunay::prelude::triangulation::repair::DelaunayTriangulationValidationError; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -630,8 +664,9 @@ pub enum DelaunayTriangulationValidationError { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ConstructionOptions, InsertionOrderStrategy}; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, vertex, +/// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -686,8 +721,9 @@ pub enum InsertionOrderStrategy { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ConstructionOptions, DedupPolicy}; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DedupPolicy, DelaunayTriangulation, vertex, +/// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -748,8 +784,9 @@ pub enum InitialSimplexStrategy { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ConstructionOptions, RetryPolicy}; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DelaunayTriangulation, RetryPolicy, vertex, +/// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -813,7 +850,7 @@ impl Default for RetryPolicy { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ +/// use delaunay::prelude::triangulation::construction::{ /// ConstructionOptions, DedupPolicy, DelaunayRepairPolicy, InsertionOrderStrategy, RetryPolicy, /// }; /// use std::num::NonZeroUsize; @@ -2414,7 +2451,7 @@ where /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2457,7 +2494,9 @@ impl DelaunayTriangulation, (), (), D> { /// or toroidal (periodic) triangulations, use [`DelaunayTriangulationBuilder`]: /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2475,7 +2514,9 @@ impl DelaunayTriangulation, (), (), D> { /// /// For toroidal (periodic) triangulations: /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.1, 0.2]), @@ -2496,8 +2537,7 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2530,11 +2570,10 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ + /// use delaunay::prelude::triangulation::construction::{ /// DelaunayConstructionFailure, DelaunayTriangulation, - /// DelaunayTriangulationConstructionError, + /// DelaunayTriangulationConstructionError, vertex, /// }; - /// use delaunay::vertex; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2614,10 +2653,9 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ - /// ConstructionOptions, DedupPolicy, InsertionOrderStrategy, + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DedupPolicy, DelaunayTriangulation, InsertionOrderStrategy, vertex, /// }; - /// use delaunay::prelude::triangulation::*; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2659,8 +2697,9 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2695,7 +2734,7 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// // Start with empty triangulation /// let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -2723,8 +2762,9 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, + /// }; /// /// let dt: DelaunayTriangulation<_, (), (), 3> = /// DelaunayTriangulation::empty_with_topology_guarantee(TopologyGuarantee::Pseudomanifold); @@ -2752,7 +2792,9 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, DelaunayTriangulationBuilder, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -2770,7 +2812,9 @@ impl DelaunayTriangulation, (), (), D> { /// ## Toroidal construction /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, DelaunayTriangulationBuilder, vertex, + /// }; /// /// // Vertices outside [0, 1)² are canonicalized before building. /// let vertices = vec![ @@ -2822,8 +2866,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; /// use delaunay::prelude::geometry::RobustKernel; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// // Start with empty triangulation using robust kernel /// let mut dt: DelaunayTriangulation, (), (), 4> = @@ -2859,8 +2903,9 @@ where /// /// ```rust /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, vertex, + /// }; /// /// let dt: DelaunayTriangulation, (), (), 3> = /// DelaunayTriangulation::with_empty_kernel_and_topology_guarantee( @@ -2912,8 +2957,7 @@ where /// /// ```rust /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -2956,8 +3000,9 @@ where /// /// ```rust /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -3002,12 +3047,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ - /// ConstructionOptions, DedupPolicy, InsertionOrderStrategy, - /// }; /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DedupPolicy, DelaunayTriangulation, InsertionOrderStrategy, + /// TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -3362,6 +3406,7 @@ where reason: TdsConstructionFailure::DuplicateUuid { .. } | TdsConstructionFailure::Validation { .. }, } | DelaunayConstructionFailure::InternalInconsistency { .. } + | DelaunayConstructionFailure::DelaunayRepair { .. } | DelaunayConstructionFailure::InsertionTopologyValidation { .. } | DelaunayConstructionFailure::FinalTopologyValidation { .. }, ) @@ -4149,14 +4194,19 @@ where /// topology/flip failures. fn map_hard_repair_error( index: usize, - repair_err: &DelaunayRepairError, + repair_err: DelaunayRepairError, ) -> DelaunayTriangulationConstructionError { let message = format!("per-insertion Delaunay repair failed at index {index}: {repair_err}"); - if is_geometric_repair_error(repair_err) { + if is_geometric_repair_error(&repair_err) { TriangulationConstructionError::GeometricDegeneracy { message }.into() } else { - TriangulationConstructionError::InternalInconsistency { message }.into() + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::DelaunayRepair { + phase: DelaunayConstructionRepairPhase::BatchLocal { index }, + source: Box::new(repair_err), + }, + ) } } @@ -4233,7 +4283,7 @@ where ); } if !Self::can_soft_fail(&repair_err) { - return Err(Self::map_hard_repair_error(index, &repair_err)); + return Err(Self::map_hard_repair_error(index, repair_err)); } tracing::debug!( idx = index, @@ -4808,7 +4858,10 @@ where ) -> Result<(), DelaunayTriangulationConstructionError> { if !self.insertion_state.use_global_repair_fallback || !Self::can_soft_fail(seeded_error) { let message = format!("Delaunay repair failed after construction: {seeded_error}"); - return Err(Self::map_completion_repair_error(message, seeded_error)); + return Err(Self::map_completion_repair_error( + message, + seeded_error.clone(), + )); } tracing::debug!( @@ -4828,19 +4881,24 @@ where "Delaunay repair failed after construction: seeded local error: \ {seeded_error}; global fallback: {global_error}" ); - Err(Self::map_completion_repair_error(message, &global_error)) + Err(Self::map_completion_repair_error(message, global_error)) } } } fn map_completion_repair_error( message: String, - repair_error: &DelaunayRepairError, + repair_error: DelaunayRepairError, ) -> DelaunayTriangulationConstructionError { - if is_geometric_repair_error(repair_error) { + if is_geometric_repair_error(&repair_error) { TriangulationConstructionError::GeometricDegeneracy { message }.into() } else { - TriangulationConstructionError::InternalInconsistency { message }.into() + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::DelaunayRepair { + phase: DelaunayConstructionRepairPhase::Completion, + source: Box::new(repair_error), + }, + ) } } @@ -5005,8 +5063,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -5031,8 +5088,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -5059,8 +5115,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -5157,7 +5212,9 @@ where /// # Examples /// /// ``` - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, Vertex, vertex, + /// }; /// /// let vertices: [Vertex; 3] = [ /// vertex!([0.0, 0.0], 10i32), @@ -5196,7 +5253,9 @@ where /// # Examples /// /// ``` - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// /// let vertices = [ /// vertex!([0.0, 0.0]), @@ -5229,8 +5288,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -5289,8 +5347,7 @@ where /// /// ```rust /// use delaunay::prelude::query::ConvexHull; - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices: Vec<_> = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -5323,7 +5380,7 @@ where /// /// ```rust /// #![allow(deprecated)] - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -5355,7 +5412,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -5392,7 +5449,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; + /// use delaunay::prelude::triangulation::validation::ValidationPolicy; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -5403,10 +5461,10 @@ where /// let mut dt: DelaunayTriangulation<_, (), (), 2> = /// DelaunayTriangulation::new(&vertices).unwrap(); /// - /// dt.set_validation_policy(delaunay::core::triangulation::ValidationPolicy::Always); + /// dt.set_validation_policy(ValidationPolicy::Always); /// assert_eq!( /// dt.validation_policy(), - /// delaunay::core::triangulation::ValidationPolicy::Always + /// ValidationPolicy::Always /// ); /// ``` #[inline] @@ -5454,7 +5512,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; + /// use delaunay::prelude::triangulation::repair::DelaunayRepairStats; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -5566,6 +5625,24 @@ where ) } + /// Applies repair-policy and topology gates to non-insertion mutating operations. + /// + /// These operations do not have a meaningful insertion cadence, so every enabled + /// repair policy permits the post-mutation repair attempt. + fn should_run_delaunay_repair_after_mutation(&self, topology: TopologyGuarantee) -> bool { + if D < 2 { + return false; + } + if self.tri.tds.number_of_cells() == 0 { + return false; + } + if self.insertion_state.delaunay_repair_policy == DelaunayRepairPolicy::Never { + return false; + } + + TopologicalOperation::FacetFlip.is_admissible_under(topology) + } + /// Enables test-only repair fallback paths without exposing a public knob. #[cfg_attr( not(test), @@ -5621,7 +5698,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// use delaunay::prelude::triangulation::repair::DelaunayRepairHeuristicConfig; /// /// let vertices = vec![ @@ -5938,7 +6015,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>().unwrap(); @@ -5955,7 +6034,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, GlobalTopology, vertex, + /// }; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>().unwrap(); @@ -5972,7 +6053,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, TopologyKind, vertex, + /// }; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>().unwrap(); @@ -5989,7 +6072,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, GlobalTopology, vertex, + /// }; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let mut dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>().unwrap(); @@ -6006,8 +6091,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, + /// }; /// /// let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); /// dt.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); @@ -6031,8 +6117,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -6061,8 +6146,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -6430,8 +6514,7 @@ where /// Incremental insertion from empty triangulation: /// /// ```rust - /// use delaunay::prelude::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// // Start with empty triangulation /// let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -6459,8 +6542,7 @@ where /// Using batch construction (traditional approach): /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// // Create initial triangulation with 5 vertices (4-simplex) /// let vertices = vec![ @@ -6575,7 +6657,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; + /// use delaunay::prelude::triangulation::insertion::InsertionOutcome; /// /// let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); /// @@ -6908,7 +6991,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = [ /// vertex!([0.0, 0.0]), @@ -6965,7 +7048,7 @@ where }; let topology = self.tri.topology_guarantee(); - if self.should_run_delaunay_repair_for(topology, 0) { + if self.should_run_delaunay_repair_after_mutation(topology) { let seed_ref = seed_cells.as_deref(); let repair_result = { self.invalidate_repair_caches(); @@ -7267,8 +7350,7 @@ where /// ```rust /// use delaunay::prelude::geometry::FastKernel; /// use delaunay::prelude::tds::Tds; - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -7314,8 +7396,9 @@ where /// /// ```rust /// use delaunay::prelude::geometry::FastKernel; - /// use delaunay::prelude::triangulation::{DelaunayTriangulation, TopologyGuarantee}; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -7365,10 +7448,9 @@ where /// /// ```rust /// use delaunay::prelude::geometry::FastKernel; - /// use delaunay::prelude::triangulation::{ - /// DelaunayTriangulation, GlobalTopology, TopologyGuarantee, + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, GlobalTopology, TopologyGuarantee, vertex, /// }; - /// use delaunay::vertex; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -7493,7 +7575,7 @@ where /// ```rust /// # use delaunay::prelude::geometry::*; /// # use delaunay::prelude::tds::Tds; -/// # use delaunay::prelude::triangulation::*; +/// # use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// # fn example() { /// // Create and serialize a triangulation /// let vertices = vec![ @@ -7540,6 +7622,7 @@ where /// use std::num::NonZeroUsize; /// /// let policy = DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()); +/// assert!(!policy.should_repair(0)); /// assert!(!policy.should_repair(3)); /// assert!(policy.should_repair(4)); /// ``` @@ -7567,8 +7650,8 @@ impl DelaunayRepairPolicy { pub const fn should_repair(self, insertion_count: usize) -> bool { match self { Self::Never => false, - Self::EveryInsertion => true, - Self::EveryN(n) => insertion_count.is_multiple_of(n.get()), + Self::EveryInsertion => insertion_count != 0, + Self::EveryN(n) => insertion_count != 0 && insertion_count.is_multiple_of(n.get()), } } } @@ -9085,10 +9168,36 @@ mod tests { dt.set_delaunay_repair_policy(DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap())); let topology = dt.topology_guarantee(); + assert!(!dt.should_run_delaunay_repair_for(topology, 0)); assert!(!dt.should_run_delaunay_repair_for(topology, 1)); assert!(dt.should_run_delaunay_repair_for(topology, 2)); } + #[test] + fn test_delaunay_repair_policy_zero_insertions_never_repairs() { + assert!(!DelaunayRepairPolicy::EveryInsertion.should_repair(0)); + assert!(!DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap()).should_repair(0)); + } + + #[test] + fn test_non_insertion_mutation_repair_gate_ignores_insertion_cadence() { + init_tracing(); + let vertices: Vec> = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + ]; + let mut dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let topology = dt.topology_guarantee(); + + dt.set_delaunay_repair_policy(DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap())); + assert!(dt.should_run_delaunay_repair_after_mutation(topology)); + + dt.set_delaunay_repair_policy(DelaunayRepairPolicy::Never); + assert!(!dt.should_run_delaunay_repair_after_mutation(topology)); + } + #[test] fn test_vertex_key_valid_after_explicit_heuristic_rebuild() { init_tracing(); @@ -10909,20 +11018,22 @@ mod tests { }; assert!(!TestDelaunay::<4>::can_soft_fail(&canonicalization_error)); - let mapped_hard = TestDelaunay::<4>::map_hard_repair_error(23, &flip_error); + let mapped_hard = TestDelaunay::<4>::map_hard_repair_error(23, flip_error); assert!( matches!( mapped_hard, DelaunayTriangulationConstructionError::Triangulation( - DelaunayConstructionFailure::InternalInconsistency { ref message } - ) if message.contains("per-insertion Delaunay repair failed at index 23") - && message.contains("Bistellar flip not supported for D=1") + DelaunayConstructionFailure::DelaunayRepair { + phase: DelaunayConstructionRepairPhase::BatchLocal { index: 23 }, + ref source, + } + ) if matches!(**source, DelaunayRepairError::Flip(FlipError::UnsupportedDimension { dimension: 1 })) ), "deterministic hard D>=4 repair failures should stop shuffled retries: {mapped_hard:?}" ); let geometric_error = DelaunayRepairError::Flip(FlipError::DegenerateCell); - let mapped_geometric = TestDelaunay::<4>::map_hard_repair_error(24, &geometric_error); + let mapped_geometric = TestDelaunay::<4>::map_hard_repair_error(24, geometric_error); assert!( matches!( mapped_geometric, @@ -10934,14 +11045,16 @@ mod tests { "geometric hard D>=4 repair failures should remain retryable degeneracies: {mapped_geometric:?}" ); - let mapped_verification = TestDelaunay::<4>::map_hard_repair_error(25, &verification_error); + let mapped_verification = TestDelaunay::<4>::map_hard_repair_error(25, verification_error); assert!( matches!( mapped_verification, DelaunayTriangulationConstructionError::Triangulation( - DelaunayConstructionFailure::InternalInconsistency { ref message } - ) if message.contains("per-insertion Delaunay repair failed at index 25") - && message.contains("removed cell frame") + DelaunayConstructionFailure::DelaunayRepair { + phase: DelaunayConstructionRepairPhase::BatchLocal { index: 25 }, + ref source, + } + ) if matches!(**source, DelaunayRepairError::VerificationFailed { .. }) ), "verification context failures should stop shuffled retries: {mapped_verification:?}" ); @@ -10960,8 +11073,7 @@ mod tests { }, }), }; - let mapped_predicate = - TestDelaunay::<4>::map_hard_repair_error(26, &predicate_verification); + let mapped_predicate = TestDelaunay::<4>::map_hard_repair_error(26, predicate_verification); assert!( matches!( mapped_predicate, diff --git a/src/triangulation/locality.rs b/src/triangulation/locality.rs index 6ae00e5a..985efd46 100644 --- a/src/triangulation/locality.rs +++ b/src/triangulation/locality.rs @@ -8,13 +8,14 @@ #![forbid(unsafe_code)] use crate::core::algorithms::locate::{ConflictError, find_conflict_region}; -use crate::core::collections::{CellKeyBuffer, FastHashSet}; +use crate::core::collections::{CellKeyBuffer, FastHashSet, fast_hash_set_with_capacity}; use crate::core::tds::{CellKey, Tds}; use crate::core::traits::data_type::DataType; use crate::geometry::kernel::Kernel; use crate::geometry::point::Point; /// Local conflict-seed collection result for exterior insertion repair. +#[must_use] pub struct LocalConflictSeedCells { /// Live cells that should seed local Delaunay repair. pub seed_cells: CellKeyBuffer, @@ -57,9 +58,13 @@ where U: DataType, V: DataType, { + let mut seen: FastHashSet = + fast_hash_set_with_capacity(seed_cells.len().saturating_add(candidate_seed_cells.len())); + seen.extend(seed_cells.iter().copied()); + let mut added = 0usize; for &cell_key in candidate_seed_cells { - if tds.contains_cell(cell_key) && !seed_cells.contains(&cell_key) { + if tds.contains_cell(cell_key) && seen.insert(cell_key) { seed_cells.push(cell_key); added = added.saturating_add(1); } diff --git a/src/triangulation/validation.rs b/src/triangulation/validation.rs new file mode 100644 index 00000000..4dd5cd3e --- /dev/null +++ b/src/triangulation/validation.rs @@ -0,0 +1,129 @@ +//! Validation scheduling helpers for triangulation construction diagnostics. +//! +//! This module contains validation-control concepts that are orthogonal to the +//! Delaunay data structure itself. Keeping them here leaves +//! [`crate::triangulation::delaunay`] focused on construction, repair, and query logic. + +#![forbid(unsafe_code)] + +use std::num::NonZeroUsize; + +/// Cadence for explicit validation checkpoints during construction diagnostics. +/// +/// This is separate from [`ValidationPolicy`](crate::core::triangulation::ValidationPolicy), +/// which controls automatic insertion-time validation inside +/// [`Triangulation`](crate::core::triangulation::Triangulation). Diagnostic +/// harnesses can use this cadence for explicit periodic +/// [`DelaunayTriangulation::is_valid`](crate::triangulation::delaunay::DelaunayTriangulation::is_valid) +/// checks without overloading repair policy or exposing raw `Option` +/// scheduling in logs. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::triangulation::validation::ValidationCadence; +/// +/// let cadence = ValidationCadence::from_optional_every(Some(128)); +/// assert!(!cadence.should_validate(0)); +/// assert!(!cadence.should_validate(127)); +/// assert!(cadence.should_validate(128)); +/// ``` +#[must_use = "validation cadence values only affect diagnostics when they are used"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidationCadence { + /// Disable explicit periodic validation checkpoints. + Never, + /// Run explicit validation every N successful insertion attempts. + EveryN(NonZeroUsize), +} + +impl ValidationCadence { + /// Converts an optional integer cadence into a typed validation cadence. + /// + /// `None` and `Some(0)` disable periodic validation. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::validation::ValidationCadence; + /// + /// assert!(matches!( + /// ValidationCadence::from_optional_every(Some(32)), + /// ValidationCadence::EveryN(every) if every.get() == 32, + /// )); + /// assert_eq!( + /// ValidationCadence::from_optional_every(None), + /// ValidationCadence::Never, + /// ); + /// ``` + pub const fn from_optional_every(validate_every: Option) -> Self { + match validate_every { + None | Some(0) => Self::Never, + Some(every) => { + if let Some(every) = NonZeroUsize::new(every) { + Self::EveryN(every) + } else { + Self::Never + } + } + } + } + + /// Returns true when validation should run for a one-based insertion count. + /// + /// A count of `0` never triggers validation because no insertion has + /// completed yet. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::validation::ValidationCadence; + /// + /// let cadence = ValidationCadence::from_optional_every(Some(4)); + /// assert!(!cadence.should_validate(0)); + /// assert!(!cadence.should_validate(3)); + /// assert!(cadence.should_validate(4)); + /// ``` + #[must_use] + pub const fn should_validate(self, insertion_count: usize) -> bool { + match self { + Self::Never => false, + Self::EveryN(every) => { + insertion_count != 0 && insertion_count.is_multiple_of(every.get()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_cadence_maps_optional_every() { + assert_eq!( + ValidationCadence::from_optional_every(None), + ValidationCadence::Never + ); + assert_eq!( + ValidationCadence::from_optional_every(Some(0)), + ValidationCadence::Never + ); + assert_eq!( + ValidationCadence::from_optional_every(Some(128)), + ValidationCadence::EveryN(NonZeroUsize::new(128).unwrap()) + ); + } + + #[test] + fn validation_cadence_should_validate_on_multiples() { + let cadence = ValidationCadence::EveryN(NonZeroUsize::new(64).unwrap()); + + assert!(!cadence.should_validate(0)); + assert!(!cadence.should_validate(63)); + assert!(cadence.should_validate(64)); + assert!(!cadence.should_validate(65)); + assert!(cadence.should_validate(128)); + assert!(!ValidationCadence::Never.should_validate(64)); + } +} diff --git a/tests/allocation_api.rs b/tests/allocation_api.rs index 8d394da5..0705af8c 100644 --- a/tests/allocation_api.rs +++ b/tests/allocation_api.rs @@ -7,7 +7,9 @@ #[cfg(feature = "count-allocations")] use allocation_counter::measure; use delaunay::prelude::geometry::*; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, DelaunayTriangulationConstructionError, TopologyGuarantee, vertex, +}; use delaunay::geometry::kernel::AdaptiveKernel; diff --git a/tests/check_perturbation_stats.rs b/tests/check_perturbation_stats.rs index 5e0370bd..b3cbf552 100644 --- a/tests/check_perturbation_stats.rs +++ b/tests/check_perturbation_stats.rs @@ -4,7 +4,11 @@ use delaunay::core::vertex::VertexBuilder; use delaunay::geometry::util::generate_random_points_seeded; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayRepairPolicy, DelaunayTriangulation, TopologyGuarantee, +}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; +use delaunay::prelude::triangulation::validation::ValidationPolicy; #[test] fn pl_manifold_insertion_is_non_negotiable_under_validation_policy_never() { diff --git a/tests/circumsphere_debug_tools.rs b/tests/circumsphere_debug_tools.rs index ecc97965..b05c7019 100644 --- a/tests/circumsphere_debug_tools.rs +++ b/tests/circumsphere_debug_tools.rs @@ -16,7 +16,7 @@ use delaunay::geometry::matrix::{Matrix, determinant}; use delaunay::geometry::util::hypot; use delaunay::prelude::geometry::*; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{Vertex, vertex}; use serde::{Deserialize, Serialize}; // Macro for standard test output formatting diff --git a/tests/conflict_region_verification.rs b/tests/conflict_region_verification.rs index f827a2f6..16c3530b 100644 --- a/tests/conflict_region_verification.rs +++ b/tests/conflict_region_verification.rs @@ -15,7 +15,7 @@ use delaunay::prelude::algorithms::{LocateResult, find_conflict_region, locate}; use delaunay::prelude::diagnostics::verify_conflict_region_completeness; use delaunay::prelude::generators::generate_random_points_in_ball_seeded; use delaunay::prelude::geometry::AdaptiveKernel; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, Vertex, vertex}; /// Verify that `verify_conflict_region_completeness` runs without panicking on /// the known-failing 3D case (35 vertices, seed 0xE30C78582376677C, ball diff --git a/tests/dedup_batch_construction.rs b/tests/dedup_batch_construction.rs index 4e509525..ecd88714 100644 --- a/tests/dedup_batch_construction.rs +++ b/tests/dedup_batch_construction.rs @@ -9,7 +9,11 @@ //! //! Dimension coverage: 2D–5D via `gen_dedup_batch_tests!`. -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DedupPolicy, DelaunayConstructionFailure, DelaunayTriangulation, + DelaunayTriangulationConstructionError, InsertionOrderStrategy, TopologyGuarantee, Vertex, + vertex, +}; // ============================================================================= // HELPERS diff --git a/tests/delaunay_edge_cases.rs b/tests/delaunay_edge_cases.rs index 294b03fc..5bf5613d 100644 --- a/tests/delaunay_edge_cases.rs +++ b/tests/delaunay_edge_cases.rs @@ -12,7 +12,10 @@ use delaunay::prelude::diagnostics::debug_print_first_delaunay_violation; use delaunay::prelude::generators::generate_random_points_in_ball_seeded; use delaunay::prelude::geometry::RobustKernel; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayConstructionFailure, DelaunayTriangulation, DelaunayTriangulationConstructionError, + TopologyGuarantee, Vertex, vertex, +}; use rand::SeedableRng; use rand::seq::SliceRandom; fn init_tracing() { diff --git a/tests/delaunay_incremental_insertion.rs b/tests/delaunay_incremental_insertion.rs index 3a7a4122..0ac4ae6b 100644 --- a/tests/delaunay_incremental_insertion.rs +++ b/tests/delaunay_incremental_insertion.rs @@ -12,7 +12,9 @@ use delaunay::prelude::algorithms::{LocateResult, find_conflict_region, locate}; use delaunay::prelude::collections::MAX_PRACTICAL_DIMENSION_SIZE; use delaunay::prelude::geometry::{AdaptiveKernel, Coordinate, Point}; use delaunay::prelude::tds::{Cell, CellKey, SmallBuffer, VertexKey, facet_key_from_vertices}; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DedupPolicy, DelaunayTriangulation, TopologyGuarantee, vertex, +}; /// Build the canonical facet key used to compare neighbor mirror facets in tests. fn facet_key_for_cell(cell: &Cell, facet_idx: usize) -> u64 { diff --git a/tests/delaunay_public_api_coverage.rs b/tests/delaunay_public_api_coverage.rs index 54fb1f43..ef91ffb0 100644 --- a/tests/delaunay_public_api_coverage.rs +++ b/tests/delaunay_public_api_coverage.rs @@ -7,11 +7,11 @@ )] use delaunay::prelude::geometry::AdaptiveKernel; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ ConstructionOptions, DedupPolicy, DelaunayTriangulation, - DelaunayTriangulationConstructionError, InsertionError, InsertionOrderStrategy, RetryPolicy, - TopologyGuarantee, + DelaunayTriangulationConstructionError, InsertionOrderStrategy, RetryPolicy, TopologyGuarantee, }; +use delaunay::prelude::triangulation::insertion::InsertionError; use delaunay::vertex; #[cfg(feature = "diagnostics")] use rand::{RngExt, SeedableRng, rngs::StdRng}; diff --git a/tests/delaunay_repair_fallback.rs b/tests/delaunay_repair_fallback.rs index 05205bbb..6700bcfc 100644 --- a/tests/delaunay_repair_fallback.rs +++ b/tests/delaunay_repair_fallback.rs @@ -4,7 +4,9 @@ //! Delaunay violations, the deterministic rebuild heuristic is triggered and //! successfully produces a valid Delaunay triangulation. -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayRepairPolicy, DelaunayTriangulation, TopologyGuarantee, vertex, +}; #[cfg(feature = "diagnostics")] fn init_tracing() { diff --git a/tests/euler_characteristic.rs b/tests/euler_characteristic.rs index 344ea8e9..a7b189a3 100644 --- a/tests/euler_characteristic.rs +++ b/tests/euler_characteristic.rs @@ -14,7 +14,10 @@ use delaunay::prelude::query::BoundaryAnalysis; use delaunay::prelude::tds::Tds; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, DelaunayTriangulationConstructionError, ExplicitConstructionError, + TopologyGuarantee, vertex, +}; use delaunay::topology::characteristics::{euler, validation}; // ============================================================================= diff --git a/tests/insert_with_statistics.rs b/tests/insert_with_statistics.rs index b2d42c28..f95add9d 100644 --- a/tests/insert_with_statistics.rs +++ b/tests/insert_with_statistics.rs @@ -14,7 +14,10 @@ //! - Bootstrap phase (< D+1 vertices) //! - Post-bootstrap phase (≥ D+1 vertices) -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, vertex, +}; +use delaunay::prelude::triangulation::insertion::{InsertionError, InsertionOutcome}; // ============================================================================= // DELAUNAY TRIANGULATION TESTS diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 085af2dc..24fc3360 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -74,17 +74,21 @@ #![forbid(unsafe_code)] use delaunay::core::operations::InsertionResult; -use delaunay::core::triangulation::TopologyGuarantee; use delaunay::geometry::kernel::RobustKernel; use delaunay::geometry::util::{ generate_random_points_in_ball_seeded, generate_random_points_seeded, safe_usize_to_scalar, }; use delaunay::prelude::tds::{InvariantKind, TriangulationValidationReport}; -use delaunay::prelude::triangulation::*; -use delaunay::triangulation::delaunay::{ - ConstructionOptions, ConstructionStatistics, ConstructionTelemetry, - DelaunayRepairHeuristicConfig, DelaunayTriangulationConstructionErrorWithStatistics, +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, ConstructionStatistics, ConstructionTelemetry, DelaunayRepairPolicy, + DelaunayTriangulation, DelaunayTriangulationConstructionErrorWithStatistics, + InitialSimplexStrategy, TopologyGuarantee, Vertex, vertex, }; +use delaunay::prelude::triangulation::insertion::{InsertionOutcome, InsertionStatistics}; +use delaunay::prelude::triangulation::repair::{ + DelaunayCheckPolicy, DelaunayRepairHeuristicConfig, +}; +use delaunay::prelude::triangulation::validation::ValidationCadence; use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; use std::env; use std::fmt; @@ -945,6 +949,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz None } }); + let validation_cadence = ValidationCadence::from_optional_every(validate_every); println!("============================================="); println!("Large-scale triangulation debug: {dimension_name}"); @@ -971,7 +976,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz println!(" topology_guarantee: {topology_guarantee:?}"); println!(" shuffle_seed: {shuffle_seed:?}"); println!(" progress_every:{progress_every}"); - println!(" validate_every:{validate_every:?}"); + println!(" validation_cadence: {validation_cadence:?}"); println!(" allow_skips: {allow_skips}"); println!(" max_skip_pct: {max_skip_pct}"); println!(" skip_final_repair: {skip_final_repair}"); @@ -1134,10 +1139,8 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz println!("Initial simplex created at insertion {}", idx + 1); } - if let Some(every) = validate_every - && every > 0 - && had_cells - && (idx + 1) % every == 0 + if had_cells + && validation_cadence.should_validate(idx + 1) && let Err(e) = dt.as_triangulation().is_valid() { println!("Topology validation failed at idx={idx}: {e}"); diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 4b0774d0..2455cf83 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -30,6 +30,10 @@ use delaunay::prelude::ordering::{ use delaunay::prelude::query::ConvexHull; #[cfg(feature = "diagnostics")] use delaunay::prelude::tds::Tds; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayConstructionFailure, DelaunayRepairPolicy, DelaunayTriangulation, + DelaunayTriangulationConstructionError, InsertionOrderStrategy, Vertex, +}; use delaunay::prelude::triangulation::delaunayize::{ DelaunayizeConfig, DelaunayizeError, DelaunayizeOutcome, delaunayize_by_flips, }; @@ -39,16 +43,12 @@ use delaunay::prelude::triangulation::insertion::{ repair_neighbor_pointers_local, }; use delaunay::prelude::triangulation::repair::{ - DelaunayCheckPolicy, DelaunayRepairDiagnostics, DelaunayRepairError, DelaunayRepairOutcome, - DelaunayRepairPolicy, DelaunayRepairStats, FlipEdgeAdjacencyError, FlipError, - FlipTriangleAdjacencyError, FlipVertexAdjacencyError, RepairQueueOrder, - verify_delaunay_for_triangulation, -}; -use delaunay::prelude::triangulation::{ - ConstructionOptions, DelaunayConstructionFailure, DelaunayRepairOperation, - DelaunayTriangulation, DelaunayTriangulationConstructionError, - DelaunayTriangulationValidationError, InsertionOrderStrategy, Vertex, + DelaunayCheckPolicy, DelaunayRepairDiagnostics, DelaunayRepairError, DelaunayRepairOperation, + DelaunayRepairOutcome, DelaunayRepairStats, DelaunayTriangulationValidationError, + FlipEdgeAdjacencyError, FlipError, FlipTriangleAdjacencyError, FlipVertexAdjacencyError, + RepairQueueOrder, verify_delaunay_for_triangulation, }; +use delaunay::prelude::triangulation::validation::ValidationCadence; use delaunay::vertex; #[derive(Debug, thiserror::Error)] @@ -104,6 +104,10 @@ fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { DelaunayConstructionFailure::GeometricDegeneracy { .. } )); assert!(matches!(LocateResult::Outside, LocateResult::Outside)); + assert!(matches!( + ValidationCadence::from_optional_every(Some(128)), + ValidationCadence::EveryN(every) if every.get() == 128 + )); assert_send_sync_unpin::(); assert_send_sync_unpin::(); Ok(()) diff --git a/tests/proptest_delaunay_triangulation.rs b/tests/proptest_delaunay_triangulation.rs index 59d4161f..37b394f3 100644 --- a/tests/proptest_delaunay_triangulation.rs +++ b/tests/proptest_delaunay_triangulation.rs @@ -33,7 +33,12 @@ use delaunay::geometry::kernel::{AdaptiveKernel, RobustKernel}; use delaunay::prelude::geometry::*; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DedupPolicy, DelaunayRepairPolicy, DelaunayTriangulation, + TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; +use delaunay::prelude::triangulation::validation::ValidationPolicy; use proptest::prelude::*; use rand::{SeedableRng, seq::SliceRandom}; diff --git a/tests/proptest_euler_characteristic.rs b/tests/proptest_euler_characteristic.rs index fc231acc..6081362c 100644 --- a/tests/proptest_euler_characteristic.rs +++ b/tests/proptest_euler_characteristic.rs @@ -17,7 +17,9 @@ //! For deterministic tests with known configurations, see `euler_characteristic.rs`. use delaunay::geometry::util::generate_random_triangulation_with_topology_guarantee; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, vertex, +}; use delaunay::topology::characteristics::{euler, validation}; use proptest::prelude::*; diff --git a/tests/proptest_flips.rs b/tests/proptest_flips.rs index cad05978..14c35f67 100644 --- a/tests/proptest_flips.rs +++ b/tests/proptest_flips.rs @@ -12,10 +12,10 @@ use ::uuid::Uuid; use delaunay::prelude::geometry::{ AdaptiveKernel, Coordinate, FastKernel, Kernel, Point, RobustKernel, }; -use delaunay::prelude::triangulation::flips::BistellarFlips; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, TopologyGuarantee, Triangulation, Vertex, }; +use delaunay::prelude::triangulation::flips::BistellarFlips; use proptest::prelude::*; use std::collections::{BTreeSet, HashMap}; diff --git a/tests/proptest_orientation.rs b/tests/proptest_orientation.rs index 14921a10..b37de51c 100644 --- a/tests/proptest_orientation.rs +++ b/tests/proptest_orientation.rs @@ -12,8 +12,10 @@ use delaunay::core::tds::{Tds, TdsError}; use delaunay::prelude::geometry::*; -use delaunay::prelude::triangulation::*; -use delaunay::triangulation::delaunay::DelaunayTriangulation; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, +}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; use proptest::prelude::*; /// Strategy for generating finite `f64` coordinates in a reasonable range. diff --git a/tests/proptest_triangulation.rs b/tests/proptest_triangulation.rs index 707002fe..16adc6b3 100644 --- a/tests/proptest_triangulation.rs +++ b/tests/proptest_triangulation.rs @@ -37,7 +37,9 @@ use ::uuid::Uuid; use delaunay::prelude::geometry::*; use delaunay::prelude::tds::CellKey; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; use proptest::prelude::*; use std::collections::HashMap; diff --git a/tests/regressions.rs b/tests/regressions.rs index 858bdf21..92f006cc 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -9,7 +9,10 @@ use delaunay::prelude::diagnostics::debug_print_first_delaunay_violation; use delaunay::prelude::generators::generate_random_points_in_ball_seeded; use delaunay::prelude::geometry::{Point, RobustKernel}; use delaunay::prelude::ordering::{hilbert_indices_prequantized, hilbert_quantize}; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, RetryPolicy, + TopologyGuarantee, Vertex, vertex, +}; /// Replays a full Hilbert ordering while keeping only the prefix that first /// exposed issue #307, so the regression stays fast and deterministic. diff --git a/tests/serialization_vertex_preservation.rs b/tests/serialization_vertex_preservation.rs index 5ee4357b..0d6d6d4b 100644 --- a/tests/serialization_vertex_preservation.rs +++ b/tests/serialization_vertex_preservation.rs @@ -13,7 +13,9 @@ use delaunay::assert_jaccard_gte; use delaunay::core::util::extract_vertex_coordinate_set; use delaunay::prelude::geometry::*; use delaunay::prelude::tds::Tds; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, TopologyGuarantee, Vertex, +}; use std::collections::HashSet; /// Test vertex preservation with duplicate coordinates From 01d08d351a010ae8d7d53fc9820b544ed82d091f Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 10 May 2026 07:29:29 -0700 Subject: [PATCH 05/15] perf: trigger batch repair on seed backlog (#341) - Add an adaptive seed-backlog trigger for batch local Delaunay repair. - Track local repair frontier sizes and cadence versus backlog triggers. - Expose the 3D large-scale repair interval in the debug just recipe. - Clarify batch repair policy docs and cover trigger/telemetry behavior with focused tests. --- justfile | 6 +- src/triangulation/delaunay.rs | 321 +++++++++++++++++++++++++++++----- tests/large_scale_debug.rs | 19 ++ 3 files changed, 304 insertions(+), 42 deletions(-) diff --git a/justfile b/justfile index 9cf231f2..d024fffa 100644 --- a/justfile +++ b/justfile @@ -233,8 +233,8 @@ coverage-ci: _ensure-cargo-llvm-cov mkdir -p coverage cargo llvm-cov {{_coverage_base_args}} --cobertura --output-path coverage/cobertura.xml -- --skip prop_ -debug-large-scale-3d n="10000": - DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 DELAUNAY_LARGE_DEBUG_N_3D={{n}} cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture +debug-large-scale-3d n="10000" repair_every="2": + DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 DELAUNAY_LARGE_DEBUG_N_3D={{n}} DELAUNAY_LARGE_DEBUG_REPAIR_EVERY={{repair_every}} cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture debug-large-scale-4d n="3000": DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 DELAUNAY_LARGE_DEBUG_N_4D={{n}} cargo test --release --test large_scale_debug debug_large_scale_4d -- --ignored --exact --nocapture @@ -281,7 +281,7 @@ help-workflows: @echo "Active large-scale debugging:" @echo " just test-diagnostics # Run diagnostics tools with output" @echo " just debug-large-scale-4d [n] # Issue #340: 4D large-scale runtime (default n=3000)" - @echo " just debug-large-scale-3d [n] # Issue #341: 3D scalability (default n=10000)" + @echo " just debug-large-scale-3d [n] [repair_every] # Issue #341: 3D scalability (defaults n=10000, repair_every=2)" @echo " just debug-large-scale-5d [n] # Issue #342: 5D feasibility (default n=1000)" @echo "" @echo "Benchmark workflows:" diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index e792544b..52f45c0d 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -85,6 +85,8 @@ pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4: usize = 12; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4: usize = 96; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4: usize = 4; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4: usize = 16; +const LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_GE_4: usize = 24; +const LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_LT_4: usize = 16; const RIDGE_LINK_REPAIR_VALIDATION_MESSAGE: &str = "Topology invalid after Delaunay repair"; fn ridge_link_repair_validation_error(err: ManifoldError) -> InsertionError { @@ -146,6 +148,55 @@ const fn local_repair_flip_budget(seed_cells_len: usize) -> usiz if raw > floor { raw } else { floor } } +/// Pending local repair frontier size that triggers an early batch repair. +/// +/// The threshold keeps sparse repair cadences from letting a large seed +/// frontier accumulate. 3D uses a lower threshold because the 3000-point sweep +/// in #341 showed that repair cost rises sharply once the pending frontier +/// grows beyond the small-batch regime. +const fn local_repair_seed_backlog_threshold() -> usize { + let factor = if D >= 4 { + LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_GE_4 + } else { + LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_LT_4 + }; + (D + 1).saturating_mul(factor) +} + +/// Reason a batch local repair pass was scheduled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BatchLocalRepairTrigger { + /// The configured [`DelaunayRepairPolicy`] cadence fired. + Cadence, + /// The pending local seed frontier exceeded the adaptive backlog threshold. + SeedBacklog, +} + +/// Decides whether batch construction should run local Delaunay repair now. +fn batch_local_repair_trigger( + policy: DelaunayRepairPolicy, + insertion_count: usize, + topology: TopologyGuarantee, + pending_seed_cells_len: usize, +) -> Option { + if policy == DelaunayRepairPolicy::Never + || pending_seed_cells_len == 0 + || !TopologicalOperation::FacetFlip.is_admissible_under(topology) + { + return None; + } + + if matches!( + policy.decide(insertion_count, topology, TopologicalOperation::FacetFlip,), + RepairDecision::Proceed + ) { + return Some(BatchLocalRepairTrigger::Cadence); + } + + (pending_seed_cells_len >= local_repair_seed_backlog_threshold::()) + .then_some(BatchLocalRepairTrigger::SeedBacklog) +} + fn batch_repair_trace_enabled() -> bool { env::var_os("DELAUNAY_BATCH_REPAIR_TRACE").is_some() } @@ -922,7 +973,11 @@ impl ConstructionOptions { self.retry_policy } - /// Returns the local Delaunay repair cadence used during batch construction. + /// Returns the automatic local Delaunay repair policy used during batch construction. + /// + /// [`DelaunayRepairPolicy::EveryN`] controls the scheduled cadence. Batch + /// construction may also run an earlier local repair when the accumulated + /// seed frontier grows large; [`DelaunayRepairPolicy::Never`] disables both. #[must_use] pub const fn batch_repair_policy(&self) -> DelaunayRepairPolicy { self.batch_repair_policy @@ -958,7 +1013,11 @@ impl ConstructionOptions { self } - /// Sets the local Delaunay repair cadence used during batch construction. + /// Sets the automatic local Delaunay repair policy used during batch construction. + /// + /// [`DelaunayRepairPolicy::EveryN`] controls the scheduled cadence. Batch + /// construction may also run an earlier local repair when the accumulated + /// seed frontier grows large; [`DelaunayRepairPolicy::Never`] disables both. #[must_use] pub const fn with_batch_repair_policy( mut self, @@ -1044,12 +1103,20 @@ pub struct ConstructionTelemetry { /// Maximum wall-clock nanoseconds spent in one post-insertion validation. pub topology_validation_nanos_max: u64, - /// Number of cadenced local Delaunay repair calls during batch construction. + /// Number of batch local Delaunay repair calls during construction. pub local_repair_calls: usize, - /// Wall-clock nanoseconds spent in cadenced local Delaunay repair. + /// Wall-clock nanoseconds spent in batch local Delaunay repair. pub local_repair_nanos: u64, - /// Maximum wall-clock nanoseconds spent in one cadenced local repair call. + /// Maximum wall-clock nanoseconds spent in one batch local repair call. pub local_repair_nanos_max: u64, + /// Total pending seed cells repaired by batch local repair calls. + pub local_repair_seed_cells_total: usize, + /// Maximum pending seed-cell frontier repaired by one batch local repair call. + pub local_repair_seed_cells_max: usize, + /// Number of batch local repair calls fired by the configured cadence. + pub local_repair_cadence_triggers: usize, + /// Number of batch local repair calls fired by the seed-backlog threshold. + pub local_repair_backlog_triggers: usize, /// Number of bulk local-repair seed accumulation calls. pub repair_seed_accumulation_calls: usize, @@ -1086,6 +1153,7 @@ impl ConstructionTelemetry { || self.hull_extension_calls > 0 || self.topology_validation_calls > 0 || self.local_repair_calls > 0 + || self.local_repair_seed_cells_total > 0 || self.repair_seed_accumulation_calls > 0 || self.global_conflict_scans > 0 } @@ -1098,13 +1166,35 @@ impl ConstructionTelemetry { self.insertion_wall_time_nanos_max = self.insertion_wall_time_nanos_max.max(elapsed_nanos); } - /// Records the wall-clock duration of one cadenced local repair call. + /// Records the wall-clock duration of one batch local repair call. pub(crate) fn record_local_repair_timing(&mut self, elapsed_nanos: u64) { self.local_repair_calls = self.local_repair_calls.saturating_add(1); self.local_repair_nanos = self.local_repair_nanos.saturating_add(elapsed_nanos); self.local_repair_nanos_max = self.local_repair_nanos_max.max(elapsed_nanos); } + /// Records the repaired local frontier size and why the repair fired. + fn record_local_repair_frontier( + &mut self, + seed_cells: usize, + trigger: BatchLocalRepairTrigger, + ) { + self.local_repair_seed_cells_total = self + .local_repair_seed_cells_total + .saturating_add(seed_cells); + self.local_repair_seed_cells_max = self.local_repair_seed_cells_max.max(seed_cells); + match trigger { + BatchLocalRepairTrigger::Cadence => { + self.local_repair_cadence_triggers = + self.local_repair_cadence_triggers.saturating_add(1); + } + BatchLocalRepairTrigger::SeedBacklog => { + self.local_repair_backlog_triggers = + self.local_repair_backlog_triggers.saturating_add(1); + } + } + } + /// Records one bulk local-repair seed accumulation step. pub(crate) fn record_repair_seed_accumulation( &mut self, @@ -1282,15 +1372,7 @@ impl ConstructionTelemetry { .topology_validation_nanos_max .max(other.topology_validation_nanos_max); - self.local_repair_calls = self - .local_repair_calls - .saturating_add(other.local_repair_calls); - self.local_repair_nanos = self - .local_repair_nanos - .saturating_add(other.local_repair_nanos); - self.local_repair_nanos_max = self - .local_repair_nanos_max - .max(other.local_repair_nanos_max); + self.merge_local_repair_from(other); self.merge_repair_seed_accumulation_from(other); @@ -1311,6 +1393,31 @@ impl ConstructionTelemetry { .saturating_add(other.global_conflict_scan_nanos); } + /// Keeps local-repair merge accounting isolated so the aggregate merge stays readable. + fn merge_local_repair_from(&mut self, other: &Self) { + self.local_repair_calls = self + .local_repair_calls + .saturating_add(other.local_repair_calls); + self.local_repair_nanos = self + .local_repair_nanos + .saturating_add(other.local_repair_nanos); + self.local_repair_nanos_max = self + .local_repair_nanos_max + .max(other.local_repair_nanos_max); + self.local_repair_seed_cells_total = self + .local_repair_seed_cells_total + .saturating_add(other.local_repair_seed_cells_total); + self.local_repair_seed_cells_max = self + .local_repair_seed_cells_max + .max(other.local_repair_seed_cells_max); + self.local_repair_cadence_triggers = self + .local_repair_cadence_triggers + .saturating_add(other.local_repair_cadence_triggers); + self.local_repair_backlog_triggers = self + .local_repair_backlog_triggers + .saturating_add(other.local_repair_backlog_triggers); + } + fn merge_repair_seed_accumulation_from(&mut self, other: &Self) { self.repair_seed_accumulation_calls = self .repair_seed_accumulation_calls @@ -4210,9 +4317,11 @@ where } } + /// Repairs the currently accumulated batch-local seed frontier. fn repair_pending_local_seed_cells( &mut self, index: usize, + trigger: BatchLocalRepairTrigger, pending_seed_cells: &mut Vec, pending_seen: &mut FastHashSet, soft_fail_seeds: &mut Vec, @@ -4226,14 +4335,16 @@ where #[cfg(test)] test_hooks::record_batch_local_repair_call(); - let max_flips = local_repair_flip_budget::(pending_seed_cells.len()); + let seed_cells_len = pending_seed_cells.len(); + let max_flips = local_repair_flip_budget::(seed_cells_len); let trace_repair = batch_repair_trace_enabled(); let repair_started = Instant::now(); if trace_repair { tracing::debug!( idx = index, - seed_cells = pending_seed_cells.len(), + seed_cells = seed_cells_len, max_flips, + trigger = ?trigger, "bulk batch repair: starting local repair" ); } @@ -4252,6 +4363,7 @@ where let repair_elapsed = repair_started.elapsed(); if let Some(telemetry) = construction_telemetry { telemetry.record_local_repair_timing(duration_nanos_saturating(repair_elapsed)); + telemetry.record_local_repair_frontier(seed_cells_len, trigger); } match repair_result { @@ -4259,7 +4371,7 @@ where if trace_repair { tracing::debug!( idx = index, - seed_cells = pending_seed_cells.len(), + seed_cells = seed_cells_len, flips = stats.flips_performed, checked = stats.facets_checked, max_queue = stats.max_queue_len, @@ -4276,7 +4388,7 @@ where if trace_repair { tracing::debug!( idx = index, - seed_cells = pending_seed_cells.len(), + seed_cells = seed_cells_len, error = %repair_err, elapsed = ?repair_elapsed, "bulk batch repair: local repair failed" @@ -4288,7 +4400,7 @@ where tracing::debug!( idx = index, error = %repair_err, - seed_cells = pending_seed_cells.len(), + seed_cells = seed_cells_len, "bulk batch repair: local repair soft-failed; deferring seeds to final repair" ); self.canonicalize_after_bulk_repair()?; @@ -4419,11 +4531,11 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - // Cadenced local Delaunay repair: accumulate the local frontier + // Batch local Delaunay repair: accumulate the local frontier // touched by each successful insertion, then repair the whole - // frontier when the policy fires. This keeps EveryN semantics - // local to the last N insertions rather than repairing only the - // final insertion in the batch. + // frontier when the policy fires or the frontier grows too large. + // This keeps EveryN semantics local to the recent insertion window + // rather than repairing only the final insertion in the batch. let topology = self.tri.topology_guarantee(); if batch_repair_policy != DelaunayRepairPolicy::Never && TopologicalOperation::FacetFlip.is_admissible_under(topology) @@ -4435,16 +4547,15 @@ where pending_repair_seeds, &mut pending_repair_seen, ); - if matches!( - batch_repair_policy.decide( - inserted_vertices, - topology, - TopologicalOperation::FacetFlip, - ), - RepairDecision::Proceed + if let Some(trigger) = batch_local_repair_trigger::( + batch_repair_policy, + inserted_vertices, + topology, + pending_repair_seeds.len(), ) { self.repair_pending_local_seed_cells( index, + trigger, pending_repair_seeds, &mut pending_repair_seen, soft_fail_seeds, @@ -4613,7 +4724,7 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - // Cadenced local repair: see the non-stats branch + // Batch local repair: see the non-stats branch // comment for full details. let topology = self.tri.topology_guarantee(); if batch_repair_policy != DelaunayRepairPolicy::Never @@ -4633,16 +4744,15 @@ where duration_nanos_saturating(seed_started.elapsed()), seed_cells_added, ); - if matches!( - batch_repair_policy.decide( - inserted_vertices, - topology, - TopologicalOperation::FacetFlip, - ), - RepairDecision::Proceed + if let Some(trigger) = batch_local_repair_trigger::( + batch_repair_policy, + inserted_vertices, + topology, + pending_repair_seeds.len(), ) { self.repair_pending_local_seed_cells( index, + trigger, pending_repair_seeds, &mut pending_repair_seen, soft_fail_seeds, @@ -7615,6 +7725,12 @@ where /// It is separate from any *validation-only* policy to allow checking the Delaunay /// property without mutating topology when needed. /// +/// During batch construction, [`DelaunayRepairPolicy::EveryN`] is a scheduled +/// cadence rather than a hard lower bound on repair frequency: construction may +/// run an additional local repair earlier when the accumulated seed frontier +/// grows large. [`DelaunayRepairPolicy::Never`] disables those automatic batch +/// repairs. +/// /// # Examples /// /// ```rust @@ -7926,6 +8042,104 @@ mod tests { LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4 ); + #[test] + fn test_local_repair_seed_backlog_threshold_uses_dimension_regimes() { + assert_eq!( + local_repair_seed_backlog_threshold::<3>(), + 4 * LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_LT_4 + ); + assert_eq!( + local_repair_seed_backlog_threshold::<4>(), + 5 * LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_GE_4 + ); + } + + #[test] + fn test_batch_local_repair_trigger_prefers_cadence_over_backlog() { + let policy = DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()); + let threshold = local_repair_seed_backlog_threshold::<3>(); + + assert_eq!( + batch_local_repair_trigger::<3>(policy, 4, TopologyGuarantee::PLManifold, threshold), + Some(BatchLocalRepairTrigger::Cadence) + ); + } + + #[test] + fn test_batch_local_repair_trigger_runs_every_insertion_below_backlog() { + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::EveryInsertion, + 1, + TopologyGuarantee::PLManifold, + 1, + ), + Some(BatchLocalRepairTrigger::Cadence) + ); + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::EveryInsertion, + 0, + TopologyGuarantee::PLManifold, + 1, + ), + None + ); + } + + #[test] + fn test_batch_local_repair_trigger_repairs_early_on_seed_backlog() { + let policy = DelaunayRepairPolicy::EveryN(NonZeroUsize::new(128).unwrap()); + let threshold = local_repair_seed_backlog_threshold::<3>(); + + assert_eq!( + batch_local_repair_trigger::<3>(policy, 7, TopologyGuarantee::PLManifold, threshold), + Some(BatchLocalRepairTrigger::SeedBacklog) + ); + assert_eq!( + batch_local_repair_trigger::<3>( + policy, + 7, + TopologyGuarantee::PLManifold, + threshold - 1 + ), + None + ); + } + + #[test] + fn test_batch_local_repair_trigger_respects_policy_and_topology() { + let threshold = local_repair_seed_backlog_threshold::<3>(); + + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::Never, + 7, + TopologyGuarantee::PLManifold, + threshold + ), + None + ); + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(128).unwrap()), + 7, + TopologyGuarantee::PLManifold, + 0 + ), + None + ); + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(128).unwrap()), + 7, + TopologyGuarantee::Pseudomanifold, + threshold + ), + Some(BatchLocalRepairTrigger::SeedBacklog) + ); + } + #[test] fn test_log_bulk_progress_if_due_updates_progress_state_only_when_due() { let sample = BatchProgressSample { @@ -8511,6 +8725,9 @@ mod tests { summary.telemetry.record_insertion(&telemetry); summary.telemetry.record_insertion_timing(1_000_000); summary.telemetry.record_local_repair_timing(2_000_000); + summary + .telemetry + .record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); summary .telemetry .record_repair_seed_accumulation(500_000, 7); @@ -8538,6 +8755,10 @@ mod tests { assert_eq!(summary.telemetry.topology_validation_nanos, 625_000); assert_eq!(summary.telemetry.local_repair_calls, 1); assert_eq!(summary.telemetry.local_repair_nanos, 2_000_000); + assert_eq!(summary.telemetry.local_repair_seed_cells_total, 11); + assert_eq!(summary.telemetry.local_repair_seed_cells_max, 11); + assert_eq!(summary.telemetry.local_repair_cadence_triggers, 0); + assert_eq!(summary.telemetry.local_repair_backlog_triggers, 1); assert_eq!(summary.telemetry.repair_seed_accumulation_calls, 1); assert_eq!(summary.telemetry.repair_seed_accumulation_nanos, 500_000); assert_eq!(summary.telemetry.repair_seed_cells_added_total, 7); @@ -8548,6 +8769,28 @@ mod tests { assert_eq!(summary.telemetry.global_conflict_scan_nanos, 250_000); } + #[test] + fn test_construction_telemetry_merge_preserves_local_repair_frontiers() { + let mut left = ConstructionTelemetry::default(); + left.record_local_repair_timing(10); + left.record_local_repair_frontier(5, BatchLocalRepairTrigger::Cadence); + + let mut right = ConstructionTelemetry::default(); + right.record_local_repair_timing(30); + right.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); + + left.merge_from(&right); + + assert!(left.has_data()); + assert_eq!(left.local_repair_calls, 2); + assert_eq!(left.local_repair_nanos, 40); + assert_eq!(left.local_repair_nanos_max, 30); + assert_eq!(left.local_repair_seed_cells_total, 16); + assert_eq!(left.local_repair_seed_cells_max, 11); + assert_eq!(left.local_repair_cadence_triggers, 1); + assert_eq!(left.local_repair_backlog_triggers, 1); + } + #[test] fn test_construction_statistics_record_insertion_tracks_skipped_variants() { init_tracing(); diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 24fc3360..44684c91 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -725,6 +725,24 @@ fn print_repair_seed_accumulation_telemetry(telemetry: &ConstructionTelemetry) { ); } +fn print_local_repair_frontier_telemetry(telemetry: &ConstructionTelemetry) { + if telemetry.local_repair_calls == 0 { + return; + } + + println!( + " local_repair_frontiers: seed_cells_total={} avg_seed_cells={} max_seed_cells={} cadence_triggers={} backlog_triggers={}", + telemetry.local_repair_seed_cells_total, + format_ratio_2( + telemetry.local_repair_seed_cells_total, + telemetry.local_repair_calls, + ), + telemetry.local_repair_seed_cells_max, + telemetry.local_repair_cadence_triggers, + telemetry.local_repair_backlog_triggers + ); +} + fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { if !telemetry.has_data() { return; @@ -795,6 +813,7 @@ fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { telemetry.local_repair_nanos, telemetry.local_repair_nanos_max, ); + print_local_repair_frontier_telemetry(telemetry); print_repair_seed_accumulation_telemetry(telemetry); if telemetry.global_conflict_scans > 0 { From a8604373f110751ae1e97a1ea1e3e31c3136e967 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 10 May 2026 08:34:23 -0700 Subject: [PATCH 06/15] perf(triangulation): fast-path retry validation (#341) - Use the flip-based Delaunay verifier when accepting shuffled construction retries instead of the brute-force property scan. - Add construction phase telemetry for preprocessing, insertion, finalization, and final validation timing. - Default the large-scale debug repair interval to 2 for the 3K repair sweep. --- docs/dev/debug_env_vars.md | 2 +- src/triangulation/delaunay.rs | 359 ++++++++++++++++++++++++++++------ tests/large_scale_debug.rs | 42 +++- 3 files changed, 342 insertions(+), 61 deletions(-) diff --git a/docs/dev/debug_env_vars.md b/docs/dev/debug_env_vars.md index c295a1ad..fa7f7023 100644 --- a/docs/dev/debug_env_vars.md +++ b/docs/dev/debug_env_vars.md @@ -134,7 +134,7 @@ and release builds. | `DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED` | **value** | Vertex shuffle seed | | `DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY` | **value** | Progress logging interval | | `DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY` | **value** | Validation interval | -| `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY` | **value** | Batch/incremental repair interval (default: 4) | +| `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY` | **value** | Batch/incremental repair interval (default: 2) | | `DELAUNAY_LARGE_DEBUG_REPAIR_MAX_FLIPS` | **value** | Flip budget override | | `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS` | **value** | Timeout (0 = no cap) | | `DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT` | **value** | Maximum skipped-vertex percentage before failing (default: 5.0) | diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 52f45c0d..3d0ef082 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -1054,6 +1054,21 @@ pub struct ConstructionTelemetry { /// Maximum wall-clock nanoseconds spent in one transactional insertion call. pub insertion_wall_time_nanos_max: u64, + /// Wall-clock nanoseconds spent preprocessing vertices before topology construction. + pub construction_preprocessing_nanos: u64, + /// Wall-clock nanoseconds spent in the bulk insertion loop, including cadenced local repair. + pub construction_insert_loop_nanos: u64, + /// Wall-clock nanoseconds spent finalizing bulk construction after the insertion loop. + pub construction_finalize_nanos: u64, + /// Wall-clock nanoseconds spent in the seeded completion repair during finalization. + pub construction_completion_repair_nanos: u64, + /// Wall-clock nanoseconds spent canonicalizing orientation during finalization. + pub construction_orientation_nanos: u64, + /// Wall-clock nanoseconds spent in final topology validation during finalization. + pub construction_topology_validation_nanos: u64, + /// Wall-clock nanoseconds spent in the final global Delaunay validation pass. + pub construction_final_delaunay_validation_nanos: u64, + /// Number of point-location calls performed during construction. pub locate_calls: usize, /// Total facet-walk steps across all point-location calls. @@ -1147,6 +1162,13 @@ impl ConstructionTelemetry { pub const fn has_data(&self) -> bool { self.insertion_wall_time_calls > 0 || self.insertion_wall_time_nanos > 0 + || self.construction_preprocessing_nanos > 0 + || self.construction_insert_loop_nanos > 0 + || self.construction_finalize_nanos > 0 + || self.construction_completion_repair_nanos > 0 + || self.construction_orientation_nanos > 0 + || self.construction_topology_validation_nanos > 0 + || self.construction_final_delaunay_validation_nanos > 0 || self.locate_calls > 0 || self.conflict_region_calls > 0 || self.cavity_insertion_calls > 0 @@ -1166,6 +1188,58 @@ impl ConstructionTelemetry { self.insertion_wall_time_nanos_max = self.insertion_wall_time_nanos_max.max(elapsed_nanos); } + /// Records the wall-clock duration of construction preprocessing. + pub(crate) const fn record_construction_preprocessing_timing(&mut self, elapsed_nanos: u64) { + self.construction_preprocessing_nanos = self + .construction_preprocessing_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of the bulk insertion loop. + pub(crate) const fn record_construction_insert_loop_timing(&mut self, elapsed_nanos: u64) { + self.construction_insert_loop_nanos = self + .construction_insert_loop_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of bulk-construction finalization. + pub(crate) const fn record_construction_finalize_timing(&mut self, elapsed_nanos: u64) { + self.construction_finalize_nanos = self + .construction_finalize_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of seeded completion repair. + const fn record_construction_completion_repair_timing(&mut self, elapsed_nanos: u64) { + self.construction_completion_repair_nanos = self + .construction_completion_repair_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of orientation canonicalization. + const fn record_construction_orientation_timing(&mut self, elapsed_nanos: u64) { + self.construction_orientation_nanos = self + .construction_orientation_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of final topology validation. + const fn record_construction_topology_validation_timing(&mut self, elapsed_nanos: u64) { + self.construction_topology_validation_nanos = self + .construction_topology_validation_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of final global Delaunay validation. + pub(crate) const fn record_construction_final_delaunay_validation_timing( + &mut self, + elapsed_nanos: u64, + ) { + self.construction_final_delaunay_validation_nanos = self + .construction_final_delaunay_validation_nanos + .saturating_add(elapsed_nanos); + } + /// Records the wall-clock duration of one batch local repair call. pub(crate) fn record_local_repair_timing(&mut self, elapsed_nanos: u64) { self.local_repair_calls = self.local_repair_calls.saturating_add(1); @@ -1311,6 +1385,8 @@ impl ConstructionTelemetry { .insertion_wall_time_nanos_max .max(other.insertion_wall_time_nanos_max); + self.merge_construction_phase_timings_from(other); + self.locate_calls = self.locate_calls.saturating_add(other.locate_calls); self.locate_walk_steps_total = self .locate_walk_steps_total @@ -1393,6 +1469,31 @@ impl ConstructionTelemetry { .saturating_add(other.global_conflict_scan_nanos); } + /// Keeps construction-phase merge accounting isolated so aggregate merges stay readable. + const fn merge_construction_phase_timings_from(&mut self, other: &Self) { + self.construction_preprocessing_nanos = self + .construction_preprocessing_nanos + .saturating_add(other.construction_preprocessing_nanos); + self.construction_insert_loop_nanos = self + .construction_insert_loop_nanos + .saturating_add(other.construction_insert_loop_nanos); + self.construction_finalize_nanos = self + .construction_finalize_nanos + .saturating_add(other.construction_finalize_nanos); + self.construction_completion_repair_nanos = self + .construction_completion_repair_nanos + .saturating_add(other.construction_completion_repair_nanos); + self.construction_orientation_nanos = self + .construction_orientation_nanos + .saturating_add(other.construction_orientation_nanos); + self.construction_topology_validation_nanos = self + .construction_topology_validation_nanos + .saturating_add(other.construction_topology_validation_nanos); + self.construction_final_delaunay_validation_nanos = self + .construction_final_delaunay_validation_nanos + .saturating_add(other.construction_final_delaunay_validation_nanos); + } + /// Keeps local-repair merge accounting isolated so the aggregate merge stays readable. fn merge_local_repair_from(&mut self, other: &Self) { self.local_repair_calls = self @@ -3286,6 +3387,10 @@ where clippy::result_large_err, reason = "Public API intentionally returns by-value construction statistics for compatibility" )] + #[expect( + clippy::too_many_lines, + reason = "Statistics constructor handles preprocessing, retry, and fallback aggregation" + )] pub fn with_options_and_statistics( kernel: &K, vertices: &[Vertex], @@ -3302,18 +3407,28 @@ where use_global_repair_fallback, } = options; - let preprocessed = Self::preprocess_vertices_for_construction( + let preprocessing_started = Instant::now(); + let preprocessed = match Self::preprocess_vertices_for_construction( vertices, dedup_policy, insertion_order, initial_simplex, - ) - .map_err( - |error| DelaunayTriangulationConstructionErrorWithStatistics { - error, - statistics: ConstructionStatistics::default(), - }, - )?; + ) { + Ok(preprocessed) => preprocessed, + Err(error) => { + let mut statistics = ConstructionStatistics::default(); + statistics + .telemetry + .record_construction_preprocessing_timing(duration_nanos_saturating( + preprocessing_started.elapsed(), + )); + return Err(DelaunayTriangulationConstructionErrorWithStatistics { + error, + statistics, + }); + } + }; + let preprocessing_nanos = duration_nanos_saturating(preprocessing_started.elapsed()); let grid_cell_size = preprocessed.grid_cell_size(); let primary_vertices: &[Vertex] = preprocessed.primary_slice(vertices); let fallback_vertices = preprocessed.fallback_slice(); @@ -3370,9 +3485,18 @@ where }; match build_with_vertices(primary_vertices) { - Ok(result) => Ok(result), - Err(primary_err) => { + Ok((dt, mut stats)) => { + stats + .telemetry + .record_construction_preprocessing_timing(preprocessing_nanos); + Ok((dt, stats)) + } + Err(mut primary_err) => { let Some(fallback) = fallback_vertices else { + primary_err + .statistics + .telemetry + .record_construction_preprocessing_timing(preprocessing_nanos); return Err(primary_err); }; @@ -3380,11 +3504,17 @@ where Ok((dt, stats)) => { let mut aggregate = primary_err.statistics; aggregate.merge_from(&stats); + aggregate + .telemetry + .record_construction_preprocessing_timing(preprocessing_nanos); Ok((dt, aggregate)) } Err(fallback_err) => { let mut aggregate = primary_err.statistics; aggregate.merge_from(&fallback_err.statistics); + aggregate + .telemetry + .record_construction_preprocessing_timing(preprocessing_nanos); Err(DelaunayTriangulationConstructionErrorWithStatistics { error: fallback_err.error, statistics: aggregate, @@ -3567,7 +3697,7 @@ where batch_repair_policy, use_global_repair_fallback, ) { - Ok(candidate) => match is_delaunay_property_only(&candidate.tri.tds) { + Ok(candidate) => match candidate.is_delaunay_via_flips() { Ok(()) => { log_construction_retry_result(0, None, 0_u64, "succeeded", None, None); return Ok(candidate); @@ -3638,7 +3768,7 @@ where batch_repair_policy, use_global_repair_fallback, ) { - Ok(candidate) => match is_delaunay_property_only(&candidate.tri.tds) { + Ok(candidate) => match candidate.is_delaunay_via_flips() { Ok(()) => { log_construction_retry_result( attempt, @@ -3758,25 +3888,34 @@ where batch_repair_policy, use_global_repair_fallback, ) { - Ok((candidate, stats)) => match is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => { - aggregate_stats.merge_from(&stats); - log_construction_retry_result( - 0, - None, - 0_u64, - "succeeded", - None, - Some(&stats), + Ok((candidate, mut stats)) => { + let delaunay_started = Instant::now(); + let delaunay_result = candidate.is_delaunay_via_flips(); + stats + .telemetry + .record_construction_final_delaunay_validation_timing( + duration_nanos_saturating(delaunay_started.elapsed()), ); - return Ok((candidate, aggregate_stats)); - } - Err(err) => { - aggregate_stats.merge_from(&stats); - last_stats.replace(stats); - format!("Delaunay property violated after construction: {err}") + match delaunay_result { + Ok(()) => { + aggregate_stats.merge_from(&stats); + log_construction_retry_result( + 0, + None, + 0_u64, + "succeeded", + None, + Some(&stats), + ); + return Ok((candidate, aggregate_stats)); + } + Err(err) => { + aggregate_stats.merge_from(&stats); + last_stats.replace(stats); + format!("Delaunay property violated after construction: {err}") + } } - }, + } Err(err) => { let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics } = err; @@ -3855,26 +3994,35 @@ where batch_repair_policy, use_global_repair_fallback, ) { - Ok((candidate, stats)) => match is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => { - aggregate_stats.merge_from(&stats); - log_construction_retry_result( - attempt, - Some(attempt_seed), - perturbation_seed, - "succeeded", - None, - Some(&stats), + Ok((candidate, mut stats)) => { + let delaunay_started = Instant::now(); + let delaunay_result = candidate.is_delaunay_via_flips(); + stats + .telemetry + .record_construction_final_delaunay_validation_timing( + duration_nanos_saturating(delaunay_started.elapsed()), ); - return Ok((candidate, aggregate_stats)); - } - Err(err) => { - aggregate_stats.merge_from(&stats); - last_stats.replace(stats); - last_error = - format!("Delaunay property violated after construction: {err}"); + match delaunay_result { + Ok(()) => { + aggregate_stats.merge_from(&stats); + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "succeeded", + None, + Some(&stats), + ); + return Ok((candidate, aggregate_stats)); + } + Err(err) => { + aggregate_stats.merge_from(&stats); + last_stats.replace(stats); + last_error = + format!("Delaunay property violated after construction: {err}"); + } } - }, + } Err(err) => { let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics } = err; @@ -4010,7 +4158,7 @@ where use_global_repair_fallback: bool, ) -> Result<(Self, ConstructionStatistics), DelaunayTriangulationConstructionErrorWithStatistics> { - let (dt, stats) = Self::build_with_kernel_inner_seeded_with_construction_statistics( + let (dt, mut stats) = Self::build_with_kernel_inner_seeded_with_construction_statistics( kernel, vertices, topology_guarantee, @@ -4026,8 +4174,14 @@ where tracing::debug!("post-construction: starting Delaunay validation (build stats)"); let delaunay_started = Instant::now(); let delaunay_result = dt.is_valid(); + let delaunay_elapsed = delaunay_started.elapsed(); + stats + .telemetry + .record_construction_final_delaunay_validation_timing(duration_nanos_saturating( + delaunay_elapsed, + )); tracing::debug!( - elapsed = ?delaunay_started.elapsed(), + elapsed = ?delaunay_elapsed, success = delaunay_result.is_ok(), "post-construction: Delaunay validation (build stats) completed" ); @@ -4146,7 +4300,8 @@ where let mut soft_fail_seeds: Vec = Vec::new(); let mut pending_repair_seeds: Vec = Vec::new(); - if let Err(error) = dt.insert_remaining_vertices_seeded( + let insert_loop_started = Instant::now(); + let insert_result = dt.insert_remaining_vertices_seeded( vertices, perturbation_seed, grid_cell_size, @@ -4154,21 +4309,35 @@ where Some(&mut stats), &mut pending_repair_seeds, &mut soft_fail_seeds, - ) { + ); + stats + .telemetry + .record_construction_insert_loop_timing(duration_nanos_saturating( + insert_loop_started.elapsed(), + )); + if let Err(error) = insert_result { return Err(DelaunayTriangulationConstructionErrorWithStatistics { error, statistics: stats, }); } - if let Err(error) = dt.finalize_bulk_construction( + let finalize_started = Instant::now(); + let finalize_result = dt.finalize_bulk_construction( original_validation_policy, original_repair_policy, run_final_repair, batch_repair_policy, &pending_repair_seeds, &soft_fail_seeds, - ) { + Some(&mut stats.telemetry), + ); + stats + .telemetry + .record_construction_finalize_timing(duration_nanos_saturating( + finalize_started.elapsed(), + )); + if let Err(error) = finalize_result { return Err(DelaunayTriangulationConstructionErrorWithStatistics { error, statistics: stats, @@ -4263,6 +4432,7 @@ where batch_repair_policy, &pending_repair_seeds, &soft_fail_seeds, + None, )?; Ok(dt) @@ -4874,6 +5044,10 @@ where /// Restores runtime policies and performs the final repair/orientation /// checks that were deferred during batch insertion. + #[expect( + clippy::too_many_arguments, + reason = "bulk finalization restores policies, repair state, and optional statistics telemetry" + )] fn finalize_bulk_construction( &mut self, original_validation_policy: ValidationPolicy, @@ -4882,6 +5056,7 @@ where batch_repair_policy: DelaunayRepairPolicy, pending_repair_seeds: &[CellKey], soft_fail_seeds: &[CellKey], + mut construction_telemetry: Option<&mut ConstructionTelemetry>, ) -> Result<(), DelaunayTriangulationConstructionError> { // Restore policies after batch construction. self.tri.validation_policy = original_validation_policy; @@ -4900,23 +5075,44 @@ where && batch_repair_policy != DelaunayRepairPolicy::Never && !completion_seed_cells.is_empty() { - self.run_seeded_completion_repair(&completion_seed_cells)?; + let repair_started = Instant::now(); + let repair_result = self.run_seeded_completion_repair(&completion_seed_cells); + if let Some(telemetry) = construction_telemetry.as_mut() { + telemetry.record_construction_completion_repair_timing(duration_nanos_saturating( + repair_started.elapsed(), + )); + } + repair_result?; } // Flip-based repair calls normalize_coherent_orientation() which makes all cells // combinatorially coherent but can leave the global sign negative. Re-canonicalize // geometric orientation to positive before validation (#258). - self.tri + let orientation_started = Instant::now(); + let orientation_result = self + .tri .normalize_and_promote_positive_orientation() - .map_err(Self::map_orientation_canonicalization_error)?; + .map_err(Self::map_orientation_canonicalization_error); + if let Some(telemetry) = construction_telemetry.as_mut() { + telemetry.record_construction_orientation_timing(duration_nanos_saturating( + orientation_started.elapsed(), + )); + } + orientation_result?; let topology = self.tri.topology_guarantee(); if topology.requires_vertex_links_at_completion() { tracing::debug!("post-construction: starting topology validation (finalize)"); let validation_started = Instant::now(); let validation_result = self.tri.validate(); + let validation_elapsed = validation_started.elapsed(); + if let Some(telemetry) = construction_telemetry.as_mut() { + telemetry.record_construction_topology_validation_timing( + duration_nanos_saturating(validation_elapsed), + ); + } tracing::debug!( - elapsed = ?validation_started.elapsed(), + elapsed = ?validation_elapsed, success = validation_result.is_ok(), "post-construction: topology validation (finalize) completed" ); @@ -8688,6 +8884,10 @@ mod tests { } #[test] + #[expect( + clippy::too_many_lines, + reason = "single-field telemetry regression covers every aggregate counter" + )] fn test_construction_statistics_record_insertion_tracks_telemetry() { init_tracing(); @@ -8731,11 +8931,50 @@ mod tests { summary .telemetry .record_repair_seed_accumulation(500_000, 7); + summary + .telemetry + .record_construction_preprocessing_timing(10_000); + summary + .telemetry + .record_construction_insert_loop_timing(20_000); + summary + .telemetry + .record_construction_finalize_timing(30_000); + summary + .telemetry + .record_construction_completion_repair_timing(40_000); + summary + .telemetry + .record_construction_orientation_timing(50_000); + summary + .telemetry + .record_construction_topology_validation_timing(60_000); + summary + .telemetry + .record_construction_final_delaunay_validation_timing(70_000); assert!(summary.telemetry.has_data()); assert_eq!(summary.telemetry.insertion_wall_time_calls, 1); assert_eq!(summary.telemetry.insertion_wall_time_nanos, 1_000_000); assert_eq!(summary.telemetry.insertion_wall_time_nanos_max, 1_000_000); + assert_eq!(summary.telemetry.construction_preprocessing_nanos, 10_000); + assert_eq!(summary.telemetry.construction_insert_loop_nanos, 20_000); + assert_eq!(summary.telemetry.construction_finalize_nanos, 30_000); + assert_eq!( + summary.telemetry.construction_completion_repair_nanos, + 40_000 + ); + assert_eq!(summary.telemetry.construction_orientation_nanos, 50_000); + assert_eq!( + summary.telemetry.construction_topology_validation_nanos, + 60_000 + ); + assert_eq!( + summary + .telemetry + .construction_final_delaunay_validation_nanos, + 70_000 + ); assert_eq!(summary.telemetry.locate_calls, 2); assert_eq!(summary.telemetry.locate_walk_steps_total, 9); assert_eq!(summary.telemetry.locate_walk_steps_max, 7); @@ -8774,14 +9013,20 @@ mod tests { let mut left = ConstructionTelemetry::default(); left.record_local_repair_timing(10); left.record_local_repair_frontier(5, BatchLocalRepairTrigger::Cadence); + left.record_construction_insert_loop_timing(100); + left.record_construction_final_delaunay_validation_timing(200); let mut right = ConstructionTelemetry::default(); right.record_local_repair_timing(30); right.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); + right.record_construction_insert_loop_timing(300); + right.record_construction_final_delaunay_validation_timing(400); left.merge_from(&right); assert!(left.has_data()); + assert_eq!(left.construction_insert_loop_nanos, 400); + assert_eq!(left.construction_final_delaunay_validation_nanos, 600); assert_eq!(left.local_repair_calls, 2); assert_eq!(left.local_repair_nanos, 40); assert_eq!(left.local_repair_nanos_max, 30); diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 44684c91..6dd330dc 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -50,8 +50,8 @@ //! DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ //! # Skip the final flip-based repair pass (faster, but may leave Delaunay violations) //! DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR=1 \ -//! # Run bounded flip repair every N successful insertions (0 disables; default: 4) -//! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=4 \ +//! # Run bounded flip repair every N successful insertions (0 disables; default: 2) +//! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=2 \ //! # Optional: trace cadenced local-repair seed counts, flips, queues, and elapsed time //! DELAUNAY_BATCH_REPAIR_TRACE=1 \ //! # Hard wall-clock cap in seconds before the harness aborts (0 = no cap; default: 600) @@ -743,6 +743,41 @@ fn print_local_repair_frontier_telemetry(telemetry: &ConstructionTelemetry) { ); } +fn print_construction_phase_telemetry(telemetry: &ConstructionTelemetry) { + let outer_total_nanos = telemetry + .construction_preprocessing_nanos + .saturating_add(telemetry.construction_insert_loop_nanos) + .saturating_add(telemetry.construction_finalize_nanos) + .saturating_add(telemetry.construction_final_delaunay_validation_nanos); + if outer_total_nanos == 0 { + return; + } + + println!( + " construction_phases: preprocessing_ms={} insert_loop_ms={} finalize_ms={} final_delaunay_validation_ms={} outer_total_ms={}", + format_nanos_as_ms(telemetry.construction_preprocessing_nanos), + format_nanos_as_ms(telemetry.construction_insert_loop_nanos), + format_nanos_as_ms(telemetry.construction_finalize_nanos), + format_nanos_as_ms(telemetry.construction_final_delaunay_validation_nanos), + format_nanos_as_ms(outer_total_nanos) + ); + + let finalize_breakdown_nanos = telemetry + .construction_completion_repair_nanos + .saturating_add(telemetry.construction_orientation_nanos) + .saturating_add(telemetry.construction_topology_validation_nanos); + if finalize_breakdown_nanos == 0 { + return; + } + + println!( + " finalize_breakdown: completion_repair_ms={} orientation_ms={} topology_validation_ms={}", + format_nanos_as_ms(telemetry.construction_completion_repair_nanos), + format_nanos_as_ms(telemetry.construction_orientation_nanos), + format_nanos_as_ms(telemetry.construction_topology_validation_nanos) + ); +} + fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { if !telemetry.has_data() { return; @@ -750,6 +785,7 @@ fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { println!(); println!(" insertion telemetry:"); + print_construction_phase_telemetry(telemetry); print_timing_summary( "insertion_wall", telemetry.insertion_wall_time_calls, @@ -958,7 +994,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz // - 0 disables incremental repair // - 1 runs repair after every insertion // - N>1 runs repair after every N successful insertions - let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(4); + let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(2); let repair_policy = repair_policy_from_repair_every(repair_every); let repair_max_flips = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_MAX_FLIPS"); let validate_every = env_usize("DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY").or_else(|| { From c3f1c73540fb14ba76dadbc70ce8a166edf2735f Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 10 May 2026 16:31:28 -0700 Subject: [PATCH 07/15] perf(triangulation): default batch repair to EveryN(2) (#341) - Default batch construction repair to EveryN(2) while keeping direct incremental repair at EveryInsertion. - Preserve final repair and validation as the acceptance gate for valid Delaunay output. - Surface construction skip and slow-insertion diagnostics through the construction prelude. - Align repair-policy docs with the batch default and the current 500/3000-point proxy results. --- docs/numerical_robustness_guide.md | 10 +- docs/workflows.md | 8 +- examples/pachner_roundtrip_4d.rs | 2 +- src/core/triangulation.rs | 12 +- src/lib.rs | 3 +- src/triangulation/delaunay.rs | 183 +++++++++++++++++++++-------- src/triangulation/locality.rs | 20 ++-- tests/large_scale_debug.rs | 11 +- tests/prelude_exports.rs | 9 +- 9 files changed, 182 insertions(+), 76 deletions(-) diff --git a/docs/numerical_robustness_guide.md b/docs/numerical_robustness_guide.md index 4bc05df5..33258394 100644 --- a/docs/numerical_robustness_guide.md +++ b/docs/numerical_robustness_guide.md @@ -320,10 +320,12 @@ and per-insertion checks handle any remaining cases. This handles near-degenerate configurations correctly out of the box. - If you need explicit `BOUNDARY`/`DEGENERATE` signals (e.g. to detect and handle cospherical configurations yourself), switch to `RobustKernel`. -- If you use `FastKernel` for 2D performance, consider setting - `DelaunayRepairPolicy::EveryN(n)` (e.g. `n = 10`) instead of the default `EveryInsertion`. - This reduces the frequency of the automatic robust-fallback repair pass while still - maintaining the Delaunay property periodically. Note that the explicit repair methods +- If you use `FastKernel` for direct incremental insertion, consider setting + `DelaunayRepairPolicy::EveryN(n)` (e.g. `n = 10`) instead of the incremental default + `EveryInsertion`. Batch construction already uses a cadenced `ConstructionOptions` + repair default with final repair/validation. This reduces the frequency of the automatic + robust-fallback repair pass while still maintaining the Delaunay property periodically. + Note that the explicit repair methods (`repair_delaunay_with_flips`, etc.) are not available with `FastKernel` — use `AdaptiveKernel` or `RobustKernel` if you need manual repair control. - If you see retryable insertion errors, frequent perturbation retries, or skipped vertices, diff --git a/docs/workflows.md b/docs/workflows.md index 8b600b5d..8e8d769b 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -68,9 +68,11 @@ levels. ## Builder API: flip-based Delaunay repair (details) The Builder API is designed to construct Delaunay triangulations, and (by default) schedules local -flip-based repair passes after insertions. - -Automatic repair scheduling is controlled by `DelaunayRepairPolicy` (default: `EveryInsertion`). +flip-based repair passes during construction. Batch construction uses `ConstructionOptions`, whose +default repair cadence is `DelaunayRepairPolicy::EveryN(2)` plus final repair/validation. That +cadence reflects the current #341 proxy sweeps at 500 and 3000 vertices; 10000-vertex runs remain +the scalability acceptance check. Direct incremental insertion keeps the lower-level +`DelaunayRepairPolicy` default at `EveryInsertion`. The explicit repair methods (`repair_delaunay_with_flips`, `repair_delaunay_with_flips_advanced`, `rebuild_with_heuristic`) require `K: ExactPredicates` at compile time. `AdaptiveKernel` and `RobustKernel` implement this trait; `FastKernel` does not. See diff --git a/examples/pachner_roundtrip_4d.rs b/examples/pachner_roundtrip_4d.rs index eb2ec39b..2fdccee9 100644 --- a/examples/pachner_roundtrip_4d.rs +++ b/examples/pachner_roundtrip_4d.rs @@ -19,7 +19,7 @@ use delaunay::prelude::triangulation::construction::{ ConstructionOptions, DelaunayTriangulationConstructionError, InsertionOrderStrategy, Vertex, }; use delaunay::prelude::triangulation::flips::*; -use delaunay::prelude::triangulation::repair::DelaunayTriangulationValidationError; +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; use std::time::Instant; type Dt4 = DelaunayTriangulation, (), (), 4>; diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 4006a017..51bfbcaf 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -4294,11 +4294,13 @@ where telemetry: &mut InsertionTelemetry, ) -> Result { let point = *vertex.point(); - let location = - locate_with_stats(&self.tds, &self.kernel, &point, hint).map(|(location, stats)| { + let location = match locate_with_stats(&self.tds, &self.kernel, &point, hint) { + Ok((location, stats)) => { Self::record_locate_telemetry(telemetry, location, &stats); - location - }); + Ok(location) + } + Err(error) => Err(error), + }; let Ok(LocateResult::InsideCell(start_cell)) = location else { return Err(Self::invariant_error_to_insertion_error(validation_err)); @@ -5539,7 +5541,7 @@ where // was required anyway. Cadenced and final Delaunay repair own // any local empty-circumsphere cleanup after the hull mutation. #[cfg(debug_assertions)] - if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { + if env::var_os("DELAUNAY_DEBUG_HULL").is_some() { tracing::debug!( "Outside insertion: skipping global conflict-region scan; using hull extension" ); diff --git a/src/lib.rs b/src/lib.rs index 550155a3..70793e8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1020,7 +1020,8 @@ pub mod prelude { DelaunayTriangulationBuilder, ExplicitConstructionError, }; pub use crate::triangulation::delaunay::{ - ConstructionOptions, ConstructionStatistics, ConstructionTelemetry, DedupPolicy, + ConstructionOptions, ConstructionSkipSample, ConstructionSlowInsertionSample, + ConstructionStatistics, ConstructionTelemetry, DedupPolicy, DelaunayConstructionFailure, DelaunayConstructionRepairPhase, DelaunayRepairPolicy, DelaunayTriangulation, DelaunayTriangulationConstructionError, DelaunayTriangulationConstructionErrorWithStatistics, InitialSimplexStrategy, diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 3d0ef082..9dfb9d2d 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -172,6 +172,20 @@ enum BatchLocalRepairTrigger { SeedBacklog, } +/// Default local-repair cadence for batch construction. +/// +/// Direct incremental insertion keeps [`DelaunayRepairPolicy::default`] at +/// [`DelaunayRepairPolicy::EveryInsertion`]. Batch construction instead uses +/// `EveryN(2)`, the best current cadence from the 500/3000-point #341 proxy +/// sweeps, while final repair and validation still enforce Delaunay correctness. +const fn default_batch_repair_policy() -> DelaunayRepairPolicy { + if let Some(every) = NonZeroUsize::new(2) { + DelaunayRepairPolicy::EveryN(every) + } else { + DelaunayRepairPolicy::EveryInsertion + } +} + /// Decides whether batch construction should run local Delaunay repair now. fn batch_local_repair_trigger( policy: DelaunayRepairPolicy, @@ -351,12 +365,16 @@ impl From for DelaunayTriangulationConstructionE } /// Construction phase that invoked flip-based Delaunay repair. +/// +/// Batch construction can run local repair at the configured cadence or earlier +/// when the pending seed frontier grows too large. Both cases are reported as +/// [`Self::BatchLocal`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] pub enum DelaunayConstructionRepairPhase { - /// Cadenced local repair after a successful bulk insertion. + /// Local repair during the bulk insertion loop. BatchLocal { - /// Zero-based input index that triggered the repair cadence. + /// Zero-based input index whose insertion triggered the repair. index: usize, }, /// Seeded or fallback repair during construction finalization. @@ -592,7 +610,7 @@ impl From for DelaunayConstructionFailure { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::DelaunayRepairOperation; +/// use delaunay::prelude::triangulation::repair::DelaunayRepairOperation; /// /// assert_eq!(DelaunayRepairOperation::VertexRemoval.to_string(), "vertex removal"); /// ``` @@ -630,7 +648,7 @@ impl fmt::Display for DelaunayRepairOperation { /// /// ```rust /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; -/// use delaunay::prelude::triangulation::repair::DelaunayTriangulationValidationError; +/// use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -943,7 +961,7 @@ impl Default for ConstructionOptions { dedup_policy: DedupPolicy::default(), initial_simplex: InitialSimplexStrategy::default(), retry_policy: RetryPolicy::default(), - batch_repair_policy: DelaunayRepairPolicy::default(), + batch_repair_policy: default_batch_repair_policy(), use_global_repair_fallback: true, } } @@ -978,6 +996,20 @@ impl ConstructionOptions { /// [`DelaunayRepairPolicy::EveryN`] controls the scheduled cadence. Batch /// construction may also run an earlier local repair when the accumulated /// seed frontier grows large; [`DelaunayRepairPolicy::Never`] disables both. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DelaunayRepairPolicy, + /// }; + /// use std::num::NonZeroUsize; + /// + /// assert_eq!( + /// ConstructionOptions::default().batch_repair_policy(), + /// DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap()), + /// ); + /// ``` #[must_use] pub const fn batch_repair_policy(&self) -> DelaunayRepairPolicy { self.batch_repair_policy @@ -1018,6 +1050,24 @@ impl ConstructionOptions { /// [`DelaunayRepairPolicy::EveryN`] controls the scheduled cadence. Batch /// construction may also run an earlier local repair when the accumulated /// seed frontier grows large; [`DelaunayRepairPolicy::Never`] disables both. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DelaunayRepairPolicy, + /// }; + /// use std::num::NonZeroUsize; + /// + /// let repair_every = NonZeroUsize::new(2).expect("literal 2 is nonzero"); + /// let options = ConstructionOptions::default() + /// .with_batch_repair_policy(DelaunayRepairPolicy::EveryN(repair_every)); + /// + /// assert_eq!( + /// options.batch_repair_policy(), + /// DelaunayRepairPolicy::EveryN(repair_every), + /// ); + /// ``` #[must_use] pub const fn with_batch_repair_policy( mut self, @@ -1041,9 +1091,10 @@ impl ConstructionOptions { /// Aggregate release-visible telemetry collected during batch construction. /// -/// These counters summarize the insertion path at a coarse level so large-scale debug runs can -/// distinguish point-location cost, scan fallback cost, local conflict-region size, and global -/// exterior conflict scans without enabling per-insertion tracing. +/// These counters summarize batch construction at a coarse level so large-scale +/// debug runs can separate construction phases, per-insertion primitive costs, +/// batch-local repair work, and global exterior conflict scans without enabling +/// per-insertion tracing. #[derive(Debug, Default, Clone)] #[non_exhaustive] pub struct ConstructionTelemetry { @@ -1056,7 +1107,7 @@ pub struct ConstructionTelemetry { /// Wall-clock nanoseconds spent preprocessing vertices before topology construction. pub construction_preprocessing_nanos: u64, - /// Wall-clock nanoseconds spent in the bulk insertion loop, including cadenced local repair. + /// Wall-clock nanoseconds spent in the bulk insertion loop, including batch local repair. pub construction_insert_loop_nanos: u64, /// Wall-clock nanoseconds spent finalizing bulk construction after the insertion loop. pub construction_finalize_nanos: u64, @@ -1157,7 +1208,19 @@ pub struct ConstructionTelemetry { } impl ConstructionTelemetry { - /// Returns true when any insertion-path telemetry was recorded. + /// Returns true when any construction telemetry was recorded. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::ConstructionTelemetry; + /// + /// let mut telemetry = ConstructionTelemetry::default(); + /// assert!(!telemetry.has_data()); + /// + /// telemetry.construction_insert_loop_nanos = 1; + /// assert!(telemetry.has_data()); + /// ``` #[must_use] pub const fn has_data(&self) -> bool { self.insertion_wall_time_calls > 0 @@ -1567,7 +1630,7 @@ pub struct ConstructionStatistics { /// Maximum number of cells removed during repair for any single insertion. pub cells_removed_max: usize, - /// Aggregate insertion-path telemetry. + /// Aggregate batch-construction telemetry. pub telemetry: ConstructionTelemetry, /// Slowest transactional insertions observed during batch construction. @@ -1709,19 +1772,48 @@ impl ConstructionStatistics { } /// Record a slow insertion sample, preserving the top samples by elapsed time. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionStatistics, DelaunayTriangulation, vertex, + /// }; + /// + /// let vertices = vec![ + /// vertex!([0.0, 0.0]), + /// vertex!([1.0, 0.0]), + /// vertex!([0.0, 1.0]), + /// vertex!([0.25, 0.25]), + /// ]; + /// let (_, stats) = + /// DelaunayTriangulation::<_, (), (), 2>::new_with_construction_statistics(&vertices) + /// .unwrap(); + /// let sample = stats + /// .slow_insertions + /// .first() + /// .cloned() + /// .expect("one non-simplex vertex produces a slow-insertion sample"); + /// + /// let mut summary = ConstructionStatistics::default(); + /// summary.record_slow_insertion_sample(sample.clone()); + /// + /// assert_eq!(summary.slow_insertions.len(), 1); + /// assert_eq!(summary.slow_insertions[0].index, sample.index); + /// ``` pub fn record_slow_insertion_sample(&mut self, sample: ConstructionSlowInsertionSample) { let insert_at = self .slow_insertions .iter() .position(|existing| sample.elapsed_nanos > existing.elapsed_nanos) .unwrap_or(self.slow_insertions.len()); - if insert_at < Self::MAX_SLOW_INSERTION_SAMPLES { - self.slow_insertions.insert(insert_at, sample); - self.slow_insertions - .truncate(Self::MAX_SLOW_INSERTION_SAMPLES); - } else if self.slow_insertions.len() < Self::MAX_SLOW_INSERTION_SAMPLES { - self.slow_insertions.push(sample); + if insert_at >= Self::MAX_SLOW_INSERTION_SAMPLES { + return; } + + self.slow_insertions.insert(insert_at, sample); + self.slow_insertions + .truncate(Self::MAX_SLOW_INSERTION_SAMPLES); } /// Merges attempt-level statistics from another construction pass. @@ -1783,11 +1875,7 @@ struct PreprocessVertices { grid_cell_size: Option, } -impl PreprocessVertices -where - T: CoordinateScalar, - U: DataType, -{ +impl PreprocessVertices { /// Borrows the preprocessed vertex order when one exists, avoiding a clone /// for policies that leave the input unchanged. fn primary_slice<'a>(&'a self, input: &'a [Vertex]) -> &'a [Vertex] { @@ -1802,7 +1890,10 @@ where /// Carries the dedup grid size forward so incremental insertion can reuse a /// compatible spatial index. - const fn grid_cell_size(&self) -> Option { + const fn grid_cell_size(&self) -> Option + where + T: Copy, + { self.grid_cell_size } } @@ -1814,7 +1905,6 @@ type PreprocessVerticesResult = fn vertex_coordinate_hash(vertex: &Vertex) -> u64 where T: CoordinateScalar, - U: DataType, { let mut hasher = FastHasher::default(); vertex.hash(&mut hasher); @@ -1828,7 +1918,6 @@ fn order_vertices_lexicographic( ) -> Vec> where T: CoordinateScalar, - U: DataType, { let mut keyed: Vec<(Vertex, u64, usize)> = vertices .into_iter() @@ -1860,7 +1949,6 @@ fn order_vertices_by_strategy( ) -> Vec> where T: CoordinateScalar, - U: DataType, { match insertion_order { InsertionOrderStrategy::Input => vertices, @@ -1882,7 +1970,6 @@ fn hash_grid_usable_for_vertices( ) -> bool where T: CoordinateScalar, - U: DataType, { if !grid.is_usable() { return false; @@ -1899,7 +1986,6 @@ fn dedup_vertices_exact_sorted( ) -> Vec> where T: CoordinateScalar, - U: DataType, { let ordered = order_vertices_lexicographic(vertices); let mut unique: Vec> = Vec::with_capacity(ordered.len()); @@ -1926,7 +2012,6 @@ fn dedup_vertices_exact_hash_grid( ) -> Vec> where T: CoordinateScalar, - U: DataType, { if !hash_grid_usable_for_vertices(grid, &vertices) { return dedup_vertices_exact_sorted(vertices); @@ -1935,13 +2020,13 @@ where let mut unique: Vec> = Vec::with_capacity(vertices.len()); for v in vertices { - let coords = v.point().coords(); + let coords = *v.point().coords(); let mut duplicate = false; let mut candidate_count = 0usize; - let used_index = grid.for_each_candidate_vertex_key(coords, |idx| { + let used_index = grid.for_each_candidate_vertex_key(&coords, |idx| { candidate_count = candidate_count.saturating_add(1); let existing_coords = unique[idx].point().coords(); - if coords_equal_exact(coords, existing_coords) { + if coords_equal_exact(&coords, existing_coords) { duplicate = true; return false; } @@ -1953,7 +2038,7 @@ where if !duplicate { let idx = unique.len(); unique.push(v); - grid.insert_vertex(idx, coords); + grid.insert_vertex(idx, &coords); } } @@ -2021,7 +2106,6 @@ fn dedup_vertices_epsilon_n2( ) -> Vec> where T: CoordinateScalar, - U: DataType, { let mut unique: Vec> = Vec::with_capacity(vertices.len()); for v in vertices { @@ -2048,7 +2132,6 @@ fn dedup_vertices_epsilon_quantized( ) -> Vec> where T: CoordinateScalar, - U: DataType, { if D > BATCH_DEDUP_MAX_DIMENSION { return dedup_vertices_epsilon_n2(vertices, epsilon); @@ -2119,7 +2202,6 @@ fn dedup_vertices_epsilon_hash_grid( ) -> Vec> where T: CoordinateScalar, - U: DataType, { if !hash_grid_usable_for_vertices(grid, &vertices) { return dedup_vertices_epsilon_quantized(vertices, epsilon); @@ -2129,10 +2211,10 @@ where let epsilon_sq = epsilon * epsilon; for v in vertices { - let coords = v.point().coords(); + let coords = *v.point().coords(); let mut duplicate = false; let mut candidate_count = 0usize; - let used_index = grid.for_each_candidate_vertex_key(coords, |idx| { + let used_index = grid.for_each_candidate_vertex_key(&coords, |idx| { candidate_count = candidate_count.saturating_add(1); let existing_coords = unique[idx].point().coords(); let mut dist_sq = T::zero(); @@ -2152,7 +2234,7 @@ where if !duplicate { let idx = unique.len(); unique.push(v); - grid.insert_vertex(idx, coords); + grid.insert_vertex(idx, &coords); } } @@ -2166,7 +2248,6 @@ fn select_balanced_simplex_indices( ) -> Option> where T: CoordinateScalar, - U: DataType, { if vertices.len() < D + 1 { return None; @@ -2268,8 +2349,8 @@ fn reorder_vertices_for_simplex( simplex_indices: &[usize], ) -> Option>> where - T: CoordinateScalar, - U: DataType, + T: Copy, + U: Copy, { if simplex_indices.len() != D + 1 { return None; @@ -2489,7 +2570,6 @@ fn log_construction_retry_result( fn vertex_coords_f64(vertex: &Vertex) -> Option> where T: CoordinateScalar, - U: DataType, { vertex .point() @@ -2510,7 +2590,6 @@ fn order_vertices_hilbert( ) -> Vec> where T: CoordinateScalar, - U: DataType, { if vertices.is_empty() || D == 0 { return vertices; @@ -5144,10 +5223,9 @@ where self.invalidate_repair_caches(); let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); repair_delaunay_local_single_pass(tds, kernel, completion_seed_cells, max_flips) - .map(|_| ()) }; let repair_outcome = match repair_result { - Ok(()) => Ok(()), + Ok(_) => Ok(()), Err(error) => self.try_final_global_repair_after_seeded_failure(&error), }; tracing::debug!( @@ -8463,6 +8541,19 @@ mod tests { } } + #[test] + fn test_construction_options_default_uses_batch_repair_cadence() { + init_tracing(); + assert_eq!( + ConstructionOptions::default().batch_repair_policy(), + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap()) + ); + assert_eq!( + DelaunayRepairPolicy::default(), + DelaunayRepairPolicy::EveryInsertion + ); + } + #[test] fn test_construction_options_builder_roundtrip() { init_tracing(); diff --git a/src/triangulation/locality.rs b/src/triangulation/locality.rs index 985efd46..3bf87e5b 100644 --- a/src/triangulation/locality.rs +++ b/src/triangulation/locality.rs @@ -16,17 +16,17 @@ use crate::geometry::point::Point; /// Local conflict-seed collection result for exterior insertion repair. #[must_use] -pub struct LocalConflictSeedCells { +pub(crate) struct LocalConflictSeedCells { /// Live cells that should seed local Delaunay repair. - pub seed_cells: CellKeyBuffer, + pub(crate) seed_cells: CellKeyBuffer, /// Number of cells returned by the local conflict-region search before any fallback seed. - pub conflict_cells_found: usize, + pub(crate) conflict_cells_found: usize, } /// Adds live, deduplicated candidate cells to a pending local repair frontier. /// /// Returns the number of cells newly appended to `pending_seed_cells`. -pub fn accumulate_live_cell_seeds( +pub(crate) fn accumulate_live_cell_seeds( tds: &Tds, candidate_seed_cells: &[CellKey], pending_seed_cells: &mut Vec, @@ -49,7 +49,7 @@ where /// Adds live, deduplicated candidate cells to a compact repair seed buffer. /// /// Returns the number of cells newly appended to `seed_cells`. -pub fn append_live_unique_cell_seeds( +pub(crate) fn append_live_unique_cell_seeds( tds: &Tds, candidate_seed_cells: &[CellKey], seed_cells: &mut CellKeyBuffer, @@ -73,7 +73,7 @@ where } /// Retains only live, deduplicated cells in a pending local repair frontier. -pub fn retain_live_cell_seeds( +pub(crate) fn retain_live_cell_seeds( tds: &Tds, seed_cells: &mut Vec, seen: &mut FastHashSet, @@ -86,13 +86,13 @@ pub fn retain_live_cell_seeds( } /// Clears a local repair frontier and its deduplication set together. -pub fn clear_cell_seed_set(seed_cells: &mut Vec, seen: &mut FastHashSet) { +pub(crate) fn clear_cell_seed_set(seed_cells: &mut Vec, seen: &mut FastHashSet) { seed_cells.clear(); seen.clear(); } /// Retains conflict cells and records removed cells as local repair seeds. -pub fn retain_cells_and_record_removed( +pub(crate) fn retain_cells_and_record_removed( conflict_cells: &mut CellKeyBuffer, repair_seed_cells: &mut CellKeyBuffer, mut keep_cell: impl FnMut(CellKey) -> bool, @@ -107,7 +107,7 @@ pub fn retain_cells_and_record_removed( } /// Replaces conflict cells and records cells missing from the replacement. -pub fn replace_cells_and_record_removed( +pub(crate) fn replace_cells_and_record_removed( conflict_cells: &mut CellKeyBuffer, repair_seed_cells: &mut CellKeyBuffer, replacement: CellKeyBuffer, @@ -127,7 +127,7 @@ pub fn replace_cells_and_record_removed( /// BFS conflict search from it gives a bounded local frontier without scanning the /// entire triangulation. If no circumsphere conflict is found, the terminal cell /// itself is still a useful local seed. -pub fn collect_local_exterior_conflict_seed_cells( +pub(crate) fn collect_local_exterior_conflict_seed_cells( tds: &Tds, kernel: &K, point: &Point, diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 6dd330dc..578a106f 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -73,7 +73,6 @@ #![forbid(unsafe_code)] -use delaunay::core::operations::InsertionResult; use delaunay::geometry::kernel::RobustKernel; use delaunay::geometry::util::{ generate_random_points_in_ball_seeded, generate_random_points_seeded, safe_usize_to_scalar, @@ -84,7 +83,9 @@ use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, DelaunayTriangulationConstructionErrorWithStatistics, InitialSimplexStrategy, TopologyGuarantee, Vertex, vertex, }; -use delaunay::prelude::triangulation::insertion::{InsertionOutcome, InsertionStatistics}; +use delaunay::prelude::triangulation::insertion::{ + InsertionOutcome, InsertionResult, InsertionStatistics, +}; use delaunay::prelude::triangulation::repair::{ DelaunayCheckPolicy, DelaunayRepairHeuristicConfig, }; @@ -236,7 +237,7 @@ impl From for InsertionSummary { fn from(stats: ConstructionStatistics) -> Self { let slow_insertions = stats .slow_insertions - .iter() + .into_iter() .map(|sample| SlowInsertionSample { index: sample.index, uuid: sample.uuid, @@ -256,7 +257,7 @@ impl From for InsertionSummary { .collect(); let skip_samples: Vec> = stats .skip_samples - .iter() + .into_iter() .map(|s| { let coords = if s.coords_available { s.coords.as_slice().try_into().map_or_else( @@ -280,7 +281,7 @@ impl From for InsertionSummary { uuid: s.uuid, coords, attempts: s.attempts, - error: s.error.clone(), + error: s.error, } }) .collect(); diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 2455cf83..1c9d06c4 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -31,7 +31,8 @@ use delaunay::prelude::query::ConvexHull; #[cfg(feature = "diagnostics")] use delaunay::prelude::tds::Tds; use delaunay::prelude::triangulation::construction::{ - ConstructionOptions, DelaunayConstructionFailure, DelaunayRepairPolicy, DelaunayTriangulation, + ConstructionOptions, ConstructionSkipSample, ConstructionSlowInsertionSample, + DelaunayConstructionFailure, DelaunayRepairPolicy, DelaunayTriangulation, DelaunayTriangulationConstructionError, InsertionOrderStrategy, Vertex, }; use delaunay::prelude::triangulation::delaunayize::{ @@ -84,6 +85,10 @@ fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { ]; let options = ConstructionOptions::default().with_insertion_order(InsertionOrderStrategy::Input); + assert!(matches!( + options.batch_repair_policy(), + DelaunayRepairPolicy::EveryN(every) if every.get() == 2 + )); let dt = DelaunayTriangulation::new_with_options(&vertices, options)?; assert_eq!(dt.topology_guarantee(), TopologyGuarantee::PLManifold); @@ -110,6 +115,8 @@ fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { )); assert_send_sync_unpin::(); assert_send_sync_unpin::(); + assert_send_sync_unpin::(); + assert_send_sync_unpin::(); Ok(()) } From e5c3d5c36f9c62ccc5f34932878e3071471d6a2c Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 10 May 2026 17:23:14 -0700 Subject: [PATCH 08/15] fix(test): tolerate degenerate Delaunay retessellation (#341) - Compare quality metrics only for cells shared across independently transformed triangulations, rejecting cases with no comparable cells. - Narrow locality repair helper visibility where the current module graph allows it while preserving core insertion repair access. - Clarify that non-substantive PRs may be declined unless justified by cleanup or tooling needs. --- CONTRIBUTING.md | 9 +++++++++ src/triangulation/locality.rs | 20 ++++++++++---------- tests/proptest_triangulation.rs | 19 ++++++++++--------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21c626f3..3dd57c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1018,6 +1018,15 @@ PRs are evaluated on: - **Style**: Does the code follow project conventions? - **Mathematical Accuracy**: Are geometric algorithms correct? +### Non-Substantive Changes + +PRs that only introduce whitespace churn, blank-line changes, formatting noise, or other +non-substantive edits may be declined unless they are part of a clearly justified cleanup or +required by project tooling. + +Accepted contributions should materially improve correctness, numerical robustness, topology +invariants, performance, documentation clarity, tests, maintainability, or user-facing behavior. + ### Handling Feedback - **Respond to all comments**: Address each piece of feedback diff --git a/src/triangulation/locality.rs b/src/triangulation/locality.rs index 3bf87e5b..42209beb 100644 --- a/src/triangulation/locality.rs +++ b/src/triangulation/locality.rs @@ -16,17 +16,17 @@ use crate::geometry::point::Point; /// Local conflict-seed collection result for exterior insertion repair. #[must_use] -pub(crate) struct LocalConflictSeedCells { +pub struct LocalConflictSeedCells { /// Live cells that should seed local Delaunay repair. - pub(crate) seed_cells: CellKeyBuffer, + pub seed_cells: CellKeyBuffer, /// Number of cells returned by the local conflict-region search before any fallback seed. - pub(crate) conflict_cells_found: usize, + pub conflict_cells_found: usize, } /// Adds live, deduplicated candidate cells to a pending local repair frontier. /// /// Returns the number of cells newly appended to `pending_seed_cells`. -pub(crate) fn accumulate_live_cell_seeds( +pub(super) fn accumulate_live_cell_seeds( tds: &Tds, candidate_seed_cells: &[CellKey], pending_seed_cells: &mut Vec, @@ -49,7 +49,7 @@ where /// Adds live, deduplicated candidate cells to a compact repair seed buffer. /// /// Returns the number of cells newly appended to `seed_cells`. -pub(crate) fn append_live_unique_cell_seeds( +pub fn append_live_unique_cell_seeds( tds: &Tds, candidate_seed_cells: &[CellKey], seed_cells: &mut CellKeyBuffer, @@ -73,7 +73,7 @@ where } /// Retains only live, deduplicated cells in a pending local repair frontier. -pub(crate) fn retain_live_cell_seeds( +pub(super) fn retain_live_cell_seeds( tds: &Tds, seed_cells: &mut Vec, seen: &mut FastHashSet, @@ -86,13 +86,13 @@ pub(crate) fn retain_live_cell_seeds( } /// Clears a local repair frontier and its deduplication set together. -pub(crate) fn clear_cell_seed_set(seed_cells: &mut Vec, seen: &mut FastHashSet) { +pub(super) fn clear_cell_seed_set(seed_cells: &mut Vec, seen: &mut FastHashSet) { seed_cells.clear(); seen.clear(); } /// Retains conflict cells and records removed cells as local repair seeds. -pub(crate) fn retain_cells_and_record_removed( +pub fn retain_cells_and_record_removed( conflict_cells: &mut CellKeyBuffer, repair_seed_cells: &mut CellKeyBuffer, mut keep_cell: impl FnMut(CellKey) -> bool, @@ -107,7 +107,7 @@ pub(crate) fn retain_cells_and_record_removed( } /// Replaces conflict cells and records cells missing from the replacement. -pub(crate) fn replace_cells_and_record_removed( +pub fn replace_cells_and_record_removed( conflict_cells: &mut CellKeyBuffer, repair_seed_cells: &mut CellKeyBuffer, replacement: CellKeyBuffer, @@ -127,7 +127,7 @@ pub(crate) fn replace_cells_and_record_removed( /// BFS conflict search from it gives a bounded local frontier without scanning the /// entire triangulation. If no circumsphere conflict is found, the terminal cell /// itself is still a useful local seed. -pub(crate) fn collect_local_exterior_conflict_seed_cells( +pub fn collect_local_exterior_conflict_seed_cells( tds: &Tds, kernel: &K, point: &Point, diff --git a/tests/proptest_triangulation.rs b/tests/proptest_triangulation.rs index 16adc6b3..a1c1219c 100644 --- a/tests/proptest_triangulation.rs +++ b/tests/proptest_triangulation.rs @@ -72,8 +72,7 @@ fn finite_coordinate() -> impl Strategy { /// Returns `Ok(())` if metrics match within tolerance, `Err(TestCaseError)` otherwise. /// /// # Returns -/// * `Ok(true)` - At least one cell was successfully matched and compared -/// * `Ok(false)` - No cells could be matched (topology changed too much) +/// * `Ok(())` - At least one cell was successfully matched and compared /// * `Err(TestCaseError)` - A metric comparison failed /// /// # Purpose @@ -84,7 +83,11 @@ fn finite_coordinate() -> impl Strategy { /// 2. Map their vertex UUIDs to transformed triangulation /// 3. Find matching cell in transformed triangulation /// 4. Compare quality metrics between matched cells -/// 5. Assert every original cell has a transformed counterpart. +/// +/// Degenerate Delaunay inputs can have more than one valid cell set, so an +/// independently constructed transformed triangulation may choose a different +/// valid tessellation. Unmatched cells are skipped; cases with no comparable +/// cells are rejected rather than treated as metric failures. fn compare_transformed_cells( dt_orig: &DelaunayTriangulation, (), (), D>, dt_transformed: &DelaunayTriangulation, (), (), D>, @@ -98,6 +101,7 @@ where { let tds_orig = dt_orig.tds(); let tds_transformed = dt_transformed.tds(); + let mut matched_cells = 0usize; // Iterate through all cells in original triangulation for orig_key in tds_orig.cell_keys() { @@ -118,7 +122,6 @@ where "all original cell UUIDs should map to transformed UUIDs" ); - let mut found_match = false; for trans_key in tds_transformed.cell_keys() { prop_assert!( tds_transformed.cell(trans_key).is_some(), @@ -134,17 +137,15 @@ where { // Found matching cell - compare quality metrics compare_fn(orig_key, trans_key)?; - found_match = true; + matched_cells += 1; break; // Found the match, no need to check other cells } } } - prop_assert!( - found_match, - "no transformed cell matched original cell {orig_key:?}" - ); } + prop_assume!(matched_cells > 0); + Ok(()) } From a3ce13c42f715c5d8fecd66a3c4267cde6b682ee Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sun, 10 May 2026 20:27:56 -0700 Subject: [PATCH 09/15] fix(triangulation): distinguish insertion telemetry (#341) - Count only full insertion validation runs in topology validation telemetry while preserving required link checks. - Exclude caller-provided conflict buffers from discovered conflict-region telemetry. - Keep large-scale debug validation cadence tied to inserted vertices and tighten triangulation proptest acceptance. - Add explicit unsafe-code forbids to remaining bench, example, and test crates. --- benches/ci_performance_suite.rs | 2 ++ benches/topology_guarantee_construction.rs | 2 ++ examples/delaunayize_repair.rs | 2 ++ examples/numerical_robustness.rs | 2 ++ src/core/algorithms/locate.rs | 2 ++ src/core/triangulation.rs | 34 ++++++++++++---------- tests/check_perturbation_stats.rs | 2 ++ tests/delaunay_incremental_insertion.rs | 2 ++ tests/euler_characteristic.rs | 2 ++ tests/large_scale_debug.rs | 11 ++++--- tests/proptest_triangulation.rs | 5 +++- 11 files changed, 45 insertions(+), 21 deletions(-) diff --git a/benches/ci_performance_suite.rs b/benches/ci_performance_suite.rs index 4cdb4bb5..cbf34aa6 100644 --- a/benches/ci_performance_suite.rs +++ b/benches/ci_performance_suite.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! CI Performance Suite - optimized performance regression testing for CI/CD //! //! This benchmark is the small, durable performance contract for the delaunay diff --git a/benches/topology_guarantee_construction.rs b/benches/topology_guarantee_construction.rs index 4a74200a..3d3fa4a0 100644 --- a/benches/topology_guarantee_construction.rs +++ b/benches/topology_guarantee_construction.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Benchmark: construction cost vs topology guarantee (2D–5D) //! //! This benchmark compares `TopologyGuarantee::Pseudomanifold`, `TopologyGuarantee::PLManifold` diff --git a/examples/delaunayize_repair.rs b/examples/delaunayize_repair.rs index 08f1b9b0..bb0e02bf 100644 --- a/examples/delaunayize_repair.rs +++ b/examples/delaunayize_repair.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! # Delaunayize-by-Flips Repair Example //! //! This example demonstrates the **delaunayize-by-flips** workflow that diff --git a/examples/numerical_robustness.rs b/examples/numerical_robustness.rs index beb09c9a..6c74fb94 100644 --- a/examples/numerical_robustness.rs +++ b/examples/numerical_robustness.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! # Numerical Robustness Example //! //! This example accompanies `docs/numerical_robustness_guide.md`. diff --git a/src/core/algorithms/locate.rs b/src/core/algorithms/locate.rs index 8bc5bc27..12c1bc81 100644 --- a/src/core/algorithms/locate.rs +++ b/src/core/algorithms/locate.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Point location algorithms for triangulations. //! //! Implements facet-walking point location for finding the cell containing diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 51bfbcaf..55e65683 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -4149,10 +4149,6 @@ where } } - fn validation_after_insertion_will_run(&self, suspicion: SuspicionFlags) -> bool { - self.validation_after_insertion_work(suspicion).is_some() - } - fn validate_after_insertion(&self, suspicion: SuspicionFlags) -> Result<(), InvariantError> { let Some(work) = self.validation_after_insertion_work(suspicion) else { return Ok(()); @@ -4254,11 +4250,12 @@ where return Ok(insert_ok); } - let validation_started = self - .validation_after_insertion_will_run(insert_ok.suspicion) - .then(Instant::now); + let validation_work = self.validation_after_insertion_work(insert_ok.suspicion); + let validation_started = validation_work.map(|_| Instant::now()); let validation_result = self.validate_after_insertion(insert_ok.suspicion); - if let Some(validation_started) = validation_started { + if let (Some(InsertionValidationWork::FullValidation), Some(validation_started)) = + (validation_work, validation_started) + { Self::record_topology_validation_telemetry( telemetry, Self::duration_nanos_saturating(validation_started.elapsed()), @@ -4316,11 +4313,12 @@ where fallback_ok.suspicion.perturbation_used = true; } - let validation_started = self - .validation_after_insertion_will_run(fallback_ok.suspicion) - .then(Instant::now); + let validation_work = self.validation_after_insertion_work(fallback_ok.suspicion); + let validation_started = validation_work.map(|_| Instant::now()); let validation_result = self.validate_after_insertion(fallback_ok.suspicion); - if let Some(validation_started) = validation_started { + if let (Some(InsertionValidationWork::FullValidation), Some(validation_started)) = + (validation_work, validation_started) + { Self::record_topology_validation_telemetry( telemetry, Self::duration_nanos_saturating(validation_started.elapsed()), @@ -5517,7 +5515,6 @@ where } } (LocateResult::InsideCell(start_cell), Some(cells)) => { - Self::record_conflict_region_telemetry(telemetry, cells.len()); // If the caller provided an empty conflict region (can happen if the Delaunay layer // computes conflicts using a strict in-sphere test), we must still replace at least // one cell; otherwise we'd create no cavity, no new cells, and leave a dangling @@ -5576,7 +5573,6 @@ where repair_seed_cells, } } else { - Self::record_conflict_region_telemetry(telemetry, cells.len()); #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { tracing::debug!( @@ -7330,10 +7326,16 @@ mod tests { tri.set_validation_policy(ValidationPolicy::OnSuspicion); tri.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); - assert!(!tri.validation_after_insertion_will_run(SuspicionFlags::default())); + assert_eq!( + tri.validation_after_insertion_work(SuspicionFlags::default()), + None + ); tri.set_topology_guarantee(TopologyGuarantee::PLManifold); - assert!(tri.validation_after_insertion_will_run(SuspicionFlags::default())); + assert_eq!( + tri.validation_after_insertion_work(SuspicionFlags::default()), + Some(InsertionValidationWork::RequiredTopologyLinks) + ); } #[test] diff --git a/tests/check_perturbation_stats.rs b/tests/check_perturbation_stats.rs index b3cbf552..07566b7a 100644 --- a/tests/check_perturbation_stats.rs +++ b/tests/check_perturbation_stats.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Regression: in `TopologyGuarantee::PLManifold` mode, incremental insertion must //! never commit a triangulation with invalid vertex links, independent of //! `ValidationPolicy`. diff --git a/tests/delaunay_incremental_insertion.rs b/tests/delaunay_incremental_insertion.rs index 0ac4ae6b..bb6b0608 100644 --- a/tests/delaunay_incremental_insertion.rs +++ b/tests/delaunay_incremental_insertion.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Integration tests for `DelaunayTriangulation` incremental insertion. //! //! These tests focus on the incremental insertion workflow and features diff --git a/tests/euler_characteristic.rs b/tests/euler_characteristic.rs index a7b189a3..e926c758 100644 --- a/tests/euler_characteristic.rs +++ b/tests/euler_characteristic.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Deterministic integration tests for Euler characteristic computation. //! //! This module tests the topology module's Euler characteristic calculation diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 578a106f..762f715b 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -1157,9 +1157,10 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz let coords = *vertex.point().coords(); let uuid = vertex.uuid(); - match dt.insert_with_statistics(vertex) { + let inserted_this_loop = match dt.insert_with_statistics(vertex) { Ok((InsertionOutcome::Inserted { .. }, stats)) => { summary.record_inserted(stats); + true } Ok((InsertionOutcome::Skipped { error }, stats)) => { let sample = SkipSample { @@ -1170,6 +1171,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz error: error.to_string(), }; summary.record_skipped(sample, stats); + false } Err(err) => { println!( @@ -1188,15 +1190,16 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz print_abort_summary::(&outcome, seed, n_points, "incremental insertion"); return outcome; } - } + }; if !had_cells && dt.number_of_cells() > 0 { had_cells = true; println!("Initial simplex created at insertion {}", idx + 1); } - if had_cells - && validation_cadence.should_validate(idx + 1) + if inserted_this_loop + && had_cells + && validation_cadence.should_validate(summary.inserted) && let Err(e) = dt.as_triangulation().is_valid() { println!("Topology validation failed at idx={idx}: {e}"); diff --git a/tests/proptest_triangulation.rs b/tests/proptest_triangulation.rs index a1c1219c..f739671a 100644 --- a/tests/proptest_triangulation.rs +++ b/tests/proptest_triangulation.rs @@ -101,10 +101,12 @@ where { let tds_orig = dt_orig.tds(); let tds_transformed = dt_transformed.tds(); + let mut cells_considered = 0usize; let mut matched_cells = 0usize; // Iterate through all cells in original triangulation for orig_key in tds_orig.cell_keys() { + cells_considered += 1; prop_assert!( tds_orig.cell(orig_key).is_some(), "original cell key from iterator should exist: {orig_key:?}" @@ -144,7 +146,8 @@ where } } - prop_assume!(matched_cells > 0); + prop_assume!(cells_considered > 1); + prop_assume!(matched_cells >= 2); Ok(()) } From 288e1ace657d412db65be43541e22f73c7cf4555 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 11 May 2026 00:22:47 -0700 Subject: [PATCH 10/15] perf(triangulation)!: tune batch construction repair (#341) - Default batch construction to the real-vertex max-volume initial simplex and every-insertion Delaunay repair cadence. - Add focused construction diagnostics telemetry for repair phase timing, queued work, seed frontiers, and construction phase costs. - Keep local repair postcondition replay tied to observed repair work so skipped flip candidates cannot suppress required validation. - Update large-scale debug docs and recipes to reflect the new defaults. BREAKING CHANGE: ConstructionTelemetry moved to the triangulation diagnostics module and focused diagnostics prelude. Batch construction now defaults to InitialSimplexStrategy::MaxVolume and DelaunayRepairPolicy::EveryInsertion, so callers relying on the previous construction telemetry import path or EveryN(2) batch cadence must opt into the old behavior explicitly. --- README.md | 1 + docs/dev/debug_env_vars.md | 4 +- docs/invariants.md | 8 +- docs/workflows.md | 4 +- justfile | 4 +- src/core/algorithms/flips.rs | 806 ++++++++++++++---- src/core/cell.rs | 51 +- src/core/facet.rs | 44 +- src/core/triangulation.rs | 89 +- src/lib.rs | 28 +- src/triangulation.rs | 4 +- src/triangulation/delaunay.rs | 1347 +++++++++++++----------------- src/triangulation/diagnostics.rs | 957 +++++++++++++++++++++ tests/large_scale_debug.rs | 142 +++- tests/prelude_exports.rs | 5 +- 15 files changed, 2519 insertions(+), 975 deletions(-) create mode 100644 src/triangulation/diagnostics.rs diff --git a/README.md b/README.md index 216cb5a4..fa74e9ed 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Choose the smallest prelude that matches the task: | Bistellar flips / Edit API | `use delaunay::prelude::triangulation::flips::*` | | Delaunay repair diagnostics and policies | `use delaunay::prelude::triangulation::repair::*` | | Delaunayize workflow | `use delaunay::prelude::triangulation::delaunayize::*` | +| Construction telemetry diagnostics | `use delaunay::prelude::triangulation::diagnostics::*` | | Construction validation cadence/policy | `use delaunay::prelude::triangulation::validation::*` | | Hilbert ordering and quantization utilities | `use delaunay::prelude::ordering::*` | | Low-level TDS cells, facets, keys, and validation reports | `use delaunay::prelude::tds::*` | diff --git a/docs/dev/debug_env_vars.md b/docs/dev/debug_env_vars.md index fa7f7023..b6c1ad1e 100644 --- a/docs/dev/debug_env_vars.md +++ b/docs/dev/debug_env_vars.md @@ -129,12 +129,12 @@ and release builds. | `DELAUNAY_LARGE_DEBUG_BALL_RADIUS` | **value** | Radius for ball distribution | | `DELAUNAY_LARGE_DEBUG_BOX_HALF_WIDTH` | **value** | Half-width for box distribution | | `DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE` | **value** | `new` (batch) or `incremental` | -| `DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX` | **value** | Batch initial simplex strategy: `first` (default) or `balanced` | +| `DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX` | **value** | Batch initial simplex strategy: `max-volume` (default), `balanced`, or `first` | | `DELAUNAY_LARGE_DEBUG_DEBUG_MODE` | **value** | `cadenced` (ridge-link) or `strict` (per-insertion vertex-link) | | `DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED` | **value** | Vertex shuffle seed | | `DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY` | **value** | Progress logging interval | | `DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY` | **value** | Validation interval | -| `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY` | **value** | Batch/incremental repair interval (default: 2) | +| `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) | | `DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT` | **value** | Maximum skipped-vertex percentage before failing (default: 5.0) | diff --git a/docs/invariants.md b/docs/invariants.md index 5c87d346..07869060 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -245,9 +245,11 @@ identifying *visible* boundary facets and retriangulating the visible region. ### Degenerate input and initial simplex construction -Construction begins by creating an initial simplex from the first `D+1` affinely independent -vertices. If no non-degenerate simplex can be formed (e.g., collinear points in 2D, coplanar in 3D), -construction fails with a geometric degeneracy error. +Construction begins by creating an initial simplex from `D+1` affinely independent real input +vertices. The default batch constructor searches a bounded pool of extreme vertices for a +large-volume simplex before falling back to the selected insertion order. If no non-degenerate +simplex can be formed (e.g., collinear points in 2D, coplanar in 3D), construction fails with a +geometric degeneracy error. This early degeneracy detection is intentional: it prevents building a combinatorial structure whose geometric interpretation is undefined. diff --git a/docs/workflows.md b/docs/workflows.md index 8e8d769b..5869ee37 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -69,8 +69,8 @@ levels. The Builder API is designed to construct Delaunay triangulations, and (by default) schedules local flip-based repair passes during construction. Batch construction uses `ConstructionOptions`, whose -default repair cadence is `DelaunayRepairPolicy::EveryN(2)` plus final repair/validation. That -cadence reflects the current #341 proxy sweeps at 500 and 3000 vertices; 10000-vertex runs remain +default repair cadence is `DelaunayRepairPolicy::EveryInsertion` plus final repair/validation. That +cadence reflects the current #341 proxy sweeps at 1000 and 3000 vertices; 10000-vertex runs remain the scalability acceptance check. Direct incremental insertion keeps the lower-level `DelaunayRepairPolicy` default at `EveryInsertion`. The explicit repair methods (`repair_delaunay_with_flips`, `repair_delaunay_with_flips_advanced`, diff --git a/justfile b/justfile index d024fffa..2e3c7e8d 100644 --- a/justfile +++ b/justfile @@ -233,7 +233,7 @@ coverage-ci: _ensure-cargo-llvm-cov mkdir -p coverage cargo llvm-cov {{_coverage_base_args}} --cobertura --output-path coverage/cobertura.xml -- --skip prop_ -debug-large-scale-3d n="10000" repair_every="2": +debug-large-scale-3d n="10000" repair_every="1": DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 DELAUNAY_LARGE_DEBUG_N_3D={{n}} DELAUNAY_LARGE_DEBUG_REPAIR_EVERY={{repair_every}} cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture debug-large-scale-4d n="3000": @@ -281,7 +281,7 @@ help-workflows: @echo "Active large-scale debugging:" @echo " just test-diagnostics # Run diagnostics tools with output" @echo " just debug-large-scale-4d [n] # Issue #340: 4D large-scale runtime (default n=3000)" - @echo " just debug-large-scale-3d [n] [repair_every] # Issue #341: 3D scalability (defaults n=10000, repair_every=2)" + @echo " just debug-large-scale-3d [n] [repair_every] # Issue #341: 3D scalability (defaults n=10000, repair_every=1)" @echo " just debug-large-scale-5d [n] # Issue #342: 5D feasibility (default n=1000)" @echo "" @echo "Benchmark workflows:" diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 09f366c4..4b92f477 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -60,6 +60,7 @@ use std::collections::VecDeque; use std::env; use std::fmt; use std::hash::{Hash, Hasher}; +use std::time::{Duration, Instant}; use thiserror::Error; type VertexKeyList = SmallBuffer; @@ -87,15 +88,31 @@ pub struct BistellarFlipKind { pub d: usize, } /// Run a single flip-repair attempt using k=2 (and k=3 in 3D+). +fn repair_delaunay_with_flips_k2_k3_attempt( + tds: &mut Tds, + kernel: &K, + seed_cells: Option<&[CellKey]>, + config: &RepairAttemptConfig, +) -> Result +where + K: Kernel, + U: DataType, + V: DataType, +{ + repair_delaunay_with_flips_k2_k3_attempt_timed(tds, kernel, seed_cells, config, None) +} + +/// Run a single flip-repair attempt while reporting queue-family timings. #[expect( clippy::too_many_lines, reason = "Repair loop contains inline tracing and queue handling for diagnostics" )] -fn repair_delaunay_with_flips_k2_k3_attempt( +fn repair_delaunay_with_flips_k2_k3_attempt_timed( tds: &mut Tds, kernel: &K, seed_cells: Option<&[CellKey]>, config: &RepairAttemptConfig, + mut timing: Option<&mut LocalRepairPhaseTiming>, ) -> Result where K: Kernel, @@ -117,26 +134,90 @@ where let mut diagnostics = RepairDiagnostics::default(); let mut queues = RepairQueues::new(); let mut last_applied_flip: Option = None; + let seed_started = timing.is_some().then(Instant::now); let used_full_reseed = seed_repair_queues(tds, seed_cells, &mut queues, &mut stats)?; + if let (Some(timing), Some(seed_started)) = (timing.as_deref_mut(), seed_started) { + timing.record_attempt_seed(seed_started.elapsed()); + } let mut touched_cells = CellKeyBuffer::new(); let mut touched_cell_set = FastHashSet::::default(); let mut prefer_secondary = false; + macro_rules! timed_step { + ($recorder:ident, $step:expr) => {{ + if timing.is_some() { + let started = Instant::now(); + let processed = $step?; + if let Some(timing) = timing.as_deref_mut() { + timing.$recorder(started.elapsed()); + } + processed + } else { + $step? + } + }}; + } + while queues.has_work() { - if prefer_secondary - && (process_ridge_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? || process_edge_queue_step( + if prefer_secondary { + let processed_ridge = timed_step!( + record_attempt_ridge, + process_ridge_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + let processed_edge = !processed_ridge + && timed_step!( + record_attempt_edge, + process_edge_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + let processed_triangle = !processed_ridge + && !processed_edge + && timed_step!( + record_attempt_triangle, + process_triangle_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + if processed_ridge || processed_edge || processed_triangle { + prefer_secondary = false; + continue; + } + } + + if timed_step!( + record_attempt_facet, + process_facet_queue_step( tds, kernel, &mut queues, @@ -147,7 +228,15 @@ where &mut last_applied_flip, &mut touched_cells, &mut touched_cell_set, - )? || process_triangle_queue_step( + ) + ) { + prefer_secondary = true; + continue; + } + + let processed_ridge = timed_step!( + record_attempt_ridge, + process_ridge_queue_step( tds, kernel, &mut queues, @@ -158,62 +247,42 @@ where &mut last_applied_flip, &mut touched_cells, &mut touched_cell_set, - )?) - { - prefer_secondary = false; - continue; - } - - if process_facet_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? { - prefer_secondary = true; - continue; - } - - if process_ridge_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? || process_edge_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? || process_triangle_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? { + ) + ); + let processed_edge = !processed_ridge + && timed_step!( + record_attempt_edge, + process_edge_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + let processed_triangle = !processed_ridge + && !processed_edge + && timed_step!( + record_attempt_triangle, + process_triangle_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + if processed_ridge || processed_edge || processed_triangle { prefer_secondary = false; } } @@ -232,6 +301,7 @@ where emit_repair_debug_summary("attempt_done", &stats, &diagnostics, config, max_flips); Ok(RepairAttemptOutcome { + postcondition_required: repair_postcondition_required(&stats, &diagnostics), stats, last_applied_flip, touched_cells, @@ -248,7 +318,6 @@ fn snapshot_removed_cell_vertices( removed_cells: &CellKeyBuffer, ) -> Result where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -508,7 +577,6 @@ fn find_cell_containing_simplex( removed_cells: &[CellKey], ) -> Option where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -906,7 +974,7 @@ fn debug_ridge_context( } let ridge_vertices = ridge_vertices_from_cell(cell, omit_a, omit_b); - let neighbor_walk = collect_cells_around_ridge(tds, ridge.cell_key(), &ridge_vertices) + let neighbor_walk = collect_cells_around_ridge(tds, ridge.cell_key(), &ridge_vertices, None) .map(|cells| cells.into_iter().collect::>()); let global_cells = cells_containing_vertices(tds, &ridge_vertices); let neighbor_snapshot: Option, MAX_PRACTICAL_DIMENSION_SIZE>> = @@ -2930,6 +2998,108 @@ pub struct DelaunayRepairStats { pub max_queue_len: usize, } +/// Wall-clock phase timing for one batch-local repair pass. +#[expect( + clippy::struct_field_names, + reason = "phase timing telemetry keeps units explicit on every exported field" +)] +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct LocalRepairPhaseTiming { + /// Nanoseconds spent cloning the TDS snapshot used for rollback. + pub(crate) snapshot_nanos: u64, + /// Nanoseconds spent applying flip-repair attempts. + pub(crate) attempt_nanos: u64, + /// Nanoseconds spent seeding repair attempt queues. + pub(crate) attempt_seed_nanos: u64, + /// Nanoseconds spent processing k=2 facet queue items. + pub(crate) attempt_facet_nanos: u64, + /// Nanoseconds spent processing k=3 ridge queue items. + pub(crate) attempt_ridge_nanos: u64, + /// Nanoseconds spent processing inverse k=2 edge queue items. + pub(crate) attempt_edge_nanos: u64, + /// Nanoseconds spent processing inverse k=3 triangle queue items. + pub(crate) attempt_triangle_nanos: u64, + /// Nanoseconds spent replaying postcondition predicates. + pub(crate) postcondition_nanos: u64, + /// Nanoseconds spent restoring the TDS from a saved snapshot. + pub(crate) restore_nanos: u64, +} + +impl LocalRepairPhaseTiming { + /// Adds rollback snapshot-clone time so setup cost stays separate from repair work. + fn record_snapshot(&mut self, elapsed: Duration) { + self.snapshot_nanos = self + .snapshot_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds total flip-attempt time across queue seeding and queue processing. + fn record_attempt(&mut self, elapsed: Duration) { + self.attempt_nanos = self + .attempt_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent building the queue for one repair attempt. + fn record_attempt_seed(&mut self, elapsed: Duration) { + self.attempt_seed_nanos = self + .attempt_seed_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent processing k=2 facet queue items. + fn record_attempt_facet(&mut self, elapsed: Duration) { + self.attempt_facet_nanos = self + .attempt_facet_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent processing k=3 ridge queue items. + fn record_attempt_ridge(&mut self, elapsed: Duration) { + self.attempt_ridge_nanos = self + .attempt_ridge_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent processing inverse k=2 edge queue items. + fn record_attempt_edge(&mut self, elapsed: Duration) { + self.attempt_edge_nanos = self + .attempt_edge_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent processing inverse k=3 triangle queue items. + fn record_attempt_triangle(&mut self, elapsed: Duration) { + self.attempt_triangle_nanos = self + .attempt_triangle_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent replaying local Delaunay postconditions after repair attempts. + fn record_postcondition(&mut self, elapsed: Duration) { + self.postcondition_nanos = self + .postcondition_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent restoring the saved TDS after a failed repair attempt. + fn record_restore(&mut self, elapsed: Duration) { + self.restore_nanos = self + .restore_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } +} + +/// Publishes one local repair pass's phase timing when the caller requested telemetry. +fn publish_local_repair_phase_timing( + timing: &mut Option<&mut LocalRepairPhaseTiming>, + phase_timing: LocalRepairPhaseTiming, +) { + if let Some(timing) = timing.as_deref_mut() { + *timing = phase_timing; + } +} + /// Crate-private repair result with the validation frontier for callers that /// need post-repair topology checks without scanning the whole TDS. #[derive(Debug, Clone)] @@ -2951,12 +3121,21 @@ pub(crate) struct DelaunayRepairRun { /// the last repair move that modified the TDS. #[derive(Debug)] struct RepairAttemptOutcome { + postcondition_required: bool, stats: DelaunayRepairStats, last_applied_flip: Option, touched_cells: CellKeyBuffer, used_full_reseed: bool, } +/// Determines whether repair changed or observed enough local state to require postcondition replay. +const fn repair_postcondition_required( + stats: &DelaunayRepairStats, + diagnostics: &RepairDiagnostics, +) -> bool { + stats.flips_performed > 0 || diagnostics.saw_applicable_repair_site +} + /// Adds newly-created cells to the repair mutation frontier without duplicates. fn record_touched_cells( touched_cells: &mut CellKeyBuffer, @@ -2970,6 +3149,22 @@ fn record_touched_cells( } } +/// Builds the local postcondition frontier from the caller's seed cells plus +/// cells created by successful flips. +fn local_postcondition_frontier( + seed_cells: &[CellKey], + touched_cells: &[CellKey], +) -> CellKeyBuffer { + let mut frontier = CellKeyBuffer::new(); + let mut seen = FastHashSet::::default(); + for &cell_key in seed_cells.iter().chain(touched_cells) { + if seen.insert(cell_key) { + frontier.push(cell_key); + } + } + frontier +} + /// Converts an attempt outcome into the crate-private repair run result. fn repair_run_from_attempt( outcome: RepairAttemptOutcome, @@ -3511,6 +3706,10 @@ where clippy::too_many_arguments, reason = "local predicate evaluation threads topology, source cells, and diagnostics explicitly" )] +#[expect( + clippy::too_many_lines, + reason = "local predicate evaluation keeps frame alignment, diagnostics, and exact predicate calls together" +)] /// Evaluate the k=2 facet flip predicate for a local Delaunay violation. fn delaunay_violation_k2_for_facet( tds: &Tds, @@ -3557,27 +3756,51 @@ where cell_vertices[0].sort_unstable_by_key(|v| v.data().as_ffi()); cell_vertices[1].sort_unstable_by_key(|v| v.data().as_ffi()); - let source_a = matching_source_cell(tds, &cell_vertices[0], source_cells).or(frame_cell); - let source_b = matching_source_cell(tds, &cell_vertices[1], source_cells).or(frame_cell); - let points_a = vertices_to_points_with_optional_lift( - tds, - topology_model, - &cell_vertices[0], - source_a, - source_cells, - )?; - let points_b = vertices_to_points_with_optional_lift( - tds, - topology_model, - &cell_vertices[1], - source_b, - source_cells, - )?; - - let opposite_point_a = - vertex_point_lifted_into_cell(tds, topology_model, opposite_a, source_b, source_cells)?; - let opposite_point_b = - vertex_point_lifted_into_cell(tds, topology_model, opposite_b, source_a, source_cells)?; + let (points_a, points_b, opposite_point_a, opposite_point_b) = + if matches!(topology_model, GlobalTopologyModelAdapter::Euclidean(_)) { + let mut point_cache = EuclideanPointCache::new(); + ( + point_cache.points_for_vertices(tds, &cell_vertices[0])?, + point_cache.points_for_vertices(tds, &cell_vertices[1])?, + point_cache.point(tds, opposite_a)?, + point_cache.point(tds, opposite_b)?, + ) + } else { + let source_a = + matching_source_cell(tds, &cell_vertices[0], source_cells).or(frame_cell); + let source_b = + matching_source_cell(tds, &cell_vertices[1], source_cells).or(frame_cell); + ( + vertices_to_points_with_optional_lift( + tds, + topology_model, + &cell_vertices[0], + source_a, + source_cells, + )?, + vertices_to_points_with_optional_lift( + tds, + topology_model, + &cell_vertices[1], + source_b, + source_cells, + )?, + vertex_point_lifted_into_cell( + tds, + topology_model, + opposite_a, + source_b, + source_cells, + )?, + vertex_point_lifted_into_cell( + tds, + topology_model, + opposite_b, + source_a, + source_cells, + )?, + ) + }; let in_a = match kernel.in_sphere(&points_a, &opposite_point_b) { Ok(value) => value, Err(e) => { @@ -3774,7 +3997,31 @@ pub(crate) fn build_k3_flip_context( ridge: RidgeHandle, ) -> Result, FlipError> where - T: CoordinateScalar, + U: DataType, + V: DataType, +{ + build_k3_flip_context_with_star_limit(tds, ridge, None) +} + +/// Builds k=3 repair context only for true three-cell ridge stars. +fn build_k3_flip_context_for_repair( + tds: &Tds, + ridge: RidgeHandle, +) -> Result, FlipError> +where + U: DataType, + V: DataType, +{ + build_k3_flip_context_with_star_limit(tds, ridge, Some(3)) +} + +/// Builds k=3 flip context while optionally rejecting ridge stars above a caller limit. +fn build_k3_flip_context_with_star_limit( + tds: &Tds, + ridge: RidgeHandle, + max_cells: Option, +) -> Result, FlipError> +where U: DataType, V: DataType, { @@ -3804,7 +4051,7 @@ where return Err(FlipError::InvalidRidgeAdjacency { cell_key }); } - let cells = collect_cells_around_ridge(tds, cell_key, &ridge_vertices)?; + let cells = collect_cells_around_ridge(tds, cell_key, &ridge_vertices, max_cells)?; if cells.len() != 3 { return Err(FlipError::InvalidRidgeMultiplicity { found: cells.len() }); } @@ -4003,6 +4250,9 @@ where .into()); } + let is_euclidean_topology = matches!(topology_model, GlobalTopologyModelAdapter::Euclidean(_)); + let mut euclidean_point_cache = EuclideanPointCache::new(); + for &missing in triangle_vertices { let mut cell_vertices: SmallBuffer = SmallBuffer::with_capacity(D + 1); @@ -4016,16 +4266,31 @@ where // Sort by VertexKey for canonical SoS perturbation ordering cell_vertices.sort_unstable_by_key(|v| v.data().as_ffi()); - let source_cell = matching_source_cell(tds, &cell_vertices, source_cells).or(frame_cell); - let points = vertices_to_points_with_optional_lift( - tds, - topology_model, - &cell_vertices, - source_cell, - source_cells, - )?; - let missing_point = - vertex_point_lifted_into_cell(tds, topology_model, missing, source_cell, source_cells)?; + let (points, missing_point) = if is_euclidean_topology { + ( + euclidean_point_cache.points_for_vertices(tds, &cell_vertices)?, + euclidean_point_cache.point(tds, missing)?, + ) + } else { + let source_cell = + matching_source_cell(tds, &cell_vertices, source_cells).or(frame_cell); + ( + vertices_to_points_with_optional_lift( + tds, + topology_model, + &cell_vertices, + source_cell, + source_cells, + )?, + vertex_point_lifted_into_cell( + tds, + topology_model, + missing, + source_cell, + source_cells, + )?, + ) + }; let in_sphere = match kernel.in_sphere(&points, &missing_point) { Ok(value) => value, @@ -4260,6 +4525,7 @@ where if !violates { continue; } + diagnostics.record_applicable_repair_site(); let signature = flip_signature( 2, @@ -4352,6 +4618,7 @@ where emit_repair_debug_summary("attempt_done", &stats, &diagnostics, config, max_flips); Ok(RepairAttemptOutcome { + postcondition_required: repair_postcondition_required(&stats, &diagnostics), stats, last_applied_flip, touched_cells, @@ -4551,6 +4818,27 @@ where U: DataType, V: DataType, { + repair_delaunay_local_single_pass_timed(tds, kernel, seed_cells, max_flips, None) +} + +/// Run a seeded, bounded repair pass while reporting phase timing to the caller. +#[expect( + clippy::too_many_lines, + reason = "bounded two-attempt repair keeps rollback, retry, and postcondition timing together" +)] +pub(crate) fn repair_delaunay_local_single_pass_timed( + tds: &mut Tds, + kernel: &K, + seed_cells: &[CellKey], + max_flips: usize, + mut timing: Option<&mut LocalRepairPhaseTiming>, +) -> Result +where + K: Kernel, + U: DataType, + V: DataType, +{ + let mut phase_timing = LocalRepairPhaseTiming::default(); // Two-attempt strategy: FIFO then LIFO queue ordering. // Predicate correctness depends on the caller supplying a kernel with // exact predicates (e.g. `AdaptiveKernel` or `RobustKernel`); @@ -4566,22 +4854,42 @@ where max_flips_override: Some(max_flips), }; // Snapshot so a failed attempt does not leave the TDS in a partially-modified state. + let snapshot_started = Instant::now(); let tds_snapshot = tds.clone(); + phase_timing.record_snapshot(snapshot_started.elapsed()); + + let attempt_started = Instant::now(); let attempt1_result = if D == 2 { repair_delaunay_with_flips_k2_attempt(tds, kernel, Some(seed_cells), &attempt1) } else { - repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, Some(seed_cells), &attempt1) + repair_delaunay_with_flips_k2_k3_attempt_timed( + tds, + kernel, + Some(seed_cells), + &attempt1, + Some(&mut phase_timing), + ) }; + phase_timing.record_attempt(attempt_started.elapsed()); + match attempt1_result { Ok(outcome) => { - if verify_repair_postcondition( + if !outcome.postcondition_required { + publish_local_repair_phase_timing(&mut timing, phase_timing); + return Ok(outcome.stats); + } + let postcondition_frontier = + local_postcondition_frontier(seed_cells, &outcome.touched_cells); + let postcondition_started = Instant::now(); + let postcondition_result = verify_local_repair_postcondition( tds, kernel, - Some(seed_cells), + &postcondition_frontier, outcome.last_applied_flip.as_ref(), - ) - .is_ok() - { + ); + phase_timing.record_postcondition(postcondition_started.elapsed()); + if postcondition_result.is_ok() { + publish_local_repair_phase_timing(&mut timing, phase_timing); return Ok(outcome.stats); } if repair_trace_enabled() { @@ -4594,36 +4902,71 @@ where } } Err(err) => { + let restore_started = Instant::now(); *tds = tds_snapshot; + phase_timing.record_restore(restore_started.elapsed()); + publish_local_repair_phase_timing(&mut timing, phase_timing); return Err(err); } } + let restore_started = Instant::now(); *tds = tds_snapshot.clone(); + phase_timing.record_restore(restore_started.elapsed()); + + let attempt_started = Instant::now(); let attempt2_result = if D == 2 { repair_delaunay_with_flips_k2_attempt(tds, kernel, Some(seed_cells), &attempt2) } else { - repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, Some(seed_cells), &attempt2) - }; - match attempt2_result { - Ok(outcome) => match verify_repair_postcondition( + repair_delaunay_with_flips_k2_k3_attempt_timed( tds, kernel, Some(seed_cells), - outcome.last_applied_flip.as_ref(), - ) { - Ok(()) => Ok(outcome.stats), - Err(verifier_err) => { - // Postcondition failed: restore the TDS so callers that - // soft-fail receive a structurally valid triangulation. - *tds = tds_snapshot; - Err(verifier_err) + &attempt2, + Some(&mut phase_timing), + ) + }; + phase_timing.record_attempt(attempt_started.elapsed()); + + match attempt2_result { + Ok(outcome) => { + if !outcome.postcondition_required { + publish_local_repair_phase_timing(&mut timing, phase_timing); + return Ok(outcome.stats); } - }, + let postcondition_frontier = + local_postcondition_frontier(seed_cells, &outcome.touched_cells); + let postcondition_started = Instant::now(); + let postcondition_result = verify_local_repair_postcondition( + tds, + kernel, + &postcondition_frontier, + outcome.last_applied_flip.as_ref(), + ); + phase_timing.record_postcondition(postcondition_started.elapsed()); + match postcondition_result { + Ok(()) => { + publish_local_repair_phase_timing(&mut timing, phase_timing); + Ok(outcome.stats) + } + Err(verifier_err) => { + // Postcondition failed: restore the TDS so callers that + // soft-fail receive a structurally valid triangulation. + let restore_started = Instant::now(); + *tds = tds_snapshot; + phase_timing.record_restore(restore_started.elapsed()); + publish_local_repair_phase_timing(&mut timing, phase_timing); + Err(verifier_err) + } + } + } Err(err) => { // On failure, restore the TDS to the pre-repair snapshot so callers that // soft-fail (e.g. D≥4 bulk construction) receive a structurally valid // triangulation rather than a partially-modified one. + let restore_started = Instant::now(); *tds = tds_snapshot; + phase_timing.record_restore(restore_started.elapsed()); + publish_local_repair_phase_timing(&mut timing, phase_timing); Err(err) } } @@ -4749,6 +5092,7 @@ where global_topology, PostconditionMode::Strict, None, + ConnectivityPostcondition::Check, ) } @@ -4772,6 +5116,30 @@ where GlobalTopology::DEFAULT, PostconditionMode::Repair, last_applied_flip, + ConnectivityPostcondition::Check, + ) +} + +/// Replays local repair postconditions without forcing the full connectivity check. +fn verify_local_repair_postcondition( + tds: &Tds, + kernel: &K, + seed_cells: &[CellKey], + last_applied_flip: Option<&LastAppliedFlip>, +) -> Result<(), DelaunayRepairError> +where + K: Kernel, + U: DataType, + V: DataType, +{ + verify_repair_postcondition_with_topology( + tds, + kernel, + Some(seed_cells), + GlobalTopology::DEFAULT, + PostconditionMode::Repair, + last_applied_flip, + ConnectivityPostcondition::Defer, ) } @@ -4781,6 +5149,12 @@ enum PostconditionMode { Strict, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConnectivityPostcondition { + Check, + Defer, +} + /// Builds a verification failure that preserves the structured flip error. fn verification_failed(context: &'static str, source: FlipError) -> DelaunayRepairError { DelaunayRepairError::VerificationFailed { @@ -4798,6 +5172,7 @@ fn verify_repair_postcondition_with_topology( global_topology: GlobalTopology, mode: PostconditionMode, last_applied_flip: Option<&LastAppliedFlip>, + connectivity: ConnectivityPostcondition, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -4812,6 +5187,7 @@ where &topology_model, mode, last_applied_flip, + connectivity, ) } @@ -4824,6 +5200,7 @@ fn verify_repair_postcondition_locally( topology_model: &GlobalTopologyModelAdapter, mode: PostconditionMode, last_applied_flip: Option<&LastAppliedFlip>, + connectivity: ConnectivityPostcondition, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -4894,12 +5271,12 @@ where mode, )?; - // After all flip predicates pass, verify that the repair did not disconnect the - // neighbor graph. Individual flips can transiently clear stale external neighbor - // pointers (which subsequent flips re-establish); checking here — after the - // complete repair pass — catches any genuine disconnection that the flip sequence - // failed to reconnect. - if !tds.is_connected() { + // After all flip predicates pass, full repair checks that the repair did not + // disconnect the neighbor graph. Batch-local construction repair defers this + // whole-TDS check to the construction finalization topology validation; doing + // it after every small local repair dominates large 3D runs without adding a + // stronger boundary guarantee than final validation already enforces. + if connectivity == ConnectivityPostcondition::Check && !tds.is_connected() { return Err(DelaunayRepairError::PostconditionFailed { message: format!( "repair pass disconnected the triangulation \ @@ -5336,6 +5713,7 @@ struct RepairDiagnostics { invalid_ridge_multiplicity_sample: Option, missing_cell_skips: usize, missing_cell_sample: Option, + saw_applicable_repair_site: bool, flip_signature_window: VecDeque, flip_signature_counts: FastHashMap, ridge_debug_emitted: usize, @@ -5526,6 +5904,11 @@ impl RepairDiagnostics { self.missing_cell_sample = Some(sample); } } + + /// Marks that a repair predicate found an applicable flip even if no mutation followed. + const fn record_applicable_repair_site(&mut self) { + self.saw_applicable_repair_site = true; + } } #[derive(Debug, Clone, Copy)] @@ -5564,6 +5947,12 @@ fn non_convergent_error( } } +/// Converts a measured duration to nanoseconds while saturating pathological +/// values that exceed telemetry counter width. +fn duration_nanos_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) +} + /// Gates the expensive repair summary behind an environment variable while /// keeping all attempts logged in a uniform shape. fn emit_repair_debug_summary( @@ -6128,7 +6517,7 @@ where }; stats.facets_checked += 1; - let context = match build_k3_flip_context(tds, ridge) { + let context = match build_k3_flip_context_for_repair(tds, ridge) { Ok(ctx) => ctx, Err( err @ (FlipError::InvalidRidgeIndex { .. } @@ -6190,6 +6579,7 @@ where if !violates { return Ok(true); } + diagnostics.record_applicable_repair_site(); if would_immediately_reverse_last_flip::( last_applied_flip.as_ref(), @@ -6382,6 +6772,7 @@ where if violates && !allow_exploratory_inverse { return Ok(true); } + diagnostics.record_applicable_repair_site(); if would_immediately_reverse_last_flip::( last_applied_flip.as_ref(), @@ -6564,6 +6955,7 @@ where if violates { return Ok(true); } + diagnostics.record_applicable_repair_site(); if would_immediately_reverse_last_flip::( last_applied_flip.as_ref(), @@ -6740,6 +7132,7 @@ where if !violates { return Ok(true); } + diagnostics.record_applicable_repair_site(); if would_immediately_reverse_last_flip::( last_applied_flip.as_ref(), @@ -6877,7 +7270,6 @@ fn ridge_vertices_from_cell( omit_b: usize, ) -> SmallBuffer where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -6899,7 +7291,6 @@ fn cell_extras_for_ridge( ridge: &SmallBuffer, ) -> Result, FlipError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -6930,26 +7321,36 @@ fn missing_opposite_for_cell( /// Walks the neighbor graph around a ridge so k=3 context construction uses the /// local star rather than a global incidence scan. +/// +/// When `max_cells` is set, the walk stops after discovering more than that +/// many incident cells. Repair uses this to reject non-k=3 edge stars as soon +/// as they are known to be too large, while public flip construction leaves the +/// value unset to preserve exact multiplicity diagnostics. fn collect_cells_around_ridge( tds: &Tds, start_cell: CellKey, ridge: &SmallBuffer, + max_cells: Option, ) -> Result where - T: CoordinateScalar, U: DataType, V: DataType, { - let mut queue: VecDeque = VecDeque::new(); - let mut visited: FastHashSet = FastHashSet::default(); + let mut queue: CellKeyBuffer = CellKeyBuffer::new(); + let mut visited: CellKeyBuffer = CellKeyBuffer::new(); let mut cells: CellKeyBuffer = CellKeyBuffer::new(); + let mut queue_cursor = 0usize; + + queue.push(start_cell); - queue.push_back(start_cell); + while queue_cursor < queue.len() { + let cell_key = queue[queue_cursor]; + queue_cursor += 1; - while let Some(cell_key) = queue.pop_front() { - if !visited.insert(cell_key) { + if visited.contains(&cell_key) { continue; } + visited.push(cell_key); let cell = tds .cell(cell_key) @@ -6969,20 +7370,20 @@ where } cells.push(cell_key); + if max_cells.is_some_and(|limit| cells.len() > limit) { + return Ok(cells); + } if let Some(neighbors) = cell.neighbors() { for &omit_idx in &omit_indices { if let Some(neighbor_key) = neighbors.get(omit_idx).copied().flatten() { - if !tds.contains_cell(neighbor_key) { + let Some(neighbor_cell) = tds.cell(neighbor_key) else { continue; - } - let neighbor_cell = tds.cell(neighbor_key).ok_or(FlipError::MissingCell { - cell_key: neighbor_key, - })?; + }; if !ridge.iter().all(|v| neighbor_cell.contains_vertex(*v)) { return Err(FlipError::InvalidRidgeAdjacency { cell_key }); } - queue.push_back(neighbor_key); + queue.push(neighbor_key); } } } @@ -6991,6 +7392,78 @@ where Ok(cells) } +/// Returns a vertex's Euclidean point without applying topology-frame lifting. +fn vertex_point( + tds: &Tds, + vertex_key: VertexKey, +) -> Result, FlipError> +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let vertex = tds + .vertex(vertex_key) + .ok_or(FlipError::MissingVertex { vertex_key })?; + Ok(*vertex.point()) +} + +/// Small per-predicate cache for Euclidean vertex coordinates. +struct EuclideanPointCache { + points: SmallBuffer<(VertexKey, Point), MAX_PRACTICAL_DIMENSION_SIZE>, +} + +impl EuclideanPointCache { + /// Starts an empty cache for one local predicate evaluation. + fn new() -> Self { + Self { + points: SmallBuffer::new(), + } + } +} + +impl EuclideanPointCache +where + T: CoordinateScalar, +{ + /// Returns a cached Euclidean point, loading it from the TDS on first use. + fn point( + &mut self, + tds: &Tds, + vertex_key: VertexKey, + ) -> Result, FlipError> + where + U: DataType, + V: DataType, + { + if let Some((_key, point)) = self.points.iter().find(|(key, _point)| *key == vertex_key) { + return Ok(*point); + } + + let point = vertex_point(tds, vertex_key)?; + self.points.push((vertex_key, point)); + Ok(point) + } + + /// Converts a small vertex-key slice into Euclidean points while sharing cache hits. + fn points_for_vertices( + &mut self, + tds: &Tds, + vertices: &[VertexKey], + ) -> Result, MAX_PRACTICAL_DIMENSION_SIZE>, FlipError> + where + U: DataType, + V: DataType, + { + let mut points: SmallBuffer, MAX_PRACTICAL_DIMENSION_SIZE> = + SmallBuffer::with_capacity(vertices.len()); + for &vertex_key in vertices { + points.push(self.point(tds, vertex_key)?); + } + Ok(points) + } +} + /// Converts vertex keys to Euclidean points for predicates that do not need a /// periodic frame. fn vertices_to_points( @@ -7005,10 +7478,7 @@ where let mut points: SmallBuffer, MAX_PRACTICAL_DIMENSION_SIZE> = SmallBuffer::with_capacity(vertices.len()); for &vkey in vertices { - let vertex = tds - .vertex(vkey) - .ok_or(FlipError::MissingVertex { vertex_key: vkey })?; - points.push(*vertex.point()); + points.push(vertex_point(tds, vkey)?); } Ok(points) } @@ -9841,6 +10311,37 @@ mod tests { CellKey::from(KeyData::from_ffi(index)) } + #[test] + fn test_local_postcondition_frontier_deduplicates_seed_and_touched_cells() { + let seed_a = synthetic_cell_key(1); + let seed_b = synthetic_cell_key(2); + let touched_a = synthetic_cell_key(3); + let frontier = local_postcondition_frontier( + &[seed_a, seed_b, seed_a], + &[seed_b, touched_a, touched_a], + ); + + assert_eq!(frontier.len(), 3); + assert_eq!(frontier[0], seed_a); + assert_eq!(frontier[1], seed_b); + assert_eq!(frontier[2], touched_a); + } + + #[test] + fn test_repair_postcondition_required_tracks_mutation_or_applicable_site() { + let mut stats = DelaunayRepairStats::default(); + let mut diagnostics = RepairDiagnostics::default(); + + assert!(!repair_postcondition_required(&stats, &diagnostics)); + + diagnostics.record_applicable_repair_site(); + assert!(repair_postcondition_required(&stats, &diagnostics)); + + diagnostics = RepairDiagnostics::default(); + stats.flips_performed = 1; + assert!(repair_postcondition_required(&stats, &diagnostics)); + } + fn dynamic_flip_rejects_bad_context_for_dimension() { init_tracing(); let mut tds: Tds = Tds::empty(); @@ -12106,9 +12607,10 @@ mod tests { let tds = dt.tds(); let local_cell = tds.cell_keys().next().unwrap(); let outcome = RepairAttemptOutcome { + postcondition_required: false, stats: DelaunayRepairStats::default(), last_applied_flip: None, - touched_cells: std::iter::once(local_cell).collect(), + touched_cells: once(local_cell).collect(), used_full_reseed: true, }; diff --git a/src/core/cell.rs b/src/core/cell.rs index 5c1df1bb..a2f8a70d 100644 --- a/src/core/cell.rs +++ b/src/core/cell.rs @@ -1648,10 +1648,17 @@ impl Hash for Cell { #[cfg(test)] mod tests { use super::*; + use crate::core::triangulation::TopologyGuarantee; use crate::core::vertex::vertex; + use crate::geometry::kernel::AdaptiveKernel; use crate::geometry::point::Point; use crate::geometry::predicates::insphere; use crate::geometry::util::{circumcenter, circumradius, circumradius_with_center}; + use crate::prelude::DelaunayTriangulation; + use crate::triangulation::builder::DelaunayTriangulationBuilder; + use crate::triangulation::delaunay::{ + ConstructionOptions, InitialSimplexStrategy, InsertionOrderStrategy, + }; use approx::assert_relative_eq; use std::{cmp, collections::hash_map::DefaultHasher, hash::Hasher}; @@ -1659,10 +1666,6 @@ mod tests { type TestVertex3D = Vertex; type TestVertex2D = Vertex; - use crate::geometry::kernel::AdaptiveKernel; - use crate::prelude::DelaunayTriangulation; - use crate::triangulation::builder::DelaunayTriangulationBuilder; - struct NonDataType(String); fn cell_with_non_data_type_metadata( @@ -2546,8 +2549,17 @@ mod tests { // Note: DelaunayTriangulation::new() creates AdaptiveKernel by default; // use with_kernel to get AdaptiveKernel for f32 vertices + let options = ConstructionOptions::default() + .with_insertion_order(InsertionOrderStrategy::Input) + .with_initial_simplex_strategy(InitialSimplexStrategy::First); let dt: DelaunayTriangulation, (), (), 3> = - DelaunayTriangulation::with_kernel(&AdaptiveKernel::new(), &vertices).unwrap(); + DelaunayTriangulation::with_topology_guarantee_and_options( + &AdaptiveKernel::new(), + &vertices, + TopologyGuarantee::DEFAULT, + options, + ) + .unwrap(); let cell_key = dt.cells().next().unwrap().0; let cell = &dt.tds().cell(cell_key).unwrap(); @@ -2555,14 +2567,27 @@ mod tests { let vertex_uuids = cell.vertex_uuids(dt.tds()).unwrap(); assert_eq!(cell.vertex_uuid_iter(dt.tds()).count(), 4); - // Verify coordinate type is preserved - let first_vertex_key = cell.vertices()[0]; - let first_vertex = &dt.tds().vertex(first_vertex_key).unwrap(); - assert_relative_eq!( - first_vertex.point().coords()[0], - 0.0f32, - epsilon = f32::EPSILON - ); + // Verify coordinate type is preserved without assuming cell-internal vertex order. + for expected_coords in [ + [0.0f32, 0.0f32, 0.0f32], + [1.0f32, 0.0f32, 0.0f32], + [0.0f32, 1.0f32, 0.0f32], + [0.0f32, 0.0f32, 1.0f32], + ] { + assert!( + cell.vertices().iter().any(|&vertex_key| { + dt.tds() + .vertex(vertex_key) + .unwrap() + .point() + .coords() + .iter() + .zip(expected_coords) + .all(|(actual, expected)| (*actual - expected).abs() <= f32::EPSILON) + }), + "Expected cell coordinates {expected_coords:?} not found" + ); + } // Verify UUIDs match the cell's vertices using iterator for (expected_uuid, returned_uuid) in diff --git a/src/core/facet.rs b/src/core/facet.rs index 1241c30f..4cdc2d59 100644 --- a/src/core/facet.rs +++ b/src/core/facet.rs @@ -1123,7 +1123,12 @@ pub fn facet_key_from_vertices(vertices: &[VertexKey]) -> u64 { mod tests { use super::*; use crate::core::tds::VertexKey; - use crate::triangulation::delaunay::DelaunayTriangulation; + use crate::core::triangulation::TopologyGuarantee; + use crate::core::vertex::Vertex; + use crate::geometry::kernel::AdaptiveKernel; + use crate::triangulation::delaunay::{ + ConstructionOptions, DelaunayTriangulation, InitialSimplexStrategy, InsertionOrderStrategy, + }; use crate::vertex; // ============================================================================= @@ -1371,16 +1376,23 @@ mod tests { #[test] fn facet_with_typed_data() { // Create 3D triangulation with typed vertex data - use crate::core::vertex::Vertex; - use crate::geometry::kernel::AdaptiveKernel; let vertices: Vec> = vec![ vertex!([0.0, 0.0, 0.0], 1), vertex!([1.0, 0.0, 0.0], 2), vertex!([0.0, 1.0, 0.0], 3), vertex!([0.0, 0.0, 1.0], 4), ]; + let options = ConstructionOptions::default() + .with_insertion_order(InsertionOrderStrategy::Input) + .with_initial_simplex_strategy(InitialSimplexStrategy::First); let dt: DelaunayTriangulation, i32, (), 3> = - DelaunayTriangulation::with_kernel(&AdaptiveKernel::new(), &vertices).unwrap(); + DelaunayTriangulation::with_topology_guarantee_and_options( + &AdaptiveKernel::new(), + &vertices, + TopologyGuarantee::DEFAULT, + options, + ) + .unwrap(); let cell_key = dt.cells().next().unwrap().0; // Create facet view for facet 0 (excludes vertex 0) @@ -1388,9 +1400,14 @@ mod tests { let facet_vertices: Vec<_> = facet.vertices().unwrap().collect(); assert_eq!(facet_vertices.len(), 3); // 3D facet should have 3 vertices (D) - assert!(facet_vertices.iter().any(|v| v.data == Some(2))); - assert!(facet_vertices.iter().any(|v| v.data == Some(3))); - assert!(facet_vertices.iter().any(|v| v.data == Some(4))); + let cell = dt.tds().cell(cell_key).expect("cell exists"); + for &vertex_key in cell.vertices().iter().skip(1) { + let expected_data = dt.tds().vertex(vertex_key).unwrap().data; + assert!( + facet_vertices.iter().any(|v| v.data == expected_data), + "Expected facet vertex data {expected_data:?} not found" + ); + } } /// Macro to generate dimension-specific facet tests for dimensions 2D-5D. @@ -1525,7 +1542,10 @@ mod tests { fn facet_1d_edge() { // Create 1D triangulation (edge with 2 vertices) let vertices = vec![vertex!([0.0]), vertex!([1.0])]; - let dt = DelaunayTriangulation::new(&vertices).unwrap(); + let options = ConstructionOptions::default() + .with_insertion_order(InsertionOrderStrategy::Input) + .with_initial_simplex_strategy(InitialSimplexStrategy::First); + let dt = DelaunayTriangulation::new_with_options(&vertices, options).unwrap(); let cell_key = dt.cells().next().unwrap().0; // Create facet view for facet 0 (excludes vertex 0) @@ -1770,9 +1790,11 @@ mod tests { let facet_vertices: Vec<_> = facet_view.vertices().unwrap().collect(); assert_eq!(facet_vertices.len(), 3); - // Get original vertices for comparison - let original_vertices = vertices; - let opposite_vertex = &original_vertices[0]; + let cell = dt.tds().cell(cell_key).expect("cell exists"); + let opposite_vertex = dt + .tds() + .vertex(cell.vertices()[0]) + .expect("opposite vertex exists"); // Facet vertices should not include the opposite vertex assert!( diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 55e65683..d1b1cb16 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -823,6 +823,12 @@ 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_cells: CellKeyBuffer, + /// Whether the insertion path can leave local Delaunay work for the caller. + /// + /// Clean interior Bowyer-Watson insertions preserve the Delaunay property. + /// Exterior hull extensions and suspicious fallback/repair paths still need + /// a local flip-repair pass. + delaunay_repair_required: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -849,6 +855,19 @@ struct LocalFacetRepairOutcome { frontier_cells: CellKeyBuffer, } +/// Result of filling one insertion cavity, including the follow-up Delaunay +/// repair requirements that depend on how the cavity was shaped. +struct CavityInsertionOutcome { + /// Locate hint for the next insertion. + hint: Option, + /// Number of cells removed during local non-manifold repair. + cells_removed: usize, + /// Cells touched by insertion that should seed follow-up local repair. + repair_seed_cells: CellKeyBuffer, + /// Whether this cavity path can leave Delaunay work for the caller. + delaunay_repair_required: bool, +} + enum InsertionSite<'a> { Interior { start_cell: CellKey, @@ -871,6 +890,8 @@ pub(crate) struct DetailedInsertionResult { pub telemetry: InsertionTelemetry, /// Local cells that should seed the caller's Delaunay repair set. pub repair_seed_cells: CellKeyBuffer, + /// Whether callers should run Delaunay repair over `repair_seed_cells`. + pub delaunay_repair_required: bool, } /// Policy controlling when the triangulation runs global validation passes. @@ -3667,6 +3688,7 @@ where stats, telemetry, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); }; @@ -3715,6 +3737,7 @@ where stats, telemetry, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } @@ -3762,6 +3785,7 @@ where inserted, cells_removed, repair_seed_cells, + delaunay_repair_required, .. }) => { stats.cells_removed_during_repair = cells_removed; @@ -3787,6 +3811,7 @@ where stats, telemetry, repair_seed_cells, + delaunay_repair_required, }); } Err(e) => { @@ -3803,6 +3828,7 @@ where stats, telemetry, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } @@ -3862,6 +3888,7 @@ where // Skipped insertions do not mutate the triangulation, so any // intermediate cavity-seed hints are irrelevant to callers. repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } else { // Non-retryable structural error (e.g., duplicate UUID) @@ -4244,6 +4271,9 @@ where if attempt > 0 { insert_ok.suspicion.perturbation_used = true; } + if insert_ok.suspicion.is_suspicious() { + insert_ok.delaunay_repair_required = true; + } // Skip Level 3 validation during bootstrap (vertices but no cells yet). if self.tds.number_of_cells() == 0 { @@ -4312,6 +4342,7 @@ where if attempt > 0 { fallback_ok.suspicion.perturbation_used = true; } + fallback_ok.delaunay_repair_required = true; let validation_work = self.validation_after_insertion_work(fallback_ok.suspicion); let validation_started = validation_work.map(|_| Instant::now()); @@ -4631,7 +4662,7 @@ where mut conflict_cells: CellKeyBuffer, fallback_cell: Option, suspicion: &mut SuspicionFlags, - ) -> Result<(Option, usize, CellKeyBuffer), InsertionError> { + ) -> Result { #[cfg(not(debug_assertions))] let _ = point; @@ -4645,12 +4676,16 @@ where suspicion.empty_conflict_region = true; suspicion.fallback_star_split = true; conflict_cells.push(start_cell); + // The fallback star-split is topologically safe but not a full + // Bowyer-Watson conflict-region replacement, so local Delaunay + // repair must revisit it. } // Preserve every cell that participates in cavity shaping and is later // removed from the final cavity so callers can seed local Delaunay // repair from the surviving fringe. let mut repair_seed_cells = CellKeyBuffer::new(); + let mut delaunay_repair_required = suspicion.fallback_star_split; // Extract cavity boundary. // @@ -4733,6 +4768,7 @@ where || format!("ridge_fan_shrink remove_cells={extra_cells:?}"), ); saw_ridge_fan_shrink = true; + delaunay_repair_required = true; let remove_set: FastHashSet = extra_cells.iter().copied().collect(); retain_cells_and_record_removed( @@ -4771,6 +4807,7 @@ where if !cells_to_add.is_empty() { // EXPAND: add the hole-filling cells. + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::debug!( add_count = cells_to_add.len(), @@ -4792,6 +4829,7 @@ where } } else if conflict_cells.len() > D + 1 { // SHRINK fallback: no non-conflict neighbors found. + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::debug!( remove_count = disconnected_cells.len(), @@ -4830,6 +4868,7 @@ where Err(ConflictError::OpenBoundary { open_cell, .. }) if conflict_cells.len() > D + 1 => { + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::debug!( ?open_cell, @@ -4916,6 +4955,7 @@ where }; suspicion.fallback_star_split = true; + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::warn!( @@ -4953,6 +4993,7 @@ where suspicion.empty_conflict_region = true; suspicion.fallback_star_split = true; + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::warn!( @@ -5066,6 +5107,7 @@ where // Only mark this as "suspicious" if we *actually* detected local facet issues // and entered the repair path. suspicion.repair_loop_entered = true; + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::debug!( @@ -5099,6 +5141,7 @@ where if removed > 0 { suspicion.cells_removed = true; + delaunay_repair_required = true; } #[cfg(debug_assertions)] @@ -5138,6 +5181,7 @@ where let repaired = self .repair_neighbors_after_local_cell_removal(&new_cells, &neighbor_repair_frontier)?; suspicion.neighbor_pointers_rebuilt = repaired > 0; + delaunay_repair_required = true; } // Canonicalize cell ordering and geometric orientation invariants. @@ -5190,7 +5234,12 @@ where append_live_unique_cell_seeds(&self.tds, &new_cells, &mut repair_seed_cells); // Return hint for next insertion - Ok((hint, total_removed, repair_seed_cells)) + Ok(CavityInsertionOutcome { + hint, + cells_removed: total_removed, + repair_seed_cells, + delaunay_repair_required: delaunay_repair_required || suspicion.is_suspicious(), + }) } /// Repair stale incident-cell pointers and detect truly isolated vertices. @@ -5402,6 +5451,7 @@ where cells_removed: 0, suspicion, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } else if num_vertices == D + 1 { // Build initial simplex from all D+1 vertices @@ -5429,6 +5479,7 @@ where cells_removed: 0, suspicion, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } @@ -5457,6 +5508,7 @@ where } // 4. Determine the supported insertion site and any conflict cells it needs. + let caller_provided_conflict_cells = conflict_cells.is_some(); let insertion_site = match (location, conflict_cells) { (LocateResult::InsideCell(start_cell), None) => { // Interior point: compute conflict region automatically. @@ -5611,12 +5663,14 @@ where telemetry, Self::duration_nanos_saturating(cavity_started.elapsed()), ); - let (hint, total_removed, repair_seed_cells) = insertion_result?; + let outcome = insertion_result?; Ok(TryInsertImplOk { - inserted: (v_key, hint), - cells_removed: total_removed, + inserted: (v_key, outcome.hint), + cells_removed: outcome.cells_removed, suspicion, - repair_seed_cells, + repair_seed_cells: outcome.repair_seed_cells, + delaunay_repair_required: outcome.delaunay_repair_required + || caller_provided_conflict_cells, }) } InsertionSite::Exterior { @@ -5644,12 +5698,13 @@ where Self::duration_nanos_saturating(cavity_started.elapsed()), ); match result { - Ok((hint, total_removed, repair_seed_cells)) => { + Ok(outcome) => { return Ok(TryInsertImplOk { - inserted: (v_key, hint), - cells_removed: total_removed, + inserted: (v_key, outcome.hint), + cells_removed: outcome.cells_removed, suspicion, - repair_seed_cells, + repair_seed_cells: outcome.repair_seed_cells, + delaunay_repair_required: true, }); } Err(err) => { @@ -5762,12 +5817,13 @@ where telemetry, Self::duration_nanos_saturating(cavity_started.elapsed()), ); - let (hint, total_removed, repair_seed_cells) = insertion_result?; + let outcome = insertion_result?; return Ok(TryInsertImplOk { - inserted: (v_key, hint), - cells_removed: total_removed, + inserted: (v_key, outcome.hint), + cells_removed: outcome.cells_removed, suspicion, - repair_seed_cells, + repair_seed_cells: outcome.repair_seed_cells, + delaunay_repair_required: true, }); } } @@ -5985,6 +6041,7 @@ where cells_removed: total_removed, suspicion, repair_seed_cells, + delaunay_repair_required: true, }) } } @@ -11289,8 +11346,8 @@ mod tests { /// This validates that perturbation is proportional to local feature size. #[test] fn test_perturbation_scale_invariance_3d() { - const EXPECTED_VERTEX_COUNT: usize = 7; - const EXPECTED_CELL_COUNT: usize = 8; + const EXPECTED_VERTEX_COUNT: usize = 8; + const EXPECTED_CELL_COUNT: usize = 10; fn build_at_scale(scale: f64) -> (usize, usize) { let base_coords: [[f64; 3]; 8] = [ diff --git a/src/lib.rs b/src/lib.rs index 70793e8f..30134f62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,7 @@ //! | Bistellar flips (Pachner moves) | `use delaunay::prelude::triangulation::flips::*` | //! | Delaunay repair and flip-based Level 4 validation | `use delaunay::prelude::triangulation::repair::*` | //! | Delaunayize workflow (repair + flip) | `use delaunay::prelude::triangulation::delaunayize::*` | +//! | Construction telemetry diagnostics | `use delaunay::prelude::triangulation::diagnostics::*` | //! | Construction validation cadence/policy | `use delaunay::prelude::triangulation::validation::*` | //! | Topology validation, Euler characteristic | `use delaunay::prelude::topology::validation::*` | //! | Topological spaces and topology traits | `use delaunay::prelude::topology::spaces::*` | @@ -353,8 +354,10 @@ //! - **Explicit verification**: Use `dt.validate()` for cumulative verification (Levels 1–4), or //! `dt.is_valid()` for Level 4 only. -// Allow multiple crate versions due to transitive dependencies -#![expect(clippy::multiple_crate_versions)] +#![expect( + clippy::multiple_crate_versions, + reason = "transitive dependency versions are controlled by upstream crates" +)] // Temporarily allow deprecated warnings during API migrations. // - Historical Facet -> FacetView and Tds construction migrations // - DelaunayTriangulation::as_triangulation_mut() removal planned for v0.8.0 @@ -1021,13 +1024,12 @@ pub mod prelude { }; pub use crate::triangulation::delaunay::{ ConstructionOptions, ConstructionSkipSample, ConstructionSlowInsertionSample, - ConstructionStatistics, ConstructionTelemetry, DedupPolicy, - DelaunayConstructionFailure, DelaunayConstructionRepairPhase, DelaunayRepairPolicy, - DelaunayTriangulation, DelaunayTriangulationConstructionError, + ConstructionStatistics, DedupPolicy, DelaunayConstructionFailure, + DelaunayConstructionRepairPhase, DelaunayRepairPolicy, DelaunayTriangulation, + DelaunayTriangulationConstructionError, DelaunayTriangulationConstructionErrorWithStatistics, InitialSimplexStrategy, InsertionOrderStrategy, RetryPolicy, }; - // Convenience macro (commonly used in docs/examples). pub use crate::vertex; } @@ -1107,6 +1109,20 @@ pub mod prelude { pub use crate::vertex; } + /// Construction telemetry diagnostics. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; + /// + /// let telemetry = ConstructionTelemetry::default(); + /// assert!(!telemetry.has_data()); + /// ``` + pub mod diagnostics { + pub use crate::triangulation::diagnostics::ConstructionTelemetry; + } + /// Validation scheduling helpers for construction diagnostics. /// /// # Examples diff --git a/src/triangulation.rs b/src/triangulation.rs index 857abe13..220dc1ee 100644 --- a/src/triangulation.rs +++ b/src/triangulation.rs @@ -6,7 +6,7 @@ //! - [`crate::core::triangulation`] owns the generic `Triangulation` container //! and low-level mutation invariants. //! - [`crate::triangulation`] owns higher-level construction, Delaunay repair, -//! validation scheduling, editing, and builder workflows. +//! diagnostics, validation scheduling, editing, and builder workflows. //! - Submodules under this namespace keep those concerns separate while this //! facade preserves the stable public import surface. //! @@ -42,6 +42,8 @@ pub mod builder; pub mod delaunay; /// End-to-end "repair then delaunayize" workflow. pub mod delaunayize; +/// Construction and performance diagnostics. +pub mod diagnostics; /// Triangulation editing operations (bistellar flips). pub mod flips; pub(crate) mod locality; diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 9dfb9d2d..421e99f6 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -7,21 +7,23 @@ use crate::core::adjacency::{AdjacencyIndex, AdjacencyIndexBuildError}; use crate::core::algorithms::flips::{ - DelaunayRepairError, DelaunayRepairRun, DelaunayRepairStats, FlipError, + DelaunayRepairError, DelaunayRepairRun, DelaunayRepairStats, FlipError, LocalRepairPhaseTiming, apply_bistellar_flip_k1_inverse, repair_delaunay_local_single_pass, - repair_delaunay_with_flips_k2_k3, repair_delaunay_with_flips_k2_k3_run, - verify_delaunay_for_triangulation, + repair_delaunay_local_single_pass_timed, repair_delaunay_with_flips_k2_k3, + repair_delaunay_with_flips_k2_k3_run, verify_delaunay_for_triangulation, }; use crate::core::algorithms::incremental_insertion::{InsertionError, TdsConstructionFailure}; use crate::core::algorithms::locate::LocateError; use crate::core::cell::{Cell, CellValidationError}; use crate::core::collections::spatial_hash_grid::HashGridIndex; -use crate::core::collections::{CellKeyBuffer, FastHashMap, FastHashSet, FastHasher, SmallBuffer}; +use crate::core::collections::{ + CellKeyBuffer, FastHashMap, FastHashSet, FastHasher, MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer, +}; use crate::core::edge::EdgeKey; use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter}; use crate::core::operations::{ - DelaunayInsertionState, InsertionOutcome, InsertionResult, InsertionStatistics, - InsertionTelemetry, RepairDecision, TopologicalOperation, + DelaunayInsertionState, InsertionOutcome, InsertionResult, InsertionStatistics, RepairDecision, + TopologicalOperation, }; use crate::core::tds::{ CellKey, InvariantError, InvariantKind, InvariantViolation, Tds, TdsConstructionError, @@ -38,11 +40,13 @@ use crate::core::util::{ }; use crate::core::vertex::Vertex; use crate::geometry::kernel::{AdaptiveKernel, ExactPredicates, Kernel, RobustKernel}; -use crate::geometry::traits::coordinate::CoordinateScalar; -use crate::geometry::util::safe_usize_to_scalar; +use crate::geometry::point::Point; +use crate::geometry::traits::coordinate::{Coordinate, CoordinateScalar}; +use crate::geometry::util::{safe_coords_to_f64, safe_usize_to_scalar, simplex_volume}; use crate::topology::manifold::{ManifoldError, validate_ridge_links_for_cells}; use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; use crate::triangulation::builder::DelaunayTriangulationBuilder; +use crate::triangulation::diagnostics::{BatchLocalRepairTrigger, ConstructionTelemetry}; use crate::triangulation::locality::{ accumulate_live_cell_seeds, clear_cell_seed_set, retain_live_cell_seeds, }; @@ -61,6 +65,7 @@ use uuid::Uuid; const DELAUNAY_SHUFFLE_ATTEMPTS: usize = 6; const DELAUNAY_SHUFFLE_SEED_SALT: u64 = 0x9E37_79B9_7F4A_7C15; +const INITIAL_SIMPLEX_MAX_VOLUME_CANDIDATE_CAP: usize = 18; // Heuristic rebuild attempts must be consistent across build profiles to avoid // release-only construction failures (see #306). @@ -163,27 +168,14 @@ const fn local_repair_seed_backlog_threshold() -> usize { (D + 1).saturating_mul(factor) } -/// Reason a batch local repair pass was scheduled. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum BatchLocalRepairTrigger { - /// The configured [`DelaunayRepairPolicy`] cadence fired. - Cadence, - /// The pending local seed frontier exceeded the adaptive backlog threshold. - SeedBacklog, -} - /// Default local-repair cadence for batch construction. /// /// Direct incremental insertion keeps [`DelaunayRepairPolicy::default`] at -/// [`DelaunayRepairPolicy::EveryInsertion`]. Batch construction instead uses -/// `EveryN(2)`, the best current cadence from the 500/3000-point #341 proxy -/// sweeps, while final repair and validation still enforce Delaunay correctness. +/// [`DelaunayRepairPolicy::EveryInsertion`]. Batch construction uses the same +/// default because the #341 1000/3000-point proxy sweeps showed every-insertion +/// repair preserved all vertices and was slightly faster than the N=2 cadence. const fn default_batch_repair_policy() -> DelaunayRepairPolicy { - if let Some(every) = NonZeroUsize::new(2) { - DelaunayRepairPolicy::EveryN(every) - } else { - DelaunayRepairPolicy::EveryInsertion - } + DelaunayRepairPolicy::EveryInsertion } /// Decides whether batch construction should run local Delaunay repair now. @@ -829,17 +821,49 @@ pub enum DedupPolicy { /// Strategy controlling how the initial D+1 simplex vertices are selected during batch construction. /// -/// The default (`First`) preserves current behavior by taking the first D+1 vertices after -/// preprocessing and insertion-ordering. The balanced strategy is opt-in and chooses a more -/// spread-out simplex using a deterministic farthest-point heuristic. +/// The default ([`MaxVolume`](Self::MaxVolume)) searches a bounded pool of real extreme vertices +/// for the largest nondegenerate simplex before construction. The +/// [`Balanced`](Self::Balanced) strategy chooses a spread-out simplex using a deterministic +/// farthest-point heuristic. The [`First`](Self::First) strategy preserves legacy behavior by +/// taking the first D+1 vertices after preprocessing and insertion-ordering. +/// +/// These strategies only change construction order. They never introduce synthetic vertices, +/// relax topology checks, or bypass final Delaunay validation. If a strategy that reorders +/// vertices cannot select a usable initial simplex, preprocessing falls back to the existing vertex +/// order and the normal construction error path decides whether the input is valid. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, InitialSimplexStrategy, +/// }; +/// +/// let options = ConstructionOptions::default(); +/// +/// assert_eq!( +/// options.initial_simplex_strategy(), +/// InitialSimplexStrategy::MaxVolume, +/// ); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] pub enum InitialSimplexStrategy { - /// Use the first D+1 vertices after preprocessing (legacy behavior). - #[default] + /// Use the first D+1 vertices after preprocessing. + /// + /// This preserves the legacy construction order and is useful when callers need exact + /// compatibility with an explicitly supplied insertion sequence. First, /// Choose a better-conditioned simplex using a deterministic farthest-point heuristic. Balanced, + /// Choose the largest-volume simplex from a bounded real-vertex candidate pool. + /// + /// This is the default because a larger real starting simplex can reduce early convex-hull + /// insertions and their associated local repair work, especially for large 3D point clouds. + /// Candidate scoring is a deterministic preprocessing heuristic; correctness still comes from + /// the ordinary construction, repair, and validation pipeline. + #[default] + MaxVolume, } /// Policy controlling deterministic "retry with alternative insertion orders" during batch @@ -1003,11 +1027,10 @@ impl ConstructionOptions { /// use delaunay::prelude::triangulation::construction::{ /// ConstructionOptions, DelaunayRepairPolicy, /// }; - /// use std::num::NonZeroUsize; /// /// assert_eq!( /// ConstructionOptions::default().batch_repair_policy(), - /// DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap()), + /// DelaunayRepairPolicy::EveryInsertion, /// ); /// ``` #[must_use] @@ -1029,6 +1052,28 @@ impl ConstructionOptions { self } /// Sets the initial simplex selection strategy. + /// + /// Use this as a construction-ordering performance knob. The strategy selects real input + /// vertices for the starting simplex and does not change repair policy, topology guarantees, + /// or final validation. Call this with [`InitialSimplexStrategy::Balanced`] or + /// [`InitialSimplexStrategy::First`] to opt out of the default + /// [`InitialSimplexStrategy::MaxVolume`] heuristic. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, InitialSimplexStrategy, + /// }; + /// + /// let options = ConstructionOptions::default() + /// .with_initial_simplex_strategy(InitialSimplexStrategy::Balanced); + /// + /// assert_eq!( + /// options.initial_simplex_strategy(), + /// InitialSimplexStrategy::Balanced, + /// ); + /// ``` #[must_use] pub const fn with_initial_simplex_strategy( mut self, @@ -1085,522 +1130,6 @@ impl ConstructionOptions { } } -// ============================================================================= -// BATCH CONSTRUCTION STATISTICS -// ============================================================================= - -/// Aggregate release-visible telemetry collected during batch construction. -/// -/// These counters summarize batch construction at a coarse level so large-scale -/// debug runs can separate construction phases, per-insertion primitive costs, -/// batch-local repair work, and global exterior conflict scans without enabling -/// per-insertion tracing. -#[derive(Debug, Default, Clone)] -#[non_exhaustive] -pub struct ConstructionTelemetry { - /// Number of transactional insertion calls with wall-clock timing. - pub insertion_wall_time_calls: usize, - /// Wall-clock nanoseconds spent in transactional insertion calls. - pub insertion_wall_time_nanos: u64, - /// Maximum wall-clock nanoseconds spent in one transactional insertion call. - pub insertion_wall_time_nanos_max: u64, - - /// Wall-clock nanoseconds spent preprocessing vertices before topology construction. - pub construction_preprocessing_nanos: u64, - /// Wall-clock nanoseconds spent in the bulk insertion loop, including batch local repair. - pub construction_insert_loop_nanos: u64, - /// Wall-clock nanoseconds spent finalizing bulk construction after the insertion loop. - pub construction_finalize_nanos: u64, - /// Wall-clock nanoseconds spent in the seeded completion repair during finalization. - pub construction_completion_repair_nanos: u64, - /// Wall-clock nanoseconds spent canonicalizing orientation during finalization. - pub construction_orientation_nanos: u64, - /// Wall-clock nanoseconds spent in final topology validation during finalization. - pub construction_topology_validation_nanos: u64, - /// Wall-clock nanoseconds spent in the final global Delaunay validation pass. - pub construction_final_delaunay_validation_nanos: u64, - - /// Number of point-location calls performed during construction. - pub locate_calls: usize, - /// Total facet-walk steps across all point-location calls. - pub locate_walk_steps_total: usize, - /// Maximum facet-walk steps taken by a single point-location call. - pub locate_walk_steps_max: usize, - /// Number of point-location calls that used a caller-provided hint. - pub locate_hint_uses: usize, - /// Number of point-location calls that fell back to a brute-force scan. - pub locate_scan_fallbacks: usize, - /// Number of point-location calls that ended inside a cell. - pub located_inside: usize, - /// Number of point-location calls that ended outside the convex hull. - pub located_outside: usize, - /// Number of point-location calls that ended on a lower-dimensional feature. - pub located_on_boundary: usize, - - /// Number of local conflict-region computations observed during construction. - pub conflict_region_calls: usize, - /// Total number of cells in local conflict regions. - pub conflict_region_cells_total: usize, - /// Maximum number of cells in a single local conflict region. - pub conflict_region_cells_max: usize, - /// Wall-clock nanoseconds spent computing local conflict regions. - pub conflict_region_nanos: u64, - /// Maximum wall-clock nanoseconds spent computing one local conflict region. - pub conflict_region_nanos_max: u64, - - /// Number of cavity insertion attempts observed during construction. - pub cavity_insertion_calls: usize, - /// Wall-clock nanoseconds spent filling cavities and wiring neighbors. - pub cavity_insertion_nanos: u64, - /// Maximum wall-clock nanoseconds spent in one cavity insertion attempt. - pub cavity_insertion_nanos_max: u64, - - /// Number of hull extension attempts observed during construction. - pub hull_extension_calls: usize, - /// Wall-clock nanoseconds spent extending the convex hull. - pub hull_extension_nanos: u64, - /// Maximum wall-clock nanoseconds spent in one hull extension attempt. - pub hull_extension_nanos_max: u64, - - /// Number of post-insertion topology validations observed during construction. - pub topology_validation_calls: usize, - /// Wall-clock nanoseconds spent in post-insertion topology validation. - pub topology_validation_nanos: u64, - /// Maximum wall-clock nanoseconds spent in one post-insertion validation. - pub topology_validation_nanos_max: u64, - - /// Number of batch local Delaunay repair calls during construction. - pub local_repair_calls: usize, - /// Wall-clock nanoseconds spent in batch local Delaunay repair. - pub local_repair_nanos: u64, - /// Maximum wall-clock nanoseconds spent in one batch local repair call. - pub local_repair_nanos_max: u64, - /// Total pending seed cells repaired by batch local repair calls. - pub local_repair_seed_cells_total: usize, - /// Maximum pending seed-cell frontier repaired by one batch local repair call. - pub local_repair_seed_cells_max: usize, - /// Number of batch local repair calls fired by the configured cadence. - pub local_repair_cadence_triggers: usize, - /// Number of batch local repair calls fired by the seed-backlog threshold. - pub local_repair_backlog_triggers: usize, - - /// Number of bulk local-repair seed accumulation calls. - pub repair_seed_accumulation_calls: usize, - /// Wall-clock nanoseconds spent accumulating bulk local-repair seeds. - pub repair_seed_accumulation_nanos: u64, - /// Maximum wall-clock nanoseconds spent in one bulk seed accumulation call. - pub repair_seed_accumulation_nanos_max: u64, - /// Total live seed cells added to pending bulk local-repair frontiers. - pub repair_seed_cells_added_total: usize, - /// Maximum live seed cells added by one bulk seed accumulation call. - pub repair_seed_cells_added_max: usize, - - /// Number of global exterior-point conflict scans. - pub global_conflict_scans: usize, - /// Total cells scanned by global exterior-point conflict scans. - pub global_conflict_cells_scanned: usize, - /// Total cells found by global exterior-point conflict scans. - pub global_conflict_cells_found_total: usize, - /// Maximum cells found by a single global exterior-point conflict scan. - pub global_conflict_cells_found_max: usize, - /// Wall-clock nanoseconds spent in global exterior-point conflict scans. - pub global_conflict_scan_nanos: u64, -} - -impl ConstructionTelemetry { - /// Returns true when any construction telemetry was recorded. - /// - /// # Examples - /// - /// ```rust - /// use delaunay::prelude::triangulation::construction::ConstructionTelemetry; - /// - /// let mut telemetry = ConstructionTelemetry::default(); - /// assert!(!telemetry.has_data()); - /// - /// telemetry.construction_insert_loop_nanos = 1; - /// assert!(telemetry.has_data()); - /// ``` - #[must_use] - pub const fn has_data(&self) -> bool { - self.insertion_wall_time_calls > 0 - || self.insertion_wall_time_nanos > 0 - || self.construction_preprocessing_nanos > 0 - || self.construction_insert_loop_nanos > 0 - || self.construction_finalize_nanos > 0 - || self.construction_completion_repair_nanos > 0 - || self.construction_orientation_nanos > 0 - || self.construction_topology_validation_nanos > 0 - || self.construction_final_delaunay_validation_nanos > 0 - || self.locate_calls > 0 - || self.conflict_region_calls > 0 - || self.cavity_insertion_calls > 0 - || self.hull_extension_calls > 0 - || self.topology_validation_calls > 0 - || self.local_repair_calls > 0 - || self.local_repair_seed_cells_total > 0 - || self.repair_seed_accumulation_calls > 0 - || self.global_conflict_scans > 0 - } - - /// Records the wall-clock duration of one transactional insertion call. - pub(crate) fn record_insertion_timing(&mut self, elapsed_nanos: u64) { - self.insertion_wall_time_calls = self.insertion_wall_time_calls.saturating_add(1); - self.insertion_wall_time_nanos = - self.insertion_wall_time_nanos.saturating_add(elapsed_nanos); - self.insertion_wall_time_nanos_max = self.insertion_wall_time_nanos_max.max(elapsed_nanos); - } - - /// Records the wall-clock duration of construction preprocessing. - pub(crate) const fn record_construction_preprocessing_timing(&mut self, elapsed_nanos: u64) { - self.construction_preprocessing_nanos = self - .construction_preprocessing_nanos - .saturating_add(elapsed_nanos); - } - - /// Records the wall-clock duration of the bulk insertion loop. - pub(crate) const fn record_construction_insert_loop_timing(&mut self, elapsed_nanos: u64) { - self.construction_insert_loop_nanos = self - .construction_insert_loop_nanos - .saturating_add(elapsed_nanos); - } - - /// Records the wall-clock duration of bulk-construction finalization. - pub(crate) const fn record_construction_finalize_timing(&mut self, elapsed_nanos: u64) { - self.construction_finalize_nanos = self - .construction_finalize_nanos - .saturating_add(elapsed_nanos); - } - - /// Records the wall-clock duration of seeded completion repair. - const fn record_construction_completion_repair_timing(&mut self, elapsed_nanos: u64) { - self.construction_completion_repair_nanos = self - .construction_completion_repair_nanos - .saturating_add(elapsed_nanos); - } - - /// Records the wall-clock duration of orientation canonicalization. - const fn record_construction_orientation_timing(&mut self, elapsed_nanos: u64) { - self.construction_orientation_nanos = self - .construction_orientation_nanos - .saturating_add(elapsed_nanos); - } - - /// Records the wall-clock duration of final topology validation. - const fn record_construction_topology_validation_timing(&mut self, elapsed_nanos: u64) { - self.construction_topology_validation_nanos = self - .construction_topology_validation_nanos - .saturating_add(elapsed_nanos); - } - - /// Records the wall-clock duration of final global Delaunay validation. - pub(crate) const fn record_construction_final_delaunay_validation_timing( - &mut self, - elapsed_nanos: u64, - ) { - self.construction_final_delaunay_validation_nanos = self - .construction_final_delaunay_validation_nanos - .saturating_add(elapsed_nanos); - } - - /// Records the wall-clock duration of one batch local repair call. - pub(crate) fn record_local_repair_timing(&mut self, elapsed_nanos: u64) { - self.local_repair_calls = self.local_repair_calls.saturating_add(1); - self.local_repair_nanos = self.local_repair_nanos.saturating_add(elapsed_nanos); - self.local_repair_nanos_max = self.local_repair_nanos_max.max(elapsed_nanos); - } - - /// Records the repaired local frontier size and why the repair fired. - fn record_local_repair_frontier( - &mut self, - seed_cells: usize, - trigger: BatchLocalRepairTrigger, - ) { - self.local_repair_seed_cells_total = self - .local_repair_seed_cells_total - .saturating_add(seed_cells); - self.local_repair_seed_cells_max = self.local_repair_seed_cells_max.max(seed_cells); - match trigger { - BatchLocalRepairTrigger::Cadence => { - self.local_repair_cadence_triggers = - self.local_repair_cadence_triggers.saturating_add(1); - } - BatchLocalRepairTrigger::SeedBacklog => { - self.local_repair_backlog_triggers = - self.local_repair_backlog_triggers.saturating_add(1); - } - } - } - - /// Records one bulk local-repair seed accumulation step. - pub(crate) fn record_repair_seed_accumulation( - &mut self, - elapsed_nanos: u64, - cells_added: usize, - ) { - self.repair_seed_accumulation_calls = self.repair_seed_accumulation_calls.saturating_add(1); - self.repair_seed_accumulation_nanos = self - .repair_seed_accumulation_nanos - .saturating_add(elapsed_nanos); - self.repair_seed_accumulation_nanos_max = - self.repair_seed_accumulation_nanos_max.max(elapsed_nanos); - self.repair_seed_cells_added_total = self - .repair_seed_cells_added_total - .saturating_add(cells_added); - self.repair_seed_cells_added_max = self.repair_seed_cells_added_max.max(cells_added); - } - - /// Adds one insertion's telemetry into this construction summary. - pub(crate) fn record_insertion(&mut self, telemetry: &InsertionTelemetry) { - self.locate_calls = self.locate_calls.saturating_add(telemetry.locate_calls); - self.locate_walk_steps_total = self - .locate_walk_steps_total - .saturating_add(telemetry.locate_walk_steps_total); - self.locate_walk_steps_max = self - .locate_walk_steps_max - .max(telemetry.locate_walk_steps_max); - self.locate_hint_uses = self - .locate_hint_uses - .saturating_add(telemetry.locate_hint_uses); - self.locate_scan_fallbacks = self - .locate_scan_fallbacks - .saturating_add(telemetry.locate_scan_fallbacks); - self.located_inside = self.located_inside.saturating_add(telemetry.located_inside); - self.located_outside = self - .located_outside - .saturating_add(telemetry.located_outside); - self.located_on_boundary = self - .located_on_boundary - .saturating_add(telemetry.located_on_boundary); - - self.conflict_region_calls = self - .conflict_region_calls - .saturating_add(telemetry.conflict_region_calls); - self.conflict_region_cells_total = self - .conflict_region_cells_total - .saturating_add(telemetry.conflict_region_cells_total); - self.conflict_region_cells_max = self - .conflict_region_cells_max - .max(telemetry.conflict_region_cells_max); - self.conflict_region_nanos = self - .conflict_region_nanos - .saturating_add(telemetry.conflict_region_nanos); - self.conflict_region_nanos_max = self - .conflict_region_nanos_max - .max(telemetry.conflict_region_nanos_max); - - self.cavity_insertion_calls = self - .cavity_insertion_calls - .saturating_add(telemetry.cavity_insertion_calls); - self.cavity_insertion_nanos = self - .cavity_insertion_nanos - .saturating_add(telemetry.cavity_insertion_nanos); - self.cavity_insertion_nanos_max = self - .cavity_insertion_nanos_max - .max(telemetry.cavity_insertion_nanos_max); - - self.hull_extension_calls = self - .hull_extension_calls - .saturating_add(telemetry.hull_extension_calls); - self.hull_extension_nanos = self - .hull_extension_nanos - .saturating_add(telemetry.hull_extension_nanos); - self.hull_extension_nanos_max = self - .hull_extension_nanos_max - .max(telemetry.hull_extension_nanos_max); - - self.topology_validation_calls = self - .topology_validation_calls - .saturating_add(telemetry.topology_validation_calls); - self.topology_validation_nanos = self - .topology_validation_nanos - .saturating_add(telemetry.topology_validation_nanos); - self.topology_validation_nanos_max = self - .topology_validation_nanos_max - .max(telemetry.topology_validation_nanos_max); - - self.global_conflict_scans = self - .global_conflict_scans - .saturating_add(telemetry.global_conflict_scans); - self.global_conflict_cells_scanned = self - .global_conflict_cells_scanned - .saturating_add(telemetry.global_conflict_cells_scanned); - self.global_conflict_cells_found_total = self - .global_conflict_cells_found_total - .saturating_add(telemetry.global_conflict_cells_found_total); - self.global_conflict_cells_found_max = self - .global_conflict_cells_found_max - .max(telemetry.global_conflict_cells_found_max); - self.global_conflict_scan_nanos = self - .global_conflict_scan_nanos - .saturating_add(telemetry.global_conflict_scan_nanos); - } - - /// Merges another construction telemetry summary into this one. - fn merge_from(&mut self, other: &Self) { - self.insertion_wall_time_nanos = self - .insertion_wall_time_nanos - .saturating_add(other.insertion_wall_time_nanos); - self.insertion_wall_time_calls = self - .insertion_wall_time_calls - .saturating_add(other.insertion_wall_time_calls); - self.insertion_wall_time_nanos_max = self - .insertion_wall_time_nanos_max - .max(other.insertion_wall_time_nanos_max); - - self.merge_construction_phase_timings_from(other); - - self.locate_calls = self.locate_calls.saturating_add(other.locate_calls); - self.locate_walk_steps_total = self - .locate_walk_steps_total - .saturating_add(other.locate_walk_steps_total); - self.locate_walk_steps_max = self.locate_walk_steps_max.max(other.locate_walk_steps_max); - self.locate_hint_uses = self.locate_hint_uses.saturating_add(other.locate_hint_uses); - self.locate_scan_fallbacks = self - .locate_scan_fallbacks - .saturating_add(other.locate_scan_fallbacks); - self.located_inside = self.located_inside.saturating_add(other.located_inside); - self.located_outside = self.located_outside.saturating_add(other.located_outside); - self.located_on_boundary = self - .located_on_boundary - .saturating_add(other.located_on_boundary); - - self.conflict_region_calls = self - .conflict_region_calls - .saturating_add(other.conflict_region_calls); - self.conflict_region_cells_total = self - .conflict_region_cells_total - .saturating_add(other.conflict_region_cells_total); - self.conflict_region_cells_max = self - .conflict_region_cells_max - .max(other.conflict_region_cells_max); - self.conflict_region_nanos = self - .conflict_region_nanos - .saturating_add(other.conflict_region_nanos); - self.conflict_region_nanos_max = self - .conflict_region_nanos_max - .max(other.conflict_region_nanos_max); - - self.cavity_insertion_calls = self - .cavity_insertion_calls - .saturating_add(other.cavity_insertion_calls); - self.cavity_insertion_nanos = self - .cavity_insertion_nanos - .saturating_add(other.cavity_insertion_nanos); - self.cavity_insertion_nanos_max = self - .cavity_insertion_nanos_max - .max(other.cavity_insertion_nanos_max); - - self.hull_extension_calls = self - .hull_extension_calls - .saturating_add(other.hull_extension_calls); - self.hull_extension_nanos = self - .hull_extension_nanos - .saturating_add(other.hull_extension_nanos); - self.hull_extension_nanos_max = self - .hull_extension_nanos_max - .max(other.hull_extension_nanos_max); - - self.topology_validation_calls = self - .topology_validation_calls - .saturating_add(other.topology_validation_calls); - self.topology_validation_nanos = self - .topology_validation_nanos - .saturating_add(other.topology_validation_nanos); - self.topology_validation_nanos_max = self - .topology_validation_nanos_max - .max(other.topology_validation_nanos_max); - - self.merge_local_repair_from(other); - - self.merge_repair_seed_accumulation_from(other); - - self.global_conflict_scans = self - .global_conflict_scans - .saturating_add(other.global_conflict_scans); - self.global_conflict_cells_scanned = self - .global_conflict_cells_scanned - .saturating_add(other.global_conflict_cells_scanned); - self.global_conflict_cells_found_total = self - .global_conflict_cells_found_total - .saturating_add(other.global_conflict_cells_found_total); - self.global_conflict_cells_found_max = self - .global_conflict_cells_found_max - .max(other.global_conflict_cells_found_max); - self.global_conflict_scan_nanos = self - .global_conflict_scan_nanos - .saturating_add(other.global_conflict_scan_nanos); - } - - /// Keeps construction-phase merge accounting isolated so aggregate merges stay readable. - const fn merge_construction_phase_timings_from(&mut self, other: &Self) { - self.construction_preprocessing_nanos = self - .construction_preprocessing_nanos - .saturating_add(other.construction_preprocessing_nanos); - self.construction_insert_loop_nanos = self - .construction_insert_loop_nanos - .saturating_add(other.construction_insert_loop_nanos); - self.construction_finalize_nanos = self - .construction_finalize_nanos - .saturating_add(other.construction_finalize_nanos); - self.construction_completion_repair_nanos = self - .construction_completion_repair_nanos - .saturating_add(other.construction_completion_repair_nanos); - self.construction_orientation_nanos = self - .construction_orientation_nanos - .saturating_add(other.construction_orientation_nanos); - self.construction_topology_validation_nanos = self - .construction_topology_validation_nanos - .saturating_add(other.construction_topology_validation_nanos); - self.construction_final_delaunay_validation_nanos = self - .construction_final_delaunay_validation_nanos - .saturating_add(other.construction_final_delaunay_validation_nanos); - } - - /// Keeps local-repair merge accounting isolated so the aggregate merge stays readable. - fn merge_local_repair_from(&mut self, other: &Self) { - self.local_repair_calls = self - .local_repair_calls - .saturating_add(other.local_repair_calls); - self.local_repair_nanos = self - .local_repair_nanos - .saturating_add(other.local_repair_nanos); - self.local_repair_nanos_max = self - .local_repair_nanos_max - .max(other.local_repair_nanos_max); - self.local_repair_seed_cells_total = self - .local_repair_seed_cells_total - .saturating_add(other.local_repair_seed_cells_total); - self.local_repair_seed_cells_max = self - .local_repair_seed_cells_max - .max(other.local_repair_seed_cells_max); - self.local_repair_cadence_triggers = self - .local_repair_cadence_triggers - .saturating_add(other.local_repair_cadence_triggers); - self.local_repair_backlog_triggers = self - .local_repair_backlog_triggers - .saturating_add(other.local_repair_backlog_triggers); - } - - fn merge_repair_seed_accumulation_from(&mut self, other: &Self) { - self.repair_seed_accumulation_calls = self - .repair_seed_accumulation_calls - .saturating_add(other.repair_seed_accumulation_calls); - self.repair_seed_accumulation_nanos = self - .repair_seed_accumulation_nanos - .saturating_add(other.repair_seed_accumulation_nanos); - self.repair_seed_accumulation_nanos_max = self - .repair_seed_accumulation_nanos_max - .max(other.repair_seed_accumulation_nanos_max); - self.repair_seed_cells_added_total = self - .repair_seed_cells_added_total - .saturating_add(other.repair_seed_cells_added_total); - self.repair_seed_cells_added_max = self - .repair_seed_cells_added_max - .max(other.repair_seed_cells_added_max); - } -} - /// Aggregate statistics collected during batch construction. /// /// This summarizes the per-vertex [`InsertionStatistics`] generated by the incremental insertion @@ -2241,6 +1770,200 @@ where unique } +/// Converts candidate simplex vertices to f64 coordinates for deterministic +/// preprocessing heuristics without hiding non-finite inputs. +fn vertices_coords_f64(vertices: &[Vertex]) -> Option> +where + T: CoordinateScalar, +{ + let mut coords_f64: Vec<[f64; D]> = Vec::with_capacity(vertices.len()); + for v in vertices { + let coords = safe_coords_to_f64(v.point().coords()).ok()?; + if coords.iter().any(|coord| !coord.is_finite()) { + return None; + } + coords_f64.push(coords); + } + Some(coords_f64) +} + +/// Computes squared Euclidean distance for initial-simplex selection +/// heuristics that only need deterministic ordering. +fn squared_distance(a: &[f64; D], b: &[f64; D]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(lhs, rhs)| { + let diff = lhs - rhs; + diff * diff + }) + .sum::() +} + +/// Appends an index once so candidate pools remain small and deterministic. +fn push_unique_index(indices: &mut Vec, idx: usize) { + if !indices.contains(&idx) { + indices.push(idx); + } +} + +/// Computes the bounded candidate-pool size for max-volume simplex search. +const fn initial_simplex_candidate_cap(point_count: usize) -> usize { + let minimum = D.saturating_add(1); + let bounded_cap = if INITIAL_SIMPLEX_MAX_VOLUME_CANDIDATE_CAP > minimum { + INITIAL_SIMPLEX_MAX_VOLUME_CANDIDATE_CAP + } else { + minimum + }; + let requested = D.saturating_add(1).saturating_mul(2).saturating_add(4); + let target = if requested < bounded_cap { + requested + } else { + bounded_cap + }; + if point_count < target { + point_count + } else { + target + } +} + +/// Finds the deterministic lexicographic anchor for a candidate pool. +fn lexicographic_min_index(coords_f64: &[[f64; D]]) -> Option { + if coords_f64.is_empty() { + return None; + } + let mut lexicographic_min = 0usize; + for idx in 1..coords_f64.len() { + if coords_f64[idx].partial_cmp(&coords_f64[lexicographic_min]) == Some(Ordering::Less) { + lexicographic_min = idx; + } + } + Some(lexicographic_min) +} + +/// Adds per-axis coordinate extrema to the candidate pool. +fn append_axis_extrema(coords_f64: &[[f64; D]], candidates: &mut Vec) { + for axis in 0..D { + let mut min_idx = 0usize; + let mut max_idx = 0usize; + for idx in 1..coords_f64.len() { + let coord = coords_f64[idx][axis]; + let min_coord = coords_f64[min_idx][axis]; + let max_coord = coords_f64[max_idx][axis]; + + match coord.partial_cmp(&min_coord) { + Some(Ordering::Less) => min_idx = idx, + Some(Ordering::Equal) + if coords_f64[idx].partial_cmp(&coords_f64[min_idx]) + == Some(Ordering::Less) => + { + min_idx = idx; + } + _ => {} + } + match coord.partial_cmp(&max_coord) { + Some(Ordering::Greater) => max_idx = idx, + Some(Ordering::Equal) + if coords_f64[idx].partial_cmp(&coords_f64[max_idx]) + == Some(Ordering::Less) => + { + max_idx = idx; + } + _ => {} + } + } + push_unique_index(candidates, min_idx); + push_unique_index(candidates, max_idx); + } +} + +/// Extends the candidate pool with farthest-point samples until it reaches the +/// configured cap or exhausts usable points. +fn extend_candidate_pool_by_farthest_points( + coords_f64: &[[f64; D]], + candidates: &mut Vec, + candidate_cap: usize, +) { + let mut selected_mask = vec![false; coords_f64.len()]; + for &idx in candidates.iter() { + selected_mask[idx] = true; + } + + let mut min_dist_sq = vec![f64::INFINITY; coords_f64.len()]; + for idx in 0..coords_f64.len() { + if selected_mask[idx] { + min_dist_sq[idx] = 0.0; + continue; + } + for &candidate_idx in candidates.iter() { + let dist = squared_distance(&coords_f64[idx], &coords_f64[candidate_idx]); + if dist < min_dist_sq[idx] { + min_dist_sq[idx] = dist; + } + } + } + + while candidates.len() < candidate_cap { + let mut best_idx: Option = None; + let mut best_dist = -1.0_f64; + + for idx in 0..coords_f64.len() { + if selected_mask[idx] { + continue; + } + let dist = min_dist_sq[idx]; + if !dist.is_finite() { + continue; + } + let replace = best_idx.is_none_or(|best_idx_val| match dist.partial_cmp(&best_dist) { + Some(Ordering::Greater) => true, + Some(Ordering::Equal) => { + coords_f64[idx].partial_cmp(&coords_f64[best_idx_val]) == Some(Ordering::Less) + } + _ => false, + }); + if replace { + best_idx = Some(idx); + best_dist = dist; + } + } + + let Some(best_idx) = best_idx else { + break; + }; + push_unique_index(candidates, best_idx); + selected_mask[best_idx] = true; + + for idx in 0..coords_f64.len() { + if selected_mask[idx] { + continue; + } + let dist = squared_distance(&coords_f64[idx], &coords_f64[best_idx]); + if dist < min_dist_sq[idx] { + min_dist_sq[idx] = dist; + } + } + } +} + +/// Chooses a bounded pool of real extreme vertices for max-volume simplex +/// search. +fn initial_simplex_candidate_pool_indices(coords_f64: &[[f64; D]]) -> Vec { + let candidate_cap = initial_simplex_candidate_cap::(coords_f64.len()); + if candidate_cap == 0 { + return Vec::new(); + } + + let mut candidates = Vec::with_capacity(candidate_cap); + if let Some(lexicographic_min) = lexicographic_min_index(coords_f64) { + push_unique_index(&mut candidates, lexicographic_min); + } + append_axis_extrema(coords_f64, &mut candidates); + extend_candidate_pool_by_farthest_points(coords_f64, &mut candidates, candidate_cap); + + candidates +} + /// Chooses a well-spread initial simplex to reduce early degeneracy in /// incremental construction. fn select_balanced_simplex_indices( @@ -2253,27 +1976,7 @@ where return None; } - let mut coords_f64: Vec<[f64; D]> = Vec::with_capacity(vertices.len()); - for v in vertices { - let mut coords = [0.0_f64; D]; - for (axis, coord) in v.point().coords().iter().enumerate() { - let c = coord.to_f64()?; - if !c.is_finite() { - return None; - } - coords[axis] = c; - } - coords_f64.push(coords); - } - let dist_sq = |a: &[f64; D], b: &[f64; D]| { - a.iter() - .zip(b.iter()) - .map(|(lhs, rhs)| { - let diff = lhs - rhs; - diff * diff - }) - .sum::() - }; + let coords_f64 = vertices_coords_f64(vertices)?; let mut seed_idx = 0usize; for i in 1..coords_f64.len() { @@ -2289,7 +1992,7 @@ where let mut min_dist_sq = vec![f64::INFINITY; coords_f64.len()]; for i in 0..coords_f64.len() { - min_dist_sq[i] = dist_sq(&coords_f64[i], &coords_f64[seed_idx]); + min_dist_sq[i] = squared_distance(&coords_f64[i], &coords_f64[seed_idx]); } min_dist_sq[seed_idx] = 0.0; @@ -2328,7 +2031,7 @@ where if selected_mask[i] { continue; } - let dist_sq = dist_sq(&coords_f64[i], &coords_f64[best_idx]); + let dist_sq = squared_distance(&coords_f64[i], &coords_f64[best_idx]); if dist_sq < min_dist_sq[i] { min_dist_sq[i] = dist_sq; } @@ -2342,6 +2045,87 @@ where } } +/// Advances a lexicographic combination in place so max-volume search can +/// enumerate bounded candidate pools without recursion. +fn advance_combination(indices: &mut [usize], upper: usize) -> bool { + let len = indices.len(); + if len > upper { + return false; + } + for pos in (0..len).rev() { + if indices[pos] < pos + upper - len { + indices[pos] += 1; + for next in pos + 1..len { + indices[next] = indices[next - 1] + 1; + } + return true; + } + } + false +} + +/// Scores a candidate simplex by f64 volume and rejects degenerate choices. +fn simplex_volume_for_indices( + coords_f64: &[[f64; D]], + simplex_indices: &[usize], +) -> Option { + if simplex_indices.len() != D + 1 { + return None; + } + + let mut points: SmallBuffer, MAX_PRACTICAL_DIMENSION_SIZE> = + SmallBuffer::with_capacity(simplex_indices.len()); + for &idx in simplex_indices { + points.push(Point::new(coords_f64[idx])); + } + simplex_volume(&points) + .ok() + .filter(|volume| volume.is_finite() && *volume > 0.0) +} + +/// Chooses the largest-volume nondegenerate real simplex from a bounded +/// extreme-vertex candidate pool. +fn select_max_volume_simplex_indices( + vertices: &[Vertex], +) -> Option> +where + T: CoordinateScalar, +{ + if vertices.len() < D + 1 { + return None; + } + + let coords_f64 = vertices_coords_f64(vertices)?; + let candidates = initial_simplex_candidate_pool_indices(&coords_f64); + if candidates.len() < D + 1 { + return None; + } + + let simplex_len = D + 1; + let mut combination: Vec = (0..simplex_len).collect(); + let mut best_volume = 0.0_f64; + let mut best_indices: Option> = None; + + loop { + let simplex_indices: SmallBuffer = combination + .iter() + .map(|&candidate_idx| candidates[candidate_idx]) + .collect(); + if let Some(volume) = simplex_volume_for_indices(&coords_f64, &simplex_indices) + && volume > best_volume + { + best_volume = volume; + best_indices = Some(simplex_indices.iter().copied().collect()); + } + + if !advance_combination(&mut combination, candidates.len()) { + break; + } + } + + best_indices +} + /// Places the selected simplex first while preserving every remaining input /// vertex exactly once. fn reorder_vertices_for_simplex( @@ -3694,6 +3478,18 @@ where (Some(base), None) } } + InitialSimplexStrategy::MaxVolume => { + let base = owned_vertices.unwrap_or_else(|| vertices.to_vec()); + if let Some(indices) = select_max_volume_simplex_indices(&base) { + if let Some(reordered) = reorder_vertices_for_simplex(&base, &indices) { + (Some(reordered), Some(base)) + } else { + (Some(base), None) + } + } else { + (Some(base), None) + } + } }; let final_slice = primary.as_deref().unwrap_or(vertices); @@ -4574,7 +4370,7 @@ where pending_seed_cells: &mut Vec, pending_seen: &mut FastHashSet, soft_fail_seeds: &mut Vec, - construction_telemetry: Option<&mut ConstructionTelemetry>, + mut construction_telemetry: Option<&mut ConstructionTelemetry>, ) -> Result<(), DelaunayTriangulationConstructionError> { retain_live_cell_seeds(&self.tri.tds, pending_seed_cells, pending_seen); if pending_seed_cells.is_empty() { @@ -4587,7 +4383,7 @@ where let seed_cells_len = pending_seed_cells.len(); let max_flips = local_repair_flip_budget::(seed_cells_len); let trace_repair = batch_repair_trace_enabled(); - let repair_started = Instant::now(); + let mut phase_timing = LocalRepairPhaseTiming::default(); if trace_repair { tracing::debug!( idx = index, @@ -4597,11 +4393,24 @@ where "bulk batch repair: starting local repair" ); } + let collect_telemetry = construction_telemetry.is_some(); + let repair_started = (collect_telemetry || trace_repair).then(Instant::now); let repair_result = { self.invalidate_repair_caches(); let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass(tds, kernel, pending_seed_cells, max_flips) + let timing = if collect_telemetry { + Some(&mut phase_timing) + } else { + None + }; + repair_delaunay_local_single_pass_timed( + tds, + kernel, + pending_seed_cells, + max_flips, + timing, + ) }; #[cfg(test)] let repair_result = if test_hooks::force_repair_nonconvergent_enabled() { @@ -4609,14 +4418,23 @@ where } else { repair_result }; - let repair_elapsed = repair_started.elapsed(); - if let Some(telemetry) = construction_telemetry { + let repair_elapsed = repair_started.map(|started| started.elapsed()); + if let Some(telemetry) = construction_telemetry.as_mut() { + let repair_elapsed = repair_elapsed.unwrap_or_default(); telemetry.record_local_repair_timing(duration_nanos_saturating(repair_elapsed)); + telemetry.record_local_repair_phase_timing(&phase_timing); telemetry.record_local_repair_frontier(seed_cells_len, trigger); } match repair_result { Ok(stats) => { + if let Some(telemetry) = construction_telemetry.as_mut() { + telemetry.record_local_repair_work( + stats.facets_checked, + stats.flips_performed, + stats.max_queue_len, + ); + } if trace_repair { tracing::debug!( idx = index, @@ -4624,7 +4442,7 @@ where flips = stats.flips_performed, checked = stats.facets_checked, max_queue = stats.max_queue_len, - elapsed = ?repair_elapsed, + elapsed = ?repair_elapsed.unwrap_or_default(), "bulk batch repair: local repair succeeded" ); } @@ -4639,7 +4457,7 @@ where idx = index, seed_cells = seed_cells_len, error = %repair_err, - elapsed = ?repair_elapsed, + elapsed = ?repair_elapsed.unwrap_or_default(), "bulk batch repair: local repair failed" ); } @@ -4759,7 +4577,12 @@ where let elapsed = started.map(|started| started.elapsed()); let insert_result = insert_result.map(|detail| { let repair_seed_cells = detail.repair_seed_cells; - (detail.outcome, detail.stats, repair_seed_cells) + ( + detail.outcome, + detail.stats, + repair_seed_cells, + detail.delaunay_repair_required, + ) }); match insert_result { Ok(( @@ -4769,6 +4592,7 @@ where }, _stats, repair_seed_cells, + delaunay_repair_required, )) => { inserted_vertices = inserted_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { @@ -4786,7 +4610,8 @@ where // This keeps EveryN semantics local to the recent insertion window // rather than repairing only the final insertion in the batch. let topology = self.tri.topology_guarantee(); - if batch_repair_policy != DelaunayRepairPolicy::Never + if delaunay_repair_required + && batch_repair_policy != DelaunayRepairPolicy::Never && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { @@ -4823,7 +4648,12 @@ where &mut batch_progress, ); } - Ok((InsertionOutcome::Skipped { error }, stats, _repair_seed_cells)) => { + Ok(( + InsertionOutcome::Skipped { error }, + stats, + _repair_seed_cells, + _delaunay_repair_required, + )) => { skipped_vertices = skipped_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { tracing::debug!( @@ -4919,6 +4749,7 @@ where detail.outcome, detail.stats, repair_seed_cells, + detail.delaunay_repair_required, detail.telemetry, ) }); @@ -4930,6 +4761,7 @@ where }, stats, repair_seed_cells, + delaunay_repair_required, telemetry, )) => { inserted_vertices = inserted_vertices.saturating_add(1); @@ -4976,7 +4808,8 @@ where // Batch local repair: see the non-stats branch // comment for full details. let topology = self.tri.topology_guarantee(); - if batch_repair_policy != DelaunayRepairPolicy::Never + if delaunay_repair_required + && batch_repair_policy != DelaunayRepairPolicy::Never && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { @@ -5024,6 +4857,7 @@ where InsertionOutcome::Skipped { error }, stats, _repair_seed_cells, + _delaunay_repair_required, telemetry, )) => { skipped_vertices = skipped_vertices.saturating_add(1); @@ -6264,6 +6098,7 @@ where })? }; let repair_seed_cells = insert_detail.repair_seed_cells; + let delaunay_repair_required = insert_detail.delaunay_repair_required; match insert_detail.outcome { InsertionOutcome::Inserted { vertex_key, hint } => { @@ -6273,18 +6108,20 @@ where .delaunay_repair_insertion_count .saturating_add(1); - candidate - .maybe_repair_after_insertion_capped( - vertex_key, - hint, - &repair_seed_cells, - max_flips_override, - ) - .map_err(|e| DelaunayRepairError::HeuristicRebuildFailed { - message: format!( - "heuristic rebuild repair failed at idx={idx} uuid={uuid} coords={coords:?}: {e}" - ), - })?; + if delaunay_repair_required { + candidate + .maybe_repair_after_insertion_capped( + vertex_key, + hint, + &repair_seed_cells, + max_flips_override, + ) + .map_err(|e| DelaunayRepairError::HeuristicRebuildFailed { + message: format!( + "heuristic rebuild repair failed at idx={idx} uuid={uuid} coords={coords:?}: {e}" + ), + })?; + } candidate .maybe_check_after_insertion() @@ -6995,6 +6832,7 @@ where )? }; let repair_seed_cells = insert_detail.repair_seed_cells; + let delaunay_repair_required = insert_detail.delaunay_repair_required; match insert_detail.outcome { InsertionOutcome::Inserted { @@ -7006,7 +6844,9 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - self.maybe_repair_after_insertion(v_key, hint, &repair_seed_cells)?; + if delaunay_repair_required { + self.maybe_repair_after_insertion(v_key, hint, &repair_seed_cells)?; + } self.maybe_check_after_insertion()?; Ok(v_key) } @@ -7096,6 +6936,7 @@ where }; let stats = insert_detail.stats; let repair_seed_cells = insert_detail.repair_seed_cells; + let delaunay_repair_required = insert_detail.delaunay_repair_required; let outcome = match insert_detail.outcome { InsertionOutcome::Inserted { vertex_key, hint } => { @@ -7104,7 +6945,9 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - self.maybe_repair_after_insertion(vertex_key, hint, &repair_seed_cells)?; + if delaunay_repair_required { + self.maybe_repair_after_insertion(vertex_key, hint, &repair_seed_cells)?; + } self.maybe_check_after_insertion()?; InsertionOutcome::Inserted { vertex_key, hint } } @@ -8217,13 +8060,12 @@ mod tests { CavityFillingError, HullExtensionReason, NeighborWiringError, repair_neighbor_pointers, }; use crate::core::algorithms::locate::{ConflictError, LocateError}; - use crate::core::operations::{InsertionResult, InsertionTelemetry}; + use crate::core::operations::InsertionResult; use crate::core::tds::{EntityKind, GeometricError, TriangulationConstructionState}; use crate::core::vertex::VertexBuilder; use crate::geometry::kernel::{AdaptiveKernel, FastKernel, RobustKernel}; use crate::geometry::point::Point; - use crate::geometry::traits::coordinate::Coordinate; - use crate::geometry::traits::coordinate::CoordinateConversionError; + use crate::geometry::traits::coordinate::{Coordinate, CoordinateConversionError}; use crate::topology::characteristics::euler::TopologyClassification; use crate::topology::traits::topological_space::ToroidalConstructionMode; use crate::triangulation::flips::BistellarFlips; @@ -8544,9 +8386,13 @@ mod tests { #[test] fn test_construction_options_default_uses_batch_repair_cadence() { init_tracing(); + assert_eq!( + ConstructionOptions::default().initial_simplex_strategy(), + InitialSimplexStrategy::MaxVolume + ); assert_eq!( ConstructionOptions::default().batch_repair_policy(), - DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap()) + DelaunayRepairPolicy::EveryInsertion ); assert_eq!( DelaunayRepairPolicy::default(), @@ -8974,159 +8820,6 @@ mod tests { assert!(matches!(stats.result, InsertionResult::Inserted)); } - #[test] - #[expect( - clippy::too_many_lines, - reason = "single-field telemetry regression covers every aggregate counter" - )] - fn test_construction_statistics_record_insertion_tracks_telemetry() { - init_tracing(); - - let mut summary = ConstructionStatistics::default(); - let telemetry = InsertionTelemetry { - locate_calls: 2, - locate_walk_steps_total: 9, - locate_walk_steps_max: 7, - locate_hint_uses: 1, - locate_scan_fallbacks: 1, - located_inside: 1, - located_outside: 1, - conflict_region_calls: 1, - conflict_region_cells_total: 4, - conflict_region_cells_max: 4, - conflict_region_nanos: 125_000, - conflict_region_nanos_max: 125_000, - cavity_insertion_calls: 1, - cavity_insertion_nanos: 375_000, - cavity_insertion_nanos_max: 375_000, - hull_extension_calls: 1, - hull_extension_nanos: 500_000, - hull_extension_nanos_max: 500_000, - topology_validation_calls: 1, - topology_validation_nanos: 625_000, - topology_validation_nanos_max: 625_000, - global_conflict_scans: 1, - global_conflict_cells_scanned: 12, - global_conflict_cells_found_total: 3, - global_conflict_cells_found_max: 3, - global_conflict_scan_nanos: 250_000, - ..InsertionTelemetry::default() - }; - - summary.telemetry.record_insertion(&telemetry); - summary.telemetry.record_insertion_timing(1_000_000); - summary.telemetry.record_local_repair_timing(2_000_000); - summary - .telemetry - .record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); - summary - .telemetry - .record_repair_seed_accumulation(500_000, 7); - summary - .telemetry - .record_construction_preprocessing_timing(10_000); - summary - .telemetry - .record_construction_insert_loop_timing(20_000); - summary - .telemetry - .record_construction_finalize_timing(30_000); - summary - .telemetry - .record_construction_completion_repair_timing(40_000); - summary - .telemetry - .record_construction_orientation_timing(50_000); - summary - .telemetry - .record_construction_topology_validation_timing(60_000); - summary - .telemetry - .record_construction_final_delaunay_validation_timing(70_000); - - assert!(summary.telemetry.has_data()); - assert_eq!(summary.telemetry.insertion_wall_time_calls, 1); - assert_eq!(summary.telemetry.insertion_wall_time_nanos, 1_000_000); - assert_eq!(summary.telemetry.insertion_wall_time_nanos_max, 1_000_000); - assert_eq!(summary.telemetry.construction_preprocessing_nanos, 10_000); - assert_eq!(summary.telemetry.construction_insert_loop_nanos, 20_000); - assert_eq!(summary.telemetry.construction_finalize_nanos, 30_000); - assert_eq!( - summary.telemetry.construction_completion_repair_nanos, - 40_000 - ); - assert_eq!(summary.telemetry.construction_orientation_nanos, 50_000); - assert_eq!( - summary.telemetry.construction_topology_validation_nanos, - 60_000 - ); - assert_eq!( - summary - .telemetry - .construction_final_delaunay_validation_nanos, - 70_000 - ); - assert_eq!(summary.telemetry.locate_calls, 2); - assert_eq!(summary.telemetry.locate_walk_steps_total, 9); - assert_eq!(summary.telemetry.locate_walk_steps_max, 7); - assert_eq!(summary.telemetry.locate_hint_uses, 1); - assert_eq!(summary.telemetry.locate_scan_fallbacks, 1); - assert_eq!(summary.telemetry.located_inside, 1); - assert_eq!(summary.telemetry.located_outside, 1); - assert_eq!(summary.telemetry.conflict_region_calls, 1); - assert_eq!(summary.telemetry.conflict_region_cells_total, 4); - assert_eq!(summary.telemetry.conflict_region_nanos, 125_000); - assert_eq!(summary.telemetry.conflict_region_nanos_max, 125_000); - assert_eq!(summary.telemetry.cavity_insertion_calls, 1); - assert_eq!(summary.telemetry.cavity_insertion_nanos, 375_000); - assert_eq!(summary.telemetry.hull_extension_calls, 1); - assert_eq!(summary.telemetry.hull_extension_nanos, 500_000); - assert_eq!(summary.telemetry.topology_validation_calls, 1); - assert_eq!(summary.telemetry.topology_validation_nanos, 625_000); - assert_eq!(summary.telemetry.local_repair_calls, 1); - assert_eq!(summary.telemetry.local_repair_nanos, 2_000_000); - assert_eq!(summary.telemetry.local_repair_seed_cells_total, 11); - assert_eq!(summary.telemetry.local_repair_seed_cells_max, 11); - assert_eq!(summary.telemetry.local_repair_cadence_triggers, 0); - assert_eq!(summary.telemetry.local_repair_backlog_triggers, 1); - assert_eq!(summary.telemetry.repair_seed_accumulation_calls, 1); - assert_eq!(summary.telemetry.repair_seed_accumulation_nanos, 500_000); - assert_eq!(summary.telemetry.repair_seed_cells_added_total, 7); - assert_eq!(summary.telemetry.repair_seed_cells_added_max, 7); - assert_eq!(summary.telemetry.global_conflict_scans, 1); - assert_eq!(summary.telemetry.global_conflict_cells_scanned, 12); - assert_eq!(summary.telemetry.global_conflict_cells_found_total, 3); - assert_eq!(summary.telemetry.global_conflict_scan_nanos, 250_000); - } - - #[test] - fn test_construction_telemetry_merge_preserves_local_repair_frontiers() { - let mut left = ConstructionTelemetry::default(); - left.record_local_repair_timing(10); - left.record_local_repair_frontier(5, BatchLocalRepairTrigger::Cadence); - left.record_construction_insert_loop_timing(100); - left.record_construction_final_delaunay_validation_timing(200); - - let mut right = ConstructionTelemetry::default(); - right.record_local_repair_timing(30); - right.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); - right.record_construction_insert_loop_timing(300); - right.record_construction_final_delaunay_validation_timing(400); - - left.merge_from(&right); - - assert!(left.has_data()); - assert_eq!(left.construction_insert_loop_nanos, 400); - assert_eq!(left.construction_final_delaunay_validation_nanos, 600); - assert_eq!(left.local_repair_calls, 2); - assert_eq!(left.local_repair_nanos, 40); - assert_eq!(left.local_repair_nanos_max, 30); - assert_eq!(left.local_repair_seed_cells_total, 16); - assert_eq!(left.local_repair_seed_cells_max, 11); - assert_eq!(left.local_repair_cadence_triggers, 1); - assert_eq!(left.local_repair_backlog_triggers, 1); - } - #[test] fn test_construction_statistics_record_insertion_tracks_skipped_variants() { init_tracing(); @@ -9257,6 +8950,107 @@ mod tests { assert!(result.is_none()); } + macro_rules! max_volume_axis_simplex_test { + ($test_name:ident, $dimension:literal, [$($coords:expr),+ $(,)?], [$($expected_idx:expr),+ $(,)?]) => { + #[test] + fn $test_name() { + init_tracing(); + let vertices: Vec> = vec![$(vertex!($coords)),+]; + + let result = select_max_volume_simplex_indices(&vertices) + .expect("max-volume simplex selection failed"); + let expected_indices = [$($expected_idx),+]; + + assert_eq!(result.len(), expected_indices.len()); + for expected_idx in expected_indices { + assert!( + result.contains(&expected_idx), + "expected selected simplex {result:?} to contain vertex index {expected_idx}" + ); + } + } + }; + } + + max_volume_axis_simplex_test!( + test_select_max_volume_simplex_indices_prefers_largest_triangle_2d, + 2, + [ + [0.0, 0.0], + [1.0, 0.0], + [0.0, 1.0], + [10.0, 0.0], + [0.0, 10.0], + [1.0, 1.0], + ], + [0, 3, 4] + ); + + max_volume_axis_simplex_test!( + test_select_max_volume_simplex_indices_prefers_largest_tetrahedron, + 3, + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 10.0], + ], + [0, 4, 5, 6] + ); + + max_volume_axis_simplex_test!( + test_select_max_volume_simplex_indices_prefers_largest_simplex_4d, + 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], + [10.0, 0.0, 0.0, 0.0], + [0.0, 10.0, 0.0, 0.0], + [0.0, 0.0, 10.0, 0.0], + [0.0, 0.0, 0.0, 10.0], + ], + [0, 5, 6, 7, 8] + ); + + max_volume_axis_simplex_test!( + test_select_max_volume_simplex_indices_prefers_largest_simplex_5d, + 5, + [ + [0.0, 0.0, 0.0, 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, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0], + [10.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 10.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 10.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 10.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 10.0], + ], + [0, 6, 7, 8, 9, 10] + ); + + #[test] + fn test_select_max_volume_simplex_indices_rejects_degenerate_pool() { + init_tracing(); + let vertices: Vec> = vec![ + vertex!([0.0, 0.0, 0.0]), + vertex!([1.0, 0.0, 0.0]), + vertex!([2.0, 0.0, 0.0]), + vertex!([3.0, 0.0, 0.0]), + ]; + + let result = select_max_volume_simplex_indices(&vertices); + assert!(result.is_none()); + } + #[test] fn test_reorder_vertices_for_simplex_valid_and_invalid() { init_tracing(); @@ -9316,6 +9110,53 @@ mod tests { assert!(preprocess.grid_cell_size().is_some()); } + #[test] + fn test_preprocess_vertices_for_construction_max_volume_sets_largest_simplex_first() { + init_tracing(); + let vertices: Vec> = 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]), + vertex!([10.0, 0.0, 0.0]), + vertex!([0.0, 10.0, 0.0]), + vertex!([0.0, 0.0, 10.0]), + ]; + + let preprocess = DelaunayTriangulation::< + AdaptiveKernel, + (), + (), + 3, + >::preprocess_vertices_for_construction( + &vertices, + DedupPolicy::Off, + InsertionOrderStrategy::Input, + InitialSimplexStrategy::MaxVolume, + ) + .expect("preprocess failed"); + + let primary = preprocess.primary_slice(&vertices); + assert!(primary.len() >= 4); + let first_simplex = &primary[..4]; + let first_simplex_contains = |expected_coords: [f64; 3]| { + first_simplex.iter().any(|vertex| { + vertex + .point() + .coords() + .iter() + .zip(expected_coords) + .all(|(actual, expected)| (*actual - expected).abs() <= f64::EPSILON) + }) + }; + + assert!(preprocess.fallback_slice().is_some()); + assert!(first_simplex_contains([0.0, 0.0, 0.0])); + assert!(first_simplex_contains([10.0, 0.0, 0.0])); + assert!(first_simplex_contains([0.0, 10.0, 0.0])); + assert!(first_simplex_contains([0.0, 0.0, 10.0])); + } + #[test] fn test_preprocess_vertices_rejects_invalid_epsilon_tolerance() { init_tracing(); diff --git a/src/triangulation/diagnostics.rs b/src/triangulation/diagnostics.rs new file mode 100644 index 00000000..9b6c664d --- /dev/null +++ b/src/triangulation/diagnostics.rs @@ -0,0 +1,957 @@ +//! Construction and performance diagnostics for triangulation workflows. +//! +//! # Examples +//! +//! ```rust +//! use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; +//! +//! let telemetry = ConstructionTelemetry::default(); +//! assert!(!telemetry.has_data()); +//! ``` + +#![forbid(unsafe_code)] + +use crate::core::algorithms::flips::LocalRepairPhaseTiming; +use crate::core::operations::InsertionTelemetry; + +/// Reason a batch local repair pass was scheduled. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BatchLocalRepairTrigger { + /// The configured repair cadence fired. + Cadence, + /// The pending repair seed frontier exceeded the backlog threshold. + SeedBacklog, +} + +/// Aggregate release-visible telemetry collected during batch construction. +/// +/// These counters summarize batch construction at a coarse level so large-scale +/// debug runs can separate construction phases, per-insertion primitive costs, +/// batch-local repair work, and global exterior conflict scans without enabling +/// per-insertion tracing. +#[derive(Debug, Default, Clone)] +#[non_exhaustive] +pub struct ConstructionTelemetry { + /// Number of transactional insertion calls with wall-clock timing. + pub insertion_wall_time_calls: usize, + /// Wall-clock nanoseconds spent in transactional insertion calls. + pub insertion_wall_time_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one transactional insertion call. + pub insertion_wall_time_nanos_max: u64, + + /// Wall-clock nanoseconds spent preprocessing vertices before topology construction. + pub construction_preprocessing_nanos: u64, + /// Wall-clock nanoseconds spent in the bulk insertion loop, including batch local repair. + pub construction_insert_loop_nanos: u64, + /// Wall-clock nanoseconds spent finalizing bulk construction after the insertion loop. + pub construction_finalize_nanos: u64, + /// Wall-clock nanoseconds spent in the seeded completion repair during finalization. + pub construction_completion_repair_nanos: u64, + /// Wall-clock nanoseconds spent canonicalizing orientation during finalization. + pub construction_orientation_nanos: u64, + /// Wall-clock nanoseconds spent in final topology validation during finalization. + pub construction_topology_validation_nanos: u64, + /// Wall-clock nanoseconds spent in the final global Delaunay validation pass. + pub construction_final_delaunay_validation_nanos: u64, + + /// Number of point-location calls performed during construction. + pub locate_calls: usize, + /// Total facet-walk steps across all point-location calls. + pub locate_walk_steps_total: usize, + /// Maximum facet-walk steps taken by a single point-location call. + pub locate_walk_steps_max: usize, + /// Number of point-location calls that used a caller-provided hint. + pub locate_hint_uses: usize, + /// Number of point-location calls that fell back to a brute-force scan. + pub locate_scan_fallbacks: usize, + /// Number of point-location calls that ended inside a cell. + pub located_inside: usize, + /// Number of point-location calls that ended outside the convex hull. + pub located_outside: usize, + /// Number of point-location calls that ended on a lower-dimensional feature. + pub located_on_boundary: usize, + + /// Number of local conflict-region computations observed during construction. + pub conflict_region_calls: usize, + /// Total number of cells in local conflict regions. + pub conflict_region_cells_total: usize, + /// Maximum number of cells in a single local conflict region. + pub conflict_region_cells_max: usize, + /// Wall-clock nanoseconds spent computing local conflict regions. + pub conflict_region_nanos: u64, + /// Maximum wall-clock nanoseconds spent computing one local conflict region. + pub conflict_region_nanos_max: u64, + + /// Number of cavity insertion attempts observed during construction. + pub cavity_insertion_calls: usize, + /// Wall-clock nanoseconds spent filling cavities and wiring neighbors. + pub cavity_insertion_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one cavity insertion attempt. + pub cavity_insertion_nanos_max: u64, + + /// Number of hull extension attempts observed during construction. + pub hull_extension_calls: usize, + /// Wall-clock nanoseconds spent extending the convex hull. + pub hull_extension_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one hull extension attempt. + pub hull_extension_nanos_max: u64, + + /// Number of post-insertion topology validations observed during construction. + pub topology_validation_calls: usize, + /// Wall-clock nanoseconds spent in post-insertion topology validation. + pub topology_validation_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one post-insertion validation. + pub topology_validation_nanos_max: u64, + + /// Number of batch local Delaunay repair calls during construction. + pub local_repair_calls: usize, + /// Wall-clock nanoseconds spent in batch local Delaunay repair. + pub local_repair_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one batch local repair call. + pub local_repair_nanos_max: u64, + /// Wall-clock nanoseconds spent cloning local-repair rollback snapshots. + pub local_repair_snapshot_nanos: u64, + /// Maximum wall-clock nanoseconds spent cloning one local-repair rollback snapshot. + pub local_repair_snapshot_nanos_max: u64, + /// Wall-clock nanoseconds spent applying local-repair flip attempts. + pub local_repair_attempt_nanos: u64, + /// Maximum wall-clock nanoseconds spent applying flip attempts in one local repair. + pub local_repair_attempt_nanos_max: u64, + /// Wall-clock nanoseconds spent seeding local-repair attempt queues. + pub local_repair_attempt_seed_nanos: u64, + /// Maximum wall-clock nanoseconds spent seeding local-repair queues in one repair. + pub local_repair_attempt_seed_nanos_max: u64, + /// Wall-clock nanoseconds spent processing k=2 facet queue items. + pub local_repair_attempt_facet_nanos: u64, + /// Maximum wall-clock nanoseconds spent processing k=2 facets in one repair. + pub local_repair_attempt_facet_nanos_max: u64, + /// Wall-clock nanoseconds spent processing k=3 ridge queue items. + pub local_repair_attempt_ridge_nanos: u64, + /// Maximum wall-clock nanoseconds spent processing k=3 ridges in one repair. + pub local_repair_attempt_ridge_nanos_max: u64, + /// Wall-clock nanoseconds spent processing inverse k=2 edge queue items. + pub local_repair_attempt_edge_nanos: u64, + /// Maximum wall-clock nanoseconds spent processing inverse k=2 edges in one repair. + pub local_repair_attempt_edge_nanos_max: u64, + /// Wall-clock nanoseconds spent processing inverse k=3 triangle queue items. + pub local_repair_attempt_triangle_nanos: u64, + /// Maximum wall-clock nanoseconds spent processing inverse k=3 triangles in one repair. + pub local_repair_attempt_triangle_nanos_max: u64, + /// Wall-clock nanoseconds spent checking local-repair postconditions. + pub local_repair_postcondition_nanos: u64, + /// Maximum wall-clock nanoseconds spent checking postconditions in one local repair. + pub local_repair_postcondition_nanos_max: u64, + /// Wall-clock nanoseconds spent restoring local-repair rollback snapshots. + pub local_repair_restore_nanos: u64, + /// Maximum wall-clock nanoseconds spent restoring one local-repair rollback snapshot. + pub local_repair_restore_nanos_max: u64, + /// Total pending seed cells repaired by batch local repair calls. + pub local_repair_seed_cells_total: usize, + /// Maximum pending seed-cell frontier repaired by one batch local repair call. + pub local_repair_seed_cells_max: usize, + /// Number of batch local repair calls fired by the configured cadence. + pub local_repair_cadence_triggers: usize, + /// Number of batch local repair calls fired by the seed-backlog threshold. + pub local_repair_backlog_triggers: usize, + /// Total queued repair items checked by successful batch local repair calls. + pub local_repair_items_checked_total: usize, + /// Total flips performed by successful batch local repair calls. + pub local_repair_flips_total: usize, + /// Maximum flips performed by one successful batch local repair call. + pub local_repair_flips_max: usize, + /// Maximum queue length reported by one successful batch local repair call. + pub local_repair_queue_len_max: usize, + /// Number of successful batch local repair calls that performed no flips. + pub local_repair_no_flip_calls: usize, + + /// Number of bulk local-repair seed accumulation calls. + pub repair_seed_accumulation_calls: usize, + /// Wall-clock nanoseconds spent accumulating bulk local-repair seeds. + pub repair_seed_accumulation_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one bulk seed accumulation call. + pub repair_seed_accumulation_nanos_max: u64, + /// Total live seed cells added to pending bulk local-repair frontiers. + pub repair_seed_cells_added_total: usize, + /// Maximum live seed cells added by one bulk seed accumulation call. + pub repair_seed_cells_added_max: usize, + + /// Number of global exterior-point conflict scans. + pub global_conflict_scans: usize, + /// Total cells scanned by global exterior-point conflict scans. + pub global_conflict_cells_scanned: usize, + /// Total cells found by global exterior-point conflict scans. + pub global_conflict_cells_found_total: usize, + /// Maximum cells found by a single global exterior-point conflict scan. + pub global_conflict_cells_found_max: usize, + /// Wall-clock nanoseconds spent in global exterior-point conflict scans. + pub global_conflict_scan_nanos: u64, +} + +impl ConstructionTelemetry { + /// Returns true when any construction telemetry was recorded. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; + /// + /// let mut telemetry = ConstructionTelemetry::default(); + /// assert!(!telemetry.has_data()); + /// + /// telemetry.construction_insert_loop_nanos = 1; + /// assert!(telemetry.has_data()); + /// ``` + #[must_use] + pub const fn has_data(&self) -> bool { + self.insertion_wall_time_calls > 0 + || self.insertion_wall_time_nanos > 0 + || self.construction_preprocessing_nanos > 0 + || self.construction_insert_loop_nanos > 0 + || self.construction_finalize_nanos > 0 + || self.construction_completion_repair_nanos > 0 + || self.construction_orientation_nanos > 0 + || self.construction_topology_validation_nanos > 0 + || self.construction_final_delaunay_validation_nanos > 0 + || self.locate_calls > 0 + || self.conflict_region_calls > 0 + || self.cavity_insertion_calls > 0 + || self.hull_extension_calls > 0 + || self.topology_validation_calls > 0 + || self.local_repair_calls > 0 + || self.local_repair_snapshot_nanos > 0 + || self.local_repair_attempt_nanos > 0 + || self.local_repair_attempt_seed_nanos > 0 + || self.local_repair_attempt_facet_nanos > 0 + || self.local_repair_attempt_ridge_nanos > 0 + || self.local_repair_attempt_edge_nanos > 0 + || self.local_repair_attempt_triangle_nanos > 0 + || self.local_repair_postcondition_nanos > 0 + || self.local_repair_restore_nanos > 0 + || self.local_repair_seed_cells_total > 0 + || self.local_repair_items_checked_total > 0 + || self.local_repair_flips_total > 0 + || self.local_repair_no_flip_calls > 0 + || self.repair_seed_accumulation_calls > 0 + || self.global_conflict_scans > 0 + } + + /// Records the wall-clock duration of one transactional insertion call. + pub(crate) fn record_insertion_timing(&mut self, elapsed_nanos: u64) { + self.insertion_wall_time_calls = self.insertion_wall_time_calls.saturating_add(1); + self.insertion_wall_time_nanos = + self.insertion_wall_time_nanos.saturating_add(elapsed_nanos); + self.insertion_wall_time_nanos_max = self.insertion_wall_time_nanos_max.max(elapsed_nanos); + } + + /// Records the wall-clock duration of construction preprocessing. + pub(crate) const fn record_construction_preprocessing_timing(&mut self, elapsed_nanos: u64) { + self.construction_preprocessing_nanos = self + .construction_preprocessing_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of the bulk insertion loop. + pub(crate) const fn record_construction_insert_loop_timing(&mut self, elapsed_nanos: u64) { + self.construction_insert_loop_nanos = self + .construction_insert_loop_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of bulk-construction finalization. + pub(crate) const fn record_construction_finalize_timing(&mut self, elapsed_nanos: u64) { + self.construction_finalize_nanos = self + .construction_finalize_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of seeded completion repair. + pub(crate) const fn record_construction_completion_repair_timing( + &mut self, + elapsed_nanos: u64, + ) { + self.construction_completion_repair_nanos = self + .construction_completion_repair_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of orientation canonicalization. + pub(crate) const fn record_construction_orientation_timing(&mut self, elapsed_nanos: u64) { + self.construction_orientation_nanos = self + .construction_orientation_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of final topology validation. + pub(crate) const fn record_construction_topology_validation_timing( + &mut self, + elapsed_nanos: u64, + ) { + self.construction_topology_validation_nanos = self + .construction_topology_validation_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of final global Delaunay validation. + pub(crate) const fn record_construction_final_delaunay_validation_timing( + &mut self, + elapsed_nanos: u64, + ) { + self.construction_final_delaunay_validation_nanos = self + .construction_final_delaunay_validation_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of one batch local repair call. + pub(crate) fn record_local_repair_timing(&mut self, elapsed_nanos: u64) { + self.local_repair_calls = self.local_repair_calls.saturating_add(1); + self.local_repair_nanos = self.local_repair_nanos.saturating_add(elapsed_nanos); + self.local_repair_nanos_max = self.local_repair_nanos_max.max(elapsed_nanos); + } + + /// Records phase timing for one batch local repair call. + pub(crate) fn record_local_repair_phase_timing(&mut self, timing: &LocalRepairPhaseTiming) { + self.local_repair_snapshot_nanos = self + .local_repair_snapshot_nanos + .saturating_add(timing.snapshot_nanos); + self.local_repair_snapshot_nanos_max = self + .local_repair_snapshot_nanos_max + .max(timing.snapshot_nanos); + self.local_repair_attempt_nanos = self + .local_repair_attempt_nanos + .saturating_add(timing.attempt_nanos); + self.local_repair_attempt_nanos_max = self + .local_repair_attempt_nanos_max + .max(timing.attempt_nanos); + self.local_repair_attempt_seed_nanos = self + .local_repair_attempt_seed_nanos + .saturating_add(timing.attempt_seed_nanos); + self.local_repair_attempt_seed_nanos_max = self + .local_repair_attempt_seed_nanos_max + .max(timing.attempt_seed_nanos); + self.local_repair_attempt_facet_nanos = self + .local_repair_attempt_facet_nanos + .saturating_add(timing.attempt_facet_nanos); + self.local_repair_attempt_facet_nanos_max = self + .local_repair_attempt_facet_nanos_max + .max(timing.attempt_facet_nanos); + self.local_repair_attempt_ridge_nanos = self + .local_repair_attempt_ridge_nanos + .saturating_add(timing.attempt_ridge_nanos); + self.local_repair_attempt_ridge_nanos_max = self + .local_repair_attempt_ridge_nanos_max + .max(timing.attempt_ridge_nanos); + self.local_repair_attempt_edge_nanos = self + .local_repair_attempt_edge_nanos + .saturating_add(timing.attempt_edge_nanos); + self.local_repair_attempt_edge_nanos_max = self + .local_repair_attempt_edge_nanos_max + .max(timing.attempt_edge_nanos); + self.local_repair_attempt_triangle_nanos = self + .local_repair_attempt_triangle_nanos + .saturating_add(timing.attempt_triangle_nanos); + self.local_repair_attempt_triangle_nanos_max = self + .local_repair_attempt_triangle_nanos_max + .max(timing.attempt_triangle_nanos); + self.local_repair_postcondition_nanos = self + .local_repair_postcondition_nanos + .saturating_add(timing.postcondition_nanos); + self.local_repair_postcondition_nanos_max = self + .local_repair_postcondition_nanos_max + .max(timing.postcondition_nanos); + self.local_repair_restore_nanos = self + .local_repair_restore_nanos + .saturating_add(timing.restore_nanos); + self.local_repair_restore_nanos_max = self + .local_repair_restore_nanos_max + .max(timing.restore_nanos); + } + + /// Records the repaired local frontier size and why the repair fired. + pub(crate) fn record_local_repair_frontier( + &mut self, + seed_cells: usize, + trigger: BatchLocalRepairTrigger, + ) { + self.local_repair_seed_cells_total = self + .local_repair_seed_cells_total + .saturating_add(seed_cells); + self.local_repair_seed_cells_max = self.local_repair_seed_cells_max.max(seed_cells); + match trigger { + BatchLocalRepairTrigger::Cadence => { + self.local_repair_cadence_triggers = + self.local_repair_cadence_triggers.saturating_add(1); + } + BatchLocalRepairTrigger::SeedBacklog => { + self.local_repair_backlog_triggers = + self.local_repair_backlog_triggers.saturating_add(1); + } + } + } + + /// Records aggregate work reported by one successful local repair pass. + pub(crate) fn record_local_repair_work( + &mut self, + items_checked: usize, + flips_performed: usize, + max_queue_len: usize, + ) { + self.local_repair_items_checked_total = self + .local_repair_items_checked_total + .saturating_add(items_checked); + self.local_repair_flips_total = self + .local_repair_flips_total + .saturating_add(flips_performed); + self.local_repair_flips_max = self.local_repair_flips_max.max(flips_performed); + self.local_repair_queue_len_max = self.local_repair_queue_len_max.max(max_queue_len); + if flips_performed == 0 { + self.local_repair_no_flip_calls = self.local_repair_no_flip_calls.saturating_add(1); + } + } + + /// Records one bulk local-repair seed accumulation step. + pub(crate) fn record_repair_seed_accumulation( + &mut self, + elapsed_nanos: u64, + cells_added: usize, + ) { + self.repair_seed_accumulation_calls = self.repair_seed_accumulation_calls.saturating_add(1); + self.repair_seed_accumulation_nanos = self + .repair_seed_accumulation_nanos + .saturating_add(elapsed_nanos); + self.repair_seed_accumulation_nanos_max = + self.repair_seed_accumulation_nanos_max.max(elapsed_nanos); + self.repair_seed_cells_added_total = self + .repair_seed_cells_added_total + .saturating_add(cells_added); + self.repair_seed_cells_added_max = self.repair_seed_cells_added_max.max(cells_added); + } + + /// Adds one insertion's telemetry into this construction summary. + pub(crate) fn record_insertion(&mut self, telemetry: &InsertionTelemetry) { + self.locate_calls = self.locate_calls.saturating_add(telemetry.locate_calls); + self.locate_walk_steps_total = self + .locate_walk_steps_total + .saturating_add(telemetry.locate_walk_steps_total); + self.locate_walk_steps_max = self + .locate_walk_steps_max + .max(telemetry.locate_walk_steps_max); + self.locate_hint_uses = self + .locate_hint_uses + .saturating_add(telemetry.locate_hint_uses); + self.locate_scan_fallbacks = self + .locate_scan_fallbacks + .saturating_add(telemetry.locate_scan_fallbacks); + self.located_inside = self.located_inside.saturating_add(telemetry.located_inside); + self.located_outside = self + .located_outside + .saturating_add(telemetry.located_outside); + self.located_on_boundary = self + .located_on_boundary + .saturating_add(telemetry.located_on_boundary); + + self.conflict_region_calls = self + .conflict_region_calls + .saturating_add(telemetry.conflict_region_calls); + self.conflict_region_cells_total = self + .conflict_region_cells_total + .saturating_add(telemetry.conflict_region_cells_total); + self.conflict_region_cells_max = self + .conflict_region_cells_max + .max(telemetry.conflict_region_cells_max); + self.conflict_region_nanos = self + .conflict_region_nanos + .saturating_add(telemetry.conflict_region_nanos); + self.conflict_region_nanos_max = self + .conflict_region_nanos_max + .max(telemetry.conflict_region_nanos_max); + + self.cavity_insertion_calls = self + .cavity_insertion_calls + .saturating_add(telemetry.cavity_insertion_calls); + self.cavity_insertion_nanos = self + .cavity_insertion_nanos + .saturating_add(telemetry.cavity_insertion_nanos); + self.cavity_insertion_nanos_max = self + .cavity_insertion_nanos_max + .max(telemetry.cavity_insertion_nanos_max); + + self.hull_extension_calls = self + .hull_extension_calls + .saturating_add(telemetry.hull_extension_calls); + self.hull_extension_nanos = self + .hull_extension_nanos + .saturating_add(telemetry.hull_extension_nanos); + self.hull_extension_nanos_max = self + .hull_extension_nanos_max + .max(telemetry.hull_extension_nanos_max); + + self.topology_validation_calls = self + .topology_validation_calls + .saturating_add(telemetry.topology_validation_calls); + self.topology_validation_nanos = self + .topology_validation_nanos + .saturating_add(telemetry.topology_validation_nanos); + self.topology_validation_nanos_max = self + .topology_validation_nanos_max + .max(telemetry.topology_validation_nanos_max); + + self.global_conflict_scans = self + .global_conflict_scans + .saturating_add(telemetry.global_conflict_scans); + self.global_conflict_cells_scanned = self + .global_conflict_cells_scanned + .saturating_add(telemetry.global_conflict_cells_scanned); + self.global_conflict_cells_found_total = self + .global_conflict_cells_found_total + .saturating_add(telemetry.global_conflict_cells_found_total); + self.global_conflict_cells_found_max = self + .global_conflict_cells_found_max + .max(telemetry.global_conflict_cells_found_max); + self.global_conflict_scan_nanos = self + .global_conflict_scan_nanos + .saturating_add(telemetry.global_conflict_scan_nanos); + } + + /// Merges another construction telemetry summary into this one. + pub(crate) fn merge_from(&mut self, other: &Self) { + self.insertion_wall_time_nanos = self + .insertion_wall_time_nanos + .saturating_add(other.insertion_wall_time_nanos); + self.insertion_wall_time_calls = self + .insertion_wall_time_calls + .saturating_add(other.insertion_wall_time_calls); + self.insertion_wall_time_nanos_max = self + .insertion_wall_time_nanos_max + .max(other.insertion_wall_time_nanos_max); + + self.merge_construction_phase_timings_from(other); + + self.locate_calls = self.locate_calls.saturating_add(other.locate_calls); + self.locate_walk_steps_total = self + .locate_walk_steps_total + .saturating_add(other.locate_walk_steps_total); + self.locate_walk_steps_max = self.locate_walk_steps_max.max(other.locate_walk_steps_max); + self.locate_hint_uses = self.locate_hint_uses.saturating_add(other.locate_hint_uses); + self.locate_scan_fallbacks = self + .locate_scan_fallbacks + .saturating_add(other.locate_scan_fallbacks); + self.located_inside = self.located_inside.saturating_add(other.located_inside); + self.located_outside = self.located_outside.saturating_add(other.located_outside); + self.located_on_boundary = self + .located_on_boundary + .saturating_add(other.located_on_boundary); + + self.conflict_region_calls = self + .conflict_region_calls + .saturating_add(other.conflict_region_calls); + self.conflict_region_cells_total = self + .conflict_region_cells_total + .saturating_add(other.conflict_region_cells_total); + self.conflict_region_cells_max = self + .conflict_region_cells_max + .max(other.conflict_region_cells_max); + self.conflict_region_nanos = self + .conflict_region_nanos + .saturating_add(other.conflict_region_nanos); + self.conflict_region_nanos_max = self + .conflict_region_nanos_max + .max(other.conflict_region_nanos_max); + + self.cavity_insertion_calls = self + .cavity_insertion_calls + .saturating_add(other.cavity_insertion_calls); + self.cavity_insertion_nanos = self + .cavity_insertion_nanos + .saturating_add(other.cavity_insertion_nanos); + self.cavity_insertion_nanos_max = self + .cavity_insertion_nanos_max + .max(other.cavity_insertion_nanos_max); + + self.hull_extension_calls = self + .hull_extension_calls + .saturating_add(other.hull_extension_calls); + self.hull_extension_nanos = self + .hull_extension_nanos + .saturating_add(other.hull_extension_nanos); + self.hull_extension_nanos_max = self + .hull_extension_nanos_max + .max(other.hull_extension_nanos_max); + + self.topology_validation_calls = self + .topology_validation_calls + .saturating_add(other.topology_validation_calls); + self.topology_validation_nanos = self + .topology_validation_nanos + .saturating_add(other.topology_validation_nanos); + self.topology_validation_nanos_max = self + .topology_validation_nanos_max + .max(other.topology_validation_nanos_max); + + self.merge_local_repair_from(other); + + self.merge_repair_seed_accumulation_from(other); + + self.global_conflict_scans = self + .global_conflict_scans + .saturating_add(other.global_conflict_scans); + self.global_conflict_cells_scanned = self + .global_conflict_cells_scanned + .saturating_add(other.global_conflict_cells_scanned); + self.global_conflict_cells_found_total = self + .global_conflict_cells_found_total + .saturating_add(other.global_conflict_cells_found_total); + self.global_conflict_cells_found_max = self + .global_conflict_cells_found_max + .max(other.global_conflict_cells_found_max); + self.global_conflict_scan_nanos = self + .global_conflict_scan_nanos + .saturating_add(other.global_conflict_scan_nanos); + } + + /// Keeps construction-phase merge accounting isolated so aggregate merges stay readable. + const fn merge_construction_phase_timings_from(&mut self, other: &Self) { + self.construction_preprocessing_nanos = self + .construction_preprocessing_nanos + .saturating_add(other.construction_preprocessing_nanos); + self.construction_insert_loop_nanos = self + .construction_insert_loop_nanos + .saturating_add(other.construction_insert_loop_nanos); + self.construction_finalize_nanos = self + .construction_finalize_nanos + .saturating_add(other.construction_finalize_nanos); + self.construction_completion_repair_nanos = self + .construction_completion_repair_nanos + .saturating_add(other.construction_completion_repair_nanos); + self.construction_orientation_nanos = self + .construction_orientation_nanos + .saturating_add(other.construction_orientation_nanos); + self.construction_topology_validation_nanos = self + .construction_topology_validation_nanos + .saturating_add(other.construction_topology_validation_nanos); + self.construction_final_delaunay_validation_nanos = self + .construction_final_delaunay_validation_nanos + .saturating_add(other.construction_final_delaunay_validation_nanos); + } + + /// Keeps local-repair merge accounting isolated so the aggregate merge stays readable. + fn merge_local_repair_from(&mut self, other: &Self) { + self.local_repair_calls = self + .local_repair_calls + .saturating_add(other.local_repair_calls); + self.local_repair_nanos = self + .local_repair_nanos + .saturating_add(other.local_repair_nanos); + self.local_repair_nanos_max = self + .local_repair_nanos_max + .max(other.local_repair_nanos_max); + self.local_repair_snapshot_nanos = self + .local_repair_snapshot_nanos + .saturating_add(other.local_repair_snapshot_nanos); + self.local_repair_snapshot_nanos_max = self + .local_repair_snapshot_nanos_max + .max(other.local_repair_snapshot_nanos_max); + self.local_repair_attempt_nanos = self + .local_repair_attempt_nanos + .saturating_add(other.local_repair_attempt_nanos); + self.local_repair_attempt_nanos_max = self + .local_repair_attempt_nanos_max + .max(other.local_repair_attempt_nanos_max); + self.local_repair_attempt_seed_nanos = self + .local_repair_attempt_seed_nanos + .saturating_add(other.local_repair_attempt_seed_nanos); + self.local_repair_attempt_seed_nanos_max = self + .local_repair_attempt_seed_nanos_max + .max(other.local_repair_attempt_seed_nanos_max); + self.local_repair_attempt_facet_nanos = self + .local_repair_attempt_facet_nanos + .saturating_add(other.local_repair_attempt_facet_nanos); + self.local_repair_attempt_facet_nanos_max = self + .local_repair_attempt_facet_nanos_max + .max(other.local_repair_attempt_facet_nanos_max); + self.local_repair_attempt_ridge_nanos = self + .local_repair_attempt_ridge_nanos + .saturating_add(other.local_repair_attempt_ridge_nanos); + self.local_repair_attempt_ridge_nanos_max = self + .local_repair_attempt_ridge_nanos_max + .max(other.local_repair_attempt_ridge_nanos_max); + self.local_repair_attempt_edge_nanos = self + .local_repair_attempt_edge_nanos + .saturating_add(other.local_repair_attempt_edge_nanos); + self.local_repair_attempt_edge_nanos_max = self + .local_repair_attempt_edge_nanos_max + .max(other.local_repair_attempt_edge_nanos_max); + self.local_repair_attempt_triangle_nanos = self + .local_repair_attempt_triangle_nanos + .saturating_add(other.local_repair_attempt_triangle_nanos); + self.local_repair_attempt_triangle_nanos_max = self + .local_repair_attempt_triangle_nanos_max + .max(other.local_repair_attempt_triangle_nanos_max); + self.local_repair_postcondition_nanos = self + .local_repair_postcondition_nanos + .saturating_add(other.local_repair_postcondition_nanos); + self.local_repair_postcondition_nanos_max = self + .local_repair_postcondition_nanos_max + .max(other.local_repair_postcondition_nanos_max); + self.local_repair_restore_nanos = self + .local_repair_restore_nanos + .saturating_add(other.local_repair_restore_nanos); + self.local_repair_restore_nanos_max = self + .local_repair_restore_nanos_max + .max(other.local_repair_restore_nanos_max); + self.local_repair_seed_cells_total = self + .local_repair_seed_cells_total + .saturating_add(other.local_repair_seed_cells_total); + self.local_repair_seed_cells_max = self + .local_repair_seed_cells_max + .max(other.local_repair_seed_cells_max); + self.local_repair_cadence_triggers = self + .local_repair_cadence_triggers + .saturating_add(other.local_repair_cadence_triggers); + self.local_repair_backlog_triggers = self + .local_repair_backlog_triggers + .saturating_add(other.local_repair_backlog_triggers); + self.local_repair_items_checked_total = self + .local_repair_items_checked_total + .saturating_add(other.local_repair_items_checked_total); + self.local_repair_flips_total = self + .local_repair_flips_total + .saturating_add(other.local_repair_flips_total); + self.local_repair_flips_max = self + .local_repair_flips_max + .max(other.local_repair_flips_max); + self.local_repair_queue_len_max = self + .local_repair_queue_len_max + .max(other.local_repair_queue_len_max); + self.local_repair_no_flip_calls = self + .local_repair_no_flip_calls + .saturating_add(other.local_repair_no_flip_calls); + } + + /// Keeps seed-accumulation merge accounting isolated from the aggregate merge body. + fn merge_repair_seed_accumulation_from(&mut self, other: &Self) { + self.repair_seed_accumulation_calls = self + .repair_seed_accumulation_calls + .saturating_add(other.repair_seed_accumulation_calls); + self.repair_seed_accumulation_nanos = self + .repair_seed_accumulation_nanos + .saturating_add(other.repair_seed_accumulation_nanos); + self.repair_seed_accumulation_nanos_max = self + .repair_seed_accumulation_nanos_max + .max(other.repair_seed_accumulation_nanos_max); + self.repair_seed_cells_added_total = self + .repair_seed_cells_added_total + .saturating_add(other.repair_seed_cells_added_total); + self.repair_seed_cells_added_max = self + .repair_seed_cells_added_max + .max(other.repair_seed_cells_added_max); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[expect( + clippy::too_many_lines, + reason = "single-field telemetry regression covers every aggregate counter" + )] + fn test_construction_telemetry_records_all_counters() { + let mut summary = ConstructionTelemetry::default(); + let telemetry = InsertionTelemetry { + locate_calls: 2, + locate_walk_steps_total: 9, + locate_walk_steps_max: 7, + locate_hint_uses: 1, + locate_scan_fallbacks: 1, + located_inside: 1, + located_outside: 1, + conflict_region_calls: 1, + conflict_region_cells_total: 4, + conflict_region_cells_max: 4, + conflict_region_nanos: 125_000, + conflict_region_nanos_max: 125_000, + cavity_insertion_calls: 1, + cavity_insertion_nanos: 375_000, + cavity_insertion_nanos_max: 375_000, + hull_extension_calls: 1, + hull_extension_nanos: 500_000, + hull_extension_nanos_max: 500_000, + topology_validation_calls: 1, + topology_validation_nanos: 625_000, + topology_validation_nanos_max: 625_000, + global_conflict_scans: 1, + global_conflict_cells_scanned: 12, + global_conflict_cells_found_total: 3, + global_conflict_cells_found_max: 3, + global_conflict_scan_nanos: 250_000, + ..InsertionTelemetry::default() + }; + + summary.record_insertion(&telemetry); + summary.record_insertion_timing(1_000_000); + summary.record_local_repair_timing(2_000_000); + summary.record_local_repair_phase_timing(&LocalRepairPhaseTiming { + snapshot_nanos: 100_000, + attempt_nanos: 1_250_000, + attempt_seed_nanos: 10_000, + attempt_facet_nanos: 750_000, + attempt_ridge_nanos: 450_000, + attempt_edge_nanos: 25_000, + attempt_triangle_nanos: 15_000, + postcondition_nanos: 500_000, + restore_nanos: 25_000, + }); + summary.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); + summary.record_local_repair_work(123, 5, 17); + summary.record_repair_seed_accumulation(500_000, 7); + summary.record_construction_preprocessing_timing(10_000); + summary.record_construction_insert_loop_timing(20_000); + summary.record_construction_finalize_timing(30_000); + summary.record_construction_completion_repair_timing(40_000); + summary.record_construction_orientation_timing(50_000); + summary.record_construction_topology_validation_timing(60_000); + summary.record_construction_final_delaunay_validation_timing(70_000); + + assert!(summary.has_data()); + assert_eq!(summary.insertion_wall_time_calls, 1); + assert_eq!(summary.insertion_wall_time_nanos, 1_000_000); + assert_eq!(summary.insertion_wall_time_nanos_max, 1_000_000); + assert_eq!(summary.construction_preprocessing_nanos, 10_000); + assert_eq!(summary.construction_insert_loop_nanos, 20_000); + assert_eq!(summary.construction_finalize_nanos, 30_000); + assert_eq!(summary.construction_completion_repair_nanos, 40_000); + assert_eq!(summary.construction_orientation_nanos, 50_000); + assert_eq!(summary.construction_topology_validation_nanos, 60_000); + assert_eq!(summary.construction_final_delaunay_validation_nanos, 70_000); + assert_eq!(summary.locate_calls, 2); + assert_eq!(summary.locate_walk_steps_total, 9); + assert_eq!(summary.locate_walk_steps_max, 7); + assert_eq!(summary.locate_hint_uses, 1); + assert_eq!(summary.locate_scan_fallbacks, 1); + assert_eq!(summary.located_inside, 1); + assert_eq!(summary.located_outside, 1); + assert_eq!(summary.conflict_region_calls, 1); + assert_eq!(summary.conflict_region_cells_total, 4); + assert_eq!(summary.conflict_region_nanos, 125_000); + assert_eq!(summary.conflict_region_nanos_max, 125_000); + assert_eq!(summary.cavity_insertion_calls, 1); + assert_eq!(summary.cavity_insertion_nanos, 375_000); + assert_eq!(summary.hull_extension_calls, 1); + assert_eq!(summary.hull_extension_nanos, 500_000); + assert_eq!(summary.topology_validation_calls, 1); + assert_eq!(summary.topology_validation_nanos, 625_000); + assert_eq!(summary.local_repair_calls, 1); + assert_eq!(summary.local_repair_nanos, 2_000_000); + assert_eq!(summary.local_repair_snapshot_nanos, 100_000); + assert_eq!(summary.local_repair_snapshot_nanos_max, 100_000); + assert_eq!(summary.local_repair_attempt_nanos, 1_250_000); + assert_eq!(summary.local_repair_attempt_nanos_max, 1_250_000); + assert_eq!(summary.local_repair_attempt_seed_nanos, 10_000); + assert_eq!(summary.local_repair_attempt_seed_nanos_max, 10_000); + assert_eq!(summary.local_repair_attempt_facet_nanos, 750_000); + assert_eq!(summary.local_repair_attempt_facet_nanos_max, 750_000); + assert_eq!(summary.local_repair_attempt_ridge_nanos, 450_000); + assert_eq!(summary.local_repair_attempt_ridge_nanos_max, 450_000); + assert_eq!(summary.local_repair_attempt_edge_nanos, 25_000); + assert_eq!(summary.local_repair_attempt_edge_nanos_max, 25_000); + assert_eq!(summary.local_repair_attempt_triangle_nanos, 15_000); + assert_eq!(summary.local_repair_attempt_triangle_nanos_max, 15_000); + assert_eq!(summary.local_repair_postcondition_nanos, 500_000); + assert_eq!(summary.local_repair_postcondition_nanos_max, 500_000); + assert_eq!(summary.local_repair_restore_nanos, 25_000); + assert_eq!(summary.local_repair_restore_nanos_max, 25_000); + assert_eq!(summary.local_repair_seed_cells_total, 11); + assert_eq!(summary.local_repair_seed_cells_max, 11); + assert_eq!(summary.local_repair_cadence_triggers, 0); + assert_eq!(summary.local_repair_backlog_triggers, 1); + assert_eq!(summary.local_repair_items_checked_total, 123); + assert_eq!(summary.local_repair_flips_total, 5); + assert_eq!(summary.local_repair_flips_max, 5); + assert_eq!(summary.local_repair_queue_len_max, 17); + assert_eq!(summary.local_repair_no_flip_calls, 0); + assert_eq!(summary.repair_seed_accumulation_calls, 1); + assert_eq!(summary.repair_seed_accumulation_nanos, 500_000); + assert_eq!(summary.repair_seed_cells_added_total, 7); + assert_eq!(summary.repair_seed_cells_added_max, 7); + assert_eq!(summary.global_conflict_scans, 1); + assert_eq!(summary.global_conflict_cells_scanned, 12); + assert_eq!(summary.global_conflict_cells_found_total, 3); + assert_eq!(summary.global_conflict_scan_nanos, 250_000); + } + + #[test] + fn test_construction_telemetry_merge_preserves_local_repair_frontiers() { + let mut left = ConstructionTelemetry::default(); + left.record_local_repair_timing(10); + left.record_local_repair_phase_timing(&LocalRepairPhaseTiming { + snapshot_nanos: 1, + attempt_nanos: 2, + attempt_seed_nanos: 3, + attempt_facet_nanos: 4, + attempt_ridge_nanos: 5, + attempt_edge_nanos: 6, + attempt_triangle_nanos: 7, + postcondition_nanos: 3, + restore_nanos: 4, + }); + left.record_local_repair_frontier(5, BatchLocalRepairTrigger::Cadence); + left.record_local_repair_work(10, 0, 5); + left.record_construction_insert_loop_timing(100); + left.record_construction_final_delaunay_validation_timing(200); + + let mut right = ConstructionTelemetry::default(); + right.record_local_repair_timing(30); + right.record_local_repair_phase_timing(&LocalRepairPhaseTiming { + snapshot_nanos: 10, + attempt_nanos: 20, + attempt_seed_nanos: 30, + attempt_facet_nanos: 40, + attempt_ridge_nanos: 50, + attempt_edge_nanos: 60, + attempt_triangle_nanos: 70, + postcondition_nanos: 30, + restore_nanos: 40, + }); + right.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); + right.record_local_repair_work(30, 4, 12); + right.record_construction_insert_loop_timing(300); + right.record_construction_final_delaunay_validation_timing(400); + + left.merge_from(&right); + + assert!(left.has_data()); + assert_eq!(left.construction_insert_loop_nanos, 400); + assert_eq!(left.construction_final_delaunay_validation_nanos, 600); + assert_eq!(left.local_repair_calls, 2); + assert_eq!(left.local_repair_nanos, 40); + assert_eq!(left.local_repair_nanos_max, 30); + assert_eq!(left.local_repair_snapshot_nanos, 11); + assert_eq!(left.local_repair_snapshot_nanos_max, 10); + assert_eq!(left.local_repair_attempt_nanos, 22); + assert_eq!(left.local_repair_attempt_nanos_max, 20); + assert_eq!(left.local_repair_attempt_seed_nanos, 33); + assert_eq!(left.local_repair_attempt_seed_nanos_max, 30); + assert_eq!(left.local_repair_attempt_facet_nanos, 44); + assert_eq!(left.local_repair_attempt_facet_nanos_max, 40); + assert_eq!(left.local_repair_attempt_ridge_nanos, 55); + assert_eq!(left.local_repair_attempt_ridge_nanos_max, 50); + assert_eq!(left.local_repair_attempt_edge_nanos, 66); + assert_eq!(left.local_repair_attempt_edge_nanos_max, 60); + assert_eq!(left.local_repair_attempt_triangle_nanos, 77); + assert_eq!(left.local_repair_attempt_triangle_nanos_max, 70); + assert_eq!(left.local_repair_postcondition_nanos, 33); + assert_eq!(left.local_repair_postcondition_nanos_max, 30); + assert_eq!(left.local_repair_restore_nanos, 44); + assert_eq!(left.local_repair_restore_nanos_max, 40); + assert_eq!(left.local_repair_seed_cells_total, 16); + assert_eq!(left.local_repair_seed_cells_max, 11); + assert_eq!(left.local_repair_cadence_triggers, 1); + assert_eq!(left.local_repair_backlog_triggers, 1); + assert_eq!(left.local_repair_items_checked_total, 40); + assert_eq!(left.local_repair_flips_total, 4); + assert_eq!(left.local_repair_flips_max, 4); + assert_eq!(left.local_repair_queue_len_max, 12); + assert_eq!(left.local_repair_no_flip_calls, 1); + } +} diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 762f715b..f430a26a 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -31,8 +31,8 @@ //! # - "new" (default): build via DelaunayTriangulation::new() which applies Hilbert ordering //! # - "incremental": manual insert loop (debug/profiling) //! DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE=new \ -//! # Initial simplex strategy for batch construction: "first" (default) or "balanced" -//! DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX=balanced \ +//! # Initial simplex strategy for batch construction: "max-volume" (default), "balanced", or "first" +//! DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX=max-volume \ //! # Debug mode: //! # - "cadenced" (default): PLManifold, ridge-link validation during insertion, //! # vertex-link validation at completion @@ -50,8 +50,8 @@ //! DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ //! # Skip the final flip-based repair pass (faster, but may leave Delaunay violations) //! DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR=1 \ -//! # Run bounded flip repair every N successful insertions (0 disables; default: 2) -//! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=2 \ +//! # Run bounded flip repair every N successful insertions (0 disables; default: 1) +//! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1 \ //! # Optional: trace cadenced local-repair seed counts, flips, queues, and elapsed time //! DELAUNAY_BATCH_REPAIR_TRACE=1 \ //! # Hard wall-clock cap in seconds before the harness aborts (0 = no cap; default: 600) @@ -79,10 +79,11 @@ use delaunay::geometry::util::{ }; use delaunay::prelude::tds::{InvariantKind, TriangulationValidationReport}; use delaunay::prelude::triangulation::construction::{ - ConstructionOptions, ConstructionStatistics, ConstructionTelemetry, DelaunayRepairPolicy, - DelaunayTriangulation, DelaunayTriangulationConstructionErrorWithStatistics, - InitialSimplexStrategy, TopologyGuarantee, Vertex, vertex, + ConstructionOptions, ConstructionStatistics, DelaunayRepairPolicy, DelaunayTriangulation, + DelaunayTriangulationConstructionErrorWithStatistics, InitialSimplexStrategy, + TopologyGuarantee, Vertex, vertex, }; +use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; use delaunay::prelude::triangulation::insertion::{ InsertionOutcome, InsertionResult, InsertionStatistics, }; @@ -393,6 +394,7 @@ fn initial_simplex_strategy_name(strategy: InitialSimplexStrategy) -> &'static s match strategy { InitialSimplexStrategy::First => "first", InitialSimplexStrategy::Balanced => "balanced", + InitialSimplexStrategy::MaxVolume => "max-volume", _ => { tracing::debug!(?strategy, "unknown initial simplex strategy"); "unknown" @@ -402,7 +404,11 @@ fn initial_simplex_strategy_name(strategy: InitialSimplexStrategy) -> &'static s fn initial_simplex_strategy_from_name(raw: &str) -> Option { let raw = raw.trim(); - if raw.is_empty() || raw.eq_ignore_ascii_case("first") { + if raw.is_empty() { + return Some(InitialSimplexStrategy::MaxVolume); + } + + if raw.eq_ignore_ascii_case("first") { return Some(InitialSimplexStrategy::First); } @@ -410,17 +416,24 @@ fn initial_simplex_strategy_from_name(raw: &str) -> Option InitialSimplexStrategy { let Ok(raw) = env::var("DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX") else { - return InitialSimplexStrategy::First; + return InitialSimplexStrategy::MaxVolume; }; initial_simplex_strategy_from_name(&raw).unwrap_or_else(|| { panic!( - "invalid DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX={raw:?} (expected 'first' or 'balanced')" + "invalid DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX={raw:?} (expected 'max-volume', 'balanced', or 'first')" ) }) } @@ -744,6 +757,95 @@ fn print_local_repair_frontier_telemetry(telemetry: &ConstructionTelemetry) { ); } +fn print_local_repair_work_telemetry(telemetry: &ConstructionTelemetry) { + if telemetry.local_repair_calls == 0 { + return; + } + + println!( + " local_repair_work: checked_total={} avg_checked={} flips_total={} avg_flips={} max_flips={} max_queue={} no_flip_calls={}", + telemetry.local_repair_items_checked_total, + format_ratio_2( + telemetry.local_repair_items_checked_total, + telemetry.local_repair_calls, + ), + telemetry.local_repair_flips_total, + format_ratio_2( + telemetry.local_repair_flips_total, + telemetry.local_repair_calls + ), + telemetry.local_repair_flips_max, + telemetry.local_repair_queue_len_max, + telemetry.local_repair_no_flip_calls + ); +} + +fn print_local_repair_phase_telemetry(telemetry: &ConstructionTelemetry) { + let phase_total = telemetry + .local_repair_snapshot_nanos + .saturating_add(telemetry.local_repair_attempt_nanos) + .saturating_add(telemetry.local_repair_postcondition_nanos) + .saturating_add(telemetry.local_repair_restore_nanos); + if phase_total == 0 { + return; + } + + print_timing_summary( + "local_repair_snapshot", + telemetry.local_repair_calls, + telemetry.local_repair_snapshot_nanos, + telemetry.local_repair_snapshot_nanos_max, + ); + print_timing_summary( + "local_repair_attempts", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_nanos, + telemetry.local_repair_attempt_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_seed", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_seed_nanos, + telemetry.local_repair_attempt_seed_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_facets", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_facet_nanos, + telemetry.local_repair_attempt_facet_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_ridges", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_ridge_nanos, + telemetry.local_repair_attempt_ridge_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_edges", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_edge_nanos, + telemetry.local_repair_attempt_edge_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_triangles", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_triangle_nanos, + telemetry.local_repair_attempt_triangle_nanos_max, + ); + print_timing_summary( + "local_repair_postconditions", + telemetry.local_repair_calls, + telemetry.local_repair_postcondition_nanos, + telemetry.local_repair_postcondition_nanos_max, + ); + print_timing_summary( + "local_repair_restores", + telemetry.local_repair_calls, + telemetry.local_repair_restore_nanos, + telemetry.local_repair_restore_nanos_max, + ); +} + fn print_construction_phase_telemetry(telemetry: &ConstructionTelemetry) { let outer_total_nanos = telemetry .construction_preprocessing_nanos @@ -850,7 +952,9 @@ fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { telemetry.local_repair_nanos, telemetry.local_repair_nanos_max, ); + print_local_repair_phase_telemetry(telemetry); print_local_repair_frontier_telemetry(telemetry); + print_local_repair_work_telemetry(telemetry); print_repair_seed_accumulation_telemetry(telemetry); if telemetry.global_conflict_scans > 0 { @@ -995,7 +1099,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz // - 0 disables incremental repair // - 1 runs repair after every insertion // - N>1 runs repair after every N successful insertions - let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(2); + let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(1); let repair_policy = repair_policy_from_repair_every(repair_every); let repair_max_flips = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_MAX_FLIPS"); let validate_every = env_usize("DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY").or_else(|| { @@ -1395,10 +1499,10 @@ fn test_topology_for_debug_mode_uses_ridge_links_by_default() { } #[test] -fn test_initial_simplex_strategy_from_name_maps_ab_switch() { +fn test_initial_simplex_strategy_from_name_maps_supported_values() { assert_eq!( initial_simplex_strategy_from_name(""), - Some(InitialSimplexStrategy::First) + Some(InitialSimplexStrategy::MaxVolume) ); assert_eq!( initial_simplex_strategy_from_name("first"), @@ -1408,11 +1512,23 @@ fn test_initial_simplex_strategy_from_name_maps_ab_switch() { initial_simplex_strategy_from_name("BALANCED"), Some(InitialSimplexStrategy::Balanced) ); + assert_eq!( + initial_simplex_strategy_from_name("max-volume"), + Some(InitialSimplexStrategy::MaxVolume) + ); + assert_eq!( + initial_simplex_strategy_from_name("MAX_VOLUME"), + Some(InitialSimplexStrategy::MaxVolume) + ); assert_eq!(initial_simplex_strategy_from_name("unknown"), None); assert_eq!( initial_simplex_strategy_name(InitialSimplexStrategy::Balanced), "balanced" ); + assert_eq!( + initial_simplex_strategy_name(InitialSimplexStrategy::MaxVolume), + "max-volume" + ); } #[test] diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 1c9d06c4..78520b53 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -38,6 +38,7 @@ use delaunay::prelude::triangulation::construction::{ use delaunay::prelude::triangulation::delaunayize::{ DelaunayizeConfig, DelaunayizeError, DelaunayizeOutcome, delaunayize_by_flips, }; +use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; use delaunay::prelude::triangulation::flips::{BistellarFlips, TopologyGuarantee}; use delaunay::prelude::triangulation::insertion::{ InsertionError, NeighborRebuildError, Tds as InsertionTds, TdsMutationError, @@ -87,7 +88,7 @@ fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { ConstructionOptions::default().with_insertion_order(InsertionOrderStrategy::Input); assert!(matches!( options.batch_repair_policy(), - DelaunayRepairPolicy::EveryN(every) if every.get() == 2 + DelaunayRepairPolicy::EveryInsertion )); let dt = DelaunayTriangulation::new_with_options(&vertices, options)?; @@ -117,6 +118,8 @@ fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { assert_send_sync_unpin::(); assert_send_sync_unpin::(); assert_send_sync_unpin::(); + let telemetry = ConstructionTelemetry::default(); + assert!(!telemetry.has_data()); Ok(()) } From 22ba790e5769a93416e6df0e947ce47dc8f80d82 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 11 May 2026 08:52:32 -0700 Subject: [PATCH 11/15] fix(triangulation): preserve repair invariants (#341) - Report dangling ridge-neighbor references as a typed flip error instead of silently skipping missing cells during ridge-star collection. - Preserve the computed insertion repair requirement when callers provide conflict cells, avoiding unnecessary Delaunay repair for clean cavities. - Gate large-scale debug telemetry output behind diagnostics and document the Homebrew uv path for Codex validation shells. --- AGENTS.md | 8 ++ src/core/algorithms/flips.rs | 82 ++++++++++++- src/core/triangulation.rs | 38 +++++- tests/large_scale_debug.rs | 227 ++++++++++++++++++++--------------- 4 files changed, 253 insertions(+), 102 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8034751b..278f0ba0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,14 @@ just check just ci ``` +Codex sandbox shells may not include Homebrew on `PATH`. When a validation +recipe or direct tooling command needs `uv`, prefer: + +```bash +PATH=/opt/homebrew/bin:$PATH just check +/opt/homebrew/bin/uv run pytest scripts/tests +``` + Refer to `docs/dev/commands.md` for full details. For tooling-alignment work, update `docs/dev/tooling-alignment.md` with the diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 4b92f477..a914043c 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -1885,6 +1885,9 @@ pub enum FlipFailureKind { /// Missing neighbor. #[error("missing neighbor")] MissingNeighbor, + /// Dangling ridge-neighbor reference. + #[error("dangling ridge neighbor")] + DanglingRidgeNeighbor, /// Invalid facet adjacency. #[error("invalid facet adjacency")] InvalidFacetAdjacency, @@ -2554,6 +2557,14 @@ pub enum FlipError { /// Missing neighbor key. neighbor_key: CellKey, }, + /// Ridge adjacency references a neighbor cell key that is no longer live. + #[error("Ridge adjacency from cell {cell_key:?} references missing neighbor {neighbor_key:?}")] + DanglingRidgeNeighbor { + /// Cell whose neighbor table contains the dangling key. + cell_key: CellKey, + /// Missing neighbor cell key. + neighbor_key: CellKey, + }, /// Facet adjacency information is inconsistent. #[error("Facet adjacency mismatch between cell {cell_key:?} and neighbor {neighbor_key:?}")] InvalidFacetAdjacency { @@ -2739,6 +2750,7 @@ impl From<&FlipError> for FlipFailureKind { FlipError::MissingCell { .. } => Self::MissingCell, FlipError::MissingVertex { .. } => Self::MissingVertex, FlipError::MissingNeighbor { .. } => Self::MissingNeighbor, + FlipError::DanglingRidgeNeighbor { .. } => Self::DanglingRidgeNeighbor, FlipError::InvalidFacetAdjacency { .. } => Self::InvalidFacetAdjacency, FlipError::InvalidFacetIndex { .. } => Self::InvalidFacetIndex, FlipError::InvalidRidgeIndex { .. } => Self::InvalidRidgeIndex, @@ -7378,7 +7390,10 @@ where for &omit_idx in &omit_indices { if let Some(neighbor_key) = neighbors.get(omit_idx).copied().flatten() { let Some(neighbor_cell) = tds.cell(neighbor_key) else { - continue; + return Err(FlipError::DanglingRidgeNeighbor { + cell_key, + neighbor_key, + }); }; if !ridge.iter().all(|v| neighbor_cell.contains_vertex(*v)) { return Err(FlipError::InvalidRidgeAdjacency { cell_key }); @@ -10835,6 +10850,62 @@ mod tests { )); } + #[test] + fn test_flip_k3_reports_dangling_ridge_neighbor_3d() { + init_tracing(); + let mut tds: Tds = Tds::empty(); + let ridge_start = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, 0.0])) + .unwrap(); + let ridge_end = tds + .insert_vertex_with_mapping(vertex!([1.0, 0.0, 0.0])) + .unwrap(); + let first_opposite = tds + .insert_vertex_with_mapping(vertex!([0.0, 1.0, 0.0])) + .unwrap(); + let second_opposite = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, 1.0])) + .unwrap(); + let dangling_opposite = tds + .insert_vertex_with_mapping(vertex!([1.0, 1.0, 1.0])) + .unwrap(); + let cell = tds + .insert_cell_with_mapping( + Cell::new( + vec![ridge_start, ridge_end, first_opposite, second_opposite], + None, + ) + .unwrap(), + ) + .unwrap(); + let dangling_neighbor = tds + .insert_cell_with_mapping( + Cell::new( + vec![ridge_start, ridge_end, first_opposite, dangling_opposite], + None, + ) + .unwrap(), + ) + .unwrap(); + + assert!(tds.cells_mut().remove(dangling_neighbor).is_some()); + let neighbors = tds + .cell_mut(cell) + .expect("test cell should exist") + .ensure_neighbors_buffer_mut(); + neighbors[0] = Some(dangling_neighbor); + + let ridge = RidgeHandle::new(cell, 0, 1); + let err = build_k3_flip_context(&tds, ridge).unwrap_err(); + assert_eq!( + err, + FlipError::DanglingRidgeNeighbor { + cell_key: cell, + neighbor_key: dangling_neighbor, + } + ); + } + #[test] fn test_flip_k2_inverse_invalid_edge_multiplicity_4d() { init_tracing(); @@ -11852,6 +11923,15 @@ mod tests { FlipFailureKind::from(&wiring_repair), FlipFailureKind::DelaunayRepairFailed ); + + let dangling_ridge_neighbor = FlipError::DanglingRidgeNeighbor { + cell_key: CellKey::from(KeyData::from_ffi(1)), + neighbor_key: CellKey::from(KeyData::from_ffi(2)), + }; + assert_eq!( + FlipFailureKind::from(&dangling_ridge_neighbor), + FlipFailureKind::DanglingRidgeNeighbor + ); } #[test] diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index d1b1cb16..0f0d1337 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -5508,7 +5508,6 @@ where } // 4. Determine the supported insertion site and any conflict cells it needs. - let caller_provided_conflict_cells = conflict_cells.is_some(); let insertion_site = match (location, conflict_cells) { (LocateResult::InsideCell(start_cell), None) => { // Interior point: compute conflict region automatically. @@ -5669,8 +5668,7 @@ where cells_removed: outcome.cells_removed, suspicion, repair_seed_cells: outcome.repair_seed_cells, - delaunay_repair_required: outcome.delaunay_repair_required - || caller_provided_conflict_cells, + delaunay_repair_required: outcome.delaunay_repair_required, }) } InsertionSite::Exterior { @@ -9632,6 +9630,40 @@ mod tests { assert!(tri.is_valid().is_ok()); } + #[test] + fn triangulation_caller_conflicts_do_not_force_delaunay_repair() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + ]; + let tds = + Triangulation::, (), (), 2>::build_initial_simplex(&vertices).unwrap(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + + let start_cell = tri.cells().next().map(|(cell_key, _)| cell_key).unwrap(); + let mut conflict_cells = CellKeyBuffer::new(); + conflict_cells.push(start_cell); + let detail = tri + .insert_with_statistics_seeded_indexed_detailed( + vertex!([0.25, 0.25]), + Some(&conflict_cells), + Some(start_cell), + 0, + None, + None, + ) + .unwrap(); + + assert!(matches!(detail.outcome, InsertionOutcome::Inserted { .. })); + assert!( + !detail.delaunay_repair_required, + "caller-provided conflict cells should preserve the cavity insertion repair flag" + ); + assert!(tri.is_valid().is_ok()); + } + #[test] fn triangulation_policy_skipped_validation_does_not_increment_telemetry() { let vertices = vec![ diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index f430a26a..ba18e264 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -84,9 +84,9 @@ use delaunay::prelude::triangulation::construction::{ TopologyGuarantee, Vertex, vertex, }; use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; -use delaunay::prelude::triangulation::insertion::{ - InsertionOutcome, InsertionResult, InsertionStatistics, -}; +#[cfg(feature = "diagnostics")] +use delaunay::prelude::triangulation::insertion::InsertionResult; +use delaunay::prelude::triangulation::insertion::{InsertionOutcome, InsertionStatistics}; use delaunay::prelude::triangulation::repair::{ DelaunayCheckPolicy, DelaunayRepairHeuristicConfig, }; @@ -148,6 +148,7 @@ struct SkipSample { } #[derive(Debug, Clone)] +#[cfg(feature = "diagnostics")] struct SlowInsertionSample { index: usize, uuid: uuid::Uuid, @@ -182,6 +183,7 @@ struct InsertionSummary { telemetry: ConstructionTelemetry, + #[cfg(feature = "diagnostics")] slow_insertions: Vec, skip_samples: Vec>, } @@ -236,6 +238,7 @@ impl InsertionSummary { impl From for InsertionSummary { fn from(stats: ConstructionStatistics) -> Self { + #[cfg(feature = "diagnostics")] let slow_insertions = stats .slow_insertions .into_iter() @@ -298,6 +301,7 @@ impl From for InsertionSummary { cells_removed_total: stats.cells_removed_total, cells_removed_max: stats.cells_removed_max, telemetry: stats.telemetry, + #[cfg(feature = "diagnostics")] slow_insertions, skip_samples, } @@ -672,14 +676,17 @@ fn print_validation_report(report: &TriangulationValidationReport) { } } +#[cfg(feature = "diagnostics")] fn usize_to_u128(value: usize) -> u128 { u128::try_from(value).expect("usize should always fit in u128") } +#[cfg(feature = "diagnostics")] fn format_ratio_2(numerator: usize, denominator: usize) -> String { format_ratio_2_u128(usize_to_u128(numerator), usize_to_u128(denominator)) } +#[cfg(feature = "diagnostics")] fn format_ratio_2_u128(numerator: u128, denominator: u128) -> String { if denominator == 0 { return "0.00".to_string(); @@ -689,11 +696,13 @@ fn format_ratio_2_u128(numerator: u128, denominator: u128) -> String { format!("{}.{:02}", scaled / 100, scaled % 100) } +#[cfg(feature = "diagnostics")] fn format_nanos_as_ms(nanos: u64) -> String { let micros = u128::from(nanos) / 1_000; format!("{}.{:03}", micros / 1_000, micros % 1_000) } +#[cfg(feature = "diagnostics")] fn format_avg_nanos_as_ms(total_nanos: u64, count: usize) -> String { if count == 0 { return "0.000".to_string(); @@ -703,6 +712,7 @@ fn format_avg_nanos_as_ms(total_nanos: u64, count: usize) -> String { format_nanos_as_ms(total_nanos / count) } +#[cfg(feature = "diagnostics")] fn print_timing_summary(label: &str, calls: usize, total_nanos: u64, max_nanos: u64) { if calls == 0 { return; @@ -716,6 +726,7 @@ fn print_timing_summary(label: &str, calls: usize, total_nanos: u64, max_nanos: ); } +#[cfg(feature = "diagnostics")] fn print_repair_seed_accumulation_telemetry(telemetry: &ConstructionTelemetry) { if telemetry.repair_seed_accumulation_calls == 0 { return; @@ -739,6 +750,7 @@ fn print_repair_seed_accumulation_telemetry(telemetry: &ConstructionTelemetry) { ); } +#[cfg(feature = "diagnostics")] fn print_local_repair_frontier_telemetry(telemetry: &ConstructionTelemetry) { if telemetry.local_repair_calls == 0 { return; @@ -757,6 +769,7 @@ fn print_local_repair_frontier_telemetry(telemetry: &ConstructionTelemetry) { ); } +#[cfg(feature = "diagnostics")] fn print_local_repair_work_telemetry(telemetry: &ConstructionTelemetry) { if telemetry.local_repair_calls == 0 { return; @@ -780,6 +793,7 @@ fn print_local_repair_work_telemetry(telemetry: &ConstructionTelemetry) { ); } +#[cfg(feature = "diagnostics")] fn print_local_repair_phase_telemetry(telemetry: &ConstructionTelemetry) { let phase_total = telemetry .local_repair_snapshot_nanos @@ -846,6 +860,7 @@ fn print_local_repair_phase_telemetry(telemetry: &ConstructionTelemetry) { ); } +#[cfg(feature = "diagnostics")] fn print_construction_phase_telemetry(telemetry: &ConstructionTelemetry) { let outer_total_nanos = telemetry .construction_preprocessing_nanos @@ -881,106 +896,119 @@ fn print_construction_phase_telemetry(telemetry: &ConstructionTelemetry) { ); } +#[cfg_attr( + not(feature = "diagnostics"), + expect( + clippy::missing_const_for_fn, + reason = "the diagnostics build of this helper emits detailed output" + ) +)] fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { - if !telemetry.has_data() { - return; - } - - println!(); - println!(" insertion telemetry:"); - print_construction_phase_telemetry(telemetry); - print_timing_summary( - "insertion_wall", - telemetry.insertion_wall_time_calls, - telemetry.insertion_wall_time_nanos, - telemetry.insertion_wall_time_nanos_max, - ); - println!( - " locate: calls={} walk_steps_total={} avg_walk={} max_walk={} hint_uses={} scan_fallbacks={}", - telemetry.locate_calls, - telemetry.locate_walk_steps_total, - format_ratio_2(telemetry.locate_walk_steps_total, telemetry.locate_calls), - telemetry.locate_walk_steps_max, - telemetry.locate_hint_uses, - telemetry.locate_scan_fallbacks - ); - println!( - " locate_results: inside={} outside={} boundary={}", - telemetry.located_inside, telemetry.located_outside, telemetry.located_on_boundary - ); + #[cfg(not(feature = "diagnostics"))] + let _ = telemetry.has_data(); - if telemetry.conflict_region_calls > 0 { + #[cfg(feature = "diagnostics")] + if telemetry.has_data() { + println!(); + println!(" insertion telemetry:"); + print_construction_phase_telemetry(telemetry); + print_timing_summary( + "insertion_wall", + telemetry.insertion_wall_time_calls, + telemetry.insertion_wall_time_nanos, + telemetry.insertion_wall_time_nanos_max, + ); println!( - " conflict_regions: calls={} cells_total={} avg_cells={} max_cells={} total_ms={} avg_ms={} max_ms={}", - telemetry.conflict_region_calls, - telemetry.conflict_region_cells_total, - format_ratio_2( - telemetry.conflict_region_cells_total, - telemetry.conflict_region_calls, - ), - telemetry.conflict_region_cells_max, - format_nanos_as_ms(telemetry.conflict_region_nanos), - format_avg_nanos_as_ms( - telemetry.conflict_region_nanos, - telemetry.conflict_region_calls, - ), - format_nanos_as_ms(telemetry.conflict_region_nanos_max) + " locate: calls={} walk_steps_total={} avg_walk={} max_walk={} hint_uses={} scan_fallbacks={}", + telemetry.locate_calls, + telemetry.locate_walk_steps_total, + format_ratio_2(telemetry.locate_walk_steps_total, telemetry.locate_calls), + telemetry.locate_walk_steps_max, + telemetry.locate_hint_uses, + telemetry.locate_scan_fallbacks ); - } - - print_timing_summary( - "cavity_insertions", - telemetry.cavity_insertion_calls, - telemetry.cavity_insertion_nanos, - telemetry.cavity_insertion_nanos_max, - ); - print_timing_summary( - "hull_extensions", - telemetry.hull_extension_calls, - telemetry.hull_extension_nanos, - telemetry.hull_extension_nanos_max, - ); - print_timing_summary( - "topology_validations", - telemetry.topology_validation_calls, - telemetry.topology_validation_nanos, - telemetry.topology_validation_nanos_max, - ); - print_timing_summary( - "local_repairs", - telemetry.local_repair_calls, - telemetry.local_repair_nanos, - telemetry.local_repair_nanos_max, - ); - print_local_repair_phase_telemetry(telemetry); - print_local_repair_frontier_telemetry(telemetry); - print_local_repair_work_telemetry(telemetry); - print_repair_seed_accumulation_telemetry(telemetry); - - if telemetry.global_conflict_scans > 0 { - let scans = u64::try_from(telemetry.global_conflict_scans) - .expect("scan count should fit in u64 for debug reporting"); println!( - " global_conflict_scans: scans={} cells_scanned_total={} avg_cells_scanned={} cells_found_total={} avg_cells_found={} max_cells_found={} total_ms={} avg_ms={}", - telemetry.global_conflict_scans, - telemetry.global_conflict_cells_scanned, - format_ratio_2( - telemetry.global_conflict_cells_scanned, + " locate_results: inside={} outside={} boundary={}", + telemetry.located_inside, telemetry.located_outside, telemetry.located_on_boundary + ); + + if telemetry.conflict_region_calls > 0 { + println!( + " conflict_regions: calls={} cells_total={} avg_cells={} max_cells={} total_ms={} avg_ms={} max_ms={}", + telemetry.conflict_region_calls, + telemetry.conflict_region_cells_total, + format_ratio_2( + telemetry.conflict_region_cells_total, + telemetry.conflict_region_calls, + ), + telemetry.conflict_region_cells_max, + format_nanos_as_ms(telemetry.conflict_region_nanos), + format_avg_nanos_as_ms( + telemetry.conflict_region_nanos, + telemetry.conflict_region_calls, + ), + format_nanos_as_ms(telemetry.conflict_region_nanos_max) + ); + } + + print_timing_summary( + "cavity_insertions", + telemetry.cavity_insertion_calls, + telemetry.cavity_insertion_nanos, + telemetry.cavity_insertion_nanos_max, + ); + print_timing_summary( + "hull_extensions", + telemetry.hull_extension_calls, + telemetry.hull_extension_nanos, + telemetry.hull_extension_nanos_max, + ); + print_timing_summary( + "topology_validations", + telemetry.topology_validation_calls, + telemetry.topology_validation_nanos, + telemetry.topology_validation_nanos_max, + ); + print_timing_summary( + "local_repairs", + telemetry.local_repair_calls, + telemetry.local_repair_nanos, + telemetry.local_repair_nanos_max, + ); + print_local_repair_phase_telemetry(telemetry); + print_local_repair_frontier_telemetry(telemetry); + print_local_repair_work_telemetry(telemetry); + print_repair_seed_accumulation_telemetry(telemetry); + + if telemetry.global_conflict_scans > 0 { + let scans = u64::try_from(telemetry.global_conflict_scans) + .expect("scan count should fit in u64 for debug reporting"); + println!( + " global_conflict_scans: scans={} cells_scanned_total={} avg_cells_scanned={} cells_found_total={} avg_cells_found={} max_cells_found={} total_ms={} avg_ms={}", telemetry.global_conflict_scans, - ), - telemetry.global_conflict_cells_found_total, - format_ratio_2( + telemetry.global_conflict_cells_scanned, + format_ratio_2( + telemetry.global_conflict_cells_scanned, + telemetry.global_conflict_scans, + ), telemetry.global_conflict_cells_found_total, - telemetry.global_conflict_scans, - ), - telemetry.global_conflict_cells_found_max, - format_nanos_as_ms(telemetry.global_conflict_scan_nanos), - format_nanos_as_ms(telemetry.global_conflict_scan_nanos / scans) - ); + format_ratio_2( + telemetry.global_conflict_cells_found_total, + telemetry.global_conflict_scans, + ), + telemetry.global_conflict_cells_found_max, + format_nanos_as_ms(telemetry.global_conflict_scan_nanos), + format_nanos_as_ms(telemetry.global_conflict_scan_nanos / scans) + ); + } } } -fn print_insertion_summary(summary: &InsertionSummary, elapsed: Duration) { +fn print_insertion_summary( + summary: &InsertionSummary, + elapsed: Duration, + include_batch_diagnostics: bool, +) { println!("Insertion summary:"); println!(" inserted: {}", summary.inserted); println!(" skipped_duplicate: {}", summary.skipped_duplicate); @@ -1005,9 +1033,12 @@ fn print_insertion_summary(summary: &InsertionSummary, elapse } } - print_construction_telemetry(&summary.telemetry); + if include_batch_diagnostics { + print_construction_telemetry(&summary.telemetry); + } - if !summary.slow_insertions.is_empty() { + #[cfg(feature = "diagnostics")] + if include_batch_diagnostics && !summary.slow_insertions.is_empty() { println!(); println!( " slow_insertions (top {} by transactional insertion wall time):", @@ -1197,7 +1228,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz ) { Ok((dt, stats)) => { let summary: InsertionSummary = stats.into(); - print_insertion_summary(&summary, t_insert.elapsed()); + print_insertion_summary(&summary, t_insert.elapsed(), true); println!("Batch construction completed in {:?}", t_batch.elapsed()); dt } @@ -1208,7 +1239,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz .. } = e; let summary: InsertionSummary = statistics.into(); - print_insertion_summary(&summary, t_insert.elapsed()); + print_insertion_summary(&summary, t_insert.elapsed(), true); println!("Batch construction failed after {:?}", t_batch.elapsed()); println!("construction failed: {error}"); let outcome = DebugOutcome::ConstructionFailure { @@ -1340,7 +1371,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz } } - print_insertion_summary(&summary, t_insert.elapsed()); + print_insertion_summary(&summary, t_insert.elapsed(), false); dt } From d83af7d351706fd91d43e82de73d1ba918e14b46 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 11 May 2026 09:43:07 -0700 Subject: [PATCH 12/15] feat(triangulation): add slow local repair telemetry (#341) - Retain bounded slow local-repair samples with insertion index, trigger, seed frontier size, checked work, flips, queue size, and phase timing. - Expose the repair trigger and sample types through the diagnostics prelude. - Print slow local-repair samples in the large-scale debug harness. - Archive the N=1 repair performance plan for #341. --- docs/archive/issue_341_n1_repair_plan.md | 47 ++++++++++ src/lib.rs | 4 +- src/triangulation/delaunay.rs | 74 +++++++++++++-- src/triangulation/diagnostics.rs | 109 ++++++++++++++++++++++- tests/large_scale_debug.rs | 28 ++++++ 5 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 docs/archive/issue_341_n1_repair_plan.md diff --git a/docs/archive/issue_341_n1_repair_plan.md b/docs/archive/issue_341_n1_repair_plan.md new file mode 100644 index 00000000..0d919d86 --- /dev/null +++ b/docs/archive/issue_341_n1_repair_plan.md @@ -0,0 +1,47 @@ +# Issue #341: N=1 Repair Performance Plan + +This note captures the current plan for resolving #341 after defaulting batch +construction repair to every insertion (`N=1`). The goal is reasonable +performance on 10K vertices in 3D without compromising correctness, +orthogonality, or valid Delaunay output. + +## Priority Order + +1. Numerical correctness +2. Topological correctness +3. Orthogonality and maintainability +4. Performance within scope + +## Current Direction + +The branch now treats `EveryInsertion` / `N=1` as the default batch repair +cadence. Because of that, the next performance work should not optimize around +the `N=2` slowdown directly unless it exposes the same hotspot that affects the +default path. + +## Plan + +1. Capture a fresh 3K 3D baseline with the default `N=1` repair policy and + existing telemetry. Confirm wall time, skipped vertices, local repair calls, + max queue, facet/ridge timing, and final validity. +2. Add diagnostics only if existing telemetry cannot identify the hotspot. Keep + any new diagnostics narrow and focused on slow local repair samples: + insertion index, trigger, seed-cell count, checked items, flips, max queue, + elapsed time, and phase timing. +3. Optimize the contents of a single local repair pass. Since `N=1` avoids + intentional seed backlog, focus inside the repair queue rather than batching + pending seed frontiers. Candidate targets include redundant facet/ridge/edge + queue entries, stale unchanged handles, and cheaper facet-first processing + before ridge escalation where correctness allows. +4. Keep safety nets unchanged: final seeded repair, final global fallback, + orientation canonicalization, and final validation must remain enabled. +5. Validate after each patch with focused flip/repair tests, large-scale debug + tests, and repository checks. +6. Scale from 3K to 10K once the default `N=1` path is stable and measurably + better. Reconsider #364 only if profiling shows snapshot/rollback or + postcondition replay dominates after queue-content optimizations. + +## Immediate Next Step + +Run a fresh 3K 3D baseline with `N=1` and inspect whether the existing local +repair telemetry is enough to identify the dominant cost. diff --git a/src/lib.rs b/src/lib.rs index 30134f62..6e9c3fb2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1120,7 +1120,9 @@ pub mod prelude { /// assert!(!telemetry.has_data()); /// ``` pub mod diagnostics { - pub use crate::triangulation::diagnostics::ConstructionTelemetry; + pub use crate::triangulation::diagnostics::{ + BatchLocalRepairTrigger, ConstructionTelemetry, LocalRepairSample, + }; } /// Validation scheduling helpers for construction diagnostics. diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 421e99f6..4ff29c44 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -46,7 +46,9 @@ use crate::geometry::util::{safe_coords_to_f64, safe_usize_to_scalar, simplex_vo use crate::topology::manifold::{ManifoldError, validate_ridge_links_for_cells}; use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; use crate::triangulation::builder::DelaunayTriangulationBuilder; -use crate::triangulation::diagnostics::{BatchLocalRepairTrigger, ConstructionTelemetry}; +use crate::triangulation::diagnostics::{ + BatchLocalRepairTrigger, ConstructionTelemetry, LocalRepairSample, +}; use crate::triangulation::locality::{ accumulate_live_cell_seeds, clear_cell_seed_set, retain_live_cell_seeds, }; @@ -4362,7 +4364,54 @@ where } } + /// Records successful local-repair telemetry in one place so the repair loop + /// stays focused on control flow. + fn record_successful_local_repair_telemetry( + telemetry: &mut ConstructionTelemetry, + index: usize, + trigger: BatchLocalRepairTrigger, + seed_cells_len: usize, + repair_elapsed: Duration, + phase_timing: &LocalRepairPhaseTiming, + stats: &DelaunayRepairStats, + ) { + telemetry.record_local_repair_work( + stats.facets_checked, + stats.flips_performed, + stats.max_queue_len, + ); + telemetry.record_local_repair_sample(LocalRepairSample { + index, + trigger, + seed_cells: seed_cells_len, + elapsed_nanos: duration_nanos_saturating(repair_elapsed), + items_checked: stats.facets_checked, + flips_performed: stats.flips_performed, + max_queue_len: stats.max_queue_len, + facet_nanos: phase_timing.attempt_facet_nanos, + ridge_nanos: phase_timing.attempt_ridge_nanos, + postcondition_nanos: phase_timing.postcondition_nanos, + }); + } + + /// Records timing and frontier telemetry for one local-repair attempt. + fn record_local_repair_attempt_telemetry( + telemetry: &mut ConstructionTelemetry, + repair_elapsed: Duration, + phase_timing: &LocalRepairPhaseTiming, + seed_cells_len: usize, + trigger: BatchLocalRepairTrigger, + ) { + telemetry.record_local_repair_timing(duration_nanos_saturating(repair_elapsed)); + telemetry.record_local_repair_phase_timing(phase_timing); + telemetry.record_local_repair_frontier(seed_cells_len, trigger); + } + /// Repairs the currently accumulated batch-local seed frontier. + #[expect( + clippy::too_many_lines, + reason = "local repair control flow keeps telemetry, rollback, and soft-fail handling together" + )] fn repair_pending_local_seed_cells( &mut self, index: usize, @@ -4420,19 +4469,26 @@ where }; let repair_elapsed = repair_started.map(|started| started.elapsed()); if let Some(telemetry) = construction_telemetry.as_mut() { - let repair_elapsed = repair_elapsed.unwrap_or_default(); - telemetry.record_local_repair_timing(duration_nanos_saturating(repair_elapsed)); - telemetry.record_local_repair_phase_timing(&phase_timing); - telemetry.record_local_repair_frontier(seed_cells_len, trigger); + Self::record_local_repair_attempt_telemetry( + telemetry, + repair_elapsed.unwrap_or_default(), + &phase_timing, + seed_cells_len, + trigger, + ); } match repair_result { Ok(stats) => { if let Some(telemetry) = construction_telemetry.as_mut() { - telemetry.record_local_repair_work( - stats.facets_checked, - stats.flips_performed, - stats.max_queue_len, + Self::record_successful_local_repair_telemetry( + telemetry, + index, + trigger, + seed_cells_len, + repair_elapsed.unwrap_or_default(), + &phase_timing, + &stats, ); } if trace_repair { diff --git a/src/triangulation/diagnostics.rs b/src/triangulation/diagnostics.rs index 9b6c664d..ae304195 100644 --- a/src/triangulation/diagnostics.rs +++ b/src/triangulation/diagnostics.rs @@ -14,15 +14,45 @@ use crate::core::algorithms::flips::LocalRepairPhaseTiming; use crate::core::operations::InsertionTelemetry; +const LOCAL_REPAIR_SAMPLE_LIMIT: usize = 8; + /// Reason a batch local repair pass was scheduled. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum BatchLocalRepairTrigger { +pub enum BatchLocalRepairTrigger { /// The configured repair cadence fired. Cadence, /// The pending repair seed frontier exceeded the backlog threshold. SeedBacklog, } +/// One slow batch-local repair sample retained for performance diagnostics. +/// +/// Samples are kept in descending wall-clock order and capped so construction +/// telemetry stays bounded even in large debug runs. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalRepairSample { + /// Vertex insertion index at which the repair ran. + pub index: usize, + /// Reason this repair pass was scheduled. + pub trigger: BatchLocalRepairTrigger, + /// Number of pending seed cells repaired by this pass. + pub seed_cells: usize, + /// Wall-clock nanoseconds spent in this local repair call. + pub elapsed_nanos: u64, + /// Number of queued repair items checked by this pass. + pub items_checked: usize, + /// Number of flips performed by this pass. + pub flips_performed: usize, + /// Maximum aggregate queue length reached by this pass. + pub max_queue_len: usize, + /// Wall-clock nanoseconds spent processing k=2 facet queue items. + pub facet_nanos: u64, + /// Wall-clock nanoseconds spent processing k=3 ridge queue items. + pub ridge_nanos: u64, + /// Wall-clock nanoseconds spent checking the local postcondition. + pub postcondition_nanos: u64, +} + /// Aggregate release-visible telemetry collected during batch construction. /// /// These counters summarize batch construction at a coarse level so large-scale @@ -163,6 +193,8 @@ pub struct ConstructionTelemetry { pub local_repair_queue_len_max: usize, /// Number of successful batch local repair calls that performed no flips. pub local_repair_no_flip_calls: usize, + /// Slowest successful batch-local repair samples, sorted by descending wall time. + pub local_repair_slow_samples: Vec, /// Number of bulk local-repair seed accumulation calls. pub repair_seed_accumulation_calls: usize, @@ -231,6 +263,7 @@ impl ConstructionTelemetry { || self.local_repair_items_checked_total > 0 || self.local_repair_flips_total > 0 || self.local_repair_no_flip_calls > 0 + || !self.local_repair_slow_samples.is_empty() || self.repair_seed_accumulation_calls > 0 || self.global_conflict_scans > 0 } @@ -408,6 +441,23 @@ impl ConstructionTelemetry { } } + /// Retains one slow local-repair sample if it belongs in the bounded top list. + pub(crate) fn record_local_repair_sample(&mut self, sample: LocalRepairSample) { + if self.local_repair_slow_samples.len() < LOCAL_REPAIR_SAMPLE_LIMIT { + self.local_repair_slow_samples.push(sample); + } else if let Some((slowest_kept_idx, _)) = self + .local_repair_slow_samples + .iter() + .enumerate() + .min_by_key(|(_idx, kept)| kept.elapsed_nanos) + && sample.elapsed_nanos > self.local_repair_slow_samples[slowest_kept_idx].elapsed_nanos + { + self.local_repair_slow_samples[slowest_kept_idx] = sample; + } + self.local_repair_slow_samples + .sort_unstable_by_key(|sample| core::cmp::Reverse(sample.elapsed_nanos)); + } + /// Records one bulk local-repair seed accumulation step. pub(crate) fn record_repair_seed_accumulation( &mut self, @@ -725,6 +775,9 @@ impl ConstructionTelemetry { self.local_repair_no_flip_calls = self .local_repair_no_flip_calls .saturating_add(other.local_repair_no_flip_calls); + for sample in &other.local_repair_slow_samples { + self.record_local_repair_sample(*sample); + } } /// Keeps seed-accumulation merge accounting isolated from the aggregate merge body. @@ -804,6 +857,18 @@ mod tests { }); summary.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); summary.record_local_repair_work(123, 5, 17); + summary.record_local_repair_sample(LocalRepairSample { + index: 42, + trigger: BatchLocalRepairTrigger::SeedBacklog, + seed_cells: 11, + elapsed_nanos: 2_000_000, + items_checked: 123, + flips_performed: 5, + max_queue_len: 17, + facet_nanos: 750_000, + ridge_nanos: 450_000, + postcondition_nanos: 500_000, + }); summary.record_repair_seed_accumulation(500_000, 7); summary.record_construction_preprocessing_timing(10_000); summary.record_construction_insert_loop_timing(20_000); @@ -870,6 +935,21 @@ mod tests { assert_eq!(summary.local_repair_flips_max, 5); assert_eq!(summary.local_repair_queue_len_max, 17); assert_eq!(summary.local_repair_no_flip_calls, 0); + assert_eq!( + summary.local_repair_slow_samples, + vec![LocalRepairSample { + index: 42, + trigger: BatchLocalRepairTrigger::SeedBacklog, + seed_cells: 11, + elapsed_nanos: 2_000_000, + items_checked: 123, + flips_performed: 5, + max_queue_len: 17, + facet_nanos: 750_000, + ridge_nanos: 450_000, + postcondition_nanos: 500_000, + }] + ); assert_eq!(summary.repair_seed_accumulation_calls, 1); assert_eq!(summary.repair_seed_accumulation_nanos, 500_000); assert_eq!(summary.repair_seed_cells_added_total, 7); @@ -897,6 +977,18 @@ mod tests { }); left.record_local_repair_frontier(5, BatchLocalRepairTrigger::Cadence); left.record_local_repair_work(10, 0, 5); + left.record_local_repair_sample(LocalRepairSample { + index: 1, + trigger: BatchLocalRepairTrigger::Cadence, + seed_cells: 5, + elapsed_nanos: 10, + items_checked: 10, + flips_performed: 0, + max_queue_len: 5, + facet_nanos: 4, + ridge_nanos: 5, + postcondition_nanos: 3, + }); left.record_construction_insert_loop_timing(100); left.record_construction_final_delaunay_validation_timing(200); @@ -915,6 +1007,18 @@ mod tests { }); right.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); right.record_local_repair_work(30, 4, 12); + right.record_local_repair_sample(LocalRepairSample { + index: 3, + trigger: BatchLocalRepairTrigger::SeedBacklog, + seed_cells: 11, + elapsed_nanos: 30, + items_checked: 30, + flips_performed: 4, + max_queue_len: 12, + facet_nanos: 40, + ridge_nanos: 50, + postcondition_nanos: 30, + }); right.record_construction_insert_loop_timing(300); right.record_construction_final_delaunay_validation_timing(400); @@ -953,5 +1057,8 @@ mod tests { assert_eq!(left.local_repair_flips_max, 4); assert_eq!(left.local_repair_queue_len_max, 12); assert_eq!(left.local_repair_no_flip_calls, 1); + assert_eq!(left.local_repair_slow_samples.len(), 2); + assert_eq!(left.local_repair_slow_samples[0].elapsed_nanos, 30); + assert_eq!(left.local_repair_slow_samples[1].elapsed_nanos, 10); } } diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index ba18e264..6fcd9423 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -793,6 +793,33 @@ fn print_local_repair_work_telemetry(telemetry: &ConstructionTelemetry) { ); } +#[cfg(feature = "diagnostics")] +fn print_local_repair_slow_samples(telemetry: &ConstructionTelemetry) { + if telemetry.local_repair_slow_samples.is_empty() { + return; + } + + println!( + " local_repair_slow_samples (top {}):", + telemetry.local_repair_slow_samples.len() + ); + for sample in &telemetry.local_repair_slow_samples { + println!( + " idx={} trigger={:?} seed_cells={} elapsed_ms={} checked={} flips={} max_queue={} facet_ms={} ridge_ms={} postcondition_ms={}", + sample.index, + sample.trigger, + sample.seed_cells, + format_nanos_as_ms(sample.elapsed_nanos), + sample.items_checked, + sample.flips_performed, + sample.max_queue_len, + format_nanos_as_ms(sample.facet_nanos), + format_nanos_as_ms(sample.ridge_nanos), + format_nanos_as_ms(sample.postcondition_nanos) + ); + } +} + #[cfg(feature = "diagnostics")] fn print_local_repair_phase_telemetry(telemetry: &ConstructionTelemetry) { let phase_total = telemetry @@ -978,6 +1005,7 @@ fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { print_local_repair_phase_telemetry(telemetry); print_local_repair_frontier_telemetry(telemetry); print_local_repair_work_telemetry(telemetry); + print_local_repair_slow_samples(telemetry); print_repair_seed_accumulation_telemetry(telemetry); if telemetry.global_conflict_scans > 0 { From 34b4bfb9a06773c895217e0bf565ef49db9d6b78 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 11 May 2026 10:37:51 -0700 Subject: [PATCH 13/15] perf: localize repair flip validation - Replace full TDS validation after Delaunay repair flips with a local cavity validation path that checks affected cells, vertex mappings, neighbor links, facet multiplicity, and orientation consistency. - Keep full validation for generic flip APIs and final/global safety checks. - Document the 3K and 10K #341 measurements and identify post-insertion topology validation as the next hotspot. --- docs/archive/issue_341_n1_repair_plan.md | 68 ++-- src/core/algorithms/flips.rs | 444 ++++++++++++++++++++++- 2 files changed, 484 insertions(+), 28 deletions(-) diff --git a/docs/archive/issue_341_n1_repair_plan.md b/docs/archive/issue_341_n1_repair_plan.md index 0d919d86..8812fe88 100644 --- a/docs/archive/issue_341_n1_repair_plan.md +++ b/docs/archive/issue_341_n1_repair_plan.md @@ -19,29 +19,55 @@ cadence. Because of that, the next performance work should not optimize around the `N=2` slowdown directly unless it exposes the same hotspot that affects the default path. +The local flip-repair path has already been improved enough that it is no +longer the dominant 10K cost. The next performance target is the topology +validation layer reached after insertion, especially ridge-link validation, +which currently rebuilds/checks more global structure than the local mutation +appears to require. + +## Latest Measurements + +### 3K 3D, `N=1` + +- Result: valid Delaunay triangulation, no skipped vertices. +- Total wall time: 54.571s. +- Insertion wall time: 52.908s. +- Local repairs: 485 calls, 5.499s total, 74.848ms max. +- Final repair: 1.082s, 0 flips. +- Final validation report: 580.243ms, OK. + +### 10K 3D, `N=1` + +- Result: valid Delaunay triangulation, no skipped vertices. +- Total wall time: 630.582s. +- Insertion loop: 622.605s. +- Local repairs: 1037 calls, 35.162s total, 384.335ms max. +- Final repair: 3.784s, 0 flips. +- Final validation report: 2.004s, OK. +- Sampling showed the current hotspot in `validate_after_insertion`, especially + `validate_ridge_links`, ridge-link graph construction, and temporary + facet/ridge key work. + ## Plan -1. Capture a fresh 3K 3D baseline with the default `N=1` repair policy and - existing telemetry. Confirm wall time, skipped vertices, local repair calls, - max queue, facet/ridge timing, and final validity. -2. Add diagnostics only if existing telemetry cannot identify the hotspot. Keep - any new diagnostics narrow and focused on slow local repair samples: - insertion index, trigger, seed-cell count, checked items, flips, max queue, - elapsed time, and phase timing. -3. Optimize the contents of a single local repair pass. Since `N=1` avoids - intentional seed backlog, focus inside the repair queue rather than batching - pending seed frontiers. Candidate targets include redundant facet/ridge/edge - queue entries, stale unchanged handles, and cheaper facet-first processing - before ridge escalation where correctness allows. -4. Keep safety nets unchanged: final seeded repair, final global fallback, - orientation canonicalization, and final validation must remain enabled. -5. Validate after each patch with focused flip/repair tests, large-scale debug - tests, and repository checks. -6. Scale from 3K to 10K once the default `N=1` path is stable and measurably - better. Reconsider #364 only if profiling shows snapshot/rollback or - postcondition replay dominates after queue-content optimizations. +1. Preserve the correctness model: every mutation must remain locally + topology-safe, and final seeded repair, final global fallback, orientation + canonicalization, and final validation must remain enabled. +2. Replace the expensive post-insertion global topology check with a scoped + topology validation path that checks only the changed cavity and its + immediate boundary where correctness permits. +3. Keep full validation available for final validation, explicit public + validation, and any path where the mutation scope cannot be represented + precisely. +4. Validate the scoped topology checker against the existing full checker in + focused tests, including interior insertions and hull extensions. +5. Re-run the 3K and 10K large-scale debug cases with `N=1`, then compare + insertion wall time, local repair time, final repair, and final validation. +6. Reconsider #364 only if profiling shows snapshot/rollback or postcondition + replay dominates after topology validation is scoped. ## Immediate Next Step -Run a fresh 3K 3D baseline with `N=1` and inspect whether the existing local -repair telemetry is enough to identify the dominant cost. +Implement a narrow scoped topology validation path for post-insertion checks, +then validate it against the full topology checker before rerunning the 10K +benchmark. diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index a914043c..2e3f63c6 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -334,6 +334,10 @@ where } /// Apply a bistellar flip using explicit k and vertex/cell slices. +#[expect( + clippy::too_many_arguments, + reason = "Flip mutation needs explicit move, cavity, policy, and validation inputs" +)] #[expect( clippy::too_many_lines, reason = "Keep flip construction, validation, and wiring together for clarity" @@ -346,6 +350,7 @@ fn apply_bistellar_flip_with_k( removed_cells: &CellKeyBuffer, direction: FlipDirection, orientation_policy: ReplacementOrientationPolicy, + validation_scope: FlipValidationScope, ) -> Result, FlipError> where T: CoordinateScalar, @@ -531,14 +536,19 @@ where trial.remove_cells_by_keys(removed_cells); - if let Err(source) = trial.is_valid() { - return Err(FlipMutationError::TrialValidation { + let validation_result = match validation_scope { + FlipValidationScope::FullTds => trial.is_valid().map_err(TdsValidationFailure::from), + FlipValidationScope::LocalCavity => { + validate_flip_trial_cavity(&trial, &new_cells, &external_facets, removed_cells) + } + }; + validation_result.map_err(|source| { + FlipError::from(FlipMutationError::TrialValidation { k_move, direction, - source: source.into(), - } - .into()); - } + source, + }) + })?; debug_assert!( trial.is_coherently_oriented(), @@ -569,6 +579,422 @@ enum ReplacementOrientationPolicy { RequirePositive, } +/// Selects the amount of TDS structure checked before committing a flip. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FlipValidationScope { + /// Validate the whole triangulation data structure. + FullTds, + /// Validate only the cells whose adjacency can change during a cavity flip. + LocalCavity, +} + +/// Checks the flip cavity after mutation without rescanning the full TDS. +fn validate_flip_trial_cavity( + tds: &Tds, + new_cells: &[CellKey], + external_facets: &[FacetHandle], + removed_cells: &[CellKey], +) -> Result<(), TdsValidationFailure> +where + U: DataType, + V: DataType, +{ + for &cell_key in removed_cells { + if tds.contains_cell(cell_key) { + return Err(TdsValidationFailure::InconsistentDataStructure { + message: format!("flip trial still contains removed cell {cell_key:?}"), + }); + } + if tds.cell_uuid_from_key(cell_key).is_some() { + return Err(TdsValidationFailure::MappingInconsistency { + entity: EntityKind::Cell, + message: format!("flip trial still maps removed cell key {cell_key:?}"), + }); + } + } + + let mut affected_cells = CellKeyBuffer::new(); + let mut affected_set = FastHashSet::default(); + for &cell_key in new_cells { + push_unique_cell_key(cell_key, &mut affected_cells, &mut affected_set); + } + for facet in external_facets { + push_unique_cell_key(facet.cell_key(), &mut affected_cells, &mut affected_set); + } + + validate_flip_trial_local_facet_sharing(tds, &affected_cells)?; + + for &cell_key in &affected_cells { + validate_flip_trial_cell(tds, cell_key, removed_cells)?; + } + + Ok(()) +} + +/// Adds a cell to a small worklist while preserving first-seen order. +fn push_unique_cell_key( + cell_key: CellKey, + cells: &mut CellKeyBuffer, + seen: &mut FastHashSet, +) { + if seen.insert(cell_key) { + cells.push(cell_key); + } +} + +/// Ensures affected replacement cells agree on shared facets and multiplicity. +fn validate_flip_trial_local_facet_sharing( + tds: &Tds, + affected_cells: &[CellKey], +) -> Result<(), TdsValidationFailure> +where + U: DataType, + V: DataType, +{ + type FacetIncidents = SmallBuffer<(CellKey, u8), 2>; + let mut facet_to_cells: FastHashMap = FastHashMap::default(); + + for &cell_key in affected_cells { + let cell = tds + .cell(cell_key) + .ok_or_else(|| TdsValidationFailure::CellNotFound { + cell_key, + context: "flip trial local facet sharing".to_string(), + })?; + if cell.number_of_vertices() != D + 1 { + return Err(TdsValidationFailure::DimensionMismatch { + expected: D + 1, + actual: cell.number_of_vertices(), + context: format!("flip trial cell {cell_key:?} arity"), + }); + } + + for facet_idx in 0..cell.number_of_vertices() { + let facet_vertices = facet_vertices_from_cell(cell, facet_idx); + let facet_idx_u8 = + u8::try_from(facet_idx).map_err(|_| TdsValidationFailure::IndexOutOfBounds { + index: facet_idx, + bound: usize::from(u8::MAX), + context: "flip trial facet index".to_string(), + })?; + facet_to_cells + .entry(facet_key_from_vertices(&facet_vertices)) + .or_default() + .push((cell_key, facet_idx_u8)); + } + } + + for (facet_key, incidents) in facet_to_cells { + match incidents.as_slice() { + [_] => {} + [(cell_a, facet_a), (cell_b, facet_b)] => { + validate_flip_trial_mutual_facet_neighbors( + tds, + facet_key, + *cell_a, + usize::from(*facet_a), + *cell_b, + usize::from(*facet_b), + )?; + } + _ => { + return Err(TdsValidationFailure::Facet { + message: format!( + "flip trial facet {facet_key} is shared by {} affected cells", + incidents.len() + ), + }); + } + } + } + + Ok(()) +} + +/// Checks one affected cell's local references after a flip mutation. +fn validate_flip_trial_cell( + tds: &Tds, + cell_key: CellKey, + removed_cells: &[CellKey], +) -> Result<(), TdsValidationFailure> +where + U: DataType, + V: DataType, +{ + let cell = tds + .cell(cell_key) + .ok_or_else(|| TdsValidationFailure::CellNotFound { + cell_key, + context: "flip trial local cell validation".to_string(), + })?; + if tds.cell_uuid_from_key(cell_key) != Some(cell.uuid()) { + return Err(TdsValidationFailure::MappingInconsistency { + entity: EntityKind::Cell, + message: format!( + "missing or inconsistent UUID mapping for flip trial cell {cell_key:?}" + ), + }); + } + + if cell.number_of_vertices() != D + 1 { + return Err(TdsValidationFailure::DimensionMismatch { + expected: D + 1, + actual: cell.number_of_vertices(), + context: format!("flip trial cell {cell_key:?} arity"), + }); + } + + validate_flip_trial_cell_vertices(tds, cell_key, cell)?; + validate_flip_trial_cell_neighbors(tds, cell_key, cell, removed_cells) +} + +/// Verifies that affected cells reference existing vertices with valid incidence. +fn validate_flip_trial_cell_vertices( + tds: &Tds, + cell_key: CellKey, + cell: &Cell, +) -> Result<(), TdsValidationFailure> +where + U: DataType, + V: DataType, +{ + let mut seen_vertices: SmallBuffer = + SmallBuffer::with_capacity(cell.number_of_vertices()); + for &vertex_key in cell.vertices() { + if seen_vertices.contains(&vertex_key) { + return Err(TdsValidationFailure::InconsistentDataStructure { + message: format!("flip trial cell {cell_key:?} repeats vertex {vertex_key:?}"), + }); + } + seen_vertices.push(vertex_key); + + let vertex = + tds.vertex(vertex_key) + .ok_or_else(|| TdsValidationFailure::VertexNotFound { + vertex_key, + context: format!("flip trial cell {cell_key:?} vertex reference"), + })?; + if tds.vertex_uuid_from_key(vertex_key) != Some(vertex.uuid()) { + return Err(TdsValidationFailure::MappingInconsistency { + entity: EntityKind::Vertex, + message: format!( + "missing or inconsistent UUID mapping for flip trial vertex {vertex_key:?}" + ), + }); + } + let Some(incident_cell_key) = vertex.incident_cell else { + continue; + }; + let incident_cell = + tds.cell(incident_cell_key) + .ok_or_else(|| TdsValidationFailure::CellNotFound { + cell_key: incident_cell_key, + context: format!("dangling incident_cell pointer from vertex {vertex_key:?}"), + })?; + if !incident_cell.contains_vertex(vertex_key) { + return Err(TdsValidationFailure::InconsistentDataStructure { + message: format!( + "Vertex {vertex_key:?} incident_cell {incident_cell_key:?} does not contain the vertex" + ), + }); + } + } + + Ok(()) +} + +/// Verifies affected-cell neighbor links, mirror facets, and orientation parity. +fn validate_flip_trial_cell_neighbors( + tds: &Tds, + cell_key: CellKey, + cell: &Cell, + removed_cells: &[CellKey], +) -> Result<(), TdsValidationFailure> +where + U: DataType, + V: DataType, +{ + let Some(neighbors) = cell.neighbors() else { + return Ok(()); + }; + if neighbors.len() != D + 1 { + return Err(TdsValidationFailure::InvalidNeighbors { + message: format!( + "Neighbor vector length {} != D+1 ({})", + neighbors.len(), + D + 1 + ), + }); + } + + for (facet_idx, neighbor_key_opt) in neighbors.iter().enumerate() { + let Some(neighbor_key) = neighbor_key_opt else { + continue; + }; + if removed_cells.contains(neighbor_key) { + return Err(TdsValidationFailure::InvalidNeighbors { + message: format!( + "Cell {cell_key:?} still references removed neighbor {neighbor_key:?}" + ), + }); + } + if *neighbor_key == cell_key { + if cell_allows_periodic_self_neighbor(cell) { + continue; + } + return Err(TdsValidationFailure::InvalidNeighbors { + message: format!( + "Cell {:?} has non-periodic self-neighbor at facet index {facet_idx}", + cell.uuid() + ), + }); + } + + let neighbor_cell = + tds.cell(*neighbor_key) + .ok_or_else(|| TdsValidationFailure::InvalidNeighbors { + message: format!("Neighbor cell {neighbor_key:?} not found"), + })?; + let mirror_idx = cell + .mirror_facet_index(facet_idx, neighbor_cell) + .ok_or_else(|| TdsValidationFailure::InvalidNeighbors { + message: format!( + "Cell {:?} facet {facet_idx} does not share a valid mirror facet with neighbor {:?}", + cell.uuid(), + neighbor_cell.uuid() + ), + })?; + validate_flip_trial_mutual_facet_neighbors( + tds, + facet_key_from_vertices(&facet_vertices_from_cell(cell, facet_idx)), + cell_key, + facet_idx, + *neighbor_key, + mirror_idx, + )?; + validate_flip_trial_neighbor_orientation( + cell_key, + cell, + facet_idx, + *neighbor_key, + neighbor_cell, + mirror_idx, + )?; + } + + Ok(()) +} + +/// Mirrors TDS validation's periodic self-neighbor allowance locally. +fn cell_allows_periodic_self_neighbor(cell: &Cell) -> bool +where + U: DataType, + V: DataType, +{ + let Some(offsets) = cell.periodic_vertex_offsets() else { + return false; + }; + !offsets.is_empty() && offsets.len() == cell.number_of_vertices() +} + +/// Requires two cells sharing an affected facet to point back to each other. +fn validate_flip_trial_mutual_facet_neighbors( + tds: &Tds, + facet_key: u64, + source_cell_key: CellKey, + source_facet: usize, + target_cell_key: CellKey, + target_facet: usize, +) -> Result<(), TdsValidationFailure> +where + U: DataType, + V: DataType, +{ + let source_cell = + tds.cell(source_cell_key) + .ok_or_else(|| TdsValidationFailure::CellNotFound { + cell_key: source_cell_key, + context: "flip trial mutual neighbor validation".to_string(), + })?; + let target_cell = + tds.cell(target_cell_key) + .ok_or_else(|| TdsValidationFailure::CellNotFound { + cell_key: target_cell_key, + context: "flip trial mutual neighbor validation".to_string(), + })?; + + let source_neighbor = source_cell + .neighbors() + .and_then(|neighbors| neighbors.get(source_facet).copied().flatten()); + let target_neighbor = target_cell + .neighbors() + .and_then(|neighbors| neighbors.get(target_facet).copied().flatten()); + + if source_neighbor != Some(target_cell_key) || target_neighbor != Some(source_cell_key) { + return Err(TdsValidationFailure::InvalidNeighbors { + message: format!( + "Interior facet {facet_key} has inconsistent neighbor pointers: {}[{source_facet}] -> {source_neighbor:?}, {}[{target_facet}] -> {target_neighbor:?}", + source_cell.uuid(), + target_cell.uuid() + ), + }); + } + + Ok(()) +} + +/// Checks coherent orientation across one locally affected neighbor pair. +fn validate_flip_trial_neighbor_orientation( + cell_key: CellKey, + cell: &Cell, + facet_idx: usize, + neighbor_key: CellKey, + neighbor_cell: &Cell, + mirror_idx: usize, +) -> Result<(), TdsValidationFailure> +where + U: DataType, + V: DataType, +{ + let source_order = facet_order(cell.vertices(), facet_idx).map_err(|err| { + TdsValidationFailure::InvalidNeighbors { + message: format!("Could not build source facet order for local flip validation: {err}"), + } + })?; + let target_order = facet_order(neighbor_cell.vertices(), mirror_idx).map_err(|err| { + TdsValidationFailure::InvalidNeighbors { + message: format!("Could not build target facet order for local flip validation: {err}"), + } + })?; + let observed_odd_permutation = + permutation_odd(&source_order, &target_order).ok_or_else(|| { + TdsValidationFailure::InconsistentDataStructure { + message: format!( + "Could not derive facet-order permutation parity between cells {:?} and {:?}", + cell.uuid(), + neighbor_cell.uuid() + ), + } + })?; + let expected_odd_permutation = (facet_idx + mirror_idx).is_multiple_of(2); + if observed_odd_permutation != expected_odd_permutation { + return Err(TdsValidationFailure::OrientationViolation { + cell1_key: cell_key, + cell1_uuid: cell.uuid(), + cell2_key: neighbor_key, + cell2_uuid: neighbor_cell.uuid(), + cell1_facet_index: facet_idx, + cell2_facet_index: mirror_idx, + facet_vertex_count: source_order.len(), + cell2_facet_vertex_count: target_order.len(), + observed_odd_permutation, + expected_odd_permutation, + }); + } + + Ok(()) +} + /// Detects replacement simplices that already exist outside the flip cavity so /// a flip cannot silently duplicate a cell. fn find_cell_containing_simplex( @@ -1286,6 +1712,7 @@ where &context.removed_cells, context.direction, ReplacementOrientationPolicy::AllowSigned, + FlipValidationScope::FullTds, )? .info) } @@ -1315,6 +1742,7 @@ where &context.removed_cells, context.direction, ReplacementOrientationPolicy::AllowSigned, + FlipValidationScope::FullTds, )? .info) } @@ -1337,6 +1765,7 @@ where &context.removed_cells, context.direction, ReplacementOrientationPolicy::RequirePositive, + FlipValidationScope::LocalCavity, ) } @@ -1358,6 +1787,7 @@ where &context.removed_cells, context.direction, ReplacementOrientationPolicy::RequirePositive, + FlipValidationScope::LocalCavity, ) } @@ -1380,6 +1810,7 @@ where &context.removed_cells, context.direction, ReplacementOrientationPolicy::RequirePositive, + FlipValidationScope::LocalCavity, ) } @@ -7260,7 +7691,6 @@ fn facet_vertices_from_cell( facet_index: usize, ) -> SmallBuffer where - T: CoordinateScalar, U: DataType, V: DataType, { From 55be4ddb54f90a547cb426aefaf8f1f82f8d3513 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 11 May 2026 18:51:21 -0700 Subject: [PATCH 14/15] perf(triangulation): scope insertion validation (#341) - Validate post-insertion topology and orientation over touched cell frontiers when the mutation scope is known. - Keep strict PL-manifold and unknown-scope paths on global required-link validation. - Report stale local validation scopes as typed CellNotFound errors. - Document the 3K and 10K timing progress for the N=1 repair plan. --- docs/archive/issue_341_n1_repair_plan.md | 73 ++++- src/core/tds.rs | 129 ++++++++ src/core/triangulation.rs | 292 ++++++++++++++++-- src/topology/manifold.rs | 359 +++++++++++++++++++---- 4 files changed, 765 insertions(+), 88 deletions(-) diff --git a/docs/archive/issue_341_n1_repair_plan.md b/docs/archive/issue_341_n1_repair_plan.md index 8812fe88..9f954701 100644 --- a/docs/archive/issue_341_n1_repair_plan.md +++ b/docs/archive/issue_341_n1_repair_plan.md @@ -20,15 +20,18 @@ the `N=2` slowdown directly unless it exposes the same hotspot that affects the default path. The local flip-repair path has already been improved enough that it is no -longer the dominant 10K cost. The next performance target is the topology -validation layer reached after insertion, especially ridge-link validation, -which currently rebuilds/checks more global structure than the local mutation -appears to require. +longer the dominant 10K cost. The post-insertion topology validation path has +also been scoped to the cells touched by each ordinary insertion. The hot +insertion path now avoids full-TDS orientation normalization when the local +mutation scope is known. The remaining dominant costs are local repair, hull +extension, and ordinary insertion overhead. ## Latest Measurements ### 3K 3D, `N=1` +#### Before Scoped Post-Insertion Topology Validation + - Result: valid Delaunay triangulation, no skipped vertices. - Total wall time: 54.571s. - Insertion wall time: 52.908s. @@ -36,8 +39,29 @@ appears to require. - Final repair: 1.082s, 0 flips. - Final validation report: 580.243ms, OK. +#### After Scoped Post-Insertion Topology Validation + +- Result: valid Delaunay triangulation, no skipped vertices. +- Total wall time: 27.991s. +- Insertion wall time: 26.356s. +- Local repairs: 485 calls, 5.561s total, 74.713ms max. +- Final repair: 1.068s, 0 flips. +- Final validation report: 565.459ms, OK. + +#### After Local Insertion Orientation Validation + +- Result: valid Delaunay triangulation, no skipped vertices. +- Total wall time: 14.599s. +- Insertion wall time: 12.950s. +- Cavity insertions: 2511 calls, 143.671ms total, 0.238ms max. +- Local repairs: 485 calls, 5.653s total, 76.645ms max. +- Final repair: 1.072s, 0 flips. +- Final validation report: 576.049ms, OK. + ### 10K 3D, `N=1` +#### Before Scoped Post-Insertion Topology Validation + - Result: valid Delaunay triangulation, no skipped vertices. - Total wall time: 630.582s. - Insertion loop: 622.605s. @@ -48,26 +72,49 @@ appears to require. `validate_ridge_links`, ridge-link graph construction, and temporary facet/ridge key work. +#### After Scoped Post-Insertion Topology Validation + +- Result: valid Delaunay triangulation, no skipped vertices. +- Total wall time: 261.368s. +- Insertion loop: 253.540s. +- Transactional insertion wall: 190.238s. +- Cavity insertions: 8959 calls, 145.820s total, 36.662ms max. +- Hull extensions: 1037 calls, 14.403s total, 43.503ms max. +- Local repairs: 1037 calls, 46.638s total, 383.214ms max. +- Final repair: 3.689s, 0 flips. +- Final validation report: 2.008s, OK. + +#### After Local Insertion Orientation Validation + +- Result: valid Delaunay triangulation, no skipped vertices. +- Total wall time: 99.466s. +- Insertion loop: 91.593s. +- Transactional insertion wall: 29.593s. +- Cavity insertions: 8959 calls, 743.262ms total, 0.496ms max. +- Hull extensions: 1037 calls, 14.276s total, 40.226ms max. +- Local repairs: 1037 calls, 45.478s total, 370.416ms max. +- Final repair: 3.713s, 0 flips. +- Final validation report: 2.015s, OK. + ## Plan 1. Preserve the correctness model: every mutation must remain locally topology-safe, and final seeded repair, final global fallback, orientation canonicalization, and final validation must remain enabled. -2. Replace the expensive post-insertion global topology check with a scoped - topology validation path that checks only the changed cavity and its - immediate boundary where correctness permits. +2. Keep the scoped post-insertion topology validation path and compare it + against full validation in focused tests whenever its scope changes. 3. Keep full validation available for final validation, explicit public validation, and any path where the mutation scope cannot be represented precisely. -4. Validate the scoped topology checker against the existing full checker in - focused tests, including interior insertions and hull extensions. +4. Profile and optimize local repair without changing correctness: prioritize + repeated facet/ridge checks, queue deduplication, and frontier narrowing. 5. Re-run the 3K and 10K large-scale debug cases with `N=1`, then compare - insertion wall time, local repair time, final repair, and final validation. + local repair time, hull-extension time, final repair, and final validation. 6. Reconsider #364 only if profiling shows snapshot/rollback or postcondition replay dominates after topology validation is scoped. ## Immediate Next Step -Implement a narrow scoped topology validation path for post-insertion checks, -then validate it against the full topology checker before rerunning the 10K -benchmark. +Decide whether the ~99s 10K result is sufficient for #341. If not, profile the +local repair facet/ridge queues at 10K scale and reduce repeated checks without +weakening the final repair or final validation safety nets. diff --git a/src/core/tds.rs b/src/core/tds.rs index cb5efa8f..73baa722 100644 --- a/src/core/tds.rs +++ b/src/core/tds.rs @@ -3556,6 +3556,87 @@ where Ok(()) } + /// Validates coherent orientation for cells touched by a local mutation. + /// + /// This checks every adjacency owned by `cells`, including adjacencies to + /// cells outside the supplied slice. It is intended for insertion and local + /// repair paths that already know the mutation frontier and want Level-2 + /// orientation safety without a full-TDS traversal. + pub(crate) fn validate_coherent_orientation_for_cells( + &self, + cells: &[CellKey], + ) -> Result<(), TdsError> { + for &cell_key in cells { + let cell = self + .cells + .get(cell_key) + .ok_or_else(|| TdsError::CellNotFound { + cell_key, + context: "local orientation validation scope".to_string(), + })?; + let Some(neighbors) = cell.neighbors() else { + continue; + }; + + for (facet_idx, neighbor_key_opt) in neighbors.iter().enumerate() { + let Some(neighbor_key) = *neighbor_key_opt else { + continue; + }; + if neighbor_key == cell_key && Self::allows_periodic_self_neighbor(cell) { + continue; + } + + let neighbor_cell = + self.cells + .get(neighbor_key) + .ok_or_else(|| TdsError::CellNotFound { + cell_key: neighbor_key, + context: format!( + "neighbor of cell {cell_key:?} during local orientation validation", + ), + })?; + + if cell.periodic_vertex_offsets().is_some() + || neighbor_cell.periodic_vertex_offsets().is_some() + { + continue; + } + + let mirror_idx = cell.mirror_facet_index(facet_idx, neighbor_cell).ok_or_else( + || TdsError::InvalidNeighbors { + message: format!( + "Could not determine mirror facet while validating local orientation: cell {:?}[{facet_idx}] -> neighbor {:?}", + cell.uuid(), + neighbor_cell.uuid(), + ), + }, + )?; + + let cell1_facet_vertices = Self::facet_vertices_in_cell_order(cell, facet_idx)?; + let cell2_facet_vertices = + Self::facet_vertices_in_cell_order(neighbor_cell, mirror_idx)?; + let (coherent, observed_odd_permutation, expected_odd_permutation) = + Self::facet_permutation_parity(cell, facet_idx, neighbor_cell, mirror_idx)?; + if !coherent { + return Err(TdsError::OrientationViolation { + cell1_key: cell_key, + cell1_uuid: cell.uuid(), + cell2_key: neighbor_key, + cell2_uuid: neighbor_cell.uuid(), + cell1_facet_index: facet_idx, + cell2_facet_index: mirror_idx, + facet_vertices: cell1_facet_vertices.into_iter().collect(), + cell2_facet_vertices: cell2_facet_vertices.into_iter().collect(), + observed_odd_permutation, + expected_odd_permutation, + }); + } + } + } + + Ok(()) + } + /// Builds a `FacetToCellsMap` with strict error handling. /// /// This method returns an error if any cell has missing vertex keys, ensuring @@ -7081,6 +7162,54 @@ mod tests { assert!(!tds.is_coherently_oriented()); } + #[test] + fn test_local_orientation_validation_checks_neighbors_outside_scope() { + let mut tds: Tds = Tds::empty(); + + let v0 = tds.insert_vertex_with_mapping(vertex!([1.0, 0.0])).unwrap(); + let v1 = tds.insert_vertex_with_mapping(vertex!([0.0, 1.0])).unwrap(); + let v2 = tds.insert_vertex_with_mapping(vertex!([2.0, 2.0])).unwrap(); + let v3 = tds.insert_vertex_with_mapping(vertex!([3.0, 3.0])).unwrap(); + + let cell1_key = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v1, v2], None).unwrap()) + .unwrap(); + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v1, v3], None).unwrap()) + .unwrap(); + tds.assign_neighbors().unwrap(); + + let err = tds + .validate_coherent_orientation_for_cells(&[cell1_key]) + .unwrap_err(); + assert!(matches!(err, TdsError::OrientationViolation { .. })); + } + + #[test] + fn test_local_orientation_validation_errors_on_missing_scope_cell() { + let mut tds: Tds = Tds::empty(); + + let v0 = tds.insert_vertex_with_mapping(vertex!([0.0, 0.0])).unwrap(); + let v1 = tds.insert_vertex_with_mapping(vertex!([1.0, 0.0])).unwrap(); + let v2 = tds.insert_vertex_with_mapping(vertex!([0.0, 1.0])).unwrap(); + + let cell_key = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v1, v2], None).unwrap()) + .unwrap(); + assert_eq!(tds.remove_cells_by_keys(&[cell_key]), 1); + + let err = tds + .validate_coherent_orientation_for_cells(&[cell_key]) + .unwrap_err(); + assert!(matches!( + err, + TdsError::CellNotFound { + cell_key: missing_key, + .. + } if missing_key == cell_key + )); + } + macro_rules! test_normalize_repairs_incoherent_adjacent_pair { ($name:ident, $dim:literal) => { #[test] diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 0f0d1337..b92d0b57 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -153,7 +153,8 @@ use crate::geometry::util::safe_scalar_to_f64; use crate::topology::characteristics::euler::{TopologyClassification, expected_chi_for}; use crate::topology::characteristics::validation::validate_triangulation_euler_with_facet_to_cells_map; use crate::topology::manifold::{ - ManifoldError, validate_closed_boundary, validate_facet_degree, validate_ridge_links, + ManifoldError, validate_closed_boundary, validate_facet_degree, + validate_local_pseudomanifold_for_cells, validate_ridge_links, validate_ridge_links_for_cells, validate_vertex_links, }; use crate::topology::traits::global_topology_model::GlobalTopologyModel; @@ -2588,6 +2589,58 @@ where Ok(()) } + /// Validates geometric orientation for a local set of cells. + fn validate_geometric_cell_orientation_for_cells( + &self, + cells: &[CellKey], + ) -> Result<(), TdsError> { + for &cell_key in cells { + let cell = self + .tds + .cell(cell_key) + .ok_or_else(|| TdsError::CellNotFound { + cell_key, + context: "local geometric orientation validation scope".to_string(), + })?; + let orientation = self.evaluate_cell_orientation_for_context( + cell_key, + cell, + "local geometric orientation validation", + "Geometric orientation predicate failed for local cell", + )?; + if orientation < 0 { + let vertex_keys: SmallBuffer = + cell.vertices().iter().copied().collect(); + tracing::debug!( + cell_uuid = %cell.uuid(), + ?cell_key, + ?vertex_keys, + orientation, + "negative geometric orientation detected during local validation", + ); + + return Err(TdsError::Geometric(GeometricError::NegativeOrientation { + message: format!( + "Cell {:?} (key {cell_key:?}, vertices {vertex_keys:?}) has negative geometric orientation; expected positive canonical orientation", + cell.uuid(), + ), + })); + } + } + + Ok(()) + } + + /// Validates local orientation invariants for cells changed by insertion. + fn validate_local_orientation_for_cells( + &self, + cells: &[CellKey], + ) -> Result<(), InsertionError> { + self.tds.validate_coherent_orientation_for_cells(cells)?; + self.validate_geometric_cell_orientation_for_cells(cells)?; + Ok(()) + } + /// Flip all negatively oriented cells to positive orientation. /// /// This applies to both Euclidean cells and periodic-lifted cells (when present). @@ -4153,6 +4206,37 @@ where Ok(()) } + /// Runs mandatory topology checks over the local cells touched by insertion. + /// + /// This preserves the same local codimension and ridge-link invariants as + /// [`validate_required_topology_links`](Self::validate_required_topology_links) + /// without rebuilding global facet/ridge maps on every ordinary insertion. + fn validate_required_topology_links_for_cells( + &self, + cells: &[CellKey], + ) -> Result<(), InvariantError> { + if self.tds.number_of_cells() == 0 { + return Ok(()); + } + + if cells.is_empty() + || self + .topology_guarantee + .requires_vertex_links_during_insertion() + { + return self.validate_required_topology_links(); + } + + if self.topology_guarantee.requires_ridge_links() { + self.tds.validate_coherent_orientation_for_cells(cells)?; + validate_local_pseudomanifold_for_cells(&self.tds, cells)?; + validate_ridge_links_for_cells(&self.tds, cells)?; + self.validate_geometric_cell_orientation_for_cells(cells)?; + } + + Ok(()) + } + fn validation_after_insertion_work( &self, suspicion: SuspicionFlags, @@ -4176,7 +4260,11 @@ where } } - fn validate_after_insertion(&self, suspicion: SuspicionFlags) -> Result<(), InvariantError> { + fn validate_after_insertion_with_scope( + &self, + suspicion: SuspicionFlags, + local_cells: Option<&[CellKey]>, + ) -> Result<(), InvariantError> { let Some(work) = self.validation_after_insertion_work(suspicion) else { return Ok(()); }; @@ -4184,9 +4272,10 @@ where self.log_validation_trigger_if_enabled(suspicion); match work { InsertionValidationWork::FullValidation => self.is_valid(), - InsertionValidationWork::RequiredTopologyLinks => { - self.validate_required_topology_links() - } + InsertionValidationWork::RequiredTopologyLinks => local_cells.map_or_else( + || self.validate_required_topology_links(), + |cells| self.validate_required_topology_links_for_cells(cells), + ), } } @@ -4282,7 +4371,10 @@ where let validation_work = self.validation_after_insertion_work(insert_ok.suspicion); let validation_started = validation_work.map(|_| Instant::now()); - let validation_result = self.validate_after_insertion(insert_ok.suspicion); + let validation_result = self.validate_after_insertion_with_scope( + insert_ok.suspicion, + Some(&insert_ok.repair_seed_cells), + ); if let (Some(InsertionValidationWork::FullValidation), Some(validation_started)) = (validation_work, validation_started) { @@ -4346,7 +4438,10 @@ where let validation_work = self.validation_after_insertion_work(fallback_ok.suspicion); let validation_started = validation_work.map(|_| Instant::now()); - let validation_result = self.validate_after_insertion(fallback_ok.suspicion); + let validation_result = self.validate_after_insertion_with_scope( + fallback_ok.suspicion, + Some(&fallback_ok.repair_seed_cells), + ); if let (Some(InsertionValidationWork::FullValidation), Some(validation_started)) = (validation_work, validation_started) { @@ -5184,8 +5279,12 @@ where delaunay_repair_required = true; } - // Canonicalize cell ordering and geometric orientation invariants. - self.normalize_and_promote_positive_orientation()?; + // New cavity cells were canonicalized on creation; validate the local + // orientation frontier without scanning the whole triangulation. + let mut orientation_cells = CellKeyBuffer::new(); + append_live_unique_cell_seeds(&self.tds, &new_cells, &mut orientation_cells); + append_live_unique_cell_seeds(&self.tds, &neighbor_repair_frontier, &mut orientation_cells); + self.validate_local_orientation_for_cells(&orientation_cells)?; // Assign an incident cell for the inserted vertex without a global rebuild. let hint = new_cells.iter().copied().find(|&ck| { @@ -5986,8 +6085,16 @@ where suspicion.neighbor_pointers_rebuilt = repaired > 0; } - // Canonicalize cell ordering and geometric orientation invariants. - self.normalize_and_promote_positive_orientation()?; + // New hull cells were canonicalized on creation; validate the + // local orientation frontier without scanning the whole TDS. + let mut orientation_cells = CellKeyBuffer::new(); + append_live_unique_cell_seeds(&self.tds, &new_cells, &mut orientation_cells); + append_live_unique_cell_seeds( + &self.tds, + &neighbor_repair_frontier, + &mut orientation_cells, + ); + self.validate_local_orientation_for_cells(&orientation_cells)?; // Assign an incident cell for the inserted vertex without a global rebuild. let hint = new_cells.iter().copied().find(|&ck| { @@ -7174,6 +7281,45 @@ mod tests { (tds, v0) } + fn build_invalid_vertex_link_tds_3d() -> (Tds, VertexKey) { + // Two tetrahedra sharing only a single vertex pass facet/ridge checks + // locally but have a disconnected vertex link at the shared vertex. + let mut tds: Tds = Tds::empty(); + + let v0 = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, 0.0])) + .unwrap(); + let v1 = tds + .insert_vertex_with_mapping(vertex!([1.0, 0.0, 0.0])) + .unwrap(); + let v2 = tds + .insert_vertex_with_mapping(vertex!([0.0, 1.0, 0.0])) + .unwrap(); + let v3 = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, 1.0])) + .unwrap(); + let v4 = tds + .insert_vertex_with_mapping(vertex!([10.0, 0.0, 0.0])) + .unwrap(); + let v5 = tds + .insert_vertex_with_mapping(vertex!([10.0, 1.0, 0.0])) + .unwrap(); + let v6 = tds + .insert_vertex_with_mapping(vertex!([10.0, 0.0, 1.0])) + .unwrap(); + + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v1, v2, v3], None).unwrap()) + .unwrap(); + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v4, v5, v6], None).unwrap()) + .unwrap(); + + tds.assign_incident_cells().unwrap(); + + (tds, v0) + } + #[test] fn test_validate_at_completion_reports_invalid_vertex_link() { let (tds, v0) = build_invalid_vertex_link_tds_2d(); @@ -7303,7 +7449,7 @@ mod tests { .unwrap(); assert_eq!(tri.number_of_cells(), 0); - tri.validate_after_insertion(SuspicionFlags::default()) + tri.validate_after_insertion_with_scope(SuspicionFlags::default(), None) .unwrap(); } @@ -7315,7 +7461,7 @@ mod tests { tri.set_validation_policy(ValidationPolicy::Always); - match tri.validate_after_insertion(SuspicionFlags::default()) { + match tri.validate_after_insertion_with_scope(SuspicionFlags::default(), None) { Err(InvariantError::Triangulation(TriangulationValidationError::Disconnected { .. })) => {} @@ -7335,7 +7481,7 @@ mod tests { // The triangulation is invalid (disconnected), but the required PL-manifold link // checks are still satisfied. assert!(tri.is_valid().is_err()); - tri.validate_after_insertion(SuspicionFlags::default()) + tri.validate_after_insertion_with_scope(SuspicionFlags::default(), None) .unwrap(); } @@ -7348,7 +7494,27 @@ mod tests { tri.set_validation_policy(ValidationPolicy::OnSuspicion); tri.set_topology_guarantee(TopologyGuarantee::PLManifold); - match tri.validate_after_insertion(SuspicionFlags::default()) { + match tri.validate_after_insertion_with_scope(SuspicionFlags::default(), None) { + Err(InvariantError::Triangulation( + TriangulationValidationError::ManifoldFacetMultiplicity { cell_count, .. }, + )) => { + assert_eq!(cell_count, 3); + } + other => panic!("Expected ManifoldFacetMultiplicity, got {other:?}"), + } + } + + #[test] + fn test_scoped_validation_catches_touched_over_shared_facet() { + let tds = build_three_triangles_sharing_edge_tds_2d(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + let scope: CellKeyBuffer = tri.tds.cell_keys().take(1).collect(); + + tri.set_validation_policy(ValidationPolicy::OnSuspicion); + tri.set_topology_guarantee(TopologyGuarantee::PLManifold); + + match tri.validate_after_insertion_with_scope(SuspicionFlags::default(), Some(&scope)) { Err(InvariantError::Triangulation( TriangulationValidationError::ManifoldFacetMultiplicity { cell_count, .. }, )) => { @@ -7358,6 +7524,93 @@ mod tests { } } + #[test] + fn test_scoped_strict_validation_falls_back_to_global_vertex_links() { + let (tds, expected_vertex_key) = build_invalid_vertex_link_tds_3d(); + let mut tri = + Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds); + let scope: CellKeyBuffer = tri.tds.cell_keys().take(1).collect(); + assert!(!scope.is_empty()); + + // Direct field assignment keeps this internal test focused on insertion-time + // strict fallback behavior even though the fixture is intentionally invalid. + tri.validation_policy = ValidationPolicy::OnSuspicion; + tri.topology_guarantee = TopologyGuarantee::PLManifoldStrict; + + match tri.validate_after_insertion_with_scope(SuspicionFlags::default(), Some(&scope)) { + Err(InvariantError::Triangulation( + TriangulationValidationError::VertexLinkNotManifold { vertex_key, .. }, + )) => { + assert_eq!(vertex_key, expected_vertex_key); + } + other => panic!("Expected VertexLinkNotManifold, got {other:?}"), + } + } + + #[test] + fn test_local_geometric_orientation_validation_errors_on_missing_scope_cell() { + 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 tds = + Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); + let mut tri = + Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds); + let cell_key = tri.tds.cell_keys().next().unwrap(); + assert_eq!(tri.tds.remove_cells_by_keys(&[cell_key]), 1); + + match tri.validate_geometric_cell_orientation_for_cells(&[cell_key]) { + Err(TdsError::CellNotFound { + cell_key: missing_key, + .. + }) => assert_eq!(missing_key, cell_key), + other => panic!("Expected CellNotFound, got {other:?}"), + } + } + + #[test] + fn test_insertion_scoped_validation_preserves_full_validity() { + 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 tds = + Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); + let mut tri = + Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds); + + tri.set_validation_policy(ValidationPolicy::OnSuspicion); + tri.set_topology_guarantee(TopologyGuarantee::PLManifold); + + let detail = tri + .insert_with_statistics_seeded_indexed_detailed( + vertex!([0.2, 0.2, 0.2]), + None, + None, + 0, + None, + None, + ) + .unwrap(); + + assert!(matches!( + detail.outcome, + InsertionOutcome::Inserted { + vertex_key: _, + hint: _ + } + )); + assert!(!detail.repair_seed_cells.is_empty()); + tri.validate_required_topology_links_for_cells(&detail.repair_seed_cells) + .unwrap(); + tri.is_valid().unwrap(); + } + #[test] fn test_validate_after_insertion_skips_when_policy_does_not_trigger_and_no_required_link_checks() { @@ -7369,7 +7622,7 @@ mod tests { tri.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); assert!(tri.is_valid().is_err()); - tri.validate_after_insertion(SuspicionFlags::default()) + tri.validate_after_insertion_with_scope(SuspicionFlags::default(), None) .unwrap(); } @@ -10833,7 +11086,7 @@ mod tests { let mut tri: Triangulation, (), (), 2> = Triangulation::new_empty(FastKernel::new()); - let points = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.5, 0.5]]; + let points = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.25, 0.25]]; for coords in &points { let (outcome, stats) = tri .insert_with_statistics(vertex!(*coords), None, None) @@ -11288,7 +11541,10 @@ mod tests { ..Default::default() }; // With Always policy and a suspicious flag, validation should still pass. - assert!(tri.validate_after_insertion(suspicion).is_ok()); + assert!( + tri.validate_after_insertion_with_scope(suspicion, None) + .is_ok() + ); } // ========================================================================= diff --git a/src/topology/manifold.rs b/src/topology/manifold.rs index fa22ed78..96668ec0 100644 --- a/src/topology/manifold.rs +++ b/src/topology/manifold.rs @@ -91,12 +91,11 @@ use crate::core::{ fast_hash_map_with_capacity, fast_hash_set_with_capacity, }, edge::EdgeKey, - facet::facet_key_from_vertices, + facet::{FacetHandle, facet_key_from_vertices}, tds::{CellKey, Tds, TdsError, VertexKey}, traits::DataType, util::hashing::stable_hash_u64_slice, }; -use crate::geometry::traits::coordinate::CoordinateScalar; use crate::topology::characteristics::euler::{ triangulated_surface_boundary_component_count, triangulated_surface_euler_characteristic, }; @@ -338,7 +337,6 @@ pub fn validate_closed_boundary( facet_to_cells: &FacetToCellsMap, ) -> Result<(), ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -437,6 +435,228 @@ where Ok(()) } +/// Validates pseudomanifold conditions for facets and boundary ridges touched +/// by `cells`. +/// +/// This is the local counterpart to [`validate_facet_degree`] plus +/// [`validate_closed_boundary`]. It expands each touched facet to its full +/// incident-cell star, then checks only boundary ridges incident to those +/// touched facets. This keeps post-insertion checks local while preserving the +/// same codimension-1 and codimension-2 invariants for the mutated region. +pub(crate) fn validate_local_pseudomanifold_for_cells( + tds: &Tds, + cells: &[CellKey], +) -> Result<(), ManifoldError> +where + U: DataType, + V: DataType, +{ + if D == 0 || cells.is_empty() { + return Ok(()); + } + + let facet_to_cells = build_local_facet_star_map(tds, cells)?; + validate_facet_degree(&facet_to_cells)?; + validate_closed_boundary_for_local_facets(tds, &facet_to_cells) +} + +/// Builds full facet-incidence entries for facets owned by the supplied cells. +fn build_local_facet_star_map( + tds: &Tds, + cells: &[CellKey], +) -> Result +where + U: DataType, + V: DataType, +{ + let mut facet_to_cells = FacetToCellsMap::default(); + let mut seen_facets: FastHashSet = FastHashSet::default(); + + for &cell_key in cells { + let cell_vertices = tds.cell_vertices(cell_key)?; + for facet_index in 0..cell_vertices.len() { + let facet_key = tds.facet_key_for_cell_facet(cell_key, facet_index)?; + if !seen_facets.insert(facet_key) { + continue; + } + + let facet_vertices = cell_facet_vertices(&cell_vertices, facet_index)?; + let incident = facet_incident_handles(tds, facet_key, &facet_vertices)?; + facet_to_cells.insert(facet_key, incident); + } + } + + Ok(facet_to_cells) +} + +/// Returns the vertices of one cell facet by omitting `facet_index`. +fn cell_facet_vertices( + cell_vertices: &[VertexKey], + facet_index: usize, +) -> Result { + if facet_index >= cell_vertices.len() { + return Err(TdsError::IndexOutOfBounds { + index: facet_index, + bound: cell_vertices.len(), + context: "local facet vertex extraction".to_string(), + } + .into()); + } + + let mut facet_vertices = VertexKeyBuffer::with_capacity(cell_vertices.len().saturating_sub(1)); + for (idx, &vertex_key) in cell_vertices.iter().enumerate() { + if idx != facet_index { + facet_vertices.push(vertex_key); + } + } + Ok(facet_vertices) +} + +/// Finds all cell/facet handles whose facet has the requested key. +fn facet_incident_handles( + tds: &Tds, + facet_key: u64, + facet_vertices: &[VertexKey], +) -> Result, ManifoldError> +where + U: DataType, + V: DataType, +{ + let candidate_cells = simplex_star_cells(tds, facet_vertices)?; + let mut handles: SmallBuffer = + SmallBuffer::with_capacity(candidate_cells.len().max(1)); + + for cell_key in candidate_cells { + let cell_vertices = tds.cell_vertices(cell_key)?; + for candidate_facet_index in 0..cell_vertices.len() { + if tds.facet_key_for_cell_facet(cell_key, candidate_facet_index)? != facet_key { + continue; + } + let Ok(facet_index) = u8::try_from(candidate_facet_index) else { + return Err(TdsError::IndexOutOfBounds { + index: candidate_facet_index, + bound: u8::MAX as usize + 1, + context: "local facet incident handle".to_string(), + } + .into()); + }; + handles.push(FacetHandle::new(cell_key, facet_index)); + } + } + + Ok(handles) +} + +/// Validates boundary closure for boundary facets present in a local facet map. +fn validate_closed_boundary_for_local_facets( + tds: &Tds, + facet_to_cells: &FacetToCellsMap, +) -> Result<(), ManifoldError> +where + U: DataType, + V: DataType, +{ + if D < 2 { + return Ok(()); + } + + let mut checked_ridges: FastHashSet = FastHashSet::default(); + for cell_facet_pairs in facet_to_cells.values() { + let [handle] = cell_facet_pairs.as_slice() else { + continue; + }; + + let cell_vertices = tds.cell_vertices(handle.cell_key())?; + let facet_vertices = cell_facet_vertices(&cell_vertices, handle.facet_index() as usize)?; + for ridge_vertices in ridge_vertices_for_facet::(&facet_vertices)? { + let ridge_key = facet_key_from_vertices(&ridge_vertices); + if !checked_ridges.insert(ridge_key) { + continue; + } + let boundary_facet_count = boundary_facet_count_for_ridge(tds, &ridge_vertices)?; + if boundary_facet_count != 2 { + return Err(ManifoldError::BoundaryRidgeMultiplicity { + ridge_key, + boundary_facet_count, + }); + } + } + } + + Ok(()) +} + +/// Enumerates all ridges of a boundary facet. +fn ridge_vertices_for_facet( + facet_vertices: &[VertexKey], +) -> Result, ManifoldError> { + if facet_vertices.len() != D { + return Err(TdsError::DimensionMismatch { + expected: D, + actual: facet_vertices.len(), + context: "local boundary facet vertex count".to_string(), + } + .into()); + } + + let mut ridges: SmallBuffer = SmallBuffer::with_capacity(D); + for omit in 0..facet_vertices.len() { + let mut ridge_vertices = VertexKeyBuffer::with_capacity(D.saturating_sub(1)); + for (idx, &vertex_key) in facet_vertices.iter().enumerate() { + if idx != omit { + ridge_vertices.push(vertex_key); + } + } + ridges.push(ridge_vertices); + } + Ok(ridges) +} + +/// Counts boundary facets in the full star of a ridge. +fn boundary_facet_count_for_ridge( + tds: &Tds, + ridge_vertices: &[VertexKey], +) -> Result +where + U: DataType, + V: DataType, +{ + let star_cells = simplex_star_cells(tds, ridge_vertices)?; + let mut count = 0usize; + let mut seen_boundary_facets: FastHashSet = FastHashSet::default(); + + for cell_key in star_cells { + let cell_vertices = tds.cell_vertices(cell_key)?; + for facet_index in 0..cell_vertices.len() { + let facet_vertices = cell_facet_vertices(&cell_vertices, facet_index)?; + if !ridge_vertices + .iter() + .all(|ridge_vertex| facet_vertices.contains(ridge_vertex)) + { + continue; + } + + let facet_key = tds.facet_key_for_cell_facet(cell_key, facet_index)?; + if !seen_boundary_facets.insert(facet_key) { + continue; + } + let handles = facet_incident_handles(tds, facet_key, &facet_vertices)?; + match handles.len() { + 1 => count = count.saturating_add(1), + 2 => {} + other => { + return Err(ManifoldError::ManifoldFacetMultiplicity { + facet_key, + cell_count: other, + }); + } + } + } + } + + Ok(count) +} + /// Computes the star of a simplex (a set of vertices) as the set of incident D-cells. /// /// This is a local combinatorial query intended for reuse by topology validation and @@ -449,7 +669,6 @@ fn simplex_star_cells( simplex_vertices: &[VertexKey], ) -> Result, ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -503,7 +722,6 @@ fn simplex_link_simplices_from_star( star_cells: &[CellKey], ) -> Result, ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -590,7 +808,6 @@ pub(crate) fn ridge_star_cells( ridge_vertices: &[VertexKey], ) -> Result, ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -618,7 +835,6 @@ pub(crate) fn ridge_link_edges_from_star( star_cells: &[CellKey], ) -> Result, ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -727,7 +943,6 @@ fn build_ridge_star_map( tds: &Tds, ) -> Result, ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -811,7 +1026,6 @@ fn build_ridge_star_map_for_cells( cells: &[CellKey], ) -> Result, ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -938,7 +1152,6 @@ fn periodic_aware_ridge_star( bare_vertices: &VertexKeyBuffer, ) -> Result, ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -1040,7 +1253,6 @@ pub fn validate_ridge_links( tds: &Tds, ) -> Result<(), ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -1122,7 +1334,6 @@ pub fn validate_ridge_links_for_cells( cells: &[CellKey], ) -> Result<(), ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -1216,7 +1427,6 @@ pub fn validate_vertex_links( facet_to_cells: &FacetToCellsMap, ) -> Result<(), ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -1244,7 +1454,6 @@ fn build_boundary_vertex_set( facet_to_cells: &FacetToCellsMap, ) -> Result, ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -1387,7 +1596,6 @@ fn validate_single_vertex_link( interior_vertex: bool, ) -> Result<(), ManifoldError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -1842,6 +2050,46 @@ mod tests { (tds, [v0, v1, v2, v3]) } + fn build_non_manifold_boundary_ridge_tds_3d() -> (Tds, CellKey, u64) { + // Two tetrahedra that share an edge but not a facet create a non-manifold boundary: + // the shared edge is incident to 4 boundary triangles. + let mut tds: Tds = Tds::empty(); + + let shared_edge_v0 = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, 0.0])) + .unwrap(); + let shared_edge_v1 = tds + .insert_vertex_with_mapping(vertex!([1.0, 0.0, 0.0])) + .unwrap(); + + let tet1_v2 = tds + .insert_vertex_with_mapping(vertex!([0.0, 1.0, 0.0])) + .unwrap(); + let tet1_v3 = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, 1.0])) + .unwrap(); + let tet2_v2 = tds + .insert_vertex_with_mapping(vertex!([0.0, -1.0, 0.0])) + .unwrap(); + let tet2_v3 = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, -1.0])) + .unwrap(); + + let touched_cell = tds + .insert_cell_with_mapping( + Cell::new(vec![shared_edge_v0, shared_edge_v1, tet1_v2, tet1_v3], None).unwrap(), + ) + .unwrap(); + let _ = tds + .insert_cell_with_mapping( + Cell::new(vec![shared_edge_v0, shared_edge_v1, tet2_v2, tet2_v3], None).unwrap(), + ) + .unwrap(); + + let expected_ridge_key = facet_key_from_vertices(&[shared_edge_v0, shared_edge_v1]); + (tds, touched_cell, expected_ridge_key) + } + #[test] fn test_validate_facet_degree_ok_for_single_tetrahedron() { let vertices = vec![ @@ -2606,50 +2854,9 @@ mod tests { #[test] fn test_validate_closed_boundary_errors_on_non_manifold_boundary_ridge() { - // Two tetrahedra that share an edge but not a facet create a non-manifold boundary: - // the shared edge is incident to 4 boundary triangles. - let mut tds: Tds = Tds::empty(); - - // Shared edge - let shared_edge_v0 = tds - .insert_vertex_with_mapping(vertex!([0.0, 0.0, 0.0])) - .unwrap(); - let shared_edge_v1 = tds - .insert_vertex_with_mapping(vertex!([1.0, 0.0, 0.0])) - .unwrap(); - - // First tetrahedron - let tet1_v2 = tds - .insert_vertex_with_mapping(vertex!([0.0, 1.0, 0.0])) - .unwrap(); - let tet1_v3 = tds - .insert_vertex_with_mapping(vertex!([0.0, 0.0, 1.0])) - .unwrap(); - - // Second tetrahedron - let tet2_v2 = tds - .insert_vertex_with_mapping(vertex!([0.0, -1.0, 0.0])) - .unwrap(); - let tet2_v3 = tds - .insert_vertex_with_mapping(vertex!([0.0, 0.0, -1.0])) - .unwrap(); - - let _ = tds - .insert_cell_with_mapping( - Cell::new(vec![shared_edge_v0, shared_edge_v1, tet1_v2, tet1_v3], None).unwrap(), - ) - .unwrap(); - let _ = tds - .insert_cell_with_mapping( - Cell::new(vec![shared_edge_v0, shared_edge_v1, tet2_v2, tet2_v3], None).unwrap(), - ) - .unwrap(); - + let (tds, _touched_cell, expected_ridge_key) = build_non_manifold_boundary_ridge_tds_3d(); let facet_to_cells = tds.build_facet_to_cells_map().unwrap(); - // The shared edge should appear in 4 boundary facets. - let expected_ridge_key = facet_key_from_vertices(&[shared_edge_v0, shared_edge_v1]); - match validate_closed_boundary(&tds, &facet_to_cells) { Err(ManifoldError::BoundaryRidgeMultiplicity { ridge_key, @@ -2662,6 +2869,44 @@ mod tests { } } + #[test] + fn test_validate_local_pseudomanifold_for_cells_errors_on_non_manifold_boundary_ridge() { + let (tds, touched_cell, expected_ridge_key) = build_non_manifold_boundary_ridge_tds_3d(); + + match validate_local_pseudomanifold_for_cells(&tds, &[touched_cell]) { + Err(ManifoldError::BoundaryRidgeMultiplicity { + ridge_key, + boundary_facet_count, + }) => { + assert_eq!(ridge_key, expected_ridge_key); + assert_eq!(boundary_facet_count, 4); + } + other => panic!("Expected BoundaryRidgeMultiplicity, got {other:?}"), + } + } + + #[test] + fn test_validate_local_pseudomanifold_for_cells_errors_on_missing_scope_cell() { + 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 mut tds = + Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); + let cell_key = tds.cell_keys().next().unwrap(); + assert_eq!(tds.remove_cells_by_keys(&[cell_key]), 1); + + match validate_local_pseudomanifold_for_cells(&tds, &[cell_key]) { + Err(ManifoldError::Tds(TdsError::CellNotFound { + cell_key: missing_key, + .. + })) => assert_eq!(missing_key, cell_key), + other => panic!("Expected CellNotFound, got {other:?}"), + } + } + #[test] fn test_validate_ridge_links_ok_for_single_tetrahedron() { let vertices = vec![ From 731444d5549d912b51e6303b41726aa67aee7a8f Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Mon, 11 May 2026 19:55:26 -0700 Subject: [PATCH 15/15] perf(triangulation): scope repair topology validation (#341) - Preserve flip-created repair frontiers after full-reseed repair attempts so ridge-link checks can stay local when the mutation scope is known. - Harden scoped topology validation by rejecting one-way neighbor references and carrying repair frontier cells into follow-up Delaunay repair seeds. - Use dedicated lifted periodic vertex identities in manifold validation so periodic images never collide with real TDS vertex keys. - Add reproducibility metadata for the recorded #341 large-scale benchmark runs. --- docs/archive/issue_341_n1_repair_plan.md | 95 +++- src/core/algorithms/flips.rs | 36 +- src/core/tds.rs | 13 + src/core/triangulation.rs | 24 +- src/topology/manifold.rs | 614 +++++++++++++++-------- src/triangulation/delaunay.rs | 102 +++- 6 files changed, 633 insertions(+), 251 deletions(-) diff --git a/docs/archive/issue_341_n1_repair_plan.md b/docs/archive/issue_341_n1_repair_plan.md index 9f954701..38800a8e 100644 --- a/docs/archive/issue_341_n1_repair_plan.md +++ b/docs/archive/issue_341_n1_repair_plan.md @@ -23,8 +23,11 @@ The local flip-repair path has already been improved enough that it is no longer the dominant 10K cost. The post-insertion topology validation path has also been scoped to the cells touched by each ordinary insertion. The hot insertion path now avoids full-TDS orientation normalization when the local -mutation scope is known. The remaining dominant costs are local repair, hull -extension, and ordinary insertion overhead. +mutation scope is known. Repair-side ridge-link validation also follows the +flip-created mutation frontier, even when a repair attempt used full-TDS queue +seeding; final full validation and defensive full fallbacks remain enabled. The +remaining dominant costs are local repair, hull extension, and ordinary +insertion overhead. ## Latest Measurements @@ -32,6 +35,13 @@ extension, and ordinary insertion overhead. #### Before Scoped Post-Insertion Topology Validation +- Metadata: commit `34b4bfb9`; hardware `Apple M4 Max`; build profile + `cargo test --release`; command + `PATH=/opt/homebrew/bin:$PATH just debug-large-scale-3d 3000 1`; env + `DELAUNAY_BULK_PROGRESS_EVERY=100`, + `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800`, + `DELAUNAY_LARGE_DEBUG_N_3D=3000`, + `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1`, `OMP_NUM_THREADS` unset. - Result: valid Delaunay triangulation, no skipped vertices. - Total wall time: 54.571s. - Insertion wall time: 52.908s. @@ -41,6 +51,13 @@ extension, and ordinary insertion overhead. #### After Scoped Post-Insertion Topology Validation +- Metadata: commit `55be4ddb`; hardware `Apple M4 Max`; build profile + `cargo test --release`; command + `PATH=/opt/homebrew/bin:$PATH just debug-large-scale-3d 3000 1`; env + `DELAUNAY_BULK_PROGRESS_EVERY=100`, + `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800`, + `DELAUNAY_LARGE_DEBUG_N_3D=3000`, + `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1`, `OMP_NUM_THREADS` unset. - Result: valid Delaunay triangulation, no skipped vertices. - Total wall time: 27.991s. - Insertion wall time: 26.356s. @@ -50,6 +67,13 @@ extension, and ordinary insertion overhead. #### After Local Insertion Orientation Validation +- Metadata: base commit `55be4ddb` with branch-local orientation patch; + hardware `Apple M4 Max`; build profile `cargo test --release`; command + `PATH=/opt/homebrew/bin:$PATH just debug-large-scale-3d 3000 1`; env + `DELAUNAY_BULK_PROGRESS_EVERY=100`, + `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800`, + `DELAUNAY_LARGE_DEBUG_N_3D=3000`, + `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1`, `OMP_NUM_THREADS` unset. - Result: valid Delaunay triangulation, no skipped vertices. - Total wall time: 14.599s. - Insertion wall time: 12.950s. @@ -58,10 +82,32 @@ extension, and ordinary insertion overhead. - Final repair: 1.072s, 0 flips. - Final validation report: 576.049ms, OK. +#### After Scoped Repair Ridge-Link Validation + +- Metadata: base commit `55be4ddb` with branch-local scoped-repair patch; + hardware `Apple M4 Max`; build profile `cargo test --release`; command + `PATH=/opt/homebrew/bin:$PATH just debug-large-scale-3d 3000 1`; env + `DELAUNAY_BULK_PROGRESS_EVERY=100`, + `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800`, + `DELAUNAY_LARGE_DEBUG_N_3D=3000`, + `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1`, `OMP_NUM_THREADS` unset. +- Result: valid Delaunay triangulation, no skipped vertices. +- Total wall time: 14.493s. +- Insertion wall time: 12.876s. +- Final repair: 1.056s, 0 flips. +- Final validation report: 560.835ms, OK. + ### 10K 3D, `N=1` #### Before Scoped Post-Insertion Topology Validation +- Metadata: commit `34b4bfb9`; hardware `Apple M4 Max`; build profile + `cargo test --release`; command + `PATH=/opt/homebrew/bin:$PATH just debug-large-scale-3d 10000 1`; env + `DELAUNAY_BULK_PROGRESS_EVERY=100`, + `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800`, + `DELAUNAY_LARGE_DEBUG_N_3D=10000`, + `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1`, `OMP_NUM_THREADS` unset. - Result: valid Delaunay triangulation, no skipped vertices. - Total wall time: 630.582s. - Insertion loop: 622.605s. @@ -74,6 +120,13 @@ extension, and ordinary insertion overhead. #### After Scoped Post-Insertion Topology Validation +- Metadata: commit `55be4ddb`; hardware `Apple M4 Max`; build profile + `cargo test --release`; command + `PATH=/opt/homebrew/bin:$PATH just debug-large-scale-3d 10000 1`; env + `DELAUNAY_BULK_PROGRESS_EVERY=100`, + `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800`, + `DELAUNAY_LARGE_DEBUG_N_3D=10000`, + `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1`, `OMP_NUM_THREADS` unset. - Result: valid Delaunay triangulation, no skipped vertices. - Total wall time: 261.368s. - Insertion loop: 253.540s. @@ -86,6 +139,13 @@ extension, and ordinary insertion overhead. #### After Local Insertion Orientation Validation +- Metadata: base commit `55be4ddb` with branch-local orientation patch; + hardware `Apple M4 Max`; build profile `cargo test --release`; command + `PATH=/opt/homebrew/bin:$PATH just debug-large-scale-3d 10000 1`; env + `DELAUNAY_BULK_PROGRESS_EVERY=100`, + `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800`, + `DELAUNAY_LARGE_DEBUG_N_3D=10000`, + `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1`, `OMP_NUM_THREADS` unset. - Result: valid Delaunay triangulation, no skipped vertices. - Total wall time: 99.466s. - Insertion loop: 91.593s. @@ -96,6 +156,30 @@ extension, and ordinary insertion overhead. - Final repair: 3.713s, 0 flips. - Final validation report: 2.015s, OK. +#### After Scoped Repair Ridge-Link Validation + +- Metadata: base commit `55be4ddb` with branch-local scoped-repair patch; + hardware `Apple M4 Max`; build profile `cargo test --release`; command + `PATH=/opt/homebrew/bin:$PATH just debug-large-scale-3d 10000 1`; env + `DELAUNAY_BULK_PROGRESS_EVERY=100`, + `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800`, + `DELAUNAY_LARGE_DEBUG_N_3D=10000`, + `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1`, `OMP_NUM_THREADS` unset. +- Result: valid Delaunay triangulation, no skipped vertices. +- Total wall time: 99.827s. +- Insertion wall time: 94.102s. +- Final repair: 3.708s, 0 flips. +- Final validation report: 2.016s, OK. +- This did not materially change the 10K wall time, which suggests full-reseed + repair ridge-link validation is not a dominant cost for this path. + +### Rejected Experiments + +- Facet-first repair queue scheduling reduced diagnostics-mode local repair + time, but the clean 10K run regressed to 100.514s total wall time. The change + was backed out; keep the alternating facet/ridge schedule unless a broader + benchmark shows a consistent win. + ## Plan 1. Preserve the correctness model: every mutation must remain locally @@ -115,6 +199,7 @@ extension, and ordinary insertion overhead. ## Immediate Next Step -Decide whether the ~99s 10K result is sufficient for #341. If not, profile the -local repair facet/ridge queues at 10K scale and reduce repeated checks without -weakening the final repair or final validation safety nets. +Profile the local repair facet/ridge queues at 10K scale with diagnostics +enabled, then reduce repeated checks without weakening the final repair or final +validation safety nets. If repair queue work is no longer dominant, inspect hull +extension timing next. diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 2e3f63c6..84e9eb9d 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -3551,9 +3551,10 @@ pub(crate) struct DelaunayRepairRun { pub stats: DelaunayRepairStats, /// Cells to validate after the final repair attempt. /// - /// Local attempts contain cells created by successful flips. Full-reseed - /// attempts contain every current cell because the repair frontier was the - /// whole triangulation. + /// This records the cells created by successful flips, regardless of + /// whether the repair queues were seeded locally or from the full TDS. The + /// queue frontier controls Delaunay postcondition replay; ridge-link + /// topology validation only needs the cells whose incidence changed. pub touched_cells: CellKeyBuffer, /// Whether the final attempt used full-TDS queue seeding. pub used_full_reseed: bool, @@ -3609,21 +3610,13 @@ fn local_postcondition_frontier( } /// Converts an attempt outcome into the crate-private repair run result. -fn repair_run_from_attempt( - outcome: RepairAttemptOutcome, - current_cells: impl IntoIterator, -) -> DelaunayRepairRun { +fn repair_run_from_attempt(outcome: RepairAttemptOutcome) -> DelaunayRepairRun { let RepairAttemptOutcome { stats, touched_cells, used_full_reseed, .. } = outcome; - let touched_cells = if used_full_reseed { - current_cells.into_iter().collect() - } else { - touched_cells - }; DelaunayRepairRun { stats, @@ -5118,7 +5111,7 @@ where retry_seed_cells, outcome.last_applied_flip.as_ref(), ) { - Ok(()) => Ok(repair_run_from_attempt(outcome, tds.cell_keys())), + Ok(()) => Ok(repair_run_from_attempt(outcome)), Err(err) => { *tds = snapshot; Err(err) @@ -5197,7 +5190,7 @@ where ) .is_ok() { - return Ok(repair_run_from_attempt(outcome, tds.cell_keys())); + return Ok(repair_run_from_attempt(outcome)); } if repair_trace_enabled() { tracing::debug!( @@ -13104,7 +13097,7 @@ mod tests { } #[test] - fn test_repair_run_full_reseed_frontier_covers_all_cells() { + fn test_repair_run_full_reseed_preserves_mutation_frontier() { init_tracing(); let vertices = vec![ vertex!([0.0, 0.0]), @@ -13124,20 +13117,15 @@ mod tests { used_full_reseed: true, }; - let run = repair_run_from_attempt(outcome, tds.cell_keys()); - let expected_cells: Vec = tds.cell_keys().collect(); + let run = repair_run_from_attempt(outcome); assert!(run.used_full_reseed); assert!( - expected_cells.len() > 1, + tds.cell_keys().count() > 1, "fixture should distinguish local and full frontiers" ); - assert_eq!(run.touched_cells.len(), expected_cells.len()); - assert!( - expected_cells - .iter() - .all(|expected| { run.touched_cells.iter().any(|touched| touched == expected) }) - ); + assert_eq!(run.touched_cells.len(), 1); + assert_eq!(run.touched_cells[0], local_cell); } #[test] diff --git a/src/core/tds.rs b/src/core/tds.rs index 73baa722..6470973a 100644 --- a/src/core/tds.rs +++ b/src/core/tds.rs @@ -3611,6 +3611,19 @@ where ), }, )?; + let has_back_reference = neighbor_cell + .neighbors() + .and_then(|neighbors| neighbors.get(mirror_idx)) + .is_some_and(|back_ref| *back_ref == Some(cell_key)); + if !has_back_reference { + return Err(TdsError::InvalidNeighbors { + message: format!( + "Cell {:?}[{facet_idx}] neighbor {:?}[{mirror_idx}] does not reference back to the cell during local orientation validation", + cell.uuid(), + neighbor_cell.uuid(), + ), + }); + } let cell1_facet_vertices = Self::facet_vertices_in_cell_order(cell, facet_idx)?; let cell2_facet_vertices = diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index b92d0b57..7acaa6af 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -4375,8 +4375,13 @@ where insert_ok.suspicion, Some(&insert_ok.repair_seed_cells), ); - if let (Some(InsertionValidationWork::FullValidation), Some(validation_started)) = - (validation_work, validation_started) + if let ( + Some( + InsertionValidationWork::FullValidation + | InsertionValidationWork::RequiredTopologyLinks, + ), + Some(validation_started), + ) = (validation_work, validation_started) { Self::record_topology_validation_telemetry( telemetry, @@ -4442,8 +4447,13 @@ where fallback_ok.suspicion, Some(&fallback_ok.repair_seed_cells), ); - if let (Some(InsertionValidationWork::FullValidation), Some(validation_started)) = - (validation_work, validation_started) + if let ( + Some( + InsertionValidationWork::FullValidation + | InsertionValidationWork::RequiredTopologyLinks, + ), + Some(validation_started), + ) = (validation_work, validation_started) { Self::record_topology_validation_telemetry( telemetry, @@ -5331,6 +5341,7 @@ where // Seed follow-up Delaunay repair from the local insertion product. Higher layers // use these cells to avoid rediscovering the inserted vertex star with a global scan. append_live_unique_cell_seeds(&self.tds, &new_cells, &mut repair_seed_cells); + append_live_unique_cell_seeds(&self.tds, &neighbor_repair_frontier, &mut repair_seed_cells); // Return hint for next insertion Ok(CavityInsertionOutcome { @@ -6136,6 +6147,11 @@ where // Return vertex key and hint for next insertion let mut repair_seed_cells = CellKeyBuffer::new(); append_live_unique_cell_seeds(&self.tds, &new_cells, &mut repair_seed_cells); + append_live_unique_cell_seeds( + &self.tds, + &neighbor_repair_frontier, + &mut repair_seed_cells, + ); append_live_unique_cell_seeds( &self.tds, &exterior_repair_seed_cells, diff --git a/src/topology/manifold.rs b/src/topology/manifold.rs index 96668ec0..e01d0ada 100644 --- a/src/topology/manifold.rs +++ b/src/topology/manifold.rs @@ -87,79 +87,111 @@ use crate::core::{ collections::{ - CellKeySet, FacetToCellsMap, FastHashMap, FastHashSet, SmallBuffer, VertexKeyBuffer, - fast_hash_map_with_capacity, fast_hash_set_with_capacity, + CellKeySet, FacetToCellsMap, FastHashMap, FastHashSet, FastHasher, SmallBuffer, + VertexKeyBuffer, fast_hash_map_with_capacity, fast_hash_set_with_capacity, }, - edge::EdgeKey, facet::{FacetHandle, facet_key_from_vertices}, tds::{CellKey, Tds, TdsError, VertexKey}, traits::DataType, - util::hashing::stable_hash_u64_slice, }; use crate::topology::characteristics::euler::{ triangulated_surface_boundary_component_count, triangulated_surface_euler_characteristic, }; -use slotmap::{Key, KeyData}; +use slotmap::Key; +use std::{ + cmp::Ordering, + hash::{Hash, Hasher}, +}; use thiserror::Error; // ============================================================================= // Periodic-aware vertex identity // ============================================================================= -/// Creates a deterministic synthetic `VertexKey` that encodes both the vertex -/// key and its periodic lattice offset. -/// -/// For an all-zero (or empty) offset the original key is returned, preserving -/// compatibility with non-periodic triangulations. For non-zero offsets a -/// hash-based synthetic key is produced so that the same `VertexKey` at -/// different periodic positions is treated as a distinct vertex in link/ridge -/// graph computations. +/// Vertex identity in a periodic covering space. /// -/// # Safety contract -/// -/// The returned key is **not** backed by a real slot in the TDS. It must only -/// be used for graph-topology checks (equality, hashing, adjacency) — never -/// for vertex-data lookups. -fn lifted_vertex_id(vk: VertexKey, offset: &[i8]) -> VertexKey { +/// This deliberately is not a `VertexKey`: lifted periodic images are graph +/// identities used by topology validators, not entries in the TDS vertex store. +#[derive(Clone, Debug, Eq, PartialEq)] +struct LiftedVertexId { + vertex_key: VertexKey, + offset: SmallBuffer, +} + +type LiftedVertexBuffer = SmallBuffer; +type LinkSimplexBuffer = SmallBuffer; + +impl LiftedVertexId { + fn base(vertex_key: VertexKey) -> Self { + Self { + vertex_key, + offset: SmallBuffer::new(), + } + } + + fn is_base(&self) -> bool { + self.offset.is_empty() + } +} + +impl Ord for LiftedVertexId { + fn cmp(&self, other: &Self) -> Ordering { + self.vertex_key + .data() + .as_ffi() + .cmp(&other.vertex_key.data().as_ffi()) + .then_with(|| self.offset.as_slice().cmp(other.offset.as_slice())) + } +} + +impl PartialOrd for LiftedVertexId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Hash for LiftedVertexId { + fn hash(&self, state: &mut H) { + self.vertex_key.data().as_ffi().hash(state); + self.offset.as_slice().hash(state); + } +} + +/// Creates a lifted vertex identity from a real TDS vertex key and periodic +/// lattice offset. +fn lifted_vertex_id(vk: VertexKey, offset: &[i8]) -> LiftedVertexId { if offset.is_empty() || offset.iter().all(|&o| o == 0) { - return vk; - } - let base = vk.data().as_ffi(); - // Mix each offset component into the hash. We use a different prime per - // position to avoid collisions between permuted offsets. - let mut h: u64 = base; - for (i, &o) in offset.iter().enumerate() { - // Shift the byte into a unique lane, then fold. - #[expect( - clippy::cast_possible_truncation, - reason = "Dimension index always fits in u32" - )] - let lane = (i as u32).wrapping_mul(8); - #[expect( - clippy::cast_sign_loss, - reason = "Intentional: offset byte reinterpreted as bit pattern for hashing" - )] - let shifted = (o as u64).rotate_left(lane); - h = h.wrapping_mul(0x517c_c1b7_2722_0a95).wrapping_add(shifted); - } - // Avalanche – ensure all bits depend on all inputs. - h ^= base; - h ^= h >> 33; - h = h.wrapping_mul(0xff51_afd7_ed55_8ccd); - h ^= h >> 33; - // Slotmap treats ffi value 0 as null; ensure we never produce it. - VertexKey::from(KeyData::from_ffi(h | 1)) + return LiftedVertexId::base(vk); + } + LiftedVertexId { + vertex_key: vk, + offset: offset.iter().copied().collect(), + } } -/// Computes a periodic-aware ridge key from lifted vertex IDs. -/// -/// The caller passes already-lifted `VertexKey`s; this function sorts them -/// and hashes via the same stable hash used by `facet_key_from_vertices`, -/// producing a key that correctly distinguishes periodic images. -fn periodic_ridge_key(lifted_vertices: &[VertexKey]) -> u64 { - let mut keys: SmallBuffer = lifted_vertices.iter().map(|k| k.data().as_ffi()).collect(); +/// Computes a periodic-aware simplex key from lifted vertex IDs. +fn periodic_simplex_key(lifted_vertices: &[LiftedVertexId]) -> u64 { + if lifted_vertices.iter().all(LiftedVertexId::is_base) { + let bare_vertices: VertexKeyBuffer = + lifted_vertices.iter().map(|id| id.vertex_key).collect(); + return facet_key_from_vertices(&bare_vertices); + } + + let mut keys: LiftedVertexBuffer = lifted_vertices.iter().cloned().collect(); keys.sort_unstable(); - stable_hash_u64_slice(&keys) + let mut hasher = FastHasher::default(); + for key in &keys { + key.hash(&mut hasher); + } + hasher.finish() +} + +fn ordered_lifted_edge(a: &LiftedVertexId, b: &LiftedVertexId) -> (LiftedVertexId, LiftedVertexId) { + if b < a { + (b.clone(), a.clone()) + } else { + (a.clone(), b.clone()) + } } /// Errors that can occur during manifold (topology) validation. @@ -475,13 +507,14 @@ where for &cell_key in cells { let cell_vertices = tds.cell_vertices(cell_key)?; for facet_index in 0..cell_vertices.len() { - let facet_key = tds.facet_key_for_cell_facet(cell_key, facet_index)?; + let (facet_vertices, facet_vertices_bare) = + cell_facet_vertex_ids(tds, cell_key, facet_index)?; + let facet_key = periodic_simplex_key(&facet_vertices); if !seen_facets.insert(facet_key) { continue; } - let facet_vertices = cell_facet_vertices(&cell_vertices, facet_index)?; - let incident = facet_incident_handles(tds, facet_key, &facet_vertices)?; + let incident = facet_incident_handles(tds, facet_key, &facet_vertices_bare)?; facet_to_cells.insert(facet_key, incident); } } @@ -489,47 +522,68 @@ where Ok(facet_to_cells) } -/// Returns the vertices of one cell facet by omitting `facet_index`. -fn cell_facet_vertices( - cell_vertices: &[VertexKey], +/// Returns lifted and bare vertices of one cell facet by omitting `facet_index`. +fn cell_facet_vertex_ids( + tds: &Tds, + cell_key: CellKey, facet_index: usize, -) -> Result { +) -> Result<(LiftedVertexBuffer, VertexKeyBuffer), ManifoldError> +where + U: DataType, + V: DataType, +{ + let cell_vertices = tds.cell_vertices(cell_key)?; if facet_index >= cell_vertices.len() { return Err(TdsError::IndexOutOfBounds { index: facet_index, bound: cell_vertices.len(), - context: "local facet vertex extraction".to_string(), + context: "local lifted facet vertex extraction".to_string(), } .into()); } - let mut facet_vertices = VertexKeyBuffer::with_capacity(cell_vertices.len().saturating_sub(1)); + let offsets = tds + .cell(cell_key) + .and_then(|cell| cell.periodic_vertex_offsets()); + let mut lifted_vertices = + LiftedVertexBuffer::with_capacity(cell_vertices.len().saturating_sub(1)); + let mut bare_vertices = VertexKeyBuffer::with_capacity(cell_vertices.len().saturating_sub(1)); + for (idx, &vertex_key) in cell_vertices.iter().enumerate() { - if idx != facet_index { - facet_vertices.push(vertex_key); + if idx == facet_index { + continue; } + bare_vertices.push(vertex_key); + let lifted = offsets.map_or_else( + || LiftedVertexId::base(vertex_key), + |cell_offsets| lifted_vertex_id(vertex_key, &cell_offsets[idx]), + ); + lifted_vertices.push(lifted); } - Ok(facet_vertices) + + Ok((lifted_vertices, bare_vertices)) } /// Finds all cell/facet handles whose facet has the requested key. fn facet_incident_handles( tds: &Tds, facet_key: u64, - facet_vertices: &[VertexKey], + facet_vertices_bare: &[VertexKey], ) -> Result, ManifoldError> where U: DataType, V: DataType, { - let candidate_cells = simplex_star_cells(tds, facet_vertices)?; + let candidate_cells = simplex_star_cells(tds, facet_vertices_bare)?; let mut handles: SmallBuffer = SmallBuffer::with_capacity(candidate_cells.len().max(1)); for cell_key in candidate_cells { let cell_vertices = tds.cell_vertices(cell_key)?; for candidate_facet_index in 0..cell_vertices.len() { - if tds.facet_key_for_cell_facet(cell_key, candidate_facet_index)? != facet_key { + let (candidate_vertices, _candidate_vertices_bare) = + cell_facet_vertex_ids(tds, cell_key, candidate_facet_index)?; + if periodic_simplex_key(&candidate_vertices) != facet_key { continue; } let Ok(facet_index) = u8::try_from(candidate_facet_index) else { @@ -566,14 +620,17 @@ where continue; }; - let cell_vertices = tds.cell_vertices(handle.cell_key())?; - let facet_vertices = cell_facet_vertices(&cell_vertices, handle.facet_index() as usize)?; - for ridge_vertices in ridge_vertices_for_facet::(&facet_vertices)? { - let ridge_key = facet_key_from_vertices(&ridge_vertices); + let (facet_vertices, facet_vertices_bare) = + cell_facet_vertex_ids(tds, handle.cell_key(), handle.facet_index() as usize)?; + for (ridge_vertices, ridge_vertices_bare) in + ridge_vertices_for_facet::(&facet_vertices, &facet_vertices_bare)? + { + let ridge_key = periodic_simplex_key(&ridge_vertices); if !checked_ridges.insert(ridge_key) { continue; } - let boundary_facet_count = boundary_facet_count_for_ridge(tds, &ridge_vertices)?; + let boundary_facet_count = + boundary_facet_count_for_ridge(tds, &ridge_vertices, &ridge_vertices_bare)?; if boundary_facet_count != 2 { return Err(ManifoldError::BoundaryRidgeMultiplicity { ridge_key, @@ -588,8 +645,9 @@ where /// Enumerates all ridges of a boundary facet. fn ridge_vertices_for_facet( - facet_vertices: &[VertexKey], -) -> Result, ManifoldError> { + facet_vertices: &[LiftedVertexId], + facet_vertices_bare: &[VertexKey], +) -> Result, ManifoldError> { if facet_vertices.len() != D { return Err(TdsError::DimensionMismatch { expected: D, @@ -598,16 +656,27 @@ fn ridge_vertices_for_facet( } .into()); } + if facet_vertices_bare.len() != D { + return Err(TdsError::DimensionMismatch { + expected: D, + actual: facet_vertices_bare.len(), + context: "local boundary facet bare vertex count".to_string(), + } + .into()); + } - let mut ridges: SmallBuffer = SmallBuffer::with_capacity(D); + let mut ridges: SmallBuffer<(LiftedVertexBuffer, VertexKeyBuffer), 8> = + SmallBuffer::with_capacity(D); for omit in 0..facet_vertices.len() { - let mut ridge_vertices = VertexKeyBuffer::with_capacity(D.saturating_sub(1)); - for (idx, &vertex_key) in facet_vertices.iter().enumerate() { + let mut ridge_vertices = LiftedVertexBuffer::with_capacity(D.saturating_sub(1)); + let mut ridge_vertices_bare = VertexKeyBuffer::with_capacity(D.saturating_sub(1)); + for (idx, vertex_key) in facet_vertices.iter().enumerate() { if idx != omit { - ridge_vertices.push(vertex_key); + ridge_vertices.push(vertex_key.clone()); + ridge_vertices_bare.push(facet_vertices_bare[idx]); } } - ridges.push(ridge_vertices); + ridges.push((ridge_vertices, ridge_vertices_bare)); } Ok(ridges) } @@ -615,20 +684,22 @@ fn ridge_vertices_for_facet( /// Counts boundary facets in the full star of a ridge. fn boundary_facet_count_for_ridge( tds: &Tds, - ridge_vertices: &[VertexKey], + ridge_vertices: &[LiftedVertexId], + ridge_vertices_bare: &[VertexKey], ) -> Result where U: DataType, V: DataType, { - let star_cells = simplex_star_cells(tds, ridge_vertices)?; + let star_cells = simplex_star_cells(tds, ridge_vertices_bare)?; let mut count = 0usize; let mut seen_boundary_facets: FastHashSet = FastHashSet::default(); for cell_key in star_cells { let cell_vertices = tds.cell_vertices(cell_key)?; for facet_index in 0..cell_vertices.len() { - let facet_vertices = cell_facet_vertices(&cell_vertices, facet_index)?; + let (facet_vertices, facet_vertices_bare) = + cell_facet_vertex_ids(tds, cell_key, facet_index)?; if !ridge_vertices .iter() .all(|ridge_vertex| facet_vertices.contains(ridge_vertex)) @@ -636,11 +707,11 @@ where continue; } - let facet_key = tds.facet_key_for_cell_facet(cell_key, facet_index)?; + let facet_key = periodic_simplex_key(&facet_vertices); if !seen_boundary_facets.insert(facet_key) { continue; } - let handles = facet_incident_handles(tds, facet_key, &facet_vertices)?; + let handles = facet_incident_handles(tds, facet_key, &facet_vertices_bare)?; match handles.len() { 1 => count = count.saturating_add(1), 2 => {} @@ -720,7 +791,7 @@ fn simplex_link_simplices_from_star( tds: &Tds, simplex_vertices: &[VertexKey], star_cells: &[CellKey], -) -> Result, ManifoldError> +) -> Result where U: DataType, V: DataType, @@ -734,8 +805,7 @@ where let expected_link_vertices = (D + 1).saturating_sub(simplex_vertices.len()); - let mut link_simplices: SmallBuffer = - SmallBuffer::with_capacity(star_cells.len()); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::with_capacity(star_cells.len()); for &cell_key in star_cells { let cell_vertices = tds.cell_vertices(cell_key)?; @@ -752,8 +822,8 @@ where .position(|cv| simplex_vertices.contains(cv)) }); - let mut link_vertices: VertexKeyBuffer = - VertexKeyBuffer::with_capacity(expected_link_vertices); + let mut link_vertices: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(expected_link_vertices); for (i, &vk) in cell_vertices.iter().enumerate() { // Membership test on bare key: the input simplex (e.g. a single // vertex) IS the same vertex regardless of periodic offset. @@ -768,7 +838,7 @@ where .collect(); lifted_vertex_id(vk, &rel) } - _ => vk, + _ => LiftedVertexId::base(vk), }; link_vertices.push(lifted); } @@ -829,11 +899,11 @@ where simplex_star_cells(tds, ridge_vertices) } -pub(crate) fn ridge_link_edges_from_star( +fn ridge_link_edges_from_star( tds: &Tds, - ridge_vertices: &[VertexKey], + ridge_vertices: &[LiftedVertexId], star_cells: &[CellKey], -) -> Result, ManifoldError> +) -> Result, ManifoldError> where U: DataType, V: DataType, @@ -853,10 +923,10 @@ where .into()); } - let mut link_edges: SmallBuffer<(VertexKey, VertexKey), 8> = + let mut link_edges: SmallBuffer<(LiftedVertexId, LiftedVertexId), 8> = SmallBuffer::with_capacity(star_cells.len()); - let mut link_vertices: VertexKeyBuffer = VertexKeyBuffer::with_capacity(2); + let mut link_vertices: LiftedVertexBuffer = LiftedVertexBuffer::with_capacity(2); for &cell_key in star_cells { let cell_vertices = tds.cell_vertices(cell_key)?; @@ -879,7 +949,10 @@ where for (i, &vk) in cell_vertices.iter().enumerate() { // Lift with absolute offsets for ridge membership test (ridge // vertices in `ridge_vertices` use absolute lifted IDs). - let abs_lifted = offsets.map_or(vk, |offs| lifted_vertex_id(vk, &offs[i])); + let abs_lifted = offsets.map_or_else( + || LiftedVertexId::base(vk), + |offs| lifted_vertex_id(vk, &offs[i]), + ); if !ridge_vertices.contains(&abs_lifted) { // Lift link vertices with *relative* offsets so adjacent // cells agree on shared vertex identity. @@ -893,7 +966,7 @@ where .collect(); lifted_vertex_id(vk, &rel) } - _ => vk, + _ => LiftedVertexId::base(vk), }; link_vertices.push(rel_lifted); } @@ -912,13 +985,13 @@ where return Err(TdsError::InconsistentDataStructure { message: format!( "Ridge link edge is a self-loop: link vertex {vk:?} repeated (cell_key={cell_key:?})", - vk = link_vertices[0], + vk = &link_vertices[0], ), } .into()); } - link_edges.push((link_vertices[0], link_vertices[1])); + link_edges.push((link_vertices[0].clone(), link_vertices[1].clone())); } Ok(link_edges) @@ -926,7 +999,7 @@ where #[derive(Clone, Debug)] struct RidgeStar { - ridge_vertices: VertexKeyBuffer, + ridge_vertices: LiftedVertexBuffer, star_cells: SmallBuffer, } @@ -965,7 +1038,8 @@ where let mut ridge_to_star: FastHashMap = fast_hash_map_with_capacity(estimated_unique_ridges); - let mut ridge_vertices: VertexKeyBuffer = VertexKeyBuffer::with_capacity(D.saturating_sub(1)); + let mut ridge_vertices: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(D.saturating_sub(1)); for (cell_key, cell) in tds.cells() { let cell_vertices = tds.cell_vertices(cell_key)?; @@ -989,7 +1063,10 @@ where continue; } // Use lifted vertex ID when periodic offsets are present. - let lifted = offsets.map_or(vk, |offs| lifted_vertex_id(vk, &offs[i])); + let lifted = offsets.map_or_else( + || LiftedVertexId::base(vk), + |offs| lifted_vertex_id(vk, &offs[i]), + ); ridge_vertices.push(lifted); } @@ -1004,11 +1081,7 @@ where // Periodic-aware key: lifted vertex IDs produce distinct // ridge keys for different offset images. - let ridge_key = if offsets.is_some() { - periodic_ridge_key(&ridge_vertices) - } else { - facet_key_from_vertices(&ridge_vertices) - }; + let ridge_key = periodic_simplex_key(&ridge_vertices); let star = ridge_to_star.entry(ridge_key).or_insert_with(|| RidgeStar { ridge_vertices: ridge_vertices.clone(), star_cells: SmallBuffer::new(), @@ -1045,13 +1118,13 @@ where // For periodic cells we store both lifted vertices (for ridge identity and // downstream link computation) and bare vertices (for `simplex_star_cells` // which looks up real TDS vertex keys). - let mut ridge_to_vertices: FastHashMap = + let mut ridge_to_vertices: FastHashMap = fast_hash_map_with_capacity(estimated_unique_ridges); let mut ridge_vertices_bare: VertexKeyBuffer = VertexKeyBuffer::with_capacity(D.saturating_sub(1)); - let mut ridge_vertices_lifted: VertexKeyBuffer = - VertexKeyBuffer::with_capacity(D.saturating_sub(1)); + let mut ridge_vertices_lifted: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(D.saturating_sub(1)); for &cell_key in cells { if !tds.contains_cell(cell_key) { @@ -1081,7 +1154,10 @@ where } ridge_vertices_bare.push(vk); // Use lifted vertex ID when periodic offsets are present. - let lifted = offsets.map_or(vk, |offs| lifted_vertex_id(vk, &offs[i])); + let lifted = offsets.map_or_else( + || LiftedVertexId::base(vk), + |offs| lifted_vertex_id(vk, &offs[i]), + ); ridge_vertices_lifted.push(lifted); } @@ -1096,11 +1172,7 @@ where // Periodic-aware key: lifted vertex IDs produce distinct // ridge keys for different offset images. - let ridge_key = if offsets.is_some() { - periodic_ridge_key(&ridge_vertices_lifted) - } else { - facet_key_from_vertices(&ridge_vertices_bare) - }; + let ridge_key = periodic_simplex_key(&ridge_vertices_lifted); ridge_to_vertices.entry(ridge_key).or_insert_with(|| { (ridge_vertices_lifted.clone(), ridge_vertices_bare.clone()) }); @@ -1148,7 +1220,7 @@ where fn periodic_aware_ridge_star( tds: &Tds, ridge_key: u64, - lifted_vertices: &VertexKeyBuffer, + lifted_vertices: &[LiftedVertexId], bare_vertices: &VertexKeyBuffer, ) -> Result, ManifoldError> where @@ -1169,9 +1241,10 @@ where Some(offs) => { let cv = tds.cell_vertices(ck)?; lifted_vertices.iter().all(|lv| { - cv.iter() - .enumerate() - .any(|(i, &vk)| lifted_vertex_id(vk, &offs[i]) == *lv) + cv.iter().enumerate().any(|(i, &vk)| { + let lifted = lifted_vertex_id(vk, &offs[i]); + &lifted == lv + }) }) } }; @@ -1508,13 +1581,13 @@ where fn validate_vertex_link_d1( vertex_key: VertexKey, interior_vertex: bool, - link_simplices: &SmallBuffer, + link_simplices: &LinkSimplexBuffer, ) -> Result<(), ManifoldError> { - let mut link_vertices: FastHashSet = + let mut link_vertices: FastHashSet = fast_hash_set_with_capacity(link_simplices.len().max(1)); for simplex in link_simplices { - for &vk in simplex { - link_vertices.insert(vk); + for vk in simplex { + link_vertices.insert(vk.clone()); } } @@ -1547,7 +1620,7 @@ fn validate_vertex_link_d1( fn validate_vertex_link_d2( vertex_key: VertexKey, interior_vertex: bool, - link_simplices: &SmallBuffer, + link_simplices: &LinkSimplexBuffer, link_vertex_count: usize, link_cell_count: usize, ) -> Result<(), ManifoldError> { @@ -1621,12 +1694,12 @@ where return validate_vertex_link_d1(vertex_key, interior_vertex, &link_simplices); } - let mut link_vertex_set: FastHashSet = + let mut link_vertex_set: FastHashSet = fast_hash_set_with_capacity(link_simplices.len().saturating_mul(D).max(1)); for simplex in &link_simplices { - for &vk in simplex { - link_vertex_set.insert(vk); + for vk in simplex { + link_vertex_set.insert(vk.clone()); } } @@ -1657,8 +1730,18 @@ where // For D>=4, Euler characteristic is not sufficient to distinguish spheres from other // closed manifolds in general, so we fall back to manifoldness-only checks. let link_topology_ok = if D == 3 { - let chi = triangulated_surface_euler_characteristic(&link_simplices); - let boundary_components = triangulated_surface_boundary_component_count(&link_simplices); + let (chi, boundary_components) = if link_simplices_are_base(&link_simplices) { + let bare_simplices = bare_link_simplices(&link_simplices); + ( + triangulated_surface_euler_characteristic(&bare_simplices), + triangulated_surface_boundary_component_count(&bare_simplices), + ) + } else { + ( + triangulated_surface_euler_characteristic_for_link(&link_simplices), + triangulated_surface_boundary_component_count_for_link(&link_simplices), + ) + }; if interior_vertex { chi == 2 && boundary_components == 0 } else { @@ -1685,32 +1768,30 @@ where } } -fn link_1_skeleton_connectivity_and_max_degree( - link_cells: &SmallBuffer, -) -> (bool, usize) { +fn link_1_skeleton_connectivity_and_max_degree(link_cells: &LinkSimplexBuffer) -> (bool, usize) { // Build adjacency from the 1-skeleton of the link. - let mut unique_edges: FastHashSet = + let mut unique_edges: FastHashSet<(LiftedVertexId, LiftedVertexId)> = fast_hash_set_with_capacity(link_cells.len().max(1)); - let mut adjacency: FastHashMap> = + let mut adjacency: FastHashMap = fast_hash_map_with_capacity(link_cells.len().saturating_mul(2).max(1)); for simplex in link_cells { // Add all edges in the simplex. for i in 0..simplex.len() { for j in (i + 1)..simplex.len() { - let e = EdgeKey::new(simplex[i], simplex[j]); - if !unique_edges.insert(e) { + let edge = ordered_lifted_edge(&simplex[i], &simplex[j]); + if !unique_edges.insert(edge.clone()) { continue; } - let (a, b) = e.endpoints(); - adjacency.entry(a).or_default().push(b); + let (a, b) = edge; + adjacency.entry(a.clone()).or_default().push(b.clone()); adjacency.entry(b).or_default().push(a); } } // Ensure isolated vertices are present in adjacency. - for &vk in simplex { - adjacency.entry(vk).or_default(); + for vk in simplex { + adjacency.entry(vk.clone()).or_default(); } } @@ -1722,22 +1803,23 @@ fn link_1_skeleton_connectivity_and_max_degree( // Connectivity check. let connected = match adjacency.iter().next() { None => true, - Some((&start, _)) => { - let mut visited: FastHashSet = + Some((start, _)) => { + let mut visited: FastHashSet = fast_hash_set_with_capacity(adjacency.len().max(1)); - let mut stack: VertexKeyBuffer = VertexKeyBuffer::with_capacity(adjacency.len().max(1)); - stack.push(start); + let mut stack: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(adjacency.len().max(1)); + stack.push(start.clone()); while let Some(v) = stack.pop() { - if !visited.insert(v) { + if !visited.insert(v.clone()) { continue; } let Some(neigh) = adjacency.get(&v) else { continue; }; - for &n in neigh { - if !visited.contains(&n) { - stack.push(n); + for n in neigh { + if !visited.contains(n) { + stack.push(n.clone()); } } } @@ -1749,29 +1831,27 @@ fn link_1_skeleton_connectivity_and_max_degree( (connected, max_degree) } -fn link_1d_graph_stats( - link_cells: &SmallBuffer, -) -> Option<(bool, usize, usize, usize)> { +fn link_1d_graph_stats(link_cells: &LinkSimplexBuffer) -> Option<(bool, usize, usize, usize)> { // In D=2, link cells are edges (2 vertices). Build an undirected graph and compute: // - connectivity // - max degree // - number of degree-1 vertices // - vertex count - let mut unique_edges: FastHashSet = + let mut unique_edges: FastHashSet<(LiftedVertexId, LiftedVertexId)> = fast_hash_set_with_capacity(link_cells.len().max(1)); - let mut adjacency: FastHashMap> = + let mut adjacency: FastHashMap> = fast_hash_map_with_capacity(link_cells.len().saturating_mul(2).max(1)); for e in link_cells { if e.len() != 2 { return None; } - let edge = EdgeKey::new(e[0], e[1]); - if !unique_edges.insert(edge) { + let edge = ordered_lifted_edge(&e[0], &e[1]); + if !unique_edges.insert(edge.clone()) { continue; } - let (a, b) = edge.endpoints(); - adjacency.entry(a).or_default().push(b); + let (a, b) = edge; + adjacency.entry(a.clone()).or_default().push(b.clone()); adjacency.entry(b).or_default().push(a); } @@ -1792,21 +1872,22 @@ fn link_1d_graph_stats( // Connectivity. let connected = match adjacency.iter().next() { None => true, - Some((&start, _)) => { - let mut visited: FastHashSet = fast_hash_set_with_capacity(vertex_count); - let mut stack: VertexKeyBuffer = VertexKeyBuffer::with_capacity(vertex_count); - stack.push(start); + Some((start, _)) => { + let mut visited: FastHashSet = + fast_hash_set_with_capacity(vertex_count); + let mut stack: LiftedVertexBuffer = LiftedVertexBuffer::with_capacity(vertex_count); + stack.push(start.clone()); while let Some(v) = stack.pop() { - if !visited.insert(v) { + if !visited.insert(v.clone()) { continue; } let Some(neigh) = adjacency.get(&v) else { continue; }; - for &n in neigh { - if !visited.contains(&n) { - stack.push(n); + for n in neigh { + if !visited.contains(n) { + stack.push(n.clone()); } } } @@ -1819,7 +1900,7 @@ fn link_1d_graph_stats( } fn validate_link_facets_and_boundary( - link_cells: &SmallBuffer, + link_cells: &LinkSimplexBuffer, interior_vertex: bool, ) -> (usize, bool) { // For a vertex link in D>=3, the link dimension is (D-1) >= 2. @@ -1828,14 +1909,15 @@ fn validate_link_facets_and_boundary( #[derive(Clone, Debug)] struct FacetInfo { - vertices: VertexKeyBuffer, + vertices: LiftedVertexBuffer, count: usize, } let mut facet_map: FastHashMap = fast_hash_map_with_capacity(link_cells.len().saturating_mul(D).max(1)); - let mut facet_vertices: VertexKeyBuffer = VertexKeyBuffer::with_capacity(D.saturating_sub(1)); + let mut facet_vertices: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(D.saturating_sub(1)); for simplex in link_cells { if simplex.len() != D { @@ -1844,18 +1926,18 @@ fn validate_link_facets_and_boundary( for omit in 0..simplex.len() { facet_vertices.clear(); - for (j, &vk) in simplex.iter().enumerate() { + for (j, vk) in simplex.iter().enumerate() { if j == omit { continue; } - facet_vertices.push(vk); + facet_vertices.push(vk.clone()); } if facet_vertices.len() != D.saturating_sub(1) { return (0, false); } - let key = facet_key_from_vertices(&facet_vertices); + let key = periodic_simplex_key(&facet_vertices); let entry = facet_map.entry(key).or_insert_with(|| FacetInfo { vertices: facet_vertices.clone(), count: 0, @@ -1888,8 +1970,8 @@ fn validate_link_facets_and_boundary( .max(1), ); - let mut ridge_vertices: VertexKeyBuffer = - VertexKeyBuffer::with_capacity(D.saturating_sub(2)); + let mut ridge_vertices: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(D.saturating_sub(2)); for info in facet_map.values() { if info.count != 1 { @@ -1899,16 +1981,16 @@ fn validate_link_facets_and_boundary( let f = &info.vertices; for omit in 0..f.len() { ridge_vertices.clear(); - for (j, &vk) in f.iter().enumerate() { + for (j, vk) in f.iter().enumerate() { if j == omit { continue; } - ridge_vertices.push(vk); + ridge_vertices.push(vk.clone()); } if ridge_vertices.len() != D.saturating_sub(2) { return (boundary_facet_count, false); } - let ridge_key = facet_key_from_vertices(&ridge_vertices); + let ridge_key = periodic_simplex_key(&ridge_vertices); *ridge_map.entry(ridge_key).or_insert(0) += 1; } } @@ -1923,35 +2005,133 @@ fn validate_link_facets_and_boundary( (boundary_facet_count, true) } +fn link_simplices_are_base(link_cells: &LinkSimplexBuffer) -> bool { + link_cells + .iter() + .flat_map(|simplex| simplex.iter()) + .all(LiftedVertexId::is_base) +} + +fn bare_link_simplices(link_cells: &LinkSimplexBuffer) -> SmallBuffer { + link_cells + .iter() + .map(|simplex| { + simplex + .iter() + .map(|vertex| vertex.vertex_key) + .collect::() + }) + .collect() +} + +fn triangulated_surface_euler_characteristic_for_link(link_cells: &LinkSimplexBuffer) -> isize { + let mut vertices: FastHashSet = + fast_hash_set_with_capacity(link_cells.len().saturating_mul(3).max(1)); + let mut edges: FastHashSet<(LiftedVertexId, LiftedVertexId)> = + fast_hash_set_with_capacity(link_cells.len().saturating_mul(3).max(1)); + + for simplex in link_cells { + for vertex in simplex { + vertices.insert(vertex.clone()); + } + for i in 0..simplex.len() { + for j in (i + 1)..simplex.len() { + edges.insert(ordered_lifted_edge(&simplex[i], &simplex[j])); + } + } + } + + vertices.len().cast_signed() - edges.len().cast_signed() + link_cells.len().cast_signed() +} + +fn triangulated_surface_boundary_component_count_for_link(link_cells: &LinkSimplexBuffer) -> usize { + let mut edge_counts: FastHashMap<(LiftedVertexId, LiftedVertexId), usize> = + fast_hash_map_with_capacity(link_cells.len().saturating_mul(3).max(1)); + + for simplex in link_cells { + if simplex.len() < 2 { + continue; + } + for i in 0..simplex.len() { + for j in (i + 1)..simplex.len() { + *edge_counts + .entry(ordered_lifted_edge(&simplex[i], &simplex[j])) + .or_insert(0) += 1; + } + } + } + + let boundary_edges: SmallBuffer<(LiftedVertexId, LiftedVertexId), 8> = edge_counts + .into_iter() + .filter_map(|(edge, count)| (count == 1).then_some(edge)) + .collect(); + if boundary_edges.is_empty() { + return 0; + } + + let mut adjacency: FastHashMap = + fast_hash_map_with_capacity(boundary_edges.len().saturating_mul(2)); + for (a, b) in boundary_edges { + adjacency.entry(a.clone()).or_default().push(b.clone()); + adjacency.entry(b).or_default().push(a); + } + + let mut visited: FastHashSet = fast_hash_set_with_capacity(adjacency.len()); + let mut components = 0usize; + for start in adjacency.keys() { + if visited.contains(start) { + continue; + } + components += 1; + let mut stack: LiftedVertexBuffer = LiftedVertexBuffer::with_capacity(adjacency.len()); + stack.push(start.clone()); + while let Some(vertex) = stack.pop() { + if !visited.insert(vertex.clone()) { + continue; + } + let Some(neighbors) = adjacency.get(&vertex) else { + continue; + }; + for neighbor in neighbors { + if !visited.contains(neighbor) { + stack.push(neighbor.clone()); + } + } + } + } + + components +} + fn validate_ridge_link_graph( ridge_key: u64, - link_edges: &[(VertexKey, VertexKey)], + link_edges: &[(LiftedVertexId, LiftedVertexId)], ) -> Result<(), ManifoldError> { // De-duplicate parallel edges defensively: if the underlying TDS contains duplicate // cells/edges, the ridge link can contain repeated edges which would otherwise inflate // degrees and edge counts. - let mut unique_edges: FastHashSet = + let mut unique_edges: FastHashSet<(LiftedVertexId, LiftedVertexId)> = fast_hash_set_with_capacity(link_edges.len().max(1)); // Build adjacency lists for the (simple) link graph. let estimated_link_vertices = link_edges.len().saturating_mul(2).max(1); - let mut adjacency: FastHashMap> = + let mut adjacency: FastHashMap> = fast_hash_map_with_capacity(estimated_link_vertices); let mut max_degree = 0usize; let mut link_edge_count = 0usize; - for &(a, b) in link_edges { - let edge = EdgeKey::new(a, b); - if !unique_edges.insert(edge) { + for (a, b) in link_edges { + let edge = ordered_lifted_edge(a, b); + if !unique_edges.insert(edge.clone()) { continue; } link_edge_count += 1; - let (a, b) = edge.endpoints(); + let (a, b) = edge; - let a_neighbors = adjacency.entry(a).or_default(); - a_neighbors.push(b); + let a_neighbors = adjacency.entry(a.clone()).or_default(); + a_neighbors.push(b.clone()); max_degree = max_degree.max(a_neighbors.len()); let b_neighbors = adjacency.entry(b).or_default(); @@ -1966,14 +2146,15 @@ fn validate_ridge_link_graph( // Connectivity check: traverse the link graph. let connected = match adjacency.iter().next() { None => true, - Some((&start, _)) => { - let mut visited: FastHashSet = + Some((start, _)) => { + let mut visited: FastHashSet = fast_hash_set_with_capacity(link_vertex_count); - let mut stack: VertexKeyBuffer = VertexKeyBuffer::with_capacity(link_vertex_count); - stack.push(start); + let mut stack: LiftedVertexBuffer = + LiftedVertexBuffer::with_capacity(link_vertex_count); + stack.push(start.clone()); while let Some(v) = stack.pop() { - if !visited.insert(v) { + if !visited.insert(v.clone()) { continue; } @@ -1981,9 +2162,9 @@ fn validate_ridge_link_graph( continue; }; - for &n in neighbors { - if !visited.contains(&n) { - stack.push(n); + for n in neighbors { + if !visited.contains(n) { + stack.push(n.clone()); } } } @@ -2027,9 +2208,9 @@ mod tests { VertexKey::from(KeyData::from_ffi(id)) } - fn simplex(vertices: &[VertexKey]) -> VertexKeyBuffer { - let mut s: VertexKeyBuffer = VertexKeyBuffer::with_capacity(vertices.len()); - s.extend(vertices.iter().copied()); + fn simplex(vertices: &[VertexKey]) -> LiftedVertexBuffer { + let mut s: LiftedVertexBuffer = LiftedVertexBuffer::with_capacity(vertices.len()); + s.extend(vertices.iter().copied().map(LiftedVertexId::base)); s } @@ -2241,7 +2422,7 @@ mod tests { fn test_validate_vertex_link_d1_accepts_interior_vertex_with_two_neighbors() { let vertex_key = vk(0); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[vk(1)])); link_simplices.push(simplex(&[vk(2)])); @@ -2252,7 +2433,7 @@ mod tests { fn test_validate_vertex_link_d1_rejects_interior_vertex_with_one_neighbor() { let vertex_key = vk(0); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[vk(1)])); match validate_vertex_link_d1(vertex_key, true, &link_simplices) { @@ -2278,7 +2459,7 @@ mod tests { fn test_validate_vertex_link_d1_accepts_boundary_vertex_with_one_neighbor() { let vertex_key = vk(0); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[vk(1)])); validate_vertex_link_d1(vertex_key, false, &link_simplices).unwrap(); @@ -2288,7 +2469,7 @@ mod tests { fn test_validate_vertex_link_d1_rejects_boundary_vertex_with_two_neighbors() { let vertex_key = vk(0); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[vk(1)])); link_simplices.push(simplex(&[vk(2)])); @@ -2318,7 +2499,7 @@ mod tests { let b = vk(2); let c = vk(3); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[a, b])); link_simplices.push(simplex(&[b, c])); link_simplices.push(simplex(&[c, a])); @@ -2333,7 +2514,7 @@ mod tests { let b = vk(2); let c = vk(3); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[a, b])); link_simplices.push(simplex(&[b, c])); @@ -2347,7 +2528,7 @@ mod tests { let b = vk(2); let c = vk(3); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[a, b])); link_simplices.push(simplex(&[b, c])); @@ -2380,7 +2561,7 @@ mod tests { let b = vk(2); let c = vk(3); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[a, b])); link_simplices.push(simplex(&[b, c])); link_simplices.push(simplex(&[c, a])); @@ -2395,7 +2576,7 @@ mod tests { fn test_validate_vertex_link_d2_rejects_non_edge_link_simplices() { let vertex_key = vk(0); - let mut link_simplices: SmallBuffer = SmallBuffer::new(); + let mut link_simplices: LinkSimplexBuffer = SmallBuffer::new(); link_simplices.push(simplex(&[vk(1)])); assert!(matches!( @@ -2714,7 +2895,7 @@ mod tests { .unwrap(); // In 3D, ridges are edges (2 vertices). Passing a single vertex is invalid. - match ridge_link_edges_from_star(&tds, &[v0], &[cell_key]) { + match ridge_link_edges_from_star(&tds, &simplex(&[v0]), &[cell_key]) { Err(ManifoldError::Tds(TdsError::DimensionMismatch { expected: 2, actual: 1, @@ -2791,7 +2972,7 @@ mod tests { } // For ridge (vertex) v0, the link edge becomes (v1, v1), which is not a simplicial edge. - match ridge_link_edges_from_star(&tds, &[v0], &[cell_key]) { + match ridge_link_edges_from_star(&tds, &simplex(&[v0]), &[cell_key]) { Err(ManifoldError::Tds(TdsError::InconsistentDataStructure { message })) => { assert!( message.contains("self-loop"), @@ -2809,7 +2990,12 @@ mod tests { let b = VertexKey::from(KeyData::from_ffi(2)); let c = VertexKey::from(KeyData::from_ffi(3)); - let edges = vec![(a, b), (b, c), (c, a), (a, b)]; + let edges = vec![ + (LiftedVertexId::base(a), LiftedVertexId::base(b)), + (LiftedVertexId::base(b), LiftedVertexId::base(c)), + (LiftedVertexId::base(c), LiftedVertexId::base(a)), + (LiftedVertexId::base(a), LiftedVertexId::base(b)), + ]; assert!(validate_ridge_link_graph(0_u64, &edges).is_ok()); } @@ -3137,7 +3323,7 @@ mod tests { .expect("expected ridge key in local ridge-star map"); // RidgeStar stores the ridge vertices; ensure its canonical key matches the map key. - assert_eq!(facet_key_from_vertices(&star.ridge_vertices), key); + assert_eq!(periodic_simplex_key(&star.ridge_vertices), key); assert_eq!(star.ridge_vertices.len(), 2); star.star_cells.iter().copied().collect() @@ -3627,8 +3813,8 @@ mod tests { // Bare [v0] finds c1, but lifted_vertex_id(v0, [99,99]) won't match // c1's lifted v0 (which is bare v0 since offset is [0,0]). let synthetic = lifted_vertex_id(v0, &[99, 99]); - let bare = simplex(&[v0]); - let lifted = simplex(&[synthetic]); + let bare: VertexKeyBuffer = std::iter::once(v0).collect(); + let lifted: LiftedVertexBuffer = std::iter::once(synthetic).collect(); match periodic_aware_ridge_star(&tds, 0x42, &lifted, &bare) { Err(ManifoldError::Tds(TdsError::InconsistentDataStructure { ref message })) => { diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 4ff29c44..752977c5 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -7146,9 +7146,10 @@ where /// Validates PL ridge links after a repair pass that actually performed flips. /// - /// `repair_delaunay_with_flips_k2_k3_run` reports whether the final attempt - /// used a full-TDS reseed. Full reseeds validate every current cell; local - /// repairs validate only cells created by flips in the final attempt. + /// Ridge-link topology only changes where flips created replacement cells, + /// so validation follows that mutation frontier even if the repair queues + /// were seeded from the full triangulation. If a repair reports flips + /// without a mutation frontier, fall back to a full cell list defensively. fn validate_ridge_links_after_repair( &self, topology: TopologyGuarantee, @@ -7166,7 +7167,13 @@ where .map_err(ridge_link_repair_validation_error) }; - if !run.used_full_reseed && !run.touched_cells.is_empty() { + if !run.touched_cells.is_empty() { + if run.used_full_reseed && env::var_os("DELAUNAY_REPAIR_TRACE").is_some() { + tracing::debug!( + "[repair] validating ridge links on {} flip-created cells after full reseed", + run.touched_cells.len() + ); + } return validate_cells(&run.touched_cells); } @@ -8143,6 +8150,55 @@ mod tests { }); } + fn wedge_two_spheres_share_vertex_tds_2d() -> (Tds, CellKey, CellKey) { + // Two closed 2D spheres (boundaries of tetrahedra) sharing one vertex are + // pseudomanifold but not PL-manifold: the shared vertex has a disconnected link. + let mut tds: Tds = Tds::empty(); + + let v0 = tds.insert_vertex_with_mapping(vertex!([0.0, 0.0])).unwrap(); + let v1 = tds.insert_vertex_with_mapping(vertex!([1.0, 0.0])).unwrap(); + let v2 = tds.insert_vertex_with_mapping(vertex!([0.0, 1.0])).unwrap(); + let v3 = tds.insert_vertex_with_mapping(vertex!([1.0, 1.0])).unwrap(); + + let incident = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v1, v2], None).unwrap()) + .unwrap(); + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v1, v3], None).unwrap()) + .unwrap(); + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v2, v3], None).unwrap()) + .unwrap(); + let nonincident = tds + .insert_cell_with_mapping(Cell::new(vec![v1, v2, v3], None).unwrap()) + .unwrap(); + + let v4 = tds + .insert_vertex_with_mapping(vertex!([10.0, 10.0])) + .unwrap(); + let v5 = tds + .insert_vertex_with_mapping(vertex!([11.0, 10.0])) + .unwrap(); + let v6 = tds + .insert_vertex_with_mapping(vertex!([10.0, 11.0])) + .unwrap(); + + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v4, v5], None).unwrap()) + .unwrap(); + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v4, v6], None).unwrap()) + .unwrap(); + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v0, v5, v6], None).unwrap()) + .unwrap(); + let _ = tds + .insert_cell_with_mapping(Cell::new(vec![v4, v5, v6], None).unwrap()) + .unwrap(); + + (tds, incident, nonincident) + } + #[test] fn test_ridge_link_repair_validation_error_routes_tds_errors_to_tds_layer() { let tds_err = TdsError::InvalidNeighbors { @@ -8405,6 +8461,44 @@ mod tests { assert!(!seeds.contains(&stale_cell)); } + #[test] + fn test_validate_ridge_links_after_full_reseed_repair_uses_mutation_frontier() { + init_tracing(); + let (tds, incident_to_invalid_ridge, nonincident) = wedge_two_spheres_share_vertex_tds_2d(); + let dt = DelaunayTriangulation::from_tds_with_topology_guarantee( + tds, + AdaptiveKernel::new(), + TopologyGuarantee::PLManifold, + ); + let stats = DelaunayRepairStats { + flips_performed: 1, + ..DelaunayRepairStats::default() + }; + + let local_run = DelaunayRepairRun { + stats: stats.clone(), + touched_cells: std::iter::once(nonincident).collect(), + used_full_reseed: true, + }; + assert!( + dt.validate_ridge_links_after_repair(TopologyGuarantee::PLManifold, &local_run) + .is_ok() + ); + + let invalid_scope_run = DelaunayRepairRun { + stats, + touched_cells: std::iter::once(incident_to_invalid_ridge).collect(), + used_full_reseed: true, + }; + assert!( + dt.validate_ridge_links_after_repair( + TopologyGuarantee::PLManifold, + &invalid_scope_run, + ) + .is_err() + ); + } + struct ForceHeuristicRebuildGuard { prior: bool, }