From fd5dbf211af14124db6cc21ceef0b821b53cdffe Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Thu, 23 Apr 2026 13:59:05 -0700 Subject: [PATCH 01/11] feat: instrument large-scale 4D debugging and widen local repair seeds - Thread cavity-touched cells through insertion as `repair_seed_cells` so post-insertion local Delaunay repair widens its frontier beyond the inserted vertex star; cells shrunk out of the conflict region during cavity reduction now participate in the next repair pass. - Accumulate ridge-fan extras across every fan in a conflict region before returning `RidgeFan`, letting one cavity-reduction step shrink all detected fans at once instead of peeling them iteration by iteration. - Add release-visible diagnostic hooks routed through `tracing::debug!`: `DELAUNAY_BULK_PROGRESS_EVERY` for periodic batch-construction progress, `DELAUNAY_DEBUG_RETRYABLE_SKIP` for retryable conflict-region skip traces, `DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE` for the first cavity-reduction chain, `DELAUNAY_DEBUG_RIDGE_FAN_ONCE` for the first detected ridge fan, and `DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET` / `DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY` for repair postcondition debugging. - Thread `last_applied_flip` through repair postcondition verification so unresolved k=2 facet and ridge snapshots can relate the violating local star to the immediately preceding flip. - Replace `ConflictError::InternalInconsistency { context: String }` with a typed `InternalInconsistencySite` enum carrying structured indices and counts, so callers can `matches!` on specific sites instead of parsing prose. - Generalize the large-scale incremental prefix bisect over `const D`, add a 4D counterpart targeting the seeded 500-point repro (`0xD225_B8A0_7E27_4AE6`), and expose it via `just debug-large-scale-4d-incremental-bisect`. - Switch the large-scale debug just recipes to `--release` and document the 2026-04-23 re-verification: historical 35-point 3D and 100-point 4D correctness repros from #306/#307 now pass, while a 500-point 4D seed still fails all shuffled retries with `Ridge fan detected: 4 facets share ridge with 3 vertices`. - Default the large-scale debug harness tracing filter to `debug` when any of the new release-visible env vars are present so library-side `tracing::debug!` events surface without extra `RUST_LOG` wiring. - Broaden `test_perturbation_retry_and_exhaustion_4d` and `test_perturbation_retry_seeded_branch_4d` to iterate over 50 seeds so the retry-path assertions stay robust to insertion-path improvements that make individual well-conditioned seeds less likely to trigger retries. --- docs/KNOWN_ISSUES_4D.md | 116 +++- docs/TODO.md | 45 +- docs/dev/debug_env_vars.md | 6 + justfile | 14 +- src/core/algorithms/flips.rs | 463 +++++++++++-- src/core/algorithms/incremental_insertion.rs | 42 +- src/core/algorithms/locate.rs | 486 +++++++++++--- src/core/triangulation.rs | 664 ++++++++++++++++--- src/triangulation/delaunay.rs | 386 ++++++++++- tests/README.md | 11 +- tests/large_scale_debug.rs | 147 ++-- 11 files changed, 2019 insertions(+), 361 deletions(-) diff --git a/docs/KNOWN_ISSUES_4D.md b/docs/KNOWN_ISSUES_4D.md index 7b264637..2cf9cb70 100644 --- a/docs/KNOWN_ISSUES_4D.md +++ b/docs/KNOWN_ISSUES_4D.md @@ -2,38 +2,71 @@ ## Status (v0.7.5) -### Current issues - -#### 4D+ bulk construction failures - -Large-scale 4D bulk construction can produce Delaunay-validation failures on -adversarial/degenerate point sets, even when local repair steps appear to succeed. +### Re-verified on 2026-04-23 (release mode) -**Severity:** High (correctness) -**Affects:** primarily large 4D bulk runs (typically 100+ vertices) -**Recommended workaround:** prefer incremental insertion for production 4D workloads +These release-mode reruns supersede the old 35-point 3D and 100-point 4D +correctness failures described below: -**#204 findings (v0.7.4):** 4D 100-point batch construction (release mode, -seed `0x9B7786C999C56A16`, ball radius=100) inserts only **12 of 100 vertices**; -88 are skipped as degeneracies. All 88 skips hit the same cell -(`CellKey(29v7)`, vertices `[6v1, 2v1, 9v1, 11v1, 7v1]`) which has negative -geometric orientation. In debug mode, per-insertion PLManifoldStrict validation -of this cell produces repeated warnings that cause extreme slowness (appears as -a hang but is not an algorithmic deadlock). The resulting 12-vertex -triangulation passes L1–L4 validation. +- 3D seed `0xE30C78582376677C` now passes at 35 vertices and at 1000 vertices. +- The 3D 1000-prefix bisect reports no failing prefix for that seed. +- 4D seed `0x9B7786C999C56A16` now inserts 100/100 vertices with zero skips and + passes validation in about 15.4s total wall time. +- The remaining open part of #204 is the default 4D 3000-point batch run, + which now has progress instrumentation and is clearly a scale/observability + problem rather than the earlier 35/100-point correctness repros. -#### 3D large-scale flip convergence - -Flip-based Delaunay repair can enter cycles (oscillating flip sequences that -never converge). The triangulation is topologically valid but may have local -Delaunay violations that flips cannot resolve. +### Current issues -**Severity:** High (correctness) -**Affects:** 3D bulk construction at moderate scale (35+ vertices with default seed) -**Root cause (updated):** predicate degeneracies have been ruled out — the #204 -debug runs show `ambiguous=0, predicate_failures=0` in every cycle report. SoS -is working correctly. The remaining cycles are caused by **cavity/topology -interactions** where a sequence of locally legal flips forms a cycle. +#### 4D+ bulk construction retry collapse + +Large-scale 4D bulk construction still has a deterministic seeded failure mode +in release mode. The historical 100-point negative-orientation skip repro is +fixed, but larger seeded cases can still enter a skip-heavy path where the +input-order attempt and every shuffled retry finish with a Delaunay-property +violation after repeated conflict-region ridge-fan degeneracies. + +**Severity:** High (4D batch-construction correctness / runtime) +**Affects:** seeded 4D batch construction at a few hundred vertices and above; +the default 3000-point large-scale debug harness remains especially expensive +to investigate. +**Recommended workaround:** use release-mode runs and smaller seeded probes when +you need quick iteration; prefer incremental insertion for production 4D +workloads if large batch runtimes are unacceptable. + +**Current rechecks (2026-04-23):** + +- 4D 100-point batch construction (release +mode, seed `0x9B7786C999C56A16`, ball radius=100) inserts **100 of 100** +vertices, skips **0**, and passes validation in ~15.4s total wall time. +- The same 4D 100-point run now exposes the retry boundary directly: attempt 0 + finishes with `inserted=86`, `skipped=14`, then retry 1 + (`perturbation_seed=0x34D84963BCC98F21`) inserts **100 of 100** and passes. +- 4D 500-point batch construction (release mode, seed `0xD225B8A07E274AE6`, + ball radius=100, allow skips) is now a smaller deterministic failure repro. + Attempts 0 through 6 all finish invalid after roughly 78–95s each, ending + with `inserted≈266–300`, `skipped≈200–234`, and the same final error: + `Cell violates Delaunay property: cell contains vertex that is inside circumsphere`. + Representative skip samples are dominated by + `Conflict region error: Ridge fan detected: 4 facets share ridge with 3 vertices`. +- 4D 3000-point batch construction (release mode, seed `0xE7E6701F918B07FA`, + ball radius=100) now emits periodic batch-progress summaries. On the first + attempt (`perturbation_seed=0x0`), it had reached: + - processed 100/3000: inserted 81, skipped 19, elapsed ~4.27s + - processed 300/3000: inserted 221, skipped 79, elapsed ~36.11s + - processed 500/3000: inserted 324, skipped 176, elapsed ~94.70s + This run was manually interrupted after capturing the 500-point progress mark. + +#### Historical 3D flip-cycle reproducer (now fixed) + +The historical 3D flip-cycle seed used by #204/#306 no longer reproduces on +the current branch in release mode. + +**Current recheck (2026-04-23):** + +- 35-point release run: passes with 35/35 inserted and validation OK +- 1000-point release run: passes with 1000/1000 inserted and validation OK in + ~69.6s total wall time +- 1000-prefix bisect: reports no failing prefix for the same seed **#204 findings (v0.7.4):** the incremental-prefix bisect found a **minimal failing prefix of 35 vertices** (seed `0xE30C78582376677C`, ball radius=100). @@ -127,17 +160,32 @@ DELAUNAY_LARGE_DEBUG_N_4D=100 DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ DELAUNAY_LARGE_DEBUG_N_4D=100 DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=0 \ cargo test --release --test large_scale_debug debug_large_scale_4d \ -- --ignored --nocapture + +# 4D 500-point seeded repro (all shuffled retries still fail) +DELAUNAY_BULK_PROGRESS_EVERY=50 \ + DELAUNAY_LARGE_DEBUG_N_4D=500 \ + DELAUNAY_LARGE_DEBUG_CASE_SEED_4D=0xD225B8A07E274AE6 \ + DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ + cargo test --release --test large_scale_debug debug_large_scale_4d \ + -- --ignored --nocapture + +# 4D prefix bisect (targets the seeded 500-point repro by default) +DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ + cargo test --release --test large_scale_debug \ + debug_large_scale_4d_incremental_prefix_bisect -- --ignored --nocapture ``` ### Recommendations - **2D:** robust at all tested sizes. -- **3D:** flip-cycle failures start at 35+ vertices with the default seed. - SoS eliminates predicate degeneracies but cavity/topology flip cycles persist. - This is the primary open correctness issue. -- **4D:** batch construction produces a negative-orientation cell early, causing - most subsequent insertions to be skipped. Use incremental insertion for - critical correctness paths. +- **3D:** the historical #306/#204 seed now passes in release mode; continue to + use the large-scale harness as a monitoring tool rather than assuming a 35-point + correctness failure still exists. +- **4D:** the historical 100-point skip repro is fixed, but seeded 500-point + and larger batch runs can still fail after all shuffled retries. Use release + mode for investigation, prefer smaller seeded probes to debug the + `Ridge fan detected` path, and use incremental insertion when you need more + predictable progress at large N. - **5D:** experimental; incremental insertion strongly recommended. Exact insphere predicates are available (5D uses a 7×7 matrix, within the stack limit). - **6D+:** exact insphere is not available (matrix exceeds stack limit); falls back diff --git a/docs/TODO.md b/docs/TODO.md index d9eae71d..ef8d2893 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -12,24 +12,26 @@ Legend: **🔴 High** · **🟡 Medium** · **🟢 Low** ## 1 · Correctness -### 🔴 3D flip-cycle non-convergence (#306, #204) +### ✅ ~~3D flip-cycle non-convergence (#306, #204)~~ — FIXED -Flip-based Delaunay repair enters cycles at ≥35 vertices (seed-dependent). -SoS eliminates predicate ambiguity; root cause is cavity/topology -interactions. This is the primary open correctness issue. +The historical 35-vertex and 1000-vertex release-mode repros no longer fail on +the current branch. The original seed `0xE30C78582376677C` now passes at 35 +vertices, at 1000 vertices, and the 1000-prefix bisect reports no failing +prefix. -**Status:** Diagnostic infrastructure (conflict-region verification, -orientation audits) shipped in #309 and #319. Repair constants unified -across build profiles in #319. Root cause narrowed but not yet fixed. +**Status:** release-mode recheck completed on 2026-04-23; keep #204 focused on +larger-scale monitoring and regression detection rather than the old #306 +correctness repro. -### 🔴 4D bulk construction vertex skipping (#307, #204) +### ✅ ~~4D bulk construction vertex skipping (#307, #204)~~ — FIXED -Batch 4D construction (100 points, specific seed) produces a -negative-orientation cell early, causing 88% of subsequent insertions to be -skipped as degeneracies. Incremental insertion is a viable workaround. +The historical 100-point 4D release-mode repro no longer skips vertices on the +current branch. The original seed `0x9B7786C999C56A16` now inserts all 100 +vertices with zero skips and passes validation. -**Status:** Same diagnostic infrastructure as above. Orientation-audit -improvements shipped. Root cause not yet fixed. +**Status:** release-mode recheck completed on 2026-04-23; keep #204 focused on +larger 4D batch-runtime/observability work rather than the old #307 +orientation-skip repro. --- @@ -53,6 +55,23 @@ optimization needed. **Status:** profiling can begin; targeted fixes possible if bottlenecks are clear. +### 🟡 4D large-scale batch runtime / observability (#204) + +The known 100-point correctness repro is fixed, but larger seeded 4D release +batch runs still degrade into skip-heavy retries and can fail all shuffled +attempts. The clearest bounded repro is now the 500-point seed +`0xD225B8A07E274AE6`, which spent ~595.9s exhausting attempts 0..6 before +failing with `Cell violates Delaunay property: cell contains vertex that is +inside circumsphere`. + +**Status:** 2026-04-23 rechecks confirmed the 100-point case is healthy and the +new retry-boundary instrumentation is working. The 500-point seeded repro shows +attempts ending around `inserted≈266–300`, `skipped≈200–234`, with skip samples +dominated by `Conflict region error: Ridge fan detected: 4 facets share ridge +with 3 vertices`. Continue #204 by tracing that conflict-region ridge-fan path +through the retryable skip logic rather than treating the issue as pure +observability. + --- ## 3 · Codebase Complexity diff --git a/docs/dev/debug_env_vars.md b/docs/dev/debug_env_vars.md index 6bc845f4..8abdb86b 100644 --- a/docs/dev/debug_env_vars.md +++ b/docs/dev/debug_env_vars.md @@ -36,6 +36,9 @@ in release builds. | Variable | Activation | Module | Description | |---|---|---|---| | `DELAUNAY_INSERT_TRACE` | presence | `triangulation.rs` | Per-insertion summary (vertex index, location, conflict size, suspicion flags) | +| `DELAUNAY_BULK_PROGRESS_EVERY` | **value** (integer) | `triangulation/delaunay.rs` | Periodic batch progress plus retry-boundary output. | +| `DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE` | presence | `triangulation.rs` | One-shot trace of the first cavity reduction chain and each re-extraction outcome. | +| `DELAUNAY_DEBUG_RETRYABLE_SKIP` | presence | `triangulation.rs` | Retryable conflict skip trace with attempt and rollback context. | | `DELAUNAY_DEBUG_SHUFFLE` | presence | `triangulation.rs` | Logs vertex shuffle order during batch construction | | `DELAUNAY_DUPLICATE_METRICS` | presence | `triangulation/delaunay.rs` | Duplicate-detection metrics (spatial hash grid stats) | @@ -54,6 +57,7 @@ in release builds. | `DELAUNAY_DEBUG_CONFLICT_PROGRESS` | presence | `locate.rs` | Periodic progress during large BFS traversals | | `DELAUNAY_DEBUG_CONFLICT_PROGRESS_EVERY` | **value** (integer) | `locate.rs` | Interval for progress logging (default: dimension-dependent) | | `DELAUNAY_DEBUG_CONFLICT_VERIFY` | presence | `triangulation.rs` | Brute-force verification of BFS conflict-region completeness with reachability analysis | +| `DELAUNAY_DEBUG_RIDGE_FAN_ONCE` | presence | `locate.rs` | One-shot dump of the first detected ridge fan (ridge vertices, boundary facets, extra cells). | ## Cavity & Hull @@ -82,9 +86,11 @@ in release builds. |---|---|---|---| | `DELAUNAY_REPAIR_TRACE` | presence | `flips.rs` | Per-flip trace: enqueue, skip, apply, context details | | `DELAUNAY_REPAIR_DEBUG_FACETS` | presence | `flips.rs` | Facet-level flip skip reasons (degenerate, duplicate, non-manifold, existing simplex) | +| `DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET` | presence | `flips.rs` | One-shot snapshot of the first unresolved k=2 facet with last-flip overlap | | `DELAUNAY_REPAIR_DEBUG_PREDICATES` | presence | `flips.rs` | Insphere classification details for k=2 and k=3 violation checks | | `DELAUNAY_REPAIR_DEBUG_RIDGE` | presence | `flips.rs` | Ridge context snapshots during k=3 repair | | `DELAUNAY_REPAIR_DEBUG_RIDGE_LIMIT` | **value** (integer) | `flips.rs` | Maximum ridge debug snapshots (default: 64) | +| `DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY` | **value** (integer) | `flips.rs` | Skip low-multiplicity snapshots; emit when `found >= N` (default: 0). | | `DELAUNAY_REPAIR_DEBUG_SUMMARY` | presence | `flips.rs` | Per-attempt repair summary (flips, checks, cycles, ambiguous, skips) | ## Predicates & Validation diff --git a/justfile b/justfile index 2a3fd608..9c5ebcac 100644 --- a/justfile +++ b/justfile @@ -215,19 +215,22 @@ coverage-ci: cargo tarpaulin {{_coverage_base_args}} --out Xml --output-dir coverage -- --skip prop_ debug-large-scale-3d-100: - DELAUNAY_LARGE_DEBUG_N_3D=100 cargo test --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture + DELAUNAY_LARGE_DEBUG_N_3D=100 cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture debug-large-scale-3d-1000: - DELAUNAY_LARGE_DEBUG_N_3D=1000 cargo test --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture + DELAUNAY_LARGE_DEBUG_N_3D=1000 cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture debug-large-scale-3d-incremental-bisect total="1000": - DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL={{total}} cargo test --test large_scale_debug debug_large_scale_3d_incremental_prefix_bisect -- --ignored --nocapture + DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL={{total}} cargo test --release --test large_scale_debug debug_large_scale_3d_incremental_prefix_bisect -- --ignored --nocapture + +debug-large-scale-4d-incremental-bisect total="500": + DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL={{total}} cargo test --release --test large_scale_debug debug_large_scale_4d_incremental_prefix_bisect -- --ignored --nocapture debug-large-scale-4d: - cargo test --test large_scale_debug debug_large_scale_4d -- --ignored --nocapture + DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 cargo test --release --test large_scale_debug debug_large_scale_4d -- --ignored --exact --nocapture debug-large-scale-4d-100: - DELAUNAY_LARGE_DEBUG_N_4D=100 DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 cargo test --test large_scale_debug debug_large_scale_4d -- --ignored --nocapture + DELAUNAY_LARGE_DEBUG_N_4D=100 DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 cargo test --release --test large_scale_debug debug_large_scale_4d -- --ignored --exact --nocapture # Default recipe shows available commands default: @@ -270,6 +273,7 @@ help-workflows: @echo " just debug-large-scale-3d-100 # Run large-scale 3D debug harness at 100 points" @echo " just debug-large-scale-3d-1000 # Run large-scale 3D debug harness at 1000 points" @echo " just debug-large-scale-3d-incremental-bisect [total] # Bisect failing 3D incremental prefix" + @echo " just debug-large-scale-4d-incremental-bisect [total] # Bisect failing 4D batch prefix" @echo " just debug-large-scale-4d-100 # Run large-scale 4D debug harness at 100 points" @echo " just debug-large-scale-4d # Run large-scale 4D debug harness" @echo "" diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index f3f01b79..9e7a9743 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -55,6 +55,7 @@ use crate::topology::traits::topological_space::GlobalTopology; use slotmap::Key; use std::borrow::Cow; use std::collections::VecDeque; +use std::env; use std::fmt; use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -91,7 +92,7 @@ fn repair_delaunay_with_flips_k2_k3_attempt( kernel: &K, seed_cells: Option<&[CellKey]>, config: &RepairAttemptConfig, -) -> Result +) -> Result where K: Kernel, U: DataType, @@ -210,7 +211,10 @@ where } emit_repair_debug_summary("attempt_done", &stats, &diagnostics, config, max_flips); - Ok(stats) + Ok(RepairAttemptOutcome { + stats, + last_applied_flip, + }) } /// Apply a bistellar flip using explicit k and vertex/cell slices. @@ -800,22 +804,26 @@ where /// Emits a bounded ridge snapshot so repair failures can distinguish bad local /// handles from genuinely inconsistent global incidence. +/// +/// The local neighbor walk and the global cell scan are logged side by side +/// because #204 currently fails in cases where those two views disagree. fn debug_ridge_context( tds: &Tds, ridge: RidgeHandle, - neighbor_walk_count: Option, + reported_multiplicity: Option, + last_applied_flip: Option<&LastAppliedFlip>, ) where T: CoordinateScalar, U: DataType, V: DataType, { - if !should_emit_ridge_debug() { + if !should_emit_ridge_debug(reported_multiplicity) { return; } let Some(cell) = tds.get_cell(ridge.cell_key()) else { tracing::debug!( ridge = ?ridge, - neighbor_walk_count, + reported_multiplicity, "repair: ridge debug skipped (cell missing)" ); return; @@ -831,28 +839,275 @@ fn debug_ridge_context( omit_a, omit_b, vertex_count = cell.number_of_vertices(), - neighbor_walk_count, + reported_multiplicity, "repair: ridge debug skipped (invalid indices)" ); return; } 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) + .map(|cells| cells.into_iter().collect::>()); let global_cells = cells_containing_vertices(tds, &ridge_vertices); let neighbor_snapshot: Option, MAX_PRACTICAL_DIMENSION_SIZE>> = cell.neighbors().map(|ns| ns.iter().copied().collect()); + let global_cell_details: Vec = global_cells + .iter() + .copied() + .map(|cell_key| ridge_incident_cell_summary(tds, cell_key, &ridge_vertices)) + .collect(); + // Attach the immediately preceding flip so the snapshot can say whether repair + // just created this ridge instead of forcing us to correlate separate log lines. + let predecessor_summary = + last_applied_flip.map(|last| predecessor_flip_summary(tds, ridge, &global_cells, last)); tracing::debug!( ridge = ?ridge, ridge_vertices = ?ridge_vertices, - neighbor_walk_count, + reported_multiplicity, + neighbor_walk = ?neighbor_walk, global_count = global_cells.len(), global_cells = ?global_cells, + global_cell_details = ?global_cell_details, + predecessor = ?predecessor_summary, cell_neighbors = ?neighbor_snapshot, "repair: ridge adjacency debug snapshot" ); } +/// Formats one incident cell around a ridge so debug output can distinguish +/// oversharing from bad local neighbor traversal. +fn ridge_incident_cell_summary( + tds: &Tds, + cell_key: CellKey, + ridge_vertices: &SmallBuffer, +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let Some(cell) = tds.get_cell(cell_key) else { + return format!("{cell_key:?}: missing"); + }; + + let extras = match cell_extras_for_ridge(cell_key, cell, ridge_vertices) { + Ok(extras) => extras, + Err(err) => return format!("{cell_key:?}: extras_error={err}"), + }; + let ridge_neighbors = ridge_neighbor_cells_for_cell(cell, ridge_vertices); + format!("{cell_key:?}: extras={extras:?} ridge_neighbors={ridge_neighbors:?}") +} + +/// Extracts the neighbors reached by omitting the two vertices opposite the +/// ridge, which is exactly the adjacency walk used by k=3 context recovery. +fn ridge_neighbor_cells_for_cell( + cell: &Cell, + ridge_vertices: &SmallBuffer, +) -> SmallBuffer +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let mut ridge_neighbors: SmallBuffer = SmallBuffer::new(); + let Some(neighbors) = cell.neighbors() else { + return ridge_neighbors; + }; + + for (idx, &vertex_key) in cell.vertices().iter().enumerate() { + if ridge_vertices.contains(&vertex_key) { + continue; + } + if let Some(neighbor_key) = neighbors.get(idx).copied().flatten() { + ridge_neighbors.push(neighbor_key); + } + } + + ridge_neighbors +} + +/// Relates the current bad ridge to the immediately preceding flip so #204 +/// traces can confirm whether repair just created the inconsistent local star. +fn predecessor_flip_summary( + tds: &Tds, + ridge: RidgeHandle, + global_cells: &[CellKey], + last_applied_flip: &LastAppliedFlip, +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let global_cells_in_new: Vec = global_cells + .iter() + .copied() + .filter(|cell_key| last_applied_flip.new_cells.contains(cell_key)) + .collect(); + // Show the predecessor's concrete simplices because cell ids alone become hard to + // interpret once slot reuse and additional flips start churning the local region. + let predecessor_new_cell_vertices: Vec = last_applied_flip + .new_cells + .iter() + .copied() + .map(|cell_key| cell_vertex_summary(tds, cell_key)) + .collect(); + + format!( + "k={} removed_face={:?} inserted_face={:?} removed_cells={:?} new_cells={:?} ridge_cell_is_new={} global_cells_in_new={global_cells_in_new:?} predecessor_new_cell_vertices={predecessor_new_cell_vertices:?}", + last_applied_flip.k_move, + last_applied_flip.removed_face_vertices, + last_applied_flip.inserted_face_vertices, + last_applied_flip.removed_cells, + last_applied_flip.new_cells, + last_applied_flip.new_cells.contains(&ridge.cell_key()), + ) +} + +/// Formats one cell's current vertex set so predecessor-flip traces can show +/// the exact simplices that were introduced before a bad ridge appeared. +fn cell_vertex_summary(tds: &Tds, cell_key: CellKey) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let Some(cell) = tds.get_cell(cell_key) else { + return format!("{cell_key:?}: missing"); + }; + format!("{cell_key:?}: vertices={:?}", cell.vertices()) +} + +/// Captures the first unresolved k=2 postcondition site so #204 debugging can +/// compare the violating facet directly against the last applied repair flip. +fn debug_postcondition_facet_context( + tds: &Tds, + facet: FacetHandle, + context: &FlipContext, + last_applied_flip: Option<&LastAppliedFlip>, +) where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + if !should_emit_postcondition_facet_debug() { + return; + } + + let removed_face_details: Vec<_> = context + .removed_face_vertices + .iter() + .filter_map(|&vkey| { + tds.get_vertex_by_key(vkey) + .map(|vertex| (vkey, *vertex.point())) + }) + .collect(); + let inserted_face_details: Vec<_> = context + .inserted_face_vertices + .iter() + .filter_map(|&vkey| { + tds.get_vertex_by_key(vkey) + .map(|vertex| (vkey, *vertex.point())) + }) + .collect(); + let incident_cell_details: Vec = context + .removed_cells + .iter() + .copied() + .map(|cell_key| facet_incident_cell_summary(tds, cell_key, &context.removed_face_vertices)) + .collect(); + let predecessor_summary = last_applied_flip + .map(|last| postcondition_facet_predecessor_summary(tds, &context.removed_cells, last)); + + tracing::debug!( + facet = ?facet, + removed_face = ?removed_face_details, + inserted_face = ?inserted_face_details, + incident_cells = ?context.removed_cells, + incident_cell_details = ?incident_cell_details, + predecessor = ?predecessor_summary, + "repair: postcondition facet debug snapshot" + ); +} + +/// Formats the two cells incident to a violating facet so postcondition traces +/// can see both their full simplex vertices and their opposite vertices. +fn facet_incident_cell_summary( + tds: &Tds, + cell_key: CellKey, + facet_vertices: &[VertexKey], +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let Some(cell) = tds.get_cell(cell_key) else { + return format!("{cell_key:?}: missing"); + }; + + let opposite_vertices: Vec = cell + .vertices() + .iter() + .copied() + .filter(|vkey| !facet_vertices.contains(vkey)) + .collect(); + let neighbor_snapshot: Option, MAX_PRACTICAL_DIMENSION_SIZE>> = + cell.neighbors().map(|ns| ns.iter().copied().collect()); + + format!( + "{cell_key:?}: vertices={:?} opposite_vertices={opposite_vertices:?} neighbors={neighbor_snapshot:?}", + cell.vertices() + ) +} + +/// Relates the first unresolved postcondition facet to the immediately +/// preceding repair flip so we can tell whether that last move touched the bad +/// local neighborhood or whether the violation was already present. +fn postcondition_facet_predecessor_summary( + tds: &Tds, + incident_cells: &[CellKey], + last_applied_flip: &LastAppliedFlip, +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let incident_cells_in_new: Vec = incident_cells + .iter() + .copied() + .filter(|cell_key| last_applied_flip.new_cells.contains(cell_key)) + .collect(); + let incident_cells_in_removed: Vec = incident_cells + .iter() + .copied() + .filter(|cell_key| last_applied_flip.removed_cells.contains(cell_key)) + .collect(); + let predecessor_new_cell_vertices: Vec = last_applied_flip + .new_cells + .iter() + .copied() + .map(|cell_key| cell_vertex_summary(tds, cell_key)) + .collect(); + let predecessor_removed_cell_vertices: Vec = last_applied_flip + .removed_cells + .iter() + .copied() + .map(|cell_key| cell_vertex_summary(tds, cell_key)) + .collect(); + + format!( + "k={} removed_face={:?} inserted_face={:?} removed_cells={:?} new_cells={:?} incident_cells_in_new={incident_cells_in_new:?} incident_cells_in_removed={incident_cells_in_removed:?} predecessor_new_cell_vertices={predecessor_new_cell_vertices:?} predecessor_removed_cell_vertices={predecessor_removed_cell_vertices:?}", + last_applied_flip.k_move, + last_applied_flip.removed_face_vertices, + last_applied_flip.inserted_face_vertices, + last_applied_flip.removed_cells, + last_applied_flip.new_cells, + ) +} + /// Check whether a k=3 ridge violates the local Delaunay condition. /// /// # Errors @@ -1675,6 +1930,16 @@ pub struct DelaunayRepairStats { /// Maximum queue length observed. pub max_queue_len: usize, } + +/// Carries both aggregate attempt stats and the final flip context so +/// postcondition diagnostics can relate the first unresolved local violation to +/// the last repair move that modified the TDS. +#[derive(Debug)] +struct RepairAttemptOutcome { + stats: DelaunayRepairStats, + last_applied_flip: Option, +} + /// Queue ordering policy for flip repair attempts. /// /// # Examples @@ -2768,7 +3033,7 @@ fn repair_delaunay_with_flips_k2_attempt( kernel: &K, seed_cells: Option<&[CellKey]>, config: &RepairAttemptConfig, -) -> Result +) -> Result where K: Kernel, U: DataType, @@ -2787,6 +3052,7 @@ where let mut queue: VecDeque<(FacetHandle, u64)> = VecDeque::new(); let mut queued: FastHashSet = FastHashSet::default(); let mut facet_handles: FastHashMap = FastHashMap::default(); + let mut last_applied_flip: Option = None; let topology_model = GlobalTopology::DEFAULT.model(); if let Some(seeds) = seed_cells { @@ -2928,6 +3194,7 @@ where }; stats.flips_performed += 1; diagnostics.record_flip_signature(signature); + last_applied_flip = Some(LastAppliedFlip::from_info(&info)); for &cell_key in &info.new_cells { enqueue_cell_facets( @@ -2954,7 +3221,10 @@ where } emit_repair_debug_summary("attempt_done", &stats, &diagnostics, config, max_flips); - Ok(stats) + Ok(RepairAttemptOutcome { + stats, + last_applied_flip, + }) } /// Repair Delaunay violations using k=2 queues, k=3 queues in 3D, @@ -3015,9 +3285,16 @@ where }; match attempt1_result { - Ok(stats) => { - if verify_repair_postcondition(tds, kernel, seed_cells).is_ok() { - return Ok(stats); + Ok(outcome) => { + if verify_repair_postcondition( + tds, + kernel, + seed_cells, + outcome.last_applied_flip.as_ref(), + ) + .is_ok() + { + return Ok(outcome.stats); } if repair_trace_enabled() { tracing::debug!( @@ -3028,14 +3305,19 @@ where // Postcondition verification failed: rerun with LIFO + full reseed. *tds = tds_snapshot; let retry_seed_cells = None; - let stats2 = if D == 2 { + let outcome2 = if D == 2 { repair_delaunay_with_flips_k2_attempt(tds, kernel, retry_seed_cells, &attempt2) } else { repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, retry_seed_cells, &attempt2) }?; - verify_repair_postcondition(tds, kernel, retry_seed_cells)?; - Ok(stats2) + verify_repair_postcondition( + tds, + kernel, + retry_seed_cells, + outcome2.last_applied_flip.as_ref(), + )?; + Ok(outcome2.stats) } Err(DelaunayRepairError::NonConvergent { .. }) => { if repair_trace_enabled() { @@ -3046,14 +3328,19 @@ where // Retry with LIFO + full reseed. *tds = tds_snapshot; let retry_seed_cells = None; - let stats2 = if D == 2 { + let outcome2 = if D == 2 { repair_delaunay_with_flips_k2_attempt(tds, kernel, retry_seed_cells, &attempt2) } else { repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, retry_seed_cells, &attempt2) }?; - verify_repair_postcondition(tds, kernel, retry_seed_cells)?; - Ok(stats2) + verify_repair_postcondition( + tds, + kernel, + retry_seed_cells, + outcome2.last_applied_flip.as_ref(), + )?; + Ok(outcome2.stats) } Err(err) => Err(err), } @@ -3117,9 +3404,16 @@ where repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, Some(seed_cells), &attempt1) }; match attempt1_result { - Ok(stats) => { - if verify_repair_postcondition(tds, kernel, Some(seed_cells)).is_ok() { - return Ok(stats); + Ok(outcome) => { + if verify_repair_postcondition( + tds, + kernel, + Some(seed_cells), + outcome.last_applied_flip.as_ref(), + ) + .is_ok() + { + return Ok(outcome.stats); } if repair_trace_enabled() { tracing::debug!("[repair] local attempt 1 postcondition failed; retrying LIFO"); @@ -3139,8 +3433,13 @@ where repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, Some(seed_cells), &attempt2) }; match attempt2_result { - Ok(stats) => match verify_repair_postcondition(tds, kernel, Some(seed_cells)) { - Ok(()) => Ok(stats), + Ok(outcome) => match verify_repair_postcondition( + 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. @@ -3276,6 +3575,7 @@ where None, global_topology, PostconditionMode::Strict, + None, ) } @@ -3285,6 +3585,7 @@ fn verify_repair_postcondition( tds: &Tds, kernel: &K, seed_cells: Option<&[CellKey]>, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -3297,6 +3598,7 @@ where seed_cells, GlobalTopology::DEFAULT, PostconditionMode::Repair, + last_applied_flip, ) } @@ -3314,6 +3616,7 @@ fn verify_repair_postcondition_with_topology( seed_cells: Option<&[CellKey]>, global_topology: GlobalTopology, mode: PostconditionMode, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -3321,7 +3624,14 @@ where V: DataType, { let topology_model = global_topology.model(); - verify_repair_postcondition_locally(tds, kernel, seed_cells, &topology_model, mode) + verify_repair_postcondition_locally( + tds, + kernel, + seed_cells, + &topology_model, + mode, + last_applied_flip, + ) } /// Replays the repair queues without mutating the TDS so postconditions cover @@ -3332,6 +3642,7 @@ fn verify_repair_postcondition_locally( seed_cells: Option<&[CellKey]>, topology_model: &GlobalTopologyModelAdapter, mode: PostconditionMode, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -3371,6 +3682,7 @@ where &config, &mut diagnostics, mode, + last_applied_flip, )?; verify_postcondition_k3_ridges( tds, @@ -3435,6 +3747,10 @@ fn handle_postcondition_predicate_failure( /// Rechecks queued facets after repair so unresolved k=2 violations surface as /// postcondition failures instead of latent invalid triangulations. +#[expect( + clippy::too_many_arguments, + reason = "Postcondition replay threads topology, diagnostics, and predecessor context explicitly" +)] fn verify_postcondition_k2_facets( tds: &Tds, kernel: &K, @@ -3443,6 +3759,7 @@ fn verify_postcondition_k2_facets( config: &RepairAttemptConfig, diagnostics: &mut RepairDiagnostics, mode: PostconditionMode, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -3496,6 +3813,7 @@ where "[repair] postcondition k=2 violation remains (facet={facet:?})" ); } + debug_postcondition_facet_context(tds, facet, &context, last_applied_flip); let mut message = format!("local k=2 violation remains after repair (facet={facet:?})"); if std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { @@ -4047,11 +4365,14 @@ struct LastAppliedFlip { k_move: usize, removed_face_vertices: SmallBuffer, inserted_face_vertices: SmallBuffer, + removed_cells: CellKeyBuffer, + new_cells: CellKeyBuffer, } impl LastAppliedFlip { /// Sorts faces so immediate-reversal detection is independent of local cell - /// vertex order. + /// vertex order. Cell lists stay empty here because this constructor is also + /// used for temporary reversal checks. fn new(k_move: usize, removed: &[VertexKey], inserted: &[VertexKey]) -> Self { let mut removed_face_vertices: SmallBuffer = removed.iter().copied().collect(); @@ -4065,8 +4386,23 @@ impl LastAppliedFlip { k_move, removed_face_vertices, inserted_face_vertices, + removed_cells: CellKeyBuffer::new(), + new_cells: CellKeyBuffer::new(), } } + + /// Preserves the concrete flip footprint so a later ridge snapshot can tell + /// whether the immediately preceding move created the bad local star. + fn from_info(info: &FlipInfo) -> Self { + let mut last = Self::new( + info.kind.k(), + &info.removed_face_vertices, + &info.inserted_face_vertices, + ); + last.removed_cells.clone_from(&info.removed_cells); + last.new_cells.clone_from(&info.new_cells); + last + } } /// Catches two-step flip oscillations before they inflate repair diagnostics or @@ -4094,31 +4430,53 @@ fn would_immediately_reverse_last_flip( /// frequently. #[inline] fn repair_trace_enabled() -> bool { - std::env::var_os("DELAUNAY_REPAIR_TRACE").is_some() + env::var_os("DELAUNAY_REPAIR_TRACE").is_some() } /// Treats full repair tracing as enabling ridge snapshots so one debug switch /// gives enough topology context. #[inline] fn repair_ridge_debug_enabled() -> bool { - std::env::var_os("DELAUNAY_REPAIR_DEBUG_RIDGE").is_some() || repair_trace_enabled() + env::var_os("DELAUNAY_REPAIR_DEBUG_RIDGE").is_some() || repair_trace_enabled() } const RIDGE_DEBUG_LIMIT_DEFAULT: usize = 64; +const RIDGE_DEBUG_MIN_MULTIPLICITY_DEFAULT: usize = 0; static RIDGE_DEBUG_EMITTED: AtomicUsize = AtomicUsize::new(0); +static POSTCONDITION_FACET_DEBUG_EMITTED: AtomicUsize = AtomicUsize::new(0); /// Rate-limits ridge snapshots to keep pathological repair runs from flooding /// logs. fn ridge_debug_limit() -> usize { - std::env::var("DELAUNAY_REPAIR_DEBUG_RIDGE_LIMIT") + env::var("DELAUNAY_REPAIR_DEBUG_RIDGE_LIMIT") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(RIDGE_DEBUG_LIMIT_DEFAULT) } +/// Lets callers skip the common multiplicity-1/2 boundary cases and capture +/// the first genuinely overshared ridge instead. +fn ridge_debug_min_multiplicity() -> usize { + env::var("DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(RIDGE_DEBUG_MIN_MULTIPLICITY_DEFAULT) +} + /// Applies the ridge debug limit atomically so concurrent tests still share a /// bounded diagnostic budget. -fn should_emit_ridge_debug() -> bool { +fn should_emit_ridge_debug(reported_multiplicity: Option) -> bool { + let min_multiplicity = ridge_debug_min_multiplicity(); + match reported_multiplicity { + // Multiplicity-based skips dominate large 4D traces, so let callers suppress + // the expected 1/2 boundary cases and wait for the first real fan. + Some(found) if found < min_multiplicity => return false, + // If the caller asked for a multiplicity threshold, suppress adjacency-only + // snapshots too so they do not consume the one-shot debug budget first. + None if min_multiplicity > 0 => return false, + _ => {} + } + let limit = ridge_debug_limit(); if limit == 0 { return false; @@ -4132,6 +4490,22 @@ fn should_emit_ridge_debug() -> bool { current < limit } +/// Keeps the first unresolved postcondition-facet snapshot opt-in because the +/// local verifier can traverse many queued facets in one pass. +#[inline] +fn postcondition_facet_debug_enabled() -> bool { + env::var_os("DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET").is_some() +} + +/// Emits at most one postcondition facet snapshot per process so the focused +/// #204 debug path stays readable. +fn should_emit_postcondition_facet_debug() -> bool { + if !postcondition_facet_debug_enabled() { + return false; + } + POSTCONDITION_FACET_DEBUG_EMITTED.fetch_add(1, Ordering::Relaxed) == 0 +} + /// Computes a dimension-sensitive flip budget so non-convergent repair fails /// predictably instead of running unbounded. fn default_max_flips(cell_count: usize) -> usize { @@ -4436,12 +4810,15 @@ where diagnostics.record_invalid_ridge_multiplicity_skip(|| { format!("ridge={ridge:?} multiplicity={found}") }); + // This is the main #204 failure mode: capture both the local ridge walk + // and the full global incidence so we can see whether repair is skipping + // a stale handle or a genuinely overshared ridge. if repair_ridge_debug_enabled() { - debug_ridge_context(tds, ridge, Some(*found)); + debug_ridge_context(tds, ridge, Some(*found), last_applied_flip.as_ref()); } } FlipError::InvalidRidgeAdjacency { .. } if repair_ridge_debug_enabled() => { - debug_ridge_context(tds, ridge, None); + debug_ridge_context(tds, ridge, None, last_applied_flip.as_ref()); } FlipError::MissingCell { cell_key } => { diagnostics.record_missing_cell_skip(|| { @@ -4562,11 +4939,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - *last_applied_flip = Some(LastAppliedFlip::new( - 3, - &context.removed_face_vertices, - &context.inserted_face_vertices, - )); + *last_applied_flip = Some(LastAppliedFlip::from_info(&info)); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -4752,11 +5125,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - *last_applied_flip = Some(LastAppliedFlip::new( - D, - &context.removed_face_vertices, - &context.inserted_face_vertices, - )); + *last_applied_flip = Some(LastAppliedFlip::from_info(&info)); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -4934,11 +5303,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - *last_applied_flip = Some(LastAppliedFlip::new( - D - 1, - &context.removed_face_vertices, - &context.inserted_face_vertices, - )); + *last_applied_flip = Some(LastAppliedFlip::from_info(&info)); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5120,11 +5485,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - *last_applied_flip = Some(LastAppliedFlip::new( - 2, - &context.removed_face_vertices, - &context.inserted_face_vertices, - )); + *last_applied_flip = Some(LastAppliedFlip::from_info(&info)); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; diff --git a/src/core/algorithms/incremental_insertion.rs b/src/core/algorithms/incremental_insertion.rs index 8423021d..4ae99f66 100644 --- a/src/core/algorithms/incremental_insertion.rs +++ b/src/core/algorithms/incremental_insertion.rs @@ -279,8 +279,12 @@ impl InsertionError { // TDS-level topology errors: only geometry/FP-related sub-variants are retryable. // Structural errors (missing cells, broken invariants) won't be fixed by perturbation. Self::TopologyValidation(tds_err) => Self::is_tds_error_retryable(tds_err), - // Conflict region errors: non-manifold facets, ridge fans, or disconnected/open cavity - // boundaries indicate degeneracy. + // Conflict region errors: only geometry-degeneracy variants are retryable. + // Structural variants (InvalidStartCell, PredicateError, CellDataAccessFailed, + // InternalInconsistency — regardless of which typed + // `InternalInconsistencySite` carries the failure context) represent caller + // or implementation errors that perturbation cannot fix, and so fall + // through to non-retryable by omission. Self::ConflictRegion(ce) => { matches!( ce, @@ -2349,6 +2353,7 @@ where #[cfg(test)] mod tests { use super::*; + use crate::core::algorithms::locate::InternalInconsistencySite; use crate::core::collections::CellKeyBuffer; use crate::core::tds::GeometricError; use crate::geometry::kernel::FastKernel; @@ -2895,6 +2900,39 @@ mod tests { }) .is_retryable() ); + // InternalInconsistency is not retryable regardless of the typed site: + // perturbation cannot fix logic errors. + assert!( + !InsertionError::ConflictRegion(ConflictError::InternalInconsistency { + site: InternalInconsistencySite::RidgeFanExtraFacetOutOfBounds { + index: 7, + boundary_facets_len: 5, + extra_facets_len: 3, + }, + }) + .is_retryable() + ); + assert!( + !InsertionError::ConflictRegion(ConflictError::InternalInconsistency { + site: InternalInconsistencySite::OpenBoundaryMissingFirstFacet { + first_facet: 4, + boundary_facets_len: 2, + facet_count: 1, + ridge_vertex_count: 2, + }, + }) + .is_retryable() + ); + assert!( + !InsertionError::ConflictRegion(ConflictError::InternalInconsistency { + site: InternalInconsistencySite::RidgeInfoMissingSecondFacet { + first_facet: 0, + boundary_facets_len: 2, + ridge_vertex_count: 2, + }, + }) + .is_retryable() + ); assert!( InsertionError::ConflictRegion(ConflictError::DisconnectedBoundary { visited: 1, diff --git a/src/core/algorithms/locate.rs b/src/core/algorithms/locate.rs index 659d949c..d1f7799d 100644 --- a/src/core/algorithms/locate.rs +++ b/src/core/algorithms/locate.rs @@ -28,7 +28,10 @@ use crate::core::util::canonical_points::{sorted_cell_points, sorted_facet_point use crate::geometry::kernel::Kernel; use crate::geometry::point::Point; use crate::geometry::traits::coordinate::{CoordinateConversionError, CoordinateScalar}; +use std::env; use std::hash::{Hash, Hasher}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(debug_assertions)] #[derive(Debug, Clone, Copy)] struct ConflictDebugConfig { @@ -39,12 +42,12 @@ struct ConflictDebugConfig { #[cfg(debug_assertions)] fn conflict_debug_config() -> &'static ConflictDebugConfig { - static CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + static CONFIG: OnceLock = OnceLock::new(); CONFIG.get_or_init(|| ConflictDebugConfig { - log_conflict: std::env::var_os("DELAUNAY_DEBUG_CONFLICT").is_some(), - progress_enabled: std::env::var_os("DELAUNAY_DEBUG_CONFLICT_PROGRESS").is_some(), - progress_every: std::env::var("DELAUNAY_DEBUG_CONFLICT_PROGRESS_EVERY") + log_conflict: env::var_os("DELAUNAY_DEBUG_CONFLICT").is_some(), + progress_enabled: env::var_os("DELAUNAY_DEBUG_CONFLICT_PROGRESS").is_some(), + progress_every: env::var("DELAUNAY_DEBUG_CONFLICT_PROGRESS_EVERY") .ok() .and_then(|value| value.parse::().ok()) .filter(|value| *value > 0) @@ -52,6 +55,14 @@ fn conflict_debug_config() -> &'static ConflictDebugConfig { }) } +static RIDGE_FAN_DUMP_ENABLED: OnceLock = OnceLock::new(); +static RIDGE_FAN_DUMP_EMITTED: AtomicBool = AtomicBool::new(false); + +/// Returns whether a one-shot release-visible ridge-fan dump is enabled. +fn ridge_fan_dump_enabled() -> bool { + *RIDGE_FAN_DUMP_ENABLED.get_or_init(|| env::var_os("DELAUNAY_DEBUG_RIDGE_FAN_ONCE").is_some()) +} + /// Result of point location query. /// /// # Examples @@ -142,6 +153,12 @@ pub enum ConflictError { }, /// Failed to access required cell data (e.g., vertices) or build facet identifiers. + /// + /// This represents a *data-sourcing* failure attributable to a specific cell key: + /// the key resolved but its vertex list, facet index, or derived identifier could + /// not be produced. For invariant violations that are *not* about a specific cell + /// (e.g., a `boundary_facets` index that must be in range by construction), use + /// [`ConflictError::InternalInconsistency`] instead of fabricating a cell key. #[error("Failed to access required data for cell {cell_key:?}: {message}")] CellDataAccessFailed { /// The cell key for which required data could not be accessed. @@ -150,6 +167,33 @@ pub enum ConflictError { message: String, }, + /// Internal invariant violation during cavity-boundary extraction. + /// + /// This is raised when an invariant that must hold by construction does not — + /// typically a `boundary_facets` or `RidgeInfo` index that is unconditionally + /// valid in correct code. Debug builds catch these with `debug_assert!` so the + /// error path is only reachable in release mode; returning it rather than + /// panicking preserves the caller's transactional rollback guarantees. + /// + /// Orthogonality: this variant is distinct from + /// [`ConflictError::CellDataAccessFailed`]. Use `CellDataAccessFailed` when + /// a specific, real cell key is the subject of the failure; use + /// `InternalInconsistency` when the failure is structural and has no such key. + /// Treated as non-retryable by [`InsertionError::is_retryable`] because + /// perturbing coordinates cannot resolve a logic error. + /// + /// The specific violation site is carried in [`InternalInconsistencySite`] + /// as a typed payload so callers can pattern-match without parsing strings. + /// + /// [`InsertionError::is_retryable`]: + /// crate::core::algorithms::incremental_insertion::InsertionError::is_retryable + #[error("Internal cavity-boundary inconsistency: {site}")] + InternalInconsistency { + /// Structured, typed description of the violated invariant — the index, + /// counts, and slice lengths that exposed the failure. + site: InternalInconsistencySite, + }, + /// Non-manifold facet detected (facet shared by more than 2 conflict cells). #[error( "Non-manifold facet detected: facet {facet_hash:#x} shared by {cell_count} conflict cells (expected ≤2)" @@ -161,19 +205,37 @@ pub enum ConflictError { cell_count: usize, }, - /// Ridge fan detected (many facets sharing same (D-2)-simplex) + /// Ridge fan detected (many facets sharing same (D-2)-simplex). + /// + /// When a single conflict region contains multiple ridge fans, + /// [`extract_cavity_boundary`] accumulates the removal candidates from every + /// fan into `extra_cells` before returning, so a single cavity-reduction step + /// can shrink all of them at once. In that case: + /// + /// - `facet_count` and `ridge_vertex_count` describe the **first** fan that + /// the boundary walk observed (a representative example, not an aggregate). + /// - `extra_cells` contains the **union** of extra-cell candidates across all + /// detected fans in the conflict region (deduplicated). + /// + /// The error message reports the representative scalars; consult + /// `extra_cells.len()` in traces when the conflict region is large enough to + /// host several fans. #[error( "Ridge fan detected: {facet_count} facets share ridge with {ridge_vertex_count} vertices (indicates degenerate geometry requiring perturbation)" )] RidgeFan { - /// Number of facets in the fan + /// Number of facets in the *first* fan encountered during the boundary + /// walk. When several ridge fans are present in the same conflict region, + /// this is a representative value, not the maximum or sum. facet_count: usize, - /// Number of vertices in the shared ridge + /// Number of vertices in the shared ridge for the first fan encountered. ridge_vertex_count: usize, - /// Cell keys of the conflict-region cells that contribute the *extra* (3rd, 4th, …) - /// facets to the fan. Removing these cells from the conflict region eliminates the - /// ridge fan, enabling cavity insertion to proceed at the cost of leaving those cells - /// temporarily non-Delaunay (fixed by the subsequent flip-repair pass). + /// Deduplicated cell keys that contribute the *extra* (3rd, 4th, …) + /// facets to one or more ridge fans in the conflict region. Removing + /// these cells from the conflict region eliminates every currently + /// detected ridge fan at once, enabling cavity insertion to proceed at + /// the cost of leaving those cells temporarily non-Delaunay (the + /// subsequent flip-repair pass restores the Delaunay property). extra_cells: Vec, }, @@ -216,10 +278,119 @@ pub enum ConflictError { }, } +/// Typed site of a [`ConflictError::InternalInconsistency`] violation. +/// +/// Each variant describes one specific invariant that `extract_cavity_boundary` +/// maintains by construction. The fields carry the indices, counts, and slice +/// lengths that would normally appear in a `format!(...)` context string, but +/// keep them as typed data so callers can `matches!` / `assert_eq!` on them and +/// so future localized formatting does not need to reparse prose. +/// +/// These paths are unreachable in debug builds — the corresponding +/// `debug_assert!` invariants fire there — and are guarded only to preserve +/// transactional-rollback semantics in release builds. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::core::algorithms::locate::{ConflictError, InternalInconsistencySite}; +/// +/// let site = InternalInconsistencySite::RidgeFanExtraFacetOutOfBounds { +/// index: 7, +/// boundary_facets_len: 5, +/// extra_facets_len: 3, +/// }; +/// let err = ConflictError::InternalInconsistency { site: site.clone() }; +/// assert!(matches!( +/// err, +/// ConflictError::InternalInconsistency { +/// site: InternalInconsistencySite::RidgeFanExtraFacetOutOfBounds { .. } +/// } +/// )); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum InternalInconsistencySite { + /// A `RidgeFan` `extra_facets` entry references an index outside the + /// `boundary_facets` slice that populated it during the same traversal. + RidgeFanExtraFacetOutOfBounds { + /// Offending `extra_facets` value that indexed outside `boundary_facets`. + index: usize, + /// Length of the boundary-facet slice at the time of the violation. + boundary_facets_len: usize, + /// Total number of entries in the offending `extra_facets` list. + extra_facets_len: usize, + }, + + /// An `OpenBoundary` `first_facet` index is out of range for + /// `boundary_facets` even though the two are written together. + OpenBoundaryMissingFirstFacet { + /// Out-of-range `first_facet` index that should have resolved to a boundary facet. + first_facet: usize, + /// Length of the boundary-facet slice at the time of the violation. + boundary_facets_len: usize, + /// Observed `facet_count` for the violating ridge. + facet_count: usize, + /// Observed ridge-vertex count for the violating ridge. + ridge_vertex_count: usize, + }, + + /// `RidgeInfo::second_facet` is `None` while `facet_count == 2`, even + /// though the two fields are written together when a second incident + /// facet is added. + RidgeInfoMissingSecondFacet { + /// `first_facet` index that was recorded alongside the missing `second_facet`. + first_facet: usize, + /// Length of the boundary-facet slice at the time of the violation. + boundary_facets_len: usize, + /// Observed ridge-vertex count for the violating ridge. + ridge_vertex_count: usize, + }, +} + +impl std::fmt::Display for InternalInconsistencySite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::RidgeFanExtraFacetOutOfBounds { + index, + boundary_facets_len, + extra_facets_len, + } => write!( + f, + "RidgeFan extra_facets index {index} out of bounds \ + (boundary_facets.len()={boundary_facets_len}, extra_facets_len={extra_facets_len})" + ), + Self::OpenBoundaryMissingFirstFacet { + first_facet, + boundary_facets_len, + facet_count, + ridge_vertex_count, + } => write!( + f, + "OpenBoundary missing first_facet index {first_facet} \ + (boundary_facets.len()={boundary_facets_len}, facet_count={facet_count}, \ + ridge_vertex_count={ridge_vertex_count})" + ), + Self::RidgeInfoMissingSecondFacet { + first_facet, + boundary_facets_len, + ridge_vertex_count, + } => write!( + f, + "RidgeInfo missing second_facet when facet_count == 2 \ + (first_facet={first_facet}, boundary_facets_len={boundary_facets_len}, \ + ridge_vertex_count={ridge_vertex_count})" + ), + } + } +} + /// Ridge incidence information used for cavity-boundary validation. #[derive(Debug, Clone)] struct RidgeInfo { ridge_vertex_count: usize, + /// Canonical vertex keys for the shared ridge. + ridge_vertices: SmallBuffer, facet_count: usize, first_facet: usize, second_facet: Option, @@ -228,6 +399,184 @@ struct RidgeInfo { extra_facets: Vec, } +fn format_vertex_refs( + tds: &Tds, + vertex_keys: &[VertexKey], +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + vertex_keys + .iter() + .map(|&vertex_key| { + let uuid = tds.get_vertex_by_key(vertex_key).map_or_else( + || String::from("missing"), + |vertex| vertex.uuid().to_string(), + ); + format!("{vertex_key:?}/{uuid}") + }) + .collect::>() + .join(", ") +} + +fn format_facet_vertices( + tds: &Tds, + handle: FacetHandle, +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let Some(cell) = tds.get_cell(handle.cell_key()) else { + return String::from(""); + }; + + let facet_index = usize::from(handle.facet_index()); + let vertex_keys: Vec = cell + .vertices() + .iter() + .enumerate() + .filter_map(|(idx, &vertex_key)| (idx != facet_index).then_some(vertex_key)) + .collect(); + format_vertex_refs(tds, &vertex_keys) +} + +fn format_cell_vertices(tds: &Tds, cell_key: CellKey) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let Some(cell) = tds.get_cell(cell_key) else { + return String::from(""); + }; + format_vertex_refs(tds, cell.vertices()) +} + +/// Emits a compact one-shot snapshot of the first detected ridge fan in a run. +/// +/// Enabled via `DELAUNAY_DEBUG_RIDGE_FAN_ONCE`. Output is routed through +/// `tracing::debug!` so it respects the configured tracing subscriber; +/// callers that want these lines during a release-mode run should set +/// `RUST_LOG=debug` (or the matching filter in the large-scale debug harness). +/// +/// The snapshot captures the shared ridge vertices, the participating boundary +/// facets, and the extra cells that cavity reduction would remove. +fn log_first_ridge_fan_dump( + tds: &Tds, + conflict_cells: &CellKeyBuffer, + boundary_facets: &CavityBoundaryBuffer, + info: &RidgeInfo, + extra_cells: &[CellKey], +) where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + if !ridge_fan_dump_enabled() || RIDGE_FAN_DUMP_EMITTED.swap(true, Ordering::Relaxed) { + return; + } + + let mut participating_indices = Vec::with_capacity(2 + info.extra_facets.len()); + participating_indices.push(info.first_facet); + if let Some(second_facet) = info.second_facet { + participating_indices.push(second_facet); + } + participating_indices.extend(info.extra_facets.iter().copied()); + + let conflict_preview: Vec = conflict_cells.iter().copied().take(16).collect(); + let ridge_vertices = format_vertex_refs(tds, info.ridge_vertices.as_slice()); + + let participating_facets: Vec = participating_indices + .iter() + .copied() + .map(|boundary_index| { + boundary_facets.get(boundary_index).copied().map_or_else( + || format!("boundary_idx={boundary_index} "), + |handle| { + format!( + "boundary_idx={} cell={:?} facet_index={} vertices=[{}]", + boundary_index, + handle.cell_key(), + handle.facet_index(), + format_facet_vertices(tds, handle), + ) + }, + ) + }) + .collect(); + + let extra_cell_details: Vec = extra_cells + .iter() + .copied() + .map(|cell_key| { + format!( + "cell={cell_key:?} vertices=[{}]", + format_cell_vertices(tds, cell_key) + ) + }) + .collect(); + + tracing::debug!( + target: "delaunay::ridge_fan_dump", + D, + conflict_cells = conflict_cells.len(), + boundary_facets = boundary_facets.len(), + facet_count = info.facet_count, + ridge_vertex_count = info.ridge_vertex_count, + extra_cells = ?extra_cells, + conflict_preview = ?conflict_preview, + ridge_vertices = %ridge_vertices, + participating_boundary_indices = ?participating_indices, + participating_facets = ?participating_facets, + extra_cell_details = ?extra_cell_details, + "ridge-fan-dump: first detected ridge fan" + ); +} + +fn collect_ridge_fan_extra_cells( + boundary_facets: &CavityBoundaryBuffer, + info: &RidgeInfo, +) -> Result, ConflictError> { + debug_assert!( + info.extra_facets + .iter() + .all(|&fi| fi < boundary_facets.len()), + "RidgeFan extra_facets index out of bounds: extra_facets={:?}, boundary_facets.len()={}", + info.extra_facets, + boundary_facets.len(), + ); + + // Deduplicate: multiple extra facets can come from the same cell. Downstream code + // expects unique cell keys when shrinking the conflict region. + let mut seen = FastHashSet::::default(); + let mut extra_cells: Vec = Vec::new(); + for &fi in &info.extra_facets { + // Every entry in `info.extra_facets` is a `boundary_facets` index written by the + // same traversal that populated `boundary_facets`, so any out-of-range value + // represents an internal invariant violation rather than a data-access failure + // attributable to a real cell. Report it as such so the error message is truthful + // (no fabricated `CellKey::default()` placeholder) and stays non-retryable. + let ck = boundary_facets + .get(fi) + .ok_or_else(|| ConflictError::InternalInconsistency { + site: InternalInconsistencySite::RidgeFanExtraFacetOutOfBounds { + index: fi, + boundary_facets_len: boundary_facets.len(), + extra_facets_len: info.extra_facets.len(), + }, + })? + .cell_key(); + if seen.insert(ck) { + extra_cells.push(ck); + } + } + Ok(extra_cells) +} + /// Indicates why facet-walking fell back to a brute-force scan. /// /// # Examples @@ -1108,7 +1457,7 @@ where } #[cfg(debug_assertions)] - let detail_enabled = std::env::var_os("DELAUNAY_DEBUG_CAVITY").is_some(); + let detail_enabled = env::var_os("DELAUNAY_DEBUG_CAVITY").is_some(); #[cfg(debug_assertions)] let start_time = std::time::Instant::now(); #[cfg(debug_assertions)] @@ -1225,9 +1574,12 @@ where let ridge_vertex_count = facet_vkeys.len() - 1; for ridge_idx in 0..facet_vkeys.len() { + let mut ridge_vertices = + SmallBuffer::::new(); let mut ridge_hasher = FastHasher::default(); for (i, &vkey) in facet_vkeys.iter().enumerate() { if i != ridge_idx { + ridge_vertices.push(vkey); vkey.hash(&mut ridge_hasher); } } @@ -1246,6 +1598,7 @@ where }) .or_insert(RidgeInfo { ridge_vertex_count, + ridge_vertices, facet_count: 1, first_facet: boundary_facet_idx, second_facet: None, @@ -1334,6 +1687,9 @@ where if !boundary_facets.is_empty() { let boundary_len = boundary_facets.len(); let mut adjacency: Vec> = vec![Vec::new(); boundary_len]; + let mut first_ridge_fan: Option<(usize, usize)> = None; + let mut ridge_fan_extra_cells: Vec = Vec::new(); + let mut ridge_fan_seen_cells = FastHashSet::::default(); for info in ridge_map.values() { // Closed manifold boundary requires exactly 2 incident facets per ridge. @@ -1350,19 +1706,19 @@ where ); } // The open facet's cell is the cell to remove to close the boundary. - // first_facet is always a valid index by construction (it is set during the - // same boundary-building traversal), so None here is an internal - // consistency error — return CellDataAccessFailed rather than a null key. + // `first_facet` is always a valid `boundary_facets` index by construction + // (it is set during the same boundary-building traversal), so a missing + // entry is an internal invariant violation rather than a cell-data-access + // failure attributable to a real cell. let open_cell = boundary_facets .get(info.first_facet) - .ok_or_else(|| ConflictError::CellDataAccessFailed { - cell_key: CellKey::default(), - message: format!( - "OpenBoundary: boundary_facets missing first_facet index {} \ - (boundary_facets.len()={})", - info.first_facet, - boundary_facets.len(), - ), + .ok_or_else(|| ConflictError::InternalInconsistency { + site: InternalInconsistencySite::OpenBoundaryMissingFirstFacet { + first_facet: info.first_facet, + boundary_facets_len: boundary_facets.len(), + facet_count: info.facet_count, + ridge_vertex_count: info.ridge_vertex_count, + }, }) .map(FacetHandle::cell_key)?; return Err(ConflictError::OpenBoundary { @@ -1385,72 +1741,46 @@ where "extract_cavity_boundary: ridge fan" ); } - // Collect the cell keys of the extra (3rd, 4th, …) facets so callers can - // reduce the conflict region to eliminate the fan without skipping the vertex. - // Every index in extra_facets is written by the same traversal that populates - // boundary_facets, so an out-of-range index is an internal logic error — assert - // loudly instead of silently dropping it with filter_map. - debug_assert!( - info.extra_facets - .iter() - .all(|&fi| fi < boundary_facets.len()), - "RidgeFan extra_facets index out of bounds: extra_facets={:?}, boundary_facets.len()={}", - info.extra_facets, - boundary_facets.len(), - ); - // Deduplicate: multiple extra facets can come from the same cell. Downstream - // code (e.g., triangulation cavity reduction) converts this to a FastHashSet and - // expects unique keys; keep the payload minimal and stable for testing. - let mut seen = FastHashSet::::default(); - let mut extra_cells: Vec = Vec::new(); - for &fi in &info.extra_facets { - let ck = boundary_facets - .get(fi) - .ok_or_else(|| ConflictError::CellDataAccessFailed { - cell_key: CellKey::default(), - message: format!( - "RidgeFan extra_facets index {fi} out of bounds \ - (boundary_facets.len()={})", - boundary_facets.len() - ), - })? - .cell_key(); - if seen.insert(ck) { - extra_cells.push(ck); + // Collect the extra cells for this fan, but keep scanning so we can shrink + // all currently-detected ridge fans in one reduction step instead of peeling + // them one hash-map iteration at a time. + let extra_cells = collect_ridge_fan_extra_cells(&boundary_facets, info)?; + log_first_ridge_fan_dump(tds, conflict_cells, &boundary_facets, info, &extra_cells); + first_ridge_fan.get_or_insert((info.facet_count, info.ridge_vertex_count)); + for cell_key in extra_cells { + if ridge_fan_seen_cells.insert(cell_key) { + ridge_fan_extra_cells.push(cell_key); } } - return Err(ConflictError::RidgeFan { - facet_count: info.facet_count, - ridge_vertex_count: info.ridge_vertex_count, - extra_cells, - }); + continue; } // facet_count == 2 let a = info.first_facet; - let b = info.second_facet.ok_or_else(|| { - // This should be impossible by construction; treat as an internal consistency error. - let fallback_cell_key = boundary_facets.first().map_or_else( - || { - // boundary_facets is non-empty by the enclosing `if`, but keep this - // branch to avoid panics and satisfy strict clippy. - CellKey::default() + // `second_facet` is populated by the same ridge-map update that increments + // `facet_count` to 2, so a `None` here is an internal invariant violation. + // Report it as such instead of fabricating a cell key. + let b = info + .second_facet + .ok_or_else(|| ConflictError::InternalInconsistency { + site: InternalInconsistencySite::RidgeInfoMissingSecondFacet { + first_facet: a, + boundary_facets_len: boundary_facets.len(), + ridge_vertex_count: info.ridge_vertex_count, }, - FacetHandle::cell_key, - ); - let cell_key = boundary_facets - .get(a) - .map_or(fallback_cell_key, FacetHandle::cell_key); - - ConflictError::CellDataAccessFailed { - cell_key, - message: "RidgeInfo missing second_facet when facet_count == 2".to_string(), - } - })?; + })?; adjacency[a].push(b); adjacency[b].push(a); } + if let Some((facet_count, ridge_vertex_count)) = first_ridge_fan { + return Err(ConflictError::RidgeFan { + facet_count, + ridge_vertex_count, + extra_cells: ridge_fan_extra_cells, + }); + } + // Connectedness: the cavity boundary must be a single component. // A disconnected boundary indicates a non-ball conflict region (e.g., shell), which // can lead to Euler characteristic violations if we proceed. diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index e04dac60..4fa8eee9 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -156,10 +156,11 @@ use core::ops::Div; use num_traits::{NumCast, One, Zero}; use std::borrow::Cow; use std::cmp::Ordering as CmpOrdering; +use std::env; use std::hash::{Hash, Hasher}; use std::sync::{ OnceLock, - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, }; use thiserror::Error; use uuid::Uuid; @@ -189,10 +190,12 @@ static DUPLICATE_DETECTION_GRID_USED: AtomicU64 = AtomicU64::new(0); static DUPLICATE_DETECTION_GRID_FALLBACKS: AtomicU64 = AtomicU64::new(0); static DUPLICATE_DETECTION_GRID_CANDIDATES: AtomicU64 = AtomicU64::new(0); static DUPLICATE_DETECTION_ENABLED: OnceLock = OnceLock::new(); +static RETRYABLE_SKIP_TRACE_ENABLED: OnceLock = OnceLock::new(); +static CAVITY_REDUCTION_TRACE_ENABLED: OnceLock = OnceLock::new(); +static CAVITY_REDUCTION_TRACE_EMITTED: AtomicBool = AtomicBool::new(false); #[cfg(test)] -static DUPLICATE_DETECTION_FORCE_ENABLED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); +static DUPLICATE_DETECTION_FORCE_ENABLED: AtomicBool = AtomicBool::new(false); #[cfg(debug_assertions)] static VERTEX_TO_CELLS_SPILL_EVENTS: AtomicU64 = AtomicU64::new(0); @@ -202,8 +205,181 @@ fn duplicate_detection_metrics_enabled() -> bool { if DUPLICATE_DETECTION_FORCE_ENABLED.load(Ordering::Relaxed) { return true; } - *DUPLICATE_DETECTION_ENABLED - .get_or_init(|| std::env::var_os("DELAUNAY_DUPLICATE_METRICS").is_some()) + *DUPLICATE_DETECTION_ENABLED.get_or_init(|| env::var_os("DELAUNAY_DUPLICATE_METRICS").is_some()) +} + +/// Caches whether retryable conflict-region skips should emit release-visible traces. +fn retryable_skip_trace_enabled() -> bool { + *RETRYABLE_SKIP_TRACE_ENABLED + .get_or_init(|| env::var_os("DELAUNAY_DEBUG_RETRYABLE_SKIP").is_some()) +} + +/// Returns whether the first cavity-reduction chain should emit release-visible tracing. +fn cavity_reduction_trace_enabled() -> bool { + *CAVITY_REDUCTION_TRACE_ENABLED + .get_or_init(|| env::var_os("DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE").is_some()) +} + +/// Extracts a compact one-line summary for retryable conflict-region failures. +/// +/// These summaries are designed for the large-scale debug harness logs, where we want +/// enough structure to correlate repeated ridge-fan failures without dumping the entire +/// conflict region. +fn retryable_conflict_trace_detail(error: &InsertionError) -> Option { + match error { + InsertionError::ConflictRegion(ConflictError::NonManifoldFacet { + facet_hash, + cell_count, + }) => Some(format!( + "kind=non_manifold_facet facet_hash={facet_hash:#x} cell_count={cell_count}" + )), + InsertionError::ConflictRegion(ConflictError::RidgeFan { + facet_count, + ridge_vertex_count, + extra_cells, + }) => Some(format!( + "kind=ridge_fan facet_count={facet_count} ridge_vertex_count={ridge_vertex_count} \ + extra_cells={}", + extra_cells.len() + )), + InsertionError::ConflictRegion(ConflictError::DisconnectedBoundary { + visited, + total, + disconnected_cells, + }) => Some(format!( + "kind=disconnected_boundary visited={visited} total={total} disconnected_cells={}", + disconnected_cells.len() + )), + InsertionError::ConflictRegion(ConflictError::OpenBoundary { + facet_count, + ridge_vertex_count, + .. + }) => Some(format!( + "kind=open_boundary facet_count={facet_count} ridge_vertex_count={ridge_vertex_count}" + )), + _ => None, + } +} + +/// Formats a compact summary for cavity-boundary extraction failures. +fn cavity_conflict_error_summary(error: &ConflictError) -> String { + match error { + ConflictError::NonManifoldFacet { + facet_hash, + cell_count, + } => format!("non_manifold_facet facet_hash={facet_hash:#x} cell_count={cell_count}"), + ConflictError::RidgeFan { + facet_count, + ridge_vertex_count, + extra_cells, + } => format!( + "ridge_fan facet_count={facet_count} ridge_vertex_count={ridge_vertex_count} \ + extra_cells={}", + extra_cells.len() + ), + ConflictError::DisconnectedBoundary { + visited, + total, + disconnected_cells, + } => format!( + "disconnected_boundary visited={visited} total={total} disconnected_cells={}", + disconnected_cells.len() + ), + ConflictError::OpenBoundary { + facet_count, + ridge_vertex_count, + open_cell, + } => format!( + "open_boundary facet_count={facet_count} ridge_vertex_count={ridge_vertex_count} \ + open_cell={open_cell:?}" + ), + ConflictError::InvalidStartCell { cell_key } => { + format!("invalid_start_cell cell_key={cell_key:?}") + } + ConflictError::PredicateError { source } => { + format!("predicate_error source={source}") + } + ConflictError::CellDataAccessFailed { cell_key, message } => { + format!("cell_data_access_failed cell_key={cell_key:?} message={message}") + } + ConflictError::InternalInconsistency { site } => { + format!("internal_inconsistency site={site}") + } + } +} + +/// Emits one-shot tracing for the first cavity-reduction chain in a run. +/// +/// Routed through `tracing::debug!`; enable with `RUST_LOG=debug` (the +/// large-scale debug harness wires this up automatically when +/// `DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE` is set). +fn log_cavity_reduction_event( + enabled: bool, + iteration: usize, + conflict_cells: &CellKeyBuffer, + event: &str, +) { + if !enabled { + return; + } + + let conflict_preview: Vec = conflict_cells.iter().copied().take(12).collect(); + tracing::debug!( + target: "delaunay::cavity_reduction", + iteration, + conflict_cells = conflict_cells.len(), + event, + conflict_preview = ?conflict_preview, + "cavity-reduction event" + ); +} + +#[expect( + clippy::too_many_arguments, + reason = "Diagnostic helper keeps retryable skip instrumentation centralized" +)] +/// Emits a single structured line for a retryable conflict-region skip after rollback. +/// +/// Logging after rollback lets the trace report both the state we tried to modify and +/// the restored cell/vertex counts that future attempts will see. Routed through +/// `tracing::debug!` so callers can filter it via `RUST_LOG`; enabled for release-mode +/// runs by `DELAUNAY_DEBUG_RETRYABLE_SKIP`. +fn log_retryable_conflict_skip( + bulk_index: Option, + uuid: Uuid, + attempt: usize, + max_attempts: usize, + used_perturbation: bool, + will_retry: bool, + cells_before_attempt: usize, + vertices_before_attempt: usize, + cells_after_rollback: usize, + vertices_after_rollback: usize, + detail: &str, + error: &InsertionError, +) { + if !retryable_skip_trace_enabled() { + return; + } + + let bulk_index_display = bulk_index.map_or_else(|| String::from("n/a"), |idx| idx.to_string()); + tracing::debug!( + target: "delaunay::retryable_skip", + bulk_index = %bulk_index_display, + uuid = %uuid, + attempt, + max_attempts, + used_perturbation, + rolled_back = true, + will_retry, + cells_before_attempt, + vertices_before_attempt, + cells_after_rollback, + vertices_after_rollback, + conflict = %detail, + error = %error, + "retryable conflict-region skip after rollback" + ); } /// Telemetry counters for duplicate-coordinate detection. @@ -528,7 +704,30 @@ impl From for InvariantError { } } -type TryInsertImplOk = ((VertexKey, Option), usize, SuspicionFlags); +struct TryInsertImplOk { + /// Inserted vertex key plus an optional locate hint for the caller. + inserted: (VertexKey, Option), + /// Number of cells removed during local non-manifold repair. + 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. + /// + /// 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. + repair_seed_cells: CellKeyBuffer, +} + +/// Internal insertion result that preserves the user-facing outcome plus +/// hidden repair seeding used by batch/debug construction paths. +pub(crate) struct DetailedInsertionResult { + /// Public insertion outcome returned to higher layers. + pub outcome: InsertionOutcome, + /// Telemetry collected while attempting the insertion. + pub stats: InsertionStatistics, + /// Extra cells that should widen the caller's local repair seed set. + pub repair_seed_cells: CellKeyBuffer, +} /// Policy controlling when the triangulation runs global validation passes. /// @@ -3125,6 +3324,7 @@ where DEFAULT_PERTURBATION_RETRIES, 0, None, + None, )?; match outcome { InsertionOutcome::Inserted { vertex_key, hint } => Ok((vertex_key, hint)), @@ -3161,29 +3361,33 @@ where DEFAULT_PERTURBATION_RETRIES, 0, None, + None, ) } /// Insert a vertex with statistics, using a custom perturbation seed and an optional - /// spatial hash-grid index. + /// spatial hash-grid index, and also return the cells that cavity reduction touched + /// and left in place. /// - /// This is intended for bulk-construction paths that maintain a local index to - /// accelerate duplicate detection and locate-hint selection. - pub(crate) fn insert_with_statistics_seeded_indexed( + /// The extra seed set stays internal so bulk construction and debug rebuilds can widen + /// their local repair frontier without changing the public insertion API. + pub(crate) fn insert_with_statistics_seeded_indexed_detailed( &mut self, vertex: Vertex, conflict_cells: Option<&CellKeyBuffer>, hint: Option, perturbation_seed: u64, index: Option<&mut HashGridIndex>, - ) -> Result<(InsertionOutcome, InsertionStatistics), InsertionError> { - self.insert_transactional( + bulk_index: Option, + ) -> Result { + self.insert_transactional_detailed( vertex, conflict_cells, hint, DEFAULT_PERTURBATION_RETRIES, perturbation_seed, index, + bulk_index, ) } @@ -3199,11 +3403,44 @@ where /// 6. If the error is non-retryable: return `Err(InsertionError)` /// /// This guarantees we transition from one valid manifold to another. + #[cfg(test)] + #[expect( + clippy::too_many_arguments, + reason = "Test helpers mirror the detailed transactional insertion signature" + )] + fn insert_transactional( + &mut self, + vertex: Vertex, + conflict_cells: Option<&CellKeyBuffer>, + hint: Option, + max_perturbation_attempts: usize, + perturbation_seed: u64, + index: Option<&mut HashGridIndex>, + bulk_index: Option, + ) -> Result<(InsertionOutcome, InsertionStatistics), InsertionError> { + let detail = self.insert_transactional_detailed( + vertex, + conflict_cells, + hint, + max_perturbation_attempts, + perturbation_seed, + index, + bulk_index, + )?; + Ok((detail.outcome, detail.stats)) + } + + /// Transactional insertion with automatic rollback and perturbation retry, plus + /// the local-repair seed cells discovered while shaping the cavity. #[expect( clippy::too_many_lines, reason = "Complex insertion logic; splitting further would harm readability" )] - fn insert_transactional( + #[expect( + clippy::too_many_arguments, + reason = "Transactional insertion needs the bulk-index diagnostic context for #204 tracing" + )] + fn insert_transactional_detailed( &mut self, vertex: Vertex, conflict_cells: Option<&CellKeyBuffer>, @@ -3211,13 +3448,19 @@ where max_perturbation_attempts: usize, perturbation_seed: u64, mut index: Option<&mut HashGridIndex>, - ) -> Result<(InsertionOutcome, InsertionStatistics), InsertionError> { + bulk_index: Option, + ) -> Result { let mut stats = InsertionStatistics::default(); let original_coords = *vertex.point().coords(); let original_uuid = vertex.uuid(); let mut current_vertex = vertex; + // Preserve the last retryable failure so an exhausted perturbation loop can + // explain why the vertex was skipped instead of reporting a generic error. let mut last_retryable_error: Option = None; + // Reuse the caller's spatial index as a locate-hint source when batch insertion did + // not already provide a better hint. This keeps retries and bulk runs on the same + // point-location path. let mut hint = hint; if hint.is_none() && let Some(index_ref) = index.as_deref() @@ -3225,6 +3468,8 @@ where hint = self.select_locate_hint_from_hash_grid(&original_coords, index_ref); } + // Scale perturbations against the local neighborhood so retries stay small relative + // to the nearby geometry instead of using a single global epsilon. let local_scale = self.estimate_local_perturbation_scale(&original_coords, hint); let duplicate_tolerance: K::Scalar = @@ -3241,7 +3486,8 @@ where for attempt in 0..=max_perturbation_attempts { stats.attempts = attempt + 1; - // Apply perturbation for retry attempts + // Attempt 0 uses the caller's coordinates verbatim; later attempts apply a + // deterministic signed perturbation so the same seed reproduces the same path. if attempt > 0 { let mut perturbed_coords = original_coords; // Progressive local-scale perturbation: magnitude grows ×10 per attempt. @@ -3267,7 +3513,11 @@ where "Failed to convert perturbation scale {epsilon_value} into scalar type" ), }); - return Ok((InsertionOutcome::Skipped { error }, stats)); + return Ok(DetailedInsertionResult { + outcome: InsertionOutcome::Skipped { error }, + stats, + repair_seed_cells: CellKeyBuffer::new(), + }); }; let perturbation_scale = epsilon * local_scale; @@ -3310,9 +3560,16 @@ where stats.result = InsertionResult::SkippedDuplicate; #[cfg(debug_assertions)] tracing::debug!("SKIPPED: {error}"); - return Ok((InsertionOutcome::Skipped { error }, stats)); + return Ok(DetailedInsertionResult { + outcome: InsertionOutcome::Skipped { error }, + stats, + repair_seed_cells: CellKeyBuffer::new(), + }); } + let cells_before_attempt = self.tds.number_of_cells(); + let vertices_before_attempt = self.tds.number_of_vertices(); + // Clone TDS for rollback (transactional semantics) let tds_snapshot = self.tds.clone(); @@ -3330,7 +3587,12 @@ where ); match result { - Ok((result, cells_removed, _suspicion)) => { + Ok(TryInsertImplOk { + inserted, + cells_removed, + repair_seed_cells, + .. + }) => { stats.cells_removed_during_repair = cells_removed; stats.result = InsertionResult::Inserted; #[cfg(debug_assertions)] @@ -3340,14 +3602,20 @@ where ); } - let (vertex_key, hint) = result; + let (vertex_key, hint) = inserted; + // Only the committed attempt updates the duplicate index. Earlier + // retries all rolled back to the pre-attempt triangulation state. if let Some(index) = index.as_deref_mut() && let Some(vertex) = self.tds.get_vertex_by_key(vertex_key) { index.insert_vertex(vertex_key, vertex.point().coords()); } - return Ok((InsertionOutcome::Inserted { vertex_key, hint }, stats)); + return Ok(DetailedInsertionResult { + outcome: InsertionOutcome::Inserted { vertex_key, hint }, + stats, + repair_seed_cells, + }); } Err(e) => { // Any error - rollback to snapshot @@ -3358,12 +3626,37 @@ where stats.result = InsertionResult::SkippedDuplicate; #[cfg(debug_assertions)] tracing::debug!("SKIPPED: {e}"); - return Ok((InsertionOutcome::Skipped { error: e }, stats)); + return Ok(DetailedInsertionResult { + outcome: InsertionOutcome::Skipped { error: e }, + stats, + repair_seed_cells: CellKeyBuffer::new(), + }); } // Check if this is a retryable error (geometric degeneracy) let is_retryable = e.is_retryable(); + // Emit the conflict summary after rollback so the trace captures the + // restored manifold state that the next retry will start from. + if retryable_skip_trace_enabled() + && let Some(detail) = retryable_conflict_trace_detail(&e) + { + log_retryable_conflict_skip( + bulk_index, + original_uuid, + attempt + 1, + max_perturbation_attempts + 1, + attempt > 0, + is_retryable && attempt < max_perturbation_attempts, + cells_before_attempt, + vertices_before_attempt, + self.tds.number_of_cells(), + self.tds.number_of_vertices(), + &detail, + &e, + ); + } + if is_retryable && attempt < max_perturbation_attempts { last_retryable_error = Some(e.clone()); #[cfg(debug_assertions)] @@ -3389,7 +3682,13 @@ where } ), ); - return Ok((InsertionOutcome::Skipped { error: e }, stats)); + return Ok(DetailedInsertionResult { + outcome: InsertionOutcome::Skipped { error: e }, + stats, + // Skipped insertions do not mutate the triangulation, so any + // intermediate cavity-seed hints are irrelevant to callers. + repair_seed_cells: CellKeyBuffer::new(), + }); } else { // Non-retryable structural error (e.g., duplicate UUID) return Err(e); @@ -3682,19 +3981,18 @@ where attempt: usize, tds_snapshot: &Tds, ) -> Result { - let (ok, cells_removed, mut suspicion) = - self.try_insert_impl(vertex, conflict_cells, hint)?; + let mut insert_ok = self.try_insert_impl(vertex, conflict_cells, hint)?; if attempt > 0 { - suspicion.perturbation_used = true; + insert_ok.suspicion.perturbation_used = true; } // Skip Level 3 validation during bootstrap (vertices but no cells yet). if self.tds.number_of_cells() == 0 { - return Ok((ok, cells_removed, suspicion)); + return Ok(insert_ok); } - if let Err(validation_err) = self.validate_after_insertion(suspicion) { + if let Err(validation_err) = self.validate_after_insertion(insert_ok.suspicion) { // 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( @@ -3705,7 +4003,7 @@ where ); } - Ok((ok, cells_removed, suspicion)) + Ok(insert_ok) } /// After a Level 3 topology validation failure, try to recover by performing a star-split @@ -3732,14 +4030,14 @@ where star_conflict.push(start_cell); match self.try_insert_impl(vertex, Some(&star_conflict), Some(start_cell)) { - Ok((fallback_ok, fallback_removed, mut fallback_suspicion)) => { - fallback_suspicion.fallback_star_split = true; + Ok(mut fallback_ok) => { + fallback_ok.suspicion.fallback_star_split = true; if attempt > 0 { - fallback_suspicion.perturbation_used = true; + fallback_ok.suspicion.perturbation_used = true; } if let Err(fallback_validation_err) = - self.validate_after_insertion(fallback_suspicion) + self.validate_after_insertion(fallback_ok.suspicion) { return Err(Self::invariant_error_to_insertion_error( fallback_validation_err, @@ -3755,7 +4053,7 @@ where "Topology safety-net: star-split fallback succeeded (start_cell={start_cell:?})" ); - Ok((fallback_ok, fallback_removed, fallback_suspicion)) + Ok(fallback_ok) } Err(fallback_err) => Err(fallback_err), } @@ -4042,7 +4340,7 @@ where mut conflict_cells: CellKeyBuffer, fallback_cell: Option, suspicion: &mut SuspicionFlags, - ) -> Result<(Option, usize), InsertionError> { + ) -> Result<(Option, usize, CellKeyBuffer), InsertionError> { #[cfg(not(debug_assertions))] let _ = point; @@ -4057,6 +4355,10 @@ where conflict_cells.push(start_cell); } + // Preserve every cell that participates in cavity shaping so callers can seed + // local Delaunay repair from cells that were shrunk out of the final cavity. + let mut repair_seed_cells = conflict_cells.clone(); + // Extract cavity boundary. // // Iteratively resolve cavity-boundary errors rather than immediately falling back to a @@ -4083,9 +4385,37 @@ where { const MAX_CAVITY_ITERATIONS: usize = 32; let mut iterations: usize = 0; + let trace_cavity_reduction = cavity_reduction_trace_enabled() + && !CAVITY_REDUCTION_TRACE_EMITTED.swap(true, Ordering::Relaxed); + let mut saw_ridge_fan_shrink = false; + + match &extraction_result { + Ok(boundary) => { + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + &format!("initial_ok boundary_facets={}", boundary.len()), + ); + } + Err(err) => { + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + &format!("initial_err {}", cavity_conflict_error_summary(err)), + ); + } + } loop { if iterations >= MAX_CAVITY_ITERATIONS { + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + "budget_exhausted", + ); break; } iterations += 1; @@ -4101,6 +4431,13 @@ where conflict_cells_before = conflict_cells.len(), "D={D}: cavity reduction (RidgeFan shrink)" ); + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + &format!("ridge_fan_shrink remove_cells={extra_cells:?}"), + ); + saw_ridge_fan_shrink = true; let remove_set: FastHashSet = extra_cells.iter().copied().collect(); conflict_cells.retain(|k| !remove_set.contains(k)); @@ -4117,15 +4454,17 @@ where let conflict_set: FastHashSet = conflict_cells.iter().copied().collect(); let mut cells_to_add: FastHashSet = FastHashSet::default(); - for &dc in disconnected_cells { - if let Some(cell) = self.tds.get_cell(dc) - && let Some(neighbors) = cell.neighbors() - { - for &neighbor_opt in neighbors { - if let Some(nk) = neighbor_opt - && !conflict_set.contains(&nk) - { - cells_to_add.insert(nk); + if !saw_ridge_fan_shrink { + for &dc in disconnected_cells { + if let Some(cell) = self.tds.get_cell(dc) + && let Some(neighbors) = cell.neighbors() + { + for &neighbor_opt in neighbors { + if let Some(nk) = neighbor_opt + && !conflict_set.contains(&nk) + { + cells_to_add.insert(nk); + } } } } @@ -4139,8 +4478,16 @@ where conflict_cells_before = conflict_cells.len(), "D={D}: cavity expansion (DisconnectedBoundary hole-fill)" ); + let added: Vec = cells_to_add.iter().copied().collect(); + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + &format!("disconnected_boundary_expand add_cells={added:?}"), + ); for k in cells_to_add { conflict_cells.push(k); + repair_seed_cells.push(k); } } else if conflict_cells.len() > D + 1 { // SHRINK fallback: no non-conflict neighbors found. @@ -4150,10 +4497,24 @@ where conflict_cells_before = conflict_cells.len(), "D={D}: cavity reduction (DisconnectedBoundary shrink fallback)" ); + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + &format!( + "disconnected_boundary_shrink remove_cells={disconnected_cells:?}" + ), + ); let remove_set: FastHashSet = disconnected_cells.iter().copied().collect(); conflict_cells.retain(|k| !remove_set.contains(k)); } else { + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + "disconnected_boundary_no_progress", + ); break; } } @@ -4168,14 +4529,46 @@ where conflict_cells_before = conflict_cells.len(), "D={D}: cavity reduction (OpenBoundary shrink)" ); + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + &format!("open_boundary_shrink open_cell={open_cell:?}"), + ); let open = *open_cell; conflict_cells.retain(|k| *k != open); } - _ => break, + _ => { + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + "no_reduction_rule_matched", + ); + break; + } } extraction_result = extract_cavity_boundary(&self.tds, &conflict_cells); + match &extraction_result { + Ok(boundary) => { + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + &format!("reextract_ok boundary_facets={}", boundary.len()), + ); + } + Err(err) => { + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + &format!("reextract_err {}", cavity_conflict_error_summary(err)), + ); + } + } } } @@ -4223,6 +4616,7 @@ where owned.push(start_cell); owned }; + repair_seed_cells.push(start_cell); Self::star_split_boundary_facets(start_cell) } else { @@ -4257,6 +4651,7 @@ where owned.push(start_cell); owned }; + repair_seed_cells.push(start_cell); boundary_facets = Self::star_split_boundary_facets(start_cell); } @@ -4474,7 +4869,7 @@ where self.validate_connectedness(&new_cells)?; // Return hint for next insertion - Ok((hint, total_removed)) + Ok((hint, total_removed, repair_seed_cells)) } /// Repair stale incident-cell pointers and detect truly isolated vertices. @@ -4567,7 +4962,12 @@ where if num_vertices < D + 1 { // Bootstrap phase: just accumulate vertices, no cells yet - return Ok(((v_key, None), 0, suspicion)); + return Ok(TryInsertImplOk { + inserted: (v_key, None), + cells_removed: 0, + suspicion, + repair_seed_cells: CellKeyBuffer::new(), + }); } else if num_vertices == D + 1 { // Build initial simplex from all D+1 vertices let all_vertices: Vec<_> = self.tds.vertices().map(|(_, v)| *v).collect(); @@ -4590,7 +4990,12 @@ where // Return first cell key for hint caching let first_cell = self.tds.cell_keys().next(); - return Ok(((v_key, first_cell), 0, suspicion)); + return Ok(TryInsertImplOk { + inserted: (v_key, first_cell), + cells_removed: 0, + suspicion, + repair_seed_cells: CellKeyBuffer::new(), + }); } // 3. Locate containing cell (for vertex D+2 and beyond) @@ -4781,14 +5186,19 @@ where let conflict_cells = conflict_cells .expect("conflict_cells should be computed above") .into_owned(); - let (hint, total_removed) = self.insert_with_conflict_region( + let (hint, total_removed, repair_seed_cells) = self.insert_with_conflict_region( v_key, &point, conflict_cells, Some(start_cell), &mut suspicion, )?; - Ok(((v_key, hint), total_removed, suspicion)) + Ok(TryInsertImplOk { + inserted: (v_key, hint), + cells_removed: total_removed, + suspicion, + repair_seed_cells, + }) } LocateResult::Outside => { if let Some(conflict_cells) = conflict_cells { @@ -4807,8 +5217,13 @@ where &mut suspicion, ); match result { - Ok((hint, total_removed)) => { - return Ok(((v_key, hint), total_removed, suspicion)); + Ok((hint, total_removed, repair_seed_cells)) => { + return Ok(TryInsertImplOk { + inserted: (v_key, hint), + cells_removed: total_removed, + suspicion, + repair_seed_cells, + }); } Err(err) => { // For exterior points, a "global" conflict region can intersect the hull, @@ -4882,14 +5297,20 @@ where suspicion.fallback_star_split = true; let mut star_conflict = CellKeyBuffer::new(); star_conflict.push(start_cell); - let (hint, total_removed) = self.insert_with_conflict_region( - v_key, - &point, - star_conflict, - Some(start_cell), - &mut suspicion, - )?; - return Ok(((v_key, hint), total_removed, suspicion)); + let (hint, total_removed, repair_seed_cells) = self + .insert_with_conflict_region( + v_key, + &point, + star_conflict, + Some(start_cell), + &mut suspicion, + )?; + return Ok(TryInsertImplOk { + inserted: (v_key, hint), + cells_removed: total_removed, + suspicion, + repair_seed_cells, + }); } } #[cfg(debug_assertions)] @@ -5096,7 +5517,12 @@ where self.validate_connectedness(&new_cells)?; // Return vertex key and hint for next insertion - Ok(((v_key, hint), total_removed, suspicion)) + Ok(TryInsertImplOk { + inserted: (v_key, hint), + cells_removed: total_removed, + suspicion, + repair_seed_cells: CellKeyBuffer::new(), + }) } LocateResult::OnFacet(_, _) | LocateResult::OnEdge(_) | LocateResult::OnVertex(_) => { // These degenerate cases are already handled at lines 772-779 above, @@ -10043,35 +10469,49 @@ mod tests { /// /// Covers: progressive scale factor, perturbation coordinate generation /// with `perturbation_seed == 0`, retry decision, and retry exhaustion. + /// + /// Iterates over multiple seeds to remain robust to insertion-path + /// improvements (e.g. ridge-fan accumulation, widened local repair) that + /// make retries less common for any individual well-conditioned seed. + /// The test's contract is that the retry path is reachable for *some* + /// seed, not that every seed must exercise it. #[test] fn test_perturbation_retry_and_exhaustion_4d() { - let points = - crate::geometry::util::generate_random_points_seeded::(20, (-10.0, 10.0), 123) - .unwrap(); - - let mut tri: Triangulation, (), (), 4> = - Triangulation::new_empty(AdaptiveKernel::new()); + // 50 seeds × 20 points = 1000 insertion attempts in 4D; empirically + // more than sufficient to trigger at least one retry or exhaustion + // from orientation/ridge-fan degeneracies regardless of improvements + // to the non-degenerate insertion path. + const SEED_COUNT: u64 = 50; + const POINTS_PER_SEED: usize = 20; + + for seed in 123..(123 + SEED_COUNT) { + let points = crate::geometry::util::generate_random_points_seeded::( + POINTS_PER_SEED, + (-10.0, 10.0), + seed, + ) + .unwrap(); - let mut any_retried = false; - let mut any_exhausted = false; + let mut tri: Triangulation, (), (), 4> = + Triangulation::new_empty(AdaptiveKernel::new()); - for point in points { - let v = VertexBuilder::default().point(point).build().unwrap(); - let (_outcome, stats) = tri.insert_with_statistics(v, None, None).unwrap(); + for point in points { + let v = VertexBuilder::default().point(point).build().unwrap(); + let (_outcome, stats) = tri.insert_with_statistics(v, None, None).unwrap(); - if stats.used_perturbation() && stats.success() { - any_retried = true; - } - if stats.skipped() && stats.attempts > 1 { - any_exhausted = true; + if (stats.used_perturbation() && stats.success()) + || (stats.skipped() && stats.attempts > 1) + { + return; + } } } - // In 4D, orientation degeneracies trigger retries frequently. - assert!( - any_retried || any_exhausted, - "4D insertion with 20 random points (seed 123) should trigger \ - at least one perturbation retry or exhaustion" + panic!( + "4D insertion with {SEED_COUNT} random seeds of {POINTS_PER_SEED} points each \ + did not trigger a perturbation retry or exhaustion; the retry path may be \ + unreachable from random well-conditioned input and needs a dedicated \ + adversarial repro." ); } @@ -10080,40 +10520,50 @@ mod tests { /// /// Covers: the `mix` computation and sign selection in the seeded path /// (lines using `perturbation_seed ^ ...`). + /// + /// Uses the same multi-seed iteration as + /// [`test_perturbation_retry_and_exhaustion_4d`] for the same reason. #[test] fn test_perturbation_retry_seeded_branch_4d() { - let points = - crate::geometry::util::generate_random_points_seeded::(20, (-10.0, 10.0), 123) - .unwrap(); - - let mut tri: Triangulation, (), (), 4> = - Triangulation::new_empty(AdaptiveKernel::new()); + const SEED_COUNT: u64 = 50; + const POINTS_PER_SEED: usize = 20; + + for seed in 123..(123 + SEED_COUNT) { + let points = crate::geometry::util::generate_random_points_seeded::( + POINTS_PER_SEED, + (-10.0, 10.0), + seed, + ) + .unwrap(); - let mut any_retried = false; + let mut tri: Triangulation, (), (), 4> = + Triangulation::new_empty(AdaptiveKernel::new()); - for point in points { - let v = VertexBuilder::default().point(point).build().unwrap(); - let (_outcome, stats) = tri - .insert_transactional( - v, - None, - None, - DEFAULT_PERTURBATION_RETRIES, - 0xDEAD_BEEF, - None, - ) - .unwrap(); + for point in points { + let v = VertexBuilder::default().point(point).build().unwrap(); + let (_outcome, stats) = tri + .insert_transactional( + v, + None, + None, + DEFAULT_PERTURBATION_RETRIES, + 0xDEAD_BEEF, + None, + None, + ) + .unwrap(); - if stats.used_perturbation() { - any_retried = true; + if stats.used_perturbation() { + return; + } } } - // Exercises the perturbation_seed != 0 branch in the retry loop. - assert!( - any_retried, - "4D seeded insertion with 20 points (seed 123) should trigger \ - at least one perturbation retry" + panic!( + "4D seeded insertion with {SEED_COUNT} random seeds of {POINTS_PER_SEED} points each \ + did not trigger a perturbation retry; the seeded retry branch \ + (perturbation_seed != 0) may be unreachable from random well-conditioned input and \ + needs a dedicated adversarial repro." ); } } diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 282a90d3..4ff9a129 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -14,7 +14,7 @@ use crate::core::algorithms::flips::{ use crate::core::algorithms::incremental_insertion::InsertionError; use crate::core::cell::{Cell, CellValidationError}; use crate::core::collections::spatial_hash_grid::HashGridIndex; -use crate::core::collections::{CellKeyBuffer, FastHashMap, FastHasher, SmallBuffer}; +use crate::core::collections::{CellKeyBuffer, FastHashMap, FastHashSet, FastHasher, SmallBuffer}; use crate::core::edge::EdgeKey; use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter}; use crate::core::operations::{ @@ -37,6 +37,7 @@ 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::topology::manifold::validate_ridge_links_for_cells; use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; use crate::triangulation::builder::DelaunayTriangulationBuilder; @@ -46,6 +47,7 @@ use rand::SeedableRng; use rand::rngs::StdRng; use rand::seq::SliceRandom; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::env; use std::hash::{Hash, Hasher}; use std::num::NonZeroUsize; use std::time::Instant; @@ -1162,6 +1164,163 @@ fn hilbert_bits_per_coord() -> Option { Some(bits_per_coord) } +/// Reads the optional batch-construction progress cadence from the environment. +/// +/// `DELAUNAY_BULK_PROGRESS_EVERY` is the canonical knob. The large-scale debug +/// harness also reuses `DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY` so manual runs can +/// request periodic progress without additional wiring. +fn bulk_progress_every_from_env() -> Option { + [ + "DELAUNAY_BULK_PROGRESS_EVERY", + "DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY", + ] + .into_iter() + .find_map(|name| { + env::var(name) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + }) + .filter(|every| *every > 0) +} + +/// Enables release-visible retry-boundary tracing for bulk construction. +fn construction_retry_trace_enabled() -> bool { + bulk_progress_every_from_env().is_some() + || env::var_os("DELAUNAY_DEBUG_SHUFFLE").is_some() + || env::var_os("DELAUNAY_INSERT_TRACE").is_some() +} + +#[derive(Clone, Copy, Debug)] +/// Snapshot of one batch-construction progress sample. +struct BatchProgressSample { + processed: usize, + inserted: usize, + skipped: usize, + cell_count: usize, + perturbation_seed: u64, +} + +#[derive(Clone, Copy, Debug)] +/// Rolling state used to compute periodic batch throughput summaries. +struct BatchProgressState { + total_vertices: usize, + progress_every: usize, + started: Instant, + last_progress: Instant, + last_processed: usize, +} + +/// Emits periodic batch-construction progress for long-running release-mode +/// investigations such as the 4D large-scale debug harness. +/// +/// Progress is emitted via `tracing::debug!`; enable with `RUST_LOG=debug` (the +/// large-scale debug harness wires this up automatically when +/// `DELAUNAY_BULK_PROGRESS_EVERY` is set). +fn log_bulk_progress_if_due(sample: BatchProgressSample, state: &mut Option) { + let Some(state) = state.as_mut() else { + return; + }; + if sample.processed == 0 { + return; + } + + // Always log the final sample, even when the total is not an exact multiple of the + // requested cadence, so interrupted runs still end with a complete progress line. + let should_log = sample.processed == state.total_vertices + || sample.processed.is_multiple_of(state.progress_every); + if !should_log { + return; + } + + let elapsed = state.started.elapsed(); + let chunk_elapsed = state.last_progress.elapsed(); + let chunk_processed = sample.processed.saturating_sub(state.last_processed); + + let overall_rate = safe_usize_to_scalar::(sample.processed).unwrap_or(f64::NAN) + / elapsed.as_secs_f64().max(1e-9); + let chunk_rate = safe_usize_to_scalar::(chunk_processed).unwrap_or(f64::NAN) + / chunk_elapsed.as_secs_f64().max(1e-9); + + tracing::debug!( + target: "delaunay::bulk_progress", + perturbation_seed = format_args!("0x{:X}", sample.perturbation_seed), + processed = sample.processed, + total_vertices = state.total_vertices, + inserted = sample.inserted, + skipped = sample.skipped, + cells = sample.cell_count, + elapsed = ?elapsed, + total_rate_pts_per_s = overall_rate, + recent_rate_pts_per_s = chunk_rate, + "bulk-construction progress" + ); + + state.last_progress = Instant::now(); + state.last_processed = sample.processed; +} + +/// Emits retry-boundary events for release-mode large-scale construction runs. +fn log_construction_retry_start(attempt: usize, attempt_seed: u64, perturbation_seed: u64) { + if !construction_retry_trace_enabled() { + return; + } + + tracing::debug!( + target: "delaunay::bulk_retry", + attempt, + attempt_seed = format_args!("0x{:X}", attempt_seed), + perturbation_seed = format_args!("0x{:X}", perturbation_seed), + "shuffled retry attempt starting" + ); +} + +/// Emits retry attempt outcomes with optional construction statistics. +fn log_construction_retry_result( + attempt: usize, + attempt_seed: Option, + perturbation_seed: u64, + outcome: &'static str, + error: Option<&str>, + stats: Option<&ConstructionStatistics>, +) { + if !construction_retry_trace_enabled() { + return; + } + + let attempt_seed_display = + attempt_seed.map_or_else(|| String::from("input-order"), |seed| format!("0x{seed:X}")); + let error_display = error.unwrap_or("-"); + + if let Some(stats) = stats { + tracing::debug!( + target: "delaunay::bulk_retry", + attempt, + attempt_seed = %attempt_seed_display, + perturbation_seed = format_args!("0x{:X}", perturbation_seed), + outcome, + inserted = stats.inserted, + skipped_duplicate = stats.skipped_duplicate, + skipped_degeneracy = stats.skipped_degeneracy, + total_attempts = stats.total_attempts, + max_attempts = stats.max_attempts, + cells_removed_total = stats.cells_removed_total, + cells_removed_max = stats.cells_removed_max, + error = %error_display, + "shuffled retry attempt result (with stats)" + ); + } else { + tracing::debug!( + target: "delaunay::bulk_retry", + attempt, + attempt_seed = %attempt_seed_display, + perturbation_seed = format_args!("0x{:X}", perturbation_seed), + outcome, + error = %error_display, + "shuffled retry attempt result" + ); + } +} + /// Sort key for Hilbert ordering: `(hilbert_index, quantized_coords, vertex, input_index)`. type HilbertSortKey = (u128, [u32; D], Vertex, usize); @@ -2220,7 +2379,7 @@ where let base_seed = base_seed.unwrap_or_else(|| Self::construction_shuffle_seed(vertices)); #[cfg(debug_assertions)] - let log_shuffle = std::env::var_os("DELAUNAY_DEBUG_SHUFFLE").is_some(); + let log_shuffle = env::var_os("DELAUNAY_DEBUG_SHUFFLE").is_some(); #[cfg(debug_assertions)] if log_shuffle { @@ -2264,6 +2423,7 @@ where "build_with_shuffled_retries: initial attempt failed: {last_error}" ); } + log_construction_retry_result(0, None, 0_u64, "failed", Some(&last_error), None); // Shuffled retries (total iterations: attempts shuffled). for attempt in 1..=attempts.get() { @@ -2289,6 +2449,7 @@ where "build_with_shuffled_retries: shuffled attempt starting" ); } + log_construction_retry_start(attempt, attempt_seed, perturbation_seed); match Self::build_with_kernel_inner_seeded( ::clone(kernel), @@ -2301,7 +2462,17 @@ where ) { Ok(candidate) => { match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => return Ok(candidate), + Ok(()) => { + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "succeeded", + None, + None, + ); + return Ok(candidate); + } Err(err) => { last_error = format!("Delaunay property violated after construction: {err}"); @@ -2326,6 +2497,14 @@ where "build_with_shuffled_retries: attempt failed: {last_error}" ); } + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "failed", + Some(&last_error), + None, + ); } // Treat persistent construction failures or Delaunay violations as hard construction @@ -2358,7 +2537,7 @@ where let base_seed = base_seed.unwrap_or_else(|| Self::construction_shuffle_seed(vertices)); #[cfg(debug_assertions)] - let log_shuffle = std::env::var_os("DELAUNAY_DEBUG_SHUFFLE").is_some(); + let log_shuffle = env::var_os("DELAUNAY_DEBUG_SHUFFLE").is_some(); #[cfg(debug_assertions)] if log_shuffle { @@ -2415,6 +2594,14 @@ where "build_with_shuffled_retries_with_construction_statistics: initial attempt failed: {last_error}" ); } + log_construction_retry_result( + 0, + None, + 0_u64, + "failed", + Some(&last_error), + last_stats.as_ref(), + ); // Shuffled retries (total iterations: attempts shuffled). for attempt in 1..=attempts.get() { @@ -2440,6 +2627,7 @@ where "build_with_shuffled_retries_with_construction_statistics: shuffled attempt starting" ); } + log_construction_retry_start(attempt, attempt_seed, perturbation_seed); match Self::build_with_kernel_inner_seeded_with_construction_statistics( ::clone(kernel), @@ -2452,7 +2640,17 @@ where ) { Ok((candidate, stats)) => { match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => return Ok((candidate, stats)), + Ok(()) => { + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "succeeded", + None, + Some(&stats), + ); + return Ok((candidate, stats)); + } Err(err) => { last_stats.replace(stats); last_error = @@ -2484,6 +2682,14 @@ where "build_with_shuffled_retries_with_construction_statistics: attempt failed: {last_error}" ); } + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "failed", + Some(&last_error), + last_stats.as_ref(), + ); } // Treat persistent construction failures or Delaunay violations as hard construction @@ -2956,7 +3162,21 @@ where } } - let trace_insertion = std::env::var_os("DELAUNAY_INSERT_TRACE").is_some(); + let trace_insertion = env::var_os("DELAUNAY_INSERT_TRACE").is_some(); + let mut batch_progress = bulk_progress_every_from_env().map(|progress_every| { + let started = Instant::now(); + BatchProgressState { + // The initial simplex is already present when this loop starts, so progress + // and throughput should only count the remaining bulk vertices. + total_vertices: vertices.len(), + progress_every, + started, + last_progress: started, + last_processed: D + 1, + } + }); + let mut inserted_vertices = D + 1; + let mut skipped_vertices = 0usize; match construction_stats { None => { @@ -2982,12 +3202,16 @@ where let started = trace_insertion.then(std::time::Instant::now); let mut insert = || { - self.tri.insert_with_statistics_seeded_indexed( + // Pass the batch index through to transactional insertion so the + // lower-layer retryable-skip trace can point back to this exact + // bulk-construction position. + self.tri.insert_with_statistics_seeded_indexed_detailed( *vertex, None, self.insertion_state.last_inserted_cell, perturbation_seed, grid_index.as_mut(), + Some(index), ) }; let insert_result = if trace_insertion { @@ -3002,6 +3226,10 @@ where insert() }; 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) + }); match insert_result { Ok(( InsertionOutcome::Inserted { @@ -3009,7 +3237,9 @@ where hint, }, _stats, + repair_seed_cells, )) => { + inserted_vertices = inserted_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { eprintln!( "[bulk] inserted idx={index} uuid={uuid} elapsed={elapsed:?}" @@ -3041,8 +3271,8 @@ where && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - let seed_cells: Vec = - self.tri.adjacent_cells(v_key).collect(); + let seed_cells = + self.collect_local_repair_seed_cells(v_key, &repair_seed_cells); if !seed_cells.is_empty() { let max_flips = if D >= 4 { (seed_cells.len() * (D + 1) * 2).max(8) @@ -3096,8 +3326,19 @@ where } } } + log_bulk_progress_if_due( + BatchProgressSample { + processed: index + 1, + inserted: inserted_vertices, + skipped: skipped_vertices, + cell_count: self.tri.tds.number_of_cells(), + perturbation_seed, + }, + &mut batch_progress, + ); } - Ok((InsertionOutcome::Skipped { error }, stats)) => { + Ok((InsertionOutcome::Skipped { error }, stats, _repair_seed_cells)) => { + skipped_vertices = skipped_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { eprintln!( "[bulk] skipped idx={index} uuid={uuid} attempts={} elapsed={elapsed:?} err={error}", @@ -3116,6 +3357,16 @@ where { let _ = (error, stats); } + log_bulk_progress_if_due( + BatchProgressSample { + processed: index + 1, + inserted: inserted_vertices, + skipped: skipped_vertices, + cell_count: self.tri.tds.number_of_cells(), + perturbation_seed, + }, + &mut batch_progress, + ); } Err(e) => { if trace_insertion && let Some(elapsed) = elapsed { @@ -3150,12 +3401,16 @@ where let started = trace_insertion.then(std::time::Instant::now); let mut insert = || { - self.tri.insert_with_statistics_seeded_indexed( + // Keep the stats and non-stats branches aligned so bulk-index-based + // tracing behaves the same regardless of whether the caller records + // construction statistics. + self.tri.insert_with_statistics_seeded_indexed_detailed( *vertex, None, self.insertion_state.last_inserted_cell, perturbation_seed, grid_index.as_mut(), + Some(index), ) }; let insert_result = if trace_insertion { @@ -3170,6 +3425,10 @@ where insert() }; 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) + }); match insert_result { Ok(( InsertionOutcome::Inserted { @@ -3177,7 +3436,9 @@ where hint, }, stats, + repair_seed_cells, )) => { + inserted_vertices = inserted_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { eprintln!( "[bulk] inserted idx={index} uuid={uuid} attempts={} elapsed={elapsed:?}", @@ -3199,8 +3460,8 @@ where && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - let seed_cells: Vec = - self.tri.adjacent_cells(v_key).collect(); + let seed_cells = + self.collect_local_repair_seed_cells(v_key, &repair_seed_cells); if !seed_cells.is_empty() { let max_flips = if D >= 4 { (seed_cells.len() * (D + 1) * 2).max(8) @@ -3254,8 +3515,19 @@ where } } } + log_bulk_progress_if_due( + BatchProgressSample { + processed: index + 1, + inserted: inserted_vertices, + skipped: skipped_vertices, + cell_count: self.tri.tds.number_of_cells(), + perturbation_seed, + }, + &mut batch_progress, + ); } - Ok((InsertionOutcome::Skipped { error }, stats)) => { + Ok((InsertionOutcome::Skipped { error }, stats, _repair_seed_cells)) => { + skipped_vertices = skipped_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { eprintln!( "[bulk] skipped idx={index} uuid={uuid} attempts={} elapsed={elapsed:?} err={error}", @@ -3291,6 +3563,16 @@ where { let _ = (error, stats); } + log_bulk_progress_if_due( + BatchProgressSample { + processed: index + 1, + inserted: inserted_vertices, + skipped: skipped_vertices, + cell_count: self.tri.tds.number_of_cells(), + perturbation_seed, + }, + &mut batch_progress, + ); } Err(e) => { if trace_insertion && let Some(elapsed) = elapsed { @@ -4329,15 +4611,16 @@ where let coords = *vertex.point().coords(); let hint = candidate.insertion_state.last_inserted_cell; - let (outcome, _stats) = { + let insert_detail = { let (tri, spatial_index) = (&mut candidate.tri, &mut candidate.spatial_index); - tri.insert_with_statistics_seeded_indexed( + tri.insert_with_statistics_seeded_indexed_detailed( vertex, None, hint, seeds.perturbation_seed, spatial_index.as_mut(), + Some(idx), ) .map_err(|e| DelaunayRepairError::HeuristicRebuildFailed { message: format!( @@ -4345,8 +4628,9 @@ where ), })? }; + let repair_seed_cells = insert_detail.repair_seed_cells; - match outcome { + match insert_detail.outcome { InsertionOutcome::Inserted { vertex_key, hint } => { candidate.insertion_state.last_inserted_cell = hint; candidate.insertion_state.delaunay_repair_insertion_count = candidate @@ -4358,6 +4642,7 @@ where .maybe_repair_after_insertion_capped( vertex_key, hint, + &repair_seed_cells, max_flips_override, ) .map_err(|e| DelaunayRepairError::HeuristicRebuildFailed { @@ -5056,18 +5341,20 @@ where let insertion_result = (|| { let hint = self.insertion_state.last_inserted_cell; - let (outcome, _stats) = { + let insert_detail = { let (tri, spatial_index) = (&mut self.tri, &mut self.spatial_index); - tri.insert_with_statistics_seeded_indexed( + tri.insert_with_statistics_seeded_indexed_detailed( vertex, None, hint, 0, spatial_index.as_mut(), + None, )? }; + let repair_seed_cells = insert_detail.repair_seed_cells; - match outcome { + match insert_detail.outcome { InsertionOutcome::Inserted { vertex_key: v_key, hint, @@ -5077,7 +5364,7 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - self.maybe_repair_after_insertion(v_key, hint)?; + self.maybe_repair_after_insertion(v_key, hint, &repair_seed_cells)?; self.maybe_check_after_insertion()?; Ok(v_key) } @@ -5153,25 +5440,28 @@ where let insertion_result = (|| { let hint = self.insertion_state.last_inserted_cell; - let (outcome, stats) = { + let insert_detail = { let (tri, spatial_index) = (&mut self.tri, &mut self.spatial_index); - tri.insert_with_statistics_seeded_indexed( + tri.insert_with_statistics_seeded_indexed_detailed( vertex, None, hint, 0, spatial_index.as_mut(), + None, )? }; + let stats = insert_detail.stats; + let repair_seed_cells = insert_detail.repair_seed_cells; - let outcome = match outcome { + let outcome = match insert_detail.outcome { InsertionOutcome::Inserted { vertex_key, hint } => { self.insertion_state.last_inserted_cell = hint; self.insertion_state.delaunay_repair_insertion_count = self .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - self.maybe_repair_after_insertion(vertex_key, hint)?; + self.maybe_repair_after_insertion(vertex_key, hint, &repair_seed_cells)?; self.maybe_check_after_insertion()?; InsertionOutcome::Inserted { vertex_key, hint } } @@ -5200,16 +5490,23 @@ where &mut self, vertex_key: VertexKey, hint: Option, + extra_seed_cells: &[CellKey], ) -> Result<(), InsertionError> { - self.maybe_repair_after_insertion_capped(vertex_key, hint, None) + self.maybe_repair_after_insertion_capped(vertex_key, hint, extra_seed_cells, None) } /// Like [`maybe_repair_after_insertion`](Self::maybe_repair_after_insertion) but /// forwards an optional per-attempt flip cap to the underlying repair functions. + /// + /// `extra_seed_cells` widens the local repair frontier beyond the inserted vertex + /// star. This is used when cavity reduction shrinks cells out of the conflict + /// region: those cells stay in the triangulation and may still need a local + /// Delaunay revisit even though they are no longer adjacent to the new vertex. fn maybe_repair_after_insertion_capped( &mut self, vertex_key: VertexKey, hint: Option, + extra_seed_cells: &[CellKey], max_flips: Option, ) -> Result<(), InsertionError> { let topology = self.tri.topology_guarantee(); @@ -5220,10 +5517,12 @@ where return Ok(()); } - let seed_cells: Vec = self.tri.adjacent_cells(vertex_key).collect(); + // Prefer the merged local frontier when we have one; otherwise fall back to the + // validated locate hint so repair can still start from the inserted star. + let seed_cells = self.collect_local_repair_seed_cells(vertex_key, extra_seed_cells); let hint_seed = hint.and_then(|ck| { if !self.tri.tds.contains_cell(ck) { - if std::env::var_os("DELAUNAY_REPAIR_TRACE").is_some() { + if env::var_os("DELAUNAY_REPAIR_TRACE").is_some() { tracing::debug!( "[repair] insertion seed hint missing (cell={ck:?}, vertex={vertex_key:?})" ); @@ -5236,7 +5535,7 @@ where .tds .get_cell(ck) .is_some_and(|cell| cell.contains_vertex(vertex_key)); - if !contains_vertex && std::env::var_os("DELAUNAY_REPAIR_TRACE").is_some() { + if !contains_vertex && env::var_os("DELAUNAY_REPAIR_TRACE").is_some() { tracing::debug!( "[repair] insertion seed hint does not contain vertex (cell={ck:?}, vertex={vertex_key:?})" ); @@ -5316,6 +5615,35 @@ where Ok(()) } + /// Merge the inserted vertex star with any cells that cavity reduction touched and + /// left in place. Stale cells are ignored so callers can pass raw cavity-trace sets. + fn collect_local_repair_seed_cells( + &self, + vertex_key: VertexKey, + extra_seed_cells: &[CellKey], + ) -> Vec { + let mut seen: FastHashSet = FastHashSet::default(); + let mut seed_cells = Vec::new(); + + // Keep the inserted vertex star first because it is the hottest local region and + // the best chance of fixing ordinary post-insertion violations cheaply. + for cell_key in self.tri.adjacent_cells(vertex_key) { + if seen.insert(cell_key) { + seed_cells.push(cell_key); + } + } + + // Then widen the frontier with cells touched by cavity shaping that survived in + // the triangulation; deduping here lets callers pass raw trace buffers safely. + for &cell_key in extra_seed_cells { + if self.tri.tds.contains_cell(cell_key) && seen.insert(cell_key) { + seed_cells.push(cell_key); + } + } + + seed_cells + } + /// Runs policy-controlled global validation after insertion so expensive /// Delaunay checks stay opt-in for incremental workflows. fn maybe_check_after_insertion(&self) -> Result<(), InsertionError> { diff --git a/tests/README.md b/tests/README.md index 7effb123..c3016a34 100644 --- a/tests/README.md +++ b/tests/README.md @@ -384,7 +384,16 @@ Integration tests for the `delaunayize_by_flips` workflow validating the public Reproduction-oriented debug harnesses for larger 3D/4D datasets, including ignored tests and bisect-style workflows. -**Run with:** `cargo test --test large_scale_debug -- --ignored --nocapture` (or the `just debug-large-scale-*` helpers) +**Run with:** `cargo test --release --test large_scale_debug -- --ignored --nocapture` (or the `just debug-large-scale-*` helpers) + +**Note:** Use `--release` for runs above roughly 30 vertices; debug-mode +overhead makes large 3D/4D cases look hung even when the algorithm is making +progress. For the `new`/batch path, set +`DELAUNAY_BULK_PROGRESS_EVERY=` to emit periodic batch-construction +summaries. For minimal seeded repros, use the ignored +`debug_large_scale_3d_incremental_prefix_bisect` and +`debug_large_scale_4d_incremental_prefix_bisect` tests (or the matching +`just debug-large-scale-*-incremental-bisect` helpers). #### [`conflict_region_verification.rs`](./conflict_region_verification.rs) diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 2c439347..97a85939 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -10,7 +10,7 @@ //! //! Run one dimension with full output: //! ```bash -//! cargo test --test large_scale_debug debug_large_scale_3d -- --ignored --nocapture +//! cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --nocapture //! ``` //! //! Override defaults via environment variables: @@ -47,7 +47,15 @@ //! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=128 \ //! # Hard wall-clock cap in seconds before the harness aborts (0 = no cap; default: 600) //! DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=600 \ -//! cargo test --test large_scale_debug debug_large_scale_4d -- --ignored --nocapture +//! # Optional: emit periodic batch-construction summaries for new()/Hilbert runs +//! DELAUNAY_BULK_PROGRESS_EVERY=100 \ +//! # Optional: dump the first cavity reduction chain once per run +//! DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE=1 \ +//! # Optional: trace retryable conflict-region skips with attempt/rollback details +//! DELAUNAY_DEBUG_RETRYABLE_SKIP=1 \ +//! # Optional: dump the first detected ridge-fan cavity snapshot once per run +//! DELAUNAY_DEBUG_RIDGE_FAN_ONCE=1 \ +//! cargo test --release --test large_scale_debug debug_large_scale_4d -- --ignored --nocapture //! ``` #![forbid(unsafe_code)] @@ -62,6 +70,7 @@ use delaunay::triangulation::delaunay::{ DelaunayTriangulationConstructionErrorWithStatistics, }; use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; +use std::env; use std::num::NonZeroUsize; use std::time::{Duration, Instant}; @@ -214,11 +223,11 @@ fn parse_u64(s: &str) -> Option { } fn env_u64(name: &str) -> Option { - std::env::var(name).ok().and_then(|v| parse_u64(&v)) + env::var(name).ok().and_then(|v| parse_u64(&v)) } fn env_usize(name: &str) -> Option { - std::env::var(name).ok().and_then(|v| { + env::var(name).ok().and_then(|v| { let trimmed = v.trim(); trimmed.parse().ok().or_else(|| { trimmed @@ -229,7 +238,7 @@ fn env_usize(name: &str) -> Option { } fn env_flag(name: &str) -> bool { - std::env::var(name).ok().is_some_and(|v| { + env::var(name).ok().is_some_and(|v| { let v = v.trim(); !v.is_empty() && v != "0" && v != "false" }) @@ -238,8 +247,27 @@ fn env_flag(name: &str) -> bool { fn init_tracing() { static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { + // Debug-level tracing is needed to surface the release-visible diagnostic hooks + // (retryable-skip, cavity-reduction, ridge-fan-dump, bulk-progress, bulk-retry) + // that are emitted through `tracing::debug!` inside the library. + let debug_env_vars = [ + "DELAUNAY_INSERT_TRACE", + "DELAUNAY_DEBUG_RETRYABLE_SKIP", + "DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE", + "DELAUNAY_DEBUG_RIDGE_FAN_ONCE", + "DELAUNAY_BULK_PROGRESS_EVERY", + "DELAUNAY_DEBUG_SHUFFLE", + ]; + let default_filter = if debug_env_vars + .iter() + .any(|name| env::var_os(name).is_some()) + { + "debug" + } else { + "warn" + }; let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")); + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter)); let _ = tracing_subscriber::fmt() .with_env_filter(filter) .with_test_writer() @@ -297,7 +325,7 @@ impl PointDistribution { } fn env_f64(name: &str) -> Option { - let Ok(raw) = std::env::var(name) else { + let Ok(raw) = env::var(name) else { return None; }; @@ -313,7 +341,7 @@ fn env_f64(name: &str) -> Option { } fn point_distribution_from_env() -> PointDistribution { - let Ok(raw) = std::env::var("DELAUNAY_LARGE_DEBUG_DISTRIBUTION") else { + let Ok(raw) = env::var("DELAUNAY_LARGE_DEBUG_DISTRIBUTION") else { return PointDistribution::Ball; }; @@ -330,7 +358,7 @@ fn point_distribution_from_env() -> PointDistribution { } fn construction_mode_from_env() -> ConstructionMode { - let Ok(raw) = std::env::var("DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE") else { + let Ok(raw) = env::var("DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE") else { return ConstructionMode::New; }; @@ -349,7 +377,7 @@ fn construction_mode_from_env() -> ConstructionMode { } fn debug_mode_from_env() -> DebugMode { - let Ok(raw) = std::env::var("DELAUNAY_LARGE_DEBUG_DEBUG_MODE") else { + let Ok(raw) = env::var("DELAUNAY_LARGE_DEBUG_DEBUG_MODE") else { return DebugMode::Cadenced; }; @@ -867,23 +895,29 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz DebugOutcome::Success } +/// Failure detail captured for one prefix probe in the bisect harness. #[derive(Debug, Clone)] -struct IncrementalFailure3d { +struct IncrementalFailure { prefix_len: usize, index: usize, uuid: uuid::Uuid, - coords: [f64; 3], + coords: [f64; D], error: String, } -fn run_incremental_prefix_3d( - vertices: &[Vertex], +/// Runs one batch-construction probe against a deterministic prefix of the input. +/// +/// The bisect harness uses this helper so every probe exercises the same path as the +/// regular `debug_large_scale_*d` tests: batch/new construction, optional final repair, +/// then full validation. +fn run_incremental_prefix( + vertices: &[Vertex], prefix_len: usize, _repair_every: usize, -) -> Result<(), IncrementalFailure3d> { +) -> Result<(), IncrementalFailure> { let kernel = RobustKernel::::new(); let prefix = &vertices[..prefix_len]; - let mut dt = match DelaunayTriangulation::, (), (), 3>::with_topology_guarantee_and_options_with_construction_statistics( + let mut dt = match DelaunayTriangulation::, (), (), D>::with_topology_guarantee_and_options_with_construction_statistics( &kernel, prefix, TopologyGuarantee::PLManifoldStrict, @@ -894,15 +928,17 @@ fn run_incremental_prefix_3d( let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics, .. } = err; + // Attribute constructor failures to the last successfully processed prefix slot + // so the operator gets a stable replay anchor instead of an abstract count. let idx = statistics .inserted .saturating_sub(1) .min(prefix_len.saturating_sub(1)); let (uuid, coords) = prefix.get(idx).copied().map_or_else( - || (uuid::Uuid::nil(), [0.0; 3]), + || (uuid::Uuid::nil(), [0.0; D]), |vertex| (vertex.uuid(), *vertex.point().coords()), ); - return Err(IncrementalFailure3d { + return Err(IncrementalFailure { prefix_len, index: idx, uuid, @@ -922,10 +958,10 @@ fn run_incremental_prefix_3d( if !env_flag("DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS") && skipped_total > 0 { let idx = prefix_len.saturating_sub(1); let (uuid, coords) = prefix.get(idx).copied().map_or_else( - || (uuid::Uuid::nil(), [0.0; 3]), + || (uuid::Uuid::nil(), [0.0; D]), |vertex| (vertex.uuid(), *vertex.point().coords()), ); - return Err(IncrementalFailure3d { + return Err(IncrementalFailure { prefix_len, index: idx, uuid, @@ -936,6 +972,8 @@ fn run_incremental_prefix_3d( }); } + // Keep the bisect path aligned with the main debug harness by allowing an optional + // final repair pass before we classify the prefix as a validation failure. if !env_flag("DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR") && dt.number_of_cells() > 0 { let _ = dt.repair_delaunay_with_flips_advanced(DelaunayRepairHeuristicConfig::default()); } @@ -943,14 +981,14 @@ fn run_incremental_prefix_3d( if let Err(report) = dt.validation_report() { let idx = prefix_len.saturating_sub(1); let (uuid, coords) = prefix.get(idx).copied().map_or_else( - || (uuid::Uuid::nil(), [0.0; 3]), + || (uuid::Uuid::nil(), [0.0; D]), |vertex| (vertex.uuid(), *vertex.point().coords()), ); let detail = report.violations.first().map_or_else( || "no violations captured".to_string(), |violation| format!("{:?}: {}", violation.kind, violation.error), ); - return Err(IncrementalFailure3d { + return Err(IncrementalFailure { prefix_len, index: idx, uuid, @@ -962,51 +1000,61 @@ fn run_incremental_prefix_3d( Ok(()) } -#[test] -#[ignore = "large-scale debug harness (manual run)"] #[expect( clippy::too_many_lines, reason = "Debug harness intentionally verbose for reproducibility and operator guidance" )] -fn debug_large_scale_3d_incremental_prefix_bisect() { +/// Binary-searches the smallest prefix that still reproduces a batch-construction failure. +/// +/// This is intentionally dimension-generic so 3D and 4D seeded repros can share the +/// same workflow while keeping their own defaults and replay commands. +fn debug_large_scale_incremental_prefix_bisect( + dimension_name: &str, + default_total_n: usize, + default_case_seed: Option, +) { init_tracing(); let total_n = env_usize("DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL") - .unwrap_or(1000) - .max(4); + .unwrap_or(default_total_n) + .max(D + 1); let base_seed = env_u64("DELAUNAY_LARGE_DEBUG_SEED").unwrap_or(42); - let case_seed = env_u64("DELAUNAY_LARGE_DEBUG_CASE_SEED_3D") + let case_seed = env_u64(&format!("DELAUNAY_LARGE_DEBUG_CASE_SEED_{D}D")) .or_else(|| env_u64("DELAUNAY_LARGE_DEBUG_CASE_SEED")) - .unwrap_or_else(|| seed_for_case::<3>(base_seed, total_n)); + .or(default_case_seed) + .unwrap_or_else(|| seed_for_case::(base_seed, total_n)); let ball_radius = env_f64("DELAUNAY_LARGE_DEBUG_BALL_RADIUS").unwrap_or(100.0); let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(128); let max_probes = env_usize("DELAUNAY_LARGE_DEBUG_PREFIX_MAX_PROBES"); let max_runtime_secs = env_usize("DELAUNAY_LARGE_DEBUG_PREFIX_MAX_RUNTIME_SECS").unwrap_or(0); println!("============================================="); - println!("3D incremental prefix bisect"); + println!("{dimension_name} incremental prefix bisect"); println!("============================================="); println!("Config:"); + println!(" D: {D}"); println!(" total_n: {total_n}"); println!(" base_seed: 0x{base_seed:X} ({base_seed})"); println!(" case_seed: 0x{case_seed:X} ({case_seed})"); println!(" ball_radius: {ball_radius}"); - println!(" repair_every: {repair_every}"); - println!(" probe_mode: new (batch, matches debug_large_scale_3d default)"); + println!(" repair_every: {repair_every} (ignored in batch/new mode)"); + println!(" probe_mode: new (batch, matches debug_large_scale_{D}d default)"); println!(" max_probes: {max_probes:?}"); println!(" max_runtime_secs:{max_runtime_secs}"); println!(); - let points = generate_random_points_in_ball_seeded::(total_n, ball_radius, case_seed) + let points = generate_random_points_in_ball_seeded::(total_n, ball_radius, case_seed) .unwrap_or_else(|e| { - panic!("failed to generate deterministic 3D ball points for bisect: {e}") + panic!("failed to generate deterministic {dimension_name} ball points for bisect: {e}") }); - let vertices: Vec> = points.into_iter().map(|p| vertex!(p)).collect(); + let vertices: Vec> = points.into_iter().map(|p| vertex!(p)).collect(); let t_bisect = Instant::now(); let mut probe_count = 0usize; - let mut run_probe = |prefix_len: usize| -> Option> { + let mut run_probe = |prefix_len: usize| -> Option>> { + // The probe limiter keeps manual investigations bounded even when a single prefix + // is expensive, which matters for the 4D retry-collapse cases. if let Some(limit) = max_probes && probe_count >= limit { @@ -1028,7 +1076,7 @@ fn debug_large_scale_3d_incremental_prefix_bisect() { probe_count = probe_count.saturating_add(1); let t_probe = Instant::now(); - let result = run_incremental_prefix_3d(&vertices, prefix_len, repair_every); + let result = run_incremental_prefix(&vertices, prefix_len, repair_every); println!( " probe #{probe_count}: prefix_len={prefix_len} -> {} ({:?})", if result.is_err() { "FAIL" } else { "PASS" }, @@ -1040,7 +1088,9 @@ fn debug_large_scale_3d_incremental_prefix_bisect() { let first_failure = match run_probe(total_n) { None => return, Some(Ok(())) => { - if let Err(mismatch) = run_incremental_prefix_3d(&vertices, total_n, repair_every) { + // Re-run the full prefix outside the closure to catch any accidental harness + // mismatch before we report that the seed no longer fails. + if let Err(mismatch) = run_incremental_prefix(&vertices, total_n, repair_every) { println!( "HARNESS MISMATCH: bisect full-prefix probe passed but canonical full-prefix recheck failed." ); @@ -1058,14 +1108,14 @@ fn debug_large_scale_3d_incremental_prefix_bisect() { "Config recap: base_seed=0x{base_seed:X} case_seed=0x{case_seed:X} ball_radius={ball_radius} repair_every={repair_every} mode=new" ); println!( - "To force a failure, increase DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL or adjust DELAUNAY_LARGE_DEBUG_CASE_SEED_3D." + "To force a failure, increase DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL or adjust DELAUNAY_LARGE_DEBUG_CASE_SEED_{D}D." ); return; } Some(Err(err)) => err, }; - let mut lo = 4; + let mut lo = D + 1; let mut hi = first_failure.prefix_len.max(lo); println!( "Full-run first failure: idx={} (prefix_len={})", @@ -1073,6 +1123,8 @@ fn debug_large_scale_3d_incremental_prefix_bisect() { ); println!("Initial binary-search range: [{lo}, {hi}]"); + // Standard binary search: shrink toward the first failing prefix while preserving + // the invariant that everything below `lo` is known-good and `hi` is known-bad. while lo < hi { let mid = lo + (hi - lo) / 2; let Some(result) = run_probe(mid) else { @@ -1098,6 +1150,7 @@ fn debug_large_scale_3d_incremental_prefix_bisect() { Some(Err(err)) => err, }; + // Double-check the boundary so we can trust the replay command we print below. if minimal_prefix > 4 { assert!( run_probe(minimal_prefix - 1).is_some_and(|result| result.is_ok()), @@ -1116,10 +1169,22 @@ fn debug_large_scale_3d_incremental_prefix_bisect() { println!(); println!("Replay command:"); println!( - " DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE=new DELAUNAY_LARGE_DEBUG_N_3D={minimal_prefix} DELAUNAY_LARGE_DEBUG_CASE_SEED_3D=0x{case_seed:X} DELAUNAY_REPAIR_DEBUG_FACETS=1 cargo test --test large_scale_debug debug_large_scale_3d -- --ignored --nocapture" + " DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE=new DELAUNAY_LARGE_DEBUG_N_{D}D={minimal_prefix} DELAUNAY_LARGE_DEBUG_CASE_SEED_{D}D=0x{case_seed:X} DELAUNAY_REPAIR_DEBUG_FACETS=1 cargo test --release --test large_scale_debug debug_large_scale_{D}d -- --ignored --nocapture" ); } +#[test] +#[ignore = "large-scale debug harness (manual run)"] +fn debug_large_scale_3d_incremental_prefix_bisect() { + debug_large_scale_incremental_prefix_bisect::<3>("3D", 1000, None); +} + +#[test] +#[ignore = "large-scale debug harness (manual run)"] +fn debug_large_scale_4d_incremental_prefix_bisect() { + debug_large_scale_incremental_prefix_bisect::<4>("4D", 500, Some(0xD225_B8A0_7E27_4AE6)); +} + /// Regression test for issue #228: 3D 1000-point flip-repair non-convergence. /// /// Before the fix, `AdaptiveKernel`'s exact+SoS predicates were overridden by From 8c110f3d1eac51ca189eb608fd6f09715afde879 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Thu, 23 Apr 2026 16:38:09 -0700 Subject: [PATCH 02/11] fix: close the 4D bulk repair retry collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise the D≥4 per-insertion repair budget, add a rate-limited escalation pass, and widen local post-repair validation so the 500-point #204 repro converges without skipped vertices. - Preserve removed-cell snapshots and predecessor context in flip diagnostics, drop stale repair seeds after cavity reduction, and re-export locate conflict diagnostics from the prelude. - Replace committed `eprintln!` diagnostics in production, tests, and benches with `tracing`, using `test-debug` and `bench-logging` gates and keeping logs out of Criterion hot loops. - Document the #204 investigation, refresh the 4D known-issues and TODO notes, and record the repository logging policy plus release-visible debug environment variables. --- AGENTS.md | 21 +- benches/large_scale_performance.rs | 57 ++- benches/microbenchmarks.rs | 45 +- benches/profiling_suite.rs | 27 +- docs/KNOWN_ISSUES_4D.md | 108 +++-- docs/TODO.md | 41 +- docs/archive/issue_204_investigation.md | 271 +++++++++++ docs/dev/debug_env_vars.md | 27 +- docs/dev/rust.md | 31 +- src/core/algorithms/flips.rs | 89 +++- src/core/triangulation.rs | 10 + src/core/util/deduplication.rs | 7 +- src/geometry/util/measures.rs | 8 +- src/geometry/util/point_generation.rs | 13 +- src/geometry/util/triangulation_generation.rs | 27 +- src/lib.rs | 8 +- src/triangulation/delaunay.rs | 433 +++++++++++++++--- tests/delaunay_edge_cases.rs | 76 ++- tests/delaunay_repair_fallback.rs | 39 +- tests/large_scale_debug.rs | 9 +- tests/proptest_delaunay_triangulation.rs | 41 +- tests/regressions.rs | 63 +++ tests/storage_backend_compatibility.rs | 64 ++- 23 files changed, 1300 insertions(+), 215 deletions(-) create mode 100644 docs/archive/issue_204_investigation.md diff --git a/AGENTS.md b/AGENTS.md index 6f1cd16a..78d9317e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -319,8 +319,25 @@ degenerate input, and tests under `tests/proptest_sos.rs` enforce that. `Vertex`, `Cell`, `Facet`, `Ridge`, `InSphere`, `Orientation`, `insphere`, `circumcenter`, `circumradius`. Avoid Rust‑ecosystem abstractions that obscure the math. -- Use `tracing::{debug,info,warn,error}!` for all runtime diagnostics. - Never `eprintln!` / `println!` outside examples and benches. +- Use `tracing::{debug,info,warn,error}!` for committed diagnostics + across production code, tests, and benchmarks, especially for + library/runtime code, non-trivial test diagnostics, and debugging of + numerical or topological invariants. +- `eprintln!` is acceptable only for short-lived local debugging while + investigating a problem; remove it before landing changes. +- Never log inside hot benchmark loops or Criterion-measured closures. + Emit setup/summary diagnostics outside the measured path instead. +- Gate non-essential test/benchmark diagnostics behind feature flags. + In this repository use `test-debug` for test diagnostics and + `bench-logging` for benchmark diagnostics, e.g.: + + ```rust + #[cfg(feature = "test-debug")] + tracing::debug!("test diagnostic"); + + #[cfg(feature = "bench-logging")] + tracing::debug!("diagnostic message"); + ``` ### Scientific notation in docs diff --git a/benches/large_scale_performance.rs b/benches/large_scale_performance.rs index 87722849..448b9dca 100644 --- a/benches/large_scale_performance.rs +++ b/benches/large_scale_performance.rs @@ -88,7 +88,37 @@ use std::sync::{Mutex, OnceLock}; use std::time::Duration; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System}; +#[cfg(feature = "bench-logging")] +fn init_tracing() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init(); + }); +} + +#[cfg(not(feature = "bench-logging"))] +const fn init_tracing() {} + +macro_rules! bench_info { + ($($arg:tt)*) => {{ + #[cfg(feature = "bench-logging")] + { + init_tracing(); + tracing::info!($($arg)*); + } + }}; +} + /// Memory usage information for benchmarking (in KiB) +#[cfg_attr( + not(feature = "bench-logging"), + expect( + dead_code, + reason = "Memory fields are unpacked by optional bench diagnostics" + ) +)] #[derive(Debug, Clone)] struct MemoryInfo { before: u64, @@ -104,7 +134,7 @@ fn get_memory_usage() -> u64 { // Log memory unit on first call for clarity in all benchmark runs UNIT_LOGGED.call_once(|| { - eprintln!("[INFO] Memory measurements in KiB (sysinfo::Process::memory() / 1024)"); + bench_info!("Memory measurements in KiB (sysinfo::Process::memory() / 1024)"); }); let pid = sysinfo::get_current_pid().expect("Failed to get current PID"); @@ -126,7 +156,8 @@ fn get_memory_usage() -> u64 { /// Get the deterministic base seed for random point generation. /// Reads `DELAUNAY_BENCH_SEED` (decimal or 0x-hex). Defaults to 42. -/// Prints the resolved seed once on first use if `PRINT_BENCH_SEED` is set. +/// Logs the resolved seed once on first use if `PRINT_BENCH_SEED` is set and +/// the `bench-logging` feature is enabled. fn get_benchmark_seed() -> u64 { static SEED: OnceLock = OnceLock::new(); *SEED.get_or_init(|| { @@ -141,7 +172,7 @@ fn get_benchmark_seed() -> u64 { .unwrap_or(42); if std::env::var("PRINT_BENCH_SEED").is_ok() { - eprintln!("Benchmark seed: 0x{seed:X} ({seed})"); + bench_info!("Benchmark seed: 0x{seed:X} ({seed})"); } seed @@ -281,16 +312,21 @@ fn bench_memory_usage(c: &mut Criterion, dimension_name: &str, n // Single measurement for memory delta - reduce sample size group.sample_size(10); + #[cfg(feature = "bench-logging")] + if std::env::var_os("BENCH_PRINT_MEM").is_some() { + let mem_info = measure_construction_with_memory::(n_points, seed); + bench_info!( + "Memory sample: before={} KiB, after={} KiB, delta={} KiB (TDS-only: {} KiB)", + mem_info.before, + mem_info.after, + mem_info.delta, + mem_info.tds_delta + ); + } + group.bench_function("construction_memory_delta", |b| { b.iter(|| { let mem_info = measure_construction_with_memory::(n_points, seed); - // Report memory usage to stderr (won't interfere with benchmark timing) - if std::env::var_os("BENCH_PRINT_MEM").is_some() { - eprintln!( - "Memory: before={} KiB, after={} KiB, delta={} KiB (TDS-only: {} KiB)", - mem_info.before, mem_info.after, mem_info.delta, mem_info.tds_delta - ); - } black_box(mem_info) }); }); @@ -527,6 +563,7 @@ fn bench_5d_suite(c: &mut Criterion) { criterion_group!( name = large_scale_benches; config = { + init_tracing(); let sample_size = std::env::var("BENCH_SAMPLE_SIZE") .ok() .and_then(|v| v.parse::().ok()) diff --git a/benches/microbenchmarks.rs b/benches/microbenchmarks.rs index 8568f3cb..40d6daee 100644 --- a/benches/microbenchmarks.rs +++ b/benches/microbenchmarks.rs @@ -20,9 +20,43 @@ use delaunay::vertex; use std::hint::black_box; use std::sync::OnceLock; +#[cfg(feature = "bench-logging")] +fn init_tracing() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init(); + }); +} + +#[cfg(not(feature = "bench-logging"))] +const fn init_tracing() {} + +macro_rules! bench_info { + ($($arg:tt)*) => {{ + #[cfg(feature = "bench-logging")] + { + init_tracing(); + tracing::info!($($arg)*); + } + }}; +} + +macro_rules! bench_warn { + ($($arg:tt)*) => {{ + #[cfg(feature = "bench-logging")] + { + init_tracing(); + tracing::warn!($($arg)*); + } + }}; +} + /// Get the deterministic seed for random point generation. /// Reads `DELAUNAY_BENCH_SEED` (decimal or 0x-hex). Defaults to 0xD1EA. -/// Prints the resolved seed once on first use if `PRINT_BENCH_SEED` is set. +/// Logs the resolved seed once on first use if `PRINT_BENCH_SEED` is set and +/// the `bench-logging` feature is enabled. fn get_benchmark_seed() -> u64 { static SEED: OnceLock = OnceLock::new(); *SEED.get_or_init(|| { @@ -36,7 +70,7 @@ fn get_benchmark_seed() -> u64 { }) .unwrap_or(0xD1EA); if std::env::var("PRINT_BENCH_SEED").is_ok() { - eprintln!("Benchmark seed: 0x{seed:X} ({seed})"); + bench_info!("Benchmark seed: 0x{seed:X} ({seed})"); } seed }) @@ -330,6 +364,7 @@ generate_incremental_construction_benchmarks!(5); /// This allows CI and local tuning without code changes. fn bench_config() -> Criterion { use std::time::Duration; + init_tracing(); let mut c = Criterion::default(); if let Some(v) = std::env::var("CRIT_SAMPLE_SIZE") @@ -338,7 +373,7 @@ fn bench_config() -> Criterion { { c = c.sample_size(v); } else if std::env::var("CRIT_SAMPLE_SIZE").is_ok() { - eprintln!("Warning: Failed to parse CRIT_SAMPLE_SIZE, using default"); + bench_warn!("Failed to parse CRIT_SAMPLE_SIZE, using default"); } if let Some(v) = std::env::var("CRIT_MEASUREMENT_MS") @@ -347,7 +382,7 @@ fn bench_config() -> Criterion { { c = c.measurement_time(Duration::from_millis(v)); } else if std::env::var("CRIT_MEASUREMENT_MS").is_ok() { - eprintln!("Warning: Failed to parse CRIT_MEASUREMENT_MS, using default"); + bench_warn!("Failed to parse CRIT_MEASUREMENT_MS, using default"); } if let Some(v) = std::env::var("CRIT_WARMUP_MS") @@ -356,7 +391,7 @@ fn bench_config() -> Criterion { { c = c.warm_up_time(Duration::from_millis(v)); } else if std::env::var("CRIT_WARMUP_MS").is_ok() { - eprintln!("Warning: Failed to parse CRIT_WARMUP_MS, using default"); + bench_warn!("Failed to parse CRIT_WARMUP_MS, using default"); } c diff --git a/benches/profiling_suite.rs b/benches/profiling_suite.rs index 67902a4b..46313e3c 100644 --- a/benches/profiling_suite.rs +++ b/benches/profiling_suite.rs @@ -66,6 +66,30 @@ use serde::{Serialize, de::DeserializeOwned}; use std::hint::black_box; use std::time::{Duration, Instant}; +#[cfg(feature = "bench-logging")] +fn init_tracing() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init(); + }); +} + +#[cfg(not(feature = "bench-logging"))] +const fn init_tracing() {} + +#[cfg(not(feature = "count-allocations"))] +macro_rules! bench_warn { + ($($arg:tt)*) => {{ + #[cfg(feature = "bench-logging")] + { + init_tracing(); + tracing::warn!($($arg)*); + } + }}; +} + // SmallBuffer size constants for different use cases const BENCHMARK_ITERATION_BUFFER_SIZE: usize = 8; // For tracking allocation info across benchmark iterations const SIMPLEX_VERTICES_BUFFER_SIZE: usize = 4; // 3D simplex = 4 vertices @@ -102,7 +126,7 @@ fn print_count_allocations_banner_once() { use std::sync::Once; static ONCE: Once = Once::new(); ONCE.call_once(|| { - eprintln!("count-allocations feature not enabled; memory stats are placeholders."); + bench_warn!("count-allocations feature not enabled; memory stats are placeholders."); }); } @@ -826,6 +850,7 @@ fn benchmark_algorithmic_bottlenecks(c: &mut Criterion) { criterion_group!( name = profiling_benches; config = { + init_tracing(); // Allow configuration via environment variables for CI stability let sample_size = std::env::var("BENCH_SAMPLE_SIZE") .ok() diff --git a/docs/KNOWN_ISSUES_4D.md b/docs/KNOWN_ISSUES_4D.md index 2cf9cb70..88833d0e 100644 --- a/docs/KNOWN_ISSUES_4D.md +++ b/docs/KNOWN_ISSUES_4D.md @@ -4,57 +4,83 @@ ### Re-verified on 2026-04-23 (release mode) -These release-mode reruns supersede the old 35-point 3D and 100-point 4D -correctness failures described below: +These release-mode reruns supersede the old 35-point 3D, 100-point 4D, and +500-point 4D correctness failures described below: - 3D seed `0xE30C78582376677C` now passes at 35 vertices and at 1000 vertices. - The 3D 1000-prefix bisect reports no failing prefix for that seed. -- 4D seed `0x9B7786C999C56A16` now inserts 100/100 vertices with zero skips and - passes validation in about 15.4s total wall time. +- 4D seed `0x9B7786C999C56A16` now completes the 100-point batch: attempt 0 + finishes with `inserted=86`, `skipped=14`, and the shuffled retry 1 + (`perturbation_seed=0x34D84963BCC98F21`) inserts 100/100 vertices with zero + skips and passes validation in about 15.4s total wall time. +- 4D seed `0xD225B8A07E274AE6` now inserts **500/500** vertices with zero + skips on the first attempt (no perturbation retries triggered) and passes + Level 1–4 validation in ~233s total wall time. See the + "Historical 4D 500-point retry-collapse reproducer (now fixed)" section + below and `docs/archive/issue_204_investigation.md` for the Fix 2 details. - The remaining open part of #204 is the default 4D 3000-point batch run, which now has progress instrumentation and is clearly a scale/observability - problem rather than the earlier 35/100-point correctness repros. + problem rather than the earlier correctness repros. ### Current issues -#### 4D+ bulk construction retry collapse - -Large-scale 4D bulk construction still has a deterministic seeded failure mode -in release mode. The historical 100-point negative-orientation skip repro is -fixed, but larger seeded cases can still enter a skip-heavy path where the -input-order attempt and every shuffled retry finish with a Delaunay-property -violation after repeated conflict-region ridge-fan degeneracies. - -**Severity:** High (4D batch-construction correctness / runtime) -**Affects:** seeded 4D batch construction at a few hundred vertices and above; -the default 3000-point large-scale debug harness remains especially expensive -to investigate. -**Recommended workaround:** use release-mode runs and smaller seeded probes when -you need quick iteration; prefer incremental insertion for production 4D -workloads if large batch runtimes are unacceptable. - -**Current rechecks (2026-04-23):** - -- 4D 100-point batch construction (release -mode, seed `0x9B7786C999C56A16`, ball radius=100) inserts **100 of 100** -vertices, skips **0**, and passes validation in ~15.4s total wall time. -- The same 4D 100-point run now exposes the retry boundary directly: attempt 0 - finishes with `inserted=86`, `skipped=14`, then retry 1 - (`perturbation_seed=0x34D84963BCC98F21`) inserts **100 of 100** and passes. +#### 4D+ bulk construction at very large scale + +The historical correctness failures at 35–100 vertices in 3D and 100–500 +vertices in 4D are now fixed. What remains is a scale/runtime concern: the +default 3000-point 4D large-scale debug harness is expensive to investigate +and may still degrade at very large input counts without a bounded test fixture. + +**Severity:** Medium (4D batch-construction runtime / observability) +**Affects:** the default 3000-point large-scale debug harness and any +similarly large seeded 4D batch inputs. +**Recommended workaround:** use release-mode runs and smaller seeded probes +when you need quick iteration; prefer incremental insertion for production +4D workloads if large batch runtimes are unacceptable. + +#### Historical 4D 500-point retry-collapse reproducer (now fixed) + +Before Fix 2 of the #204 plan, 4D seed `0xD225B8A07E274AE6` (ball radius 100, +allow skips) exhausted all 7 shuffled retries. Each attempt finished with +`inserted≈266–300`, `skipped≈200–234`, and the same final error: +`Cell violates Delaunay property: cell contains vertex that is inside +circumsphere`. Representative skip samples were dominated by +`Conflict region error: Ridge fan detected: 4 facets share ridge with 3 +vertices`. + +**Root cause:** the D≥4 per-insertion local-repair flip budget was too tight +(50-flip ceiling vs. observed `max_queue` p95 = 312), so repair never drained +its backlog. The D≥4 soft-fail arm then silently continued after each failed +local repair, accumulating unresolved k=2 postcondition violations and +negative-orientation cells into the next insertion's conflict BFS. See +`docs/archive/issue_204_investigation.md` for the full measurement and +root-cause analysis. + +**Fix (2026-04-23):** Fix 2 of the #204 plan raised the D≥4 flip budget +(`LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4 = 12`, +`LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4 = 96`) and added one escalation pass +(`LOCAL_REPAIR_ESCALATION_BUDGET_FACTOR_D_GE_4 = 4`, rate-limited by +`LOCAL_REPAIR_ESCALATION_MIN_GAP = 8`) with the full TDS as seed set before +the soft-fail arm accepts a non-convergent repair. The #307 orientation +relaxation stays in place so flip repair still has its chance at eventual +consistency; the budget bump is what lets that chance materialize. Regression +coverage lives in +`tests/regressions.rs::regression_issue_204_4d_500_local_repair_budget` +(gated behind `slow-tests`). + +**Current recheck (2026-04-23):** + - 4D 500-point batch construction (release mode, seed `0xD225B8A07E274AE6`, - ball radius=100, allow skips) is now a smaller deterministic failure repro. - Attempts 0 through 6 all finish invalid after roughly 78–95s each, ending - with `inserted≈266–300`, `skipped≈200–234`, and the same final error: - `Cell violates Delaunay property: cell contains vertex that is inside circumsphere`. - Representative skip samples are dominated by - `Conflict region error: Ridge fan detected: 4 facets share ridge with 3 vertices`. + ball radius=100) inserts **500 of 500** vertices, skips **0**, and passes + `validation_report` (Levels 1–4) in ~233.4s total wall time. +- Only 2 local-repair budget hits were observed, both resolved by the new + escalation path. No `DisconnectedBoundary`, `RidgeFan`, or + `postcondition k=2` retryable-skip traces fired (down from 501 / 31 / 711 + respectively on the pre-fix run). - 4D 3000-point batch construction (release mode, seed `0xE7E6701F918B07FA`, - ball radius=100) now emits periodic batch-progress summaries. On the first - attempt (`perturbation_seed=0x0`), it had reached: - - processed 100/3000: inserted 81, skipped 19, elapsed ~4.27s - - processed 300/3000: inserted 221, skipped 79, elapsed ~36.11s - - processed 500/3000: inserted 324, skipped 176, elapsed ~94.70s - This run was manually interrupted after capturing the 500-point progress mark. + ball radius=100) still emits periodic batch-progress summaries. Large-scale + runtime characterisation at 3000+ points remains open under the + "4D+ bulk construction at very large scale" item above. #### Historical 3D flip-cycle reproducer (now fixed) diff --git a/docs/TODO.md b/docs/TODO.md index ef8d2893..bd7370cf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,6 +1,6 @@ # TODO — Weaknesses & Risks -**As of:** 2026-04-12 · post-v0.7.5 (main, unreleased) +**As of:** 2026-04-23 · post-v0.7.5 (main, unreleased) Identified during a full codebase evaluation. Items are grouped by category and prioritized by severity. Scoping notes reference the *next release* @@ -55,22 +55,33 @@ optimization needed. **Status:** profiling can begin; targeted fixes possible if bottlenecks are clear. +### ✅ ~~4D 500-point local-repair retry collapse (#204)~~ — FIXED + +The 4D 500-point seed `0xD225B8A07E274AE6` (ball radius 100) used to exhaust +all 7 shuffled retries with `inserted≈266–300`, `skipped≈200–234`, and a final +`Cell violates Delaunay property: cell contains vertex that is inside +circumsphere` error. Fix 2 of the #204 plan (budget raise + one escalation +pass; see `docs/archive/issue_204_investigation.md`) resolved it: the same +seed now inserts 500/500 vertices with zero skips and passes Level 1–4 +validation in ~233s. + +**Status:** release-mode recheck completed on 2026-04-23; regression coverage +lives in `tests/regressions.rs::regression_issue_204_4d_500_local_repair_budget` +(gated behind `slow-tests`). Continue #204 on the remaining 3000-point +scale/observability work item below. + ### 🟡 4D large-scale batch runtime / observability (#204) -The known 100-point correctness repro is fixed, but larger seeded 4D release -batch runs still degrade into skip-heavy retries and can fail all shuffled -attempts. The clearest bounded repro is now the 500-point seed -`0xD225B8A07E274AE6`, which spent ~595.9s exhausting attempts 0..6 before -failing with `Cell violates Delaunay property: cell contains vertex that is -inside circumsphere`. - -**Status:** 2026-04-23 rechecks confirmed the 100-point case is healthy and the -new retry-boundary instrumentation is working. The 500-point seeded repro shows -attempts ending around `inserted≈266–300`, `skipped≈200–234`, with skip samples -dominated by `Conflict region error: Ridge fan detected: 4 facets share ridge -with 3 vertices`. Continue #204 by tracing that conflict-region ridge-fan path -through the retryable skip logic rather than treating the issue as pure -observability. +The default 4D 3000-point large-scale debug harness still exercises a +batch-construction path that is expensive to investigate and has no bounded +test fixture. With the 100-point and 500-point correctness repros closed, +what's left is a runtime/observability concern at very large input counts. + +**Status:** PR #339 added batch-progress, retry-boundary, and retryable-skip +instrumentation that make 3000-point runs tractable to monitor. Future #204 +work should (a) characterise the 3000-point run's steady-state runtime in +release mode and (b) decide whether to add a bounded 3000-point regression +fixture or keep large-scale coverage as a manual debug harness only. --- diff --git a/docs/archive/issue_204_investigation.md b/docs/archive/issue_204_investigation.md new file mode 100644 index 00000000..307cf2ed --- /dev/null +++ b/docs/archive/issue_204_investigation.md @@ -0,0 +1,271 @@ +# Issue #204 — 4D 500-point retry collapse investigation + +**Date:** 2026-04-23 +**Branch:** `fix/204-large-scale-debug` (post PR #339 instrumentation) +**Seed:** 4D, ball radius 100, `DELAUNAY_LARGE_DEBUG_CASE_SEED_4D=0xD225B8A07E274AE6`, 500 points, `DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1` +**Raw log:** `logs/2026-04-23-4d-ridge-fan-500.log` (2238 lines; run aborted at 240s wall clock) + +## Scope + +Capture the structure of the 500-point 4D ridge-fan retry collapse described in +`docs/KNOWN_ISSUES_4D.md` and `docs/TODO.md §2` so we can pick the smallest fix +that actually closes it, rather than one that only addresses the visible symptom. + +## What the instrumentation said + +Counts across the partial run (the timeout interrupted attempt 0 before it +completed): + +- `kind=disconnected_boundary` retryable-skip lines: **501** +- `kind=ridge_fan` retryable-skip lines: **31** +- `kind=non_manifold` / `kind=open_boundary`: 0 each +- `normalize_and_promote_positive_orientation: N cells still appear negative` + warnings: dozens, with N ranging from 2 up to 33 (seen repeatedly from + insertion index ~60 onward) +- `negative geometric orientation detected during validation` (cell passing + validation with `orientation=-1`): dozens, same pattern +- `bulk D≥4: per-insertion repair non-convergent; continuing`: hundreds, for + both `Delaunay repair failed to converge after 40/50 flips` and + `Delaunay repair postcondition failed: local k=2 violation remains` + +Representative first ridge fan (line 42, insertion index ≈ 61): + +- `D=4`, `conflict_cells=156`, `boundary_facets=190` +- `facet_count=4`, `ridge_vertex_count=3` +- `extra_cells=[CellKey(482v9), CellKey(785v1)]` +- `participating_boundary_indices=[30, 52, 103, 150]` +- Ridge vertices: `VertexKey(35v1)`, `VertexKey(40v1)`, `VertexKey(49v1)` (and + `VertexKey(61v1)` appears as the fourth vertex of boundary_idx=30's facet) + +Representative retryable-skip sequence for `bulk_index=158`: + +- Attempts 1–4, all with `conflict=kind=disconnected_boundary visited=5 + total=10 disconnected_cells=1`, identical counts across perturbation retries. +- `cells_before_attempt=cells_after_rollback=2389`, + `vertices_before_attempt=vertices_after_rollback=157`. Rollback works, so + state across retries really is the same. + +## Mapping traces onto the plan's hypotheses + +The plan in `#204 fix` proposed four hypotheses. The traces refute or re-weight +them as follows. + +### H1 — Cospherical inclusion produces ridge fans + +Partially true but not the dominant mode. Ridge-fan is only 6% of retryable +skips; the cavity-reduction log (`ridge_fan_shrink` then `reextract`) succeeds +at unwinding most ridge fans, thanks to PR #339's cross-fan accumulation. The +31 remaining ridge-fan skips are all that survive reduction; the other 94% of +skips are `DisconnectedBoundary`. + +### H2 — Cavity reduction cannot converge + +Partially true, but the reason is different. On the first ridge-fan sample, +`cavity_reduction` emits exactly two events: + +1. iteration=0 `initial_ok boundary_facets=40` +2. iteration=1 `no_reduction_rule_matched` + +So the first cavity was fine for that insertion. The problem is that +subsequent insertions keep hitting `DisconnectedBoundary`, which can only +reduce via EXPAND-with-non-conflict-neighbors or SHRINK-fallback, and the trace +shows it often escapes neither. + +### H3 — Perturbation step too small + +**False.** Attempts 1–4 for the same `bulk_index` produce identical +`visited/total/disconnected_cells` counts. That is the canonical signature of +perturbation not changing the conflict-region topology at all, which only +happens when the surrounding triangulation is itself non-manifold — the +cavity BFS walks the same broken neighbor graph regardless of the vertex's +exact coordinates. Fix C from the plan would have no effect here. + +### H4 — Skip-driven post-construction violation + +**Partially true, but not the root cause.** The final +`Cell violates Delaunay property: cell contains vertex that is inside +circumsphere` error is downstream of something earlier: by the time skips +start accumulating, hundreds of cells with orientation = −1 have already been +retained by `normalize_and_promote_positive_orientation`, and the flip-repair +path has accepted hundreds of unresolved k=2 violations. The cavity BFS walks +into these cells and rightly reports `DisconnectedBoundary`, because the +triangulation is no longer a valid manifold at that point. + +## Actual root cause + +**Repair acceptance of broken state, compounded over insertions.** + +Two code paths in the bulk-construction loop swallow violations that should +be hard failures in 4D: + +1. `normalize_and_promote_positive_orientation` accepts "residual negative + cells" after its bounded promotion passes, logging them as "likely + near-degenerate FP noise". In D≥4 the residual can be tens of cells per + insertion; these survive and break the geometric invariants + downstream. +2. `bulk D≥4: per-insertion repair non-convergent; continuing` soft-fails both + `Delaunay repair failed to converge after N flips` and + `Delaunay repair postcondition failed: local k=2 violation remains` — with + a 50-flip per-attempt ceiling and queues that routinely show + `max_queue=271`. The queue is growing faster than it drains. + +Once the triangulation has negative-orientation cells and unresolved local +violations in it, the conflict-region BFS for the next insertion walks an +inconsistent neighbor graph, producing `DisconnectedBoundary` skips that +perturbation cannot repair. + +## Revised fix direction + +The fix must live entirely inside the repair and retry layers. The #307 +orientation relaxation (accepting residual negative-orientation cells after +bounded promotion) stays in place so the flip-repair path still has its +chance at eventual consistency; the problem is that that chance isn't +actually being granted today. + +- **Fix 2 — Raise the per-insertion flip budget for D≥4 and escalate + before soft-fail.** The observed queue sizes (180–271) dwarf the 50-flip + ceiling. Quadruple the D≥4 budget, and before the soft-fail logs and + continues, escalate once to a 4× budget with the full TDS as seed set. +- **Fix 3 — Abort the retry loop early when perturbation yields identical + conflict-region counts.** If attempt `n` rolls back to the same cell/vertex + counts and produces the same `conflict=kind=...` detail as attempt `n−1`, + further perturbation is pointless; surface it as a non-retryable skip + instead of burning the remaining attempts that always fail. +- **Fix 4 — Triggered global-repair cadence when local repair stalls.** + Count consecutive D≥4 soft-fails; when the counter crosses a threshold, + synchronously invoke the existing global flip-repair entry point with a + bounded budget and reset. This is what finally gives eventual consistency + a chance to materialize on the stream of cumulative violations that the + #307 relaxation intentionally permits. + +Tightening `normalize_and_promote_positive_orientation` itself is out of +scope: the relaxation exists by design (resolved #307), and the fix target +is making the flip-repair path actually drain what the relaxation allows +to accumulate. Fixes A/B/C from the original plan are deferred: they either +target the wrong layer (A, B) or are a no-op on the observed data (C). + +## Non-findings worth recording + +- The one-shot `ridge-fan-dump` emitted exactly one entry before the test + aborted; the dump is firing at the first detected fan, as intended. +- `bulk-progress` traces show the first attempt reaches + `processed=150 total_vertices=500 inserted=149 skipped=1 cells=2282` in + about 11.8s. The timeout hit mid-attempt, not during shuffled retries, so + the "all 7 shuffled retries fail" summary in the old KNOWN_ISSUES_4D note + is still unverified on this branch at 240s; longer runs are needed to + confirm, though the per-attempt shape clearly degrades. +- No `non_manifold_facet` / `open_boundary` retryable skips fired. The + cavity-reduction path handles those cleanly when they do occur. + +## Step 2a measurement — Actual flip-budget demand (2026-04-23) + +Extracted from `logs/2026-04-23-4d-ridge-fan-500.log` using read-only +grep/awk pipelines: + +### Failure-mode breakdown (total 922 `bulk D` soft-fail events in the run) + +- `Delaunay repair failed to converge after N flips`: **211** (23%). +- `Delaunay repair postcondition failed: local k=2 violation remains`: **711** (77%). + +The postcondition-failure plurality is significant: raising the flip budget +addresses the 23% convergence-failure slice; the 77% postcondition slice is +where Fix 4's triggered global repair was expected to contribute. + +### `max_queue` at convergence failure (n=211) + +```text +min=91 p50=207 p90=281 p95=312 p99=409 max=416 mean=210.7 +``` + +Interpretation: even the minimum queue observed (91) is nearly 2× the +typical 50-flip local-repair budget (`seed_cells.len() * (D+1) * 2` with +`seed_cells.len()≈5`, so `5*5*2 = 50`). At p95 the queue reaches 312 — the +repair is provably never able to drain the backlog inside the budget. + +### `checked_facets` at convergence failure (n=211) + +```text +min=104 p50=1180 p90=1338 p95=1379 max=7585 mean=1168.1 +``` + +Shows the work the repair is doing before the budget kills it. These are +traversal counts, not flip counts. + +### Which budget did each failure hit? (`failed to converge after N flips`) + +```text +N=10: 6 N=20: 14 N=30: 4 N=40: 3 +N=50: 179 (85% of convergence failures) +N=60: 3 N=280: 1 N=310: 1 +``` + +Budget ∈ {10, 20, 30, 40, 50, 60, 280, 310} reflects the +`seed_cells.len() * (D+1) * 2, floor=8` formula across varying cavity sizes. +The dominant 85% hit N=50 (typical 5-cell seed set). + +### Chosen Fix 2 constants + +Declared as `pub(crate) const` in `src/triangulation/delaunay.rs`: + +- `LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4 = 12` (was inline `2`) +- `LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4 = 96` (was inline `8`) +- `LOCAL_REPAIR_ESCALATION_BUDGET_FACTOR_D_GE_4 = 4` +- `LOCAL_REPAIR_ESCALATION_MIN_GAP = 8` + +Budget evaluated for a typical 5-cell seed set becomes `5*5*12 = 300`, +which covers p50 (207) and p90 (281) and brushes p95 (312). The tail +(p95–p99, 312–409) is handled by the escalation path (4× budget with the +full TDS as seed set, rate-limited by `ESCALATION_MIN_GAP`). + +The `_D_GE_4` suffix keeps the 2D/3D paths unchanged. D<4 retains the +existing `* 4, floor=16` formula which is already adequate for its repair +queues. + +### Follow-up measurement (deferred) + +The data above is from a budget-limited run: every sample is a failure. +We do not know the distribution of `flips_performed` at successful local +repair exit (nobody logs it today). If Fix 2 does not hold up on a larger +seed, add a `tracing::debug!` at successful exit from +`repair_delaunay_local_*` logging `(flips, max_queue, seed_cells)` and +re-run to get the *success* distribution. + +## Step 2 result — Fix 2 alone closed the 500-point 4D case (2026-04-23) + +Run recorded in `logs/2026-04-23-4d-fix2-full-500.log`. With the budget +constants above plus the escalation path in place, the 500-point 4D seed +`0xD225B8A07E274AE6` now: + +- Inserts **500 of 500 vertices with 0 skips**. +- Uses `max_attempts=1` (no perturbation retries triggered across the entire + run). +- Removes 0 cells during insertion repair. +- Completes batch insertion in **229.8 s**, final flip-repair in 2.37 s + (`flips=0`: triangulation already Delaunay), and validation in 1.24 s, for + a total wall time of **233.4 s**. +- Passes full `validation_report` (Levels 1–4). + +During the 500-point insertion the shorter 90-second probe observed only two +local-repair budget hits, both resolved by the escalation path +(`escalation succeeded: 2`, `escalation also non-convergent: 0`, +`failed to converge (local budget hit): 2`). No `DisconnectedBoundary`, +`RidgeFan`, or `postcondition k=2` soft-fails fired — a complete collapse of +the pre-fix failure-mode distribution (501 / 31 / 711 respectively). + +Implications for the remaining plan: + +- **Fix 3 (no-progress perturbation detection)** is not needed for this case: + perturbation is never invoked. The no-progress detector would be useful for + future seeds that still hit perturbation exhaustion, but it is no longer + on the critical path for #204. +- **Fix 4 (triggered global-repair cadence)** is not needed for this case: + the local repair's raised budget plus one escalation drained the backlog + entirely, so consecutive soft-fails never accumulate to the threshold. +- Both remain documented in the plan as contingent fallbacks if a future + seed (e.g. larger point counts or a different distribution) surfaces a + residual failure that the budget + escalation cannot close. + +## Next step + +Proceed to Step 3 of the plan (regression test) and Step 4 (documentation +and TODO refresh). diff --git a/docs/dev/debug_env_vars.md b/docs/dev/debug_env_vars.md index 8abdb86b..bffdad0d 100644 --- a/docs/dev/debug_env_vars.md +++ b/docs/dev/debug_env_vars.md @@ -1,9 +1,14 @@ # Debug Environment Variables Comprehensive reference for all `DELAUNAY_*` environment variables used for -runtime diagnostics and debugging. All variables are **debug-only** unless -noted — they are gated behind `#[cfg(debug_assertions)]` and have no effect -in release builds. +runtime diagnostics and debugging. + +**Build mode**: most variables are **debug-only** — gated behind +`#[cfg(debug_assertions)]` and have no effect in release builds. Variables +marked `[release]` in the tables below are also active in release builds +(read via plain `env::var_os` / `env::var` without a debug-assertions gate). +This matters for large-scale investigations that need to run under +`cargo test --release` or `cargo bench`. **Activation**: most variables are presence-activated (any value works, e.g. `DELAUNAY_DEBUG_CAVITY=1`). Variables that read a **value** are marked below. @@ -35,12 +40,12 @@ in release builds. | Variable | Activation | Module | Description | |---|---|---|---| -| `DELAUNAY_INSERT_TRACE` | presence | `triangulation.rs` | Per-insertion summary (vertex index, location, conflict size, suspicion flags) | -| `DELAUNAY_BULK_PROGRESS_EVERY` | **value** (integer) | `triangulation/delaunay.rs` | Periodic batch progress plus retry-boundary output. | -| `DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE` | presence | `triangulation.rs` | One-shot trace of the first cavity reduction chain and each re-extraction outcome. | -| `DELAUNAY_DEBUG_RETRYABLE_SKIP` | presence | `triangulation.rs` | Retryable conflict skip trace with attempt and rollback context. | +| `DELAUNAY_INSERT_TRACE` | presence | `triangulation.rs` | `[release]` Per-insertion summary (vertex index, location, conflict size, suspicion flags) | +| `DELAUNAY_BULK_PROGRESS_EVERY` | **value** (integer) | `triangulation/delaunay.rs` | `[release]` Periodic batch progress plus retry-boundary output. | +| `DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE` | presence | `triangulation.rs` | `[release]` One-shot trace of first cavity reduction chain + re-extractions. | +| `DELAUNAY_DEBUG_RETRYABLE_SKIP` | presence | `triangulation.rs` | `[release]` Retryable conflict skip trace with attempt and rollback context. | | `DELAUNAY_DEBUG_SHUFFLE` | presence | `triangulation.rs` | Logs vertex shuffle order during batch construction | -| `DELAUNAY_DUPLICATE_METRICS` | presence | `triangulation/delaunay.rs` | Duplicate-detection metrics (spatial hash grid stats) | +| `DELAUNAY_DUPLICATE_METRICS` | presence | `triangulation/delaunay.rs` | `[release]` Duplicate-detection metrics (spatial hash grid stats) | ## Point Location @@ -57,7 +62,7 @@ in release builds. | `DELAUNAY_DEBUG_CONFLICT_PROGRESS` | presence | `locate.rs` | Periodic progress during large BFS traversals | | `DELAUNAY_DEBUG_CONFLICT_PROGRESS_EVERY` | **value** (integer) | `locate.rs` | Interval for progress logging (default: dimension-dependent) | | `DELAUNAY_DEBUG_CONFLICT_VERIFY` | presence | `triangulation.rs` | Brute-force verification of BFS conflict-region completeness with reachability analysis | -| `DELAUNAY_DEBUG_RIDGE_FAN_ONCE` | presence | `locate.rs` | One-shot dump of the first detected ridge fan (ridge vertices, boundary facets, extra cells). | +| `DELAUNAY_DEBUG_RIDGE_FAN_ONCE` | presence | `locate.rs` | `[release]` One-shot dump of first detected ridge fan (ridge verts, boundary facets, extras). | ## Cavity & Hull @@ -86,11 +91,11 @@ in release builds. |---|---|---|---| | `DELAUNAY_REPAIR_TRACE` | presence | `flips.rs` | Per-flip trace: enqueue, skip, apply, context details | | `DELAUNAY_REPAIR_DEBUG_FACETS` | presence | `flips.rs` | Facet-level flip skip reasons (degenerate, duplicate, non-manifold, existing simplex) | -| `DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET` | presence | `flips.rs` | One-shot snapshot of the first unresolved k=2 facet with last-flip overlap | +| `DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET` | presence | `flips.rs` | `[release]` One-shot snapshot of the first unresolved k=2 facet with last-flip overlap | | `DELAUNAY_REPAIR_DEBUG_PREDICATES` | presence | `flips.rs` | Insphere classification details for k=2 and k=3 violation checks | | `DELAUNAY_REPAIR_DEBUG_RIDGE` | presence | `flips.rs` | Ridge context snapshots during k=3 repair | | `DELAUNAY_REPAIR_DEBUG_RIDGE_LIMIT` | **value** (integer) | `flips.rs` | Maximum ridge debug snapshots (default: 64) | -| `DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY` | **value** (integer) | `flips.rs` | Skip low-multiplicity snapshots; emit when `found >= N` (default: 0). | +| `DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY` | **value** (integer) | `flips.rs` | `[release]` Skip low-mult; emit when `found >= N` (default: 0). | | `DELAUNAY_REPAIR_DEBUG_SUMMARY` | presence | `flips.rs` | Per-attempt repair summary (flips, checks, cycles, ambiguous, skips) | ## Predicates & Validation diff --git a/docs/dev/rust.md b/docs/dev/rust.md index deae13bc..78c0fcf6 100644 --- a/docs/dev/rust.md +++ b/docs/dev/rust.md @@ -726,8 +726,11 @@ with migration guidance. ## Logging and Diagnostics -Use `tracing` for all runtime diagnostics. **Never use `eprintln!`** or -`println!` for debug output — use `tracing::debug!`, `tracing::trace!`, etc. +Use `tracing` for committed diagnostics across production code, tests, +and benchmarks. This includes library/runtime code, non-trivial test +diagnostics, and debugging of numerical instability or topological +invariants. Prefer `tracing::debug!`, `tracing::trace!`, etc. over +ad-hoc printing. This ensures all diagnostic output is: @@ -735,6 +738,10 @@ This ensures all diagnostic output is: - structured and machine-parseable - suppressible in production builds +`eprintln!` is acceptable only for short-lived local debugging while +investigating an issue. Do not leave it in committed code when `tracing` +or a typed error path is more appropriate. + Debug hooks gated on environment variables should still use `tracing`: ```rust @@ -744,6 +751,26 @@ if std::env::var_os("DELAUNAY_DEBUG_FOO").is_some() { } ``` +### Tests and Benchmarks + +- Use `tracing` for non-trivial test diagnostics rather than + `eprintln!`, especially when diagnosing geometric predicate behavior, + invariant failures, or shrink/reproduction context. +- Never log inside hot benchmark loops or Criterion-measured closures. + Emit diagnostics before or after the measured path so measurements stay + meaningful. +- Gate non-essential test and benchmark diagnostics behind feature flags. + In this repository, use `test-debug` for test diagnostics and + `bench-logging` for benchmark diagnostics: + +```rust +#[cfg(feature = "test-debug")] +tracing::debug!("test diagnostic"); + +#[cfg(feature = "bench-logging")] +tracing::debug!("benchmark diagnostic"); +``` + --- ## Preferred Patch Style diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 9e7a9743..1d9669fc 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -409,6 +409,24 @@ where message: e.to_string(), })?; + // Snapshot the removed cells' vertex lists BEFORE removal so postcondition + // diagnostics can reconstruct the lost simplices. After + // `tds.remove_cells_by_keys` runs, `tds.get_cell(removed_key)` returns + // `None`, which would strip the most useful context from predecessor-flip + // traces (see #204 investigation). + let removed_cell_vertices: SmallBuffer< + SmallBuffer, + MAX_PRACTICAL_DIMENSION_SIZE, + > = removed_cells + .iter() + .copied() + .map(|cell_key| { + tds.get_cell(cell_key) + .map(|cell| cell.vertices().iter().copied().collect()) + .unwrap_or_default() + }) + .collect(); + tds.remove_cells_by_keys(removed_cells); debug_assert!( @@ -423,6 +441,7 @@ where new_cells, removed_face_vertices: removed_face_vertices.iter().copied().collect(), inserted_face_vertices: inserted_face_vertices.iter().copied().collect(), + removed_cell_vertices, }) } @@ -1091,12 +1110,11 @@ where .copied() .map(|cell_key| cell_vertex_summary(tds, cell_key)) .collect(); - let predecessor_removed_cell_vertices: Vec = last_applied_flip - .removed_cells - .iter() - .copied() - .map(|cell_key| cell_vertex_summary(tds, cell_key)) - .collect(); + // Removed cells are already deleted from the TDS by the time this summary + // runs, so reach for the pre-flip snapshot in `LastAppliedFlip` to avoid + // emitting "CellKey(N): missing" for every entry. + let predecessor_removed_cell_vertices: Vec = + last_applied_flip.removed_cell_vertex_lines(); format!( "k={} removed_face={:?} inserted_face={:?} removed_cells={:?} new_cells={:?} incident_cells_in_new={incident_cells_in_new:?} incident_cells_in_removed={incident_cells_in_removed:?} predecessor_new_cell_vertices={predecessor_new_cell_vertices:?} predecessor_removed_cell_vertices={predecessor_removed_cell_vertices:?}", @@ -1742,6 +1760,12 @@ pub enum FlipError { /// SmallBuffer::new(); /// inserted_face_vertices.push(VertexKey::from(KeyData::from_ffi(4))); /// +/// let mut removed_cell_vertices: SmallBuffer< +/// SmallBuffer, +/// MAX_PRACTICAL_DIMENSION_SIZE, +/// > = SmallBuffer::new(); +/// removed_cell_vertices.push(SmallBuffer::new()); +/// /// let info: FlipInfo<3> = FlipInfo { /// kind: BistellarFlipKind::k2(3), /// direction: FlipDirection::Forward, @@ -1749,6 +1773,7 @@ pub enum FlipError { /// new_cells, /// removed_face_vertices, /// inserted_face_vertices, +/// removed_cell_vertices, /// }; /// assert_eq!(info.kind.k(), 2); /// ``` @@ -1766,6 +1791,17 @@ pub struct FlipInfo { pub removed_face_vertices: SmallBuffer, /// The inserted-face simplex (complementary simplex). pub inserted_face_vertices: SmallBuffer, + /// Snapshot of each removed cell's vertex list, captured **before** the + /// flip's `remove_cells_by_keys` call. Entries correspond 1:1 with + /// `removed_cells`. An empty inner buffer indicates the cell was already + /// missing at snapshot time. + /// + /// This exists so postcondition diagnostics can reconstruct the removed + /// simplices after the keys in `removed_cells` are stale. + pub removed_cell_vertices: SmallBuffer< + SmallBuffer, + MAX_PRACTICAL_DIMENSION_SIZE, + >, } /// Const-generic flip context for a k-move (forward or inverse). @@ -3692,6 +3728,7 @@ where &config, &mut diagnostics, mode, + last_applied_flip, )?; verify_postcondition_inverse_k2_edges( tds, @@ -3862,6 +3899,10 @@ where /// Rechecks queued ridges after repair so higher-dimensional k=3 violations get /// the same explicit postcondition treatment as facets. +#[expect( + clippy::too_many_arguments, + reason = "Postcondition replay threads topology, diagnostics, and predecessor context explicitly (matches k=2 signature)" +)] fn verify_postcondition_k3_ridges( tds: &Tds, kernel: &K, @@ -3870,6 +3911,7 @@ fn verify_postcondition_k3_ridges( config: &RepairAttemptConfig, diagnostics: &mut RepairDiagnostics, mode: PostconditionMode, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -3926,6 +3968,11 @@ where "[repair] postcondition k=3 violation remains (ridge={ridge:?})" ); } + // Emit the ridge adjacency snapshot — including the immediately + // preceding flip, when available — so #204-style ridge + // diagnostics carry the same predecessor-flip context as the + // k=2 facet path via `debug_postcondition_facet_context`. + debug_ridge_context(tds, ridge, None, last_applied_flip); return Err(DelaunayRepairError::PostconditionFailed { message: format!("local k=3 violation remains after repair (ridge={ridge:?})"), }); @@ -4367,6 +4414,13 @@ struct LastAppliedFlip { inserted_face_vertices: SmallBuffer, removed_cells: CellKeyBuffer, new_cells: CellKeyBuffer, + /// Snapshot of each removed cell's vertex list captured before the flip's + /// `remove_cells_by_keys` call; pairs 1:1 with `removed_cells`. Empty + /// inner buffers indicate snapshot-time-missing cells. + removed_cell_vertices: SmallBuffer< + SmallBuffer, + MAX_PRACTICAL_DIMENSION_SIZE, + >, } impl LastAppliedFlip { @@ -4388,6 +4442,7 @@ impl LastAppliedFlip { inserted_face_vertices, removed_cells: CellKeyBuffer::new(), new_cells: CellKeyBuffer::new(), + removed_cell_vertices: SmallBuffer::new(), } } @@ -4401,8 +4456,30 @@ impl LastAppliedFlip { ); last.removed_cells.clone_from(&info.removed_cells); last.new_cells.clone_from(&info.new_cells); + last.removed_cell_vertices + .clone_from(&info.removed_cell_vertices); last } + + /// Formats each removed cell as `CellKey(N): vertices=[...]` using the + /// snapshot captured before the flip's cell removal. Falls back to + /// `missing-snapshot` when the snapshot row is empty (either the cell was + /// gone at snapshot time or `Self::new` produced the placeholder). + fn removed_cell_vertex_lines(&self) -> Vec { + self.removed_cells + .iter() + .copied() + .enumerate() + .map( + |(idx, cell_key)| match self.removed_cell_vertices.get(idx) { + Some(verts) if !verts.is_empty() => { + format!("{cell_key:?}: vertices={verts:?}") + } + _ => format!("{cell_key:?}: missing-snapshot"), + }, + ) + .collect() + } } /// Catches two-step flip oscillations before they inflate repair diagnostics or diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 4fa8eee9..394f980f 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -4718,6 +4718,16 @@ where Some(&conflict_cells), )?; + // Drop any repair-seed entries that are about to be deleted. Cavity + // reduction shrinks `conflict_cells` in place; the cells that were in + // the *initial* conflict region but remain in the final reduced set + // will be removed by `remove_cells_by_keys` below, so their keys + // become stale. Callers filter with `contains_cell` as a safety net, + // but the contract of `repair_seed_cells` is "cells that participated + // in cavity shaping and survived", so the filter belongs here. + let dead_conflict_cells: FastHashSet = conflict_cells.iter().copied().collect(); + repair_seed_cells.retain(|ck| !dead_conflict_cells.contains(ck)); + // Remove conflict cells (now that new cells are wired up) let _removed_count = self.tds.remove_cells_by_keys(&conflict_cells); diff --git a/src/core/util/deduplication.rs b/src/core/util/deduplication.rs index 44f46a6d..b45359f5 100644 --- a/src/core/util/deduplication.rs +++ b/src/core/util/deduplication.rs @@ -124,7 +124,7 @@ where U: DataType, { if epsilon < T::zero() { - eprintln!("dedup_vertices_epsilon received negative epsilon; enforcing contract"); + tracing::error!("dedup_vertices_epsilon received negative epsilon; enforcing contract"); } assert!( epsilon >= T::zero(), @@ -240,8 +240,9 @@ pub(crate) fn coords_within_epsilon( .fold(T::zero(), |acc, d| acc + d); let epsilon_sq = epsilon * epsilon; - if cfg!(debug_assertions) && dist_sq == epsilon_sq { - eprintln!( + #[cfg(debug_assertions)] + if dist_sq == epsilon_sq { + tracing::debug!( "[dedup_vertices_epsilon] distance equals epsilon; keeping point (strict < epsilon)" ); } diff --git a/src/geometry/util/measures.rs b/src/geometry/util/measures.rs index 35b25375..4f65a78e 100644 --- a/src/geometry/util/measures.rs +++ b/src/geometry/util/measures.rs @@ -81,10 +81,10 @@ where { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_UNUSED_IMPORTS").is_some() { - eprintln!( - "measures::simplex_volume called (points_len={}, D={})", - points.len(), - D + tracing::debug!( + points_len = points.len(), + dimension = D, + "measures::simplex_volume called" ); } if points.len() != D + 1 { diff --git a/src/geometry/util/point_generation.rs b/src/geometry/util/point_generation.rs index 13ec00e8..5c9049a8 100644 --- a/src/geometry/util/point_generation.rs +++ b/src/geometry/util/point_generation.rs @@ -157,7 +157,11 @@ pub fn generate_random_points Result>, RandomPointGenerationError> { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_UNUSED_IMPORTS").is_some() { - eprintln!("point_generation::generate_random_points called (n_points={n_points}, D={D})"); + tracing::debug!( + n_points, + dimension = D, + "point_generation::generate_random_points called" + ); } // Validate range if range.0 >= range.1 { @@ -227,8 +231,11 @@ pub fn generate_random_points_seeded(seed_cells_len: usize) -> usize { + let (factor, floor) = if D >= 4 { + ( + LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4, + LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4, + ) + } else { + ( + LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4, + LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4, + ) + }; + let raw = seed_cells_len.saturating_mul(D + 1).saturating_mul(factor); + if raw > floor { raw } else { floor } +} + thread_local! { static HEURISTIC_REBUILD_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -3137,6 +3242,99 @@ 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 + /// canonicalization failures after a successful escalation, which are + /// 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 = { + 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" + ); + if stats.flips_performed > 0 { + self.canonicalize_after_bulk_repair()?; + } + Ok(LocalRepairEscalationOutcome::Succeeded { stats }) + } + Err(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, + }) + } + } + } + /// Inserts the non-simplex vertices under a fixed perturbation seed so bulk /// construction retries are reproducible. #[allow(clippy::too_many_lines)] @@ -3167,16 +3365,24 @@ where let started = Instant::now(); BatchProgressState { // The initial simplex is already present when this loop starts, so progress - // and throughput should only count the remaining bulk vertices. - total_vertices: vertices.len(), + // and throughput only count the remaining bulk vertices — the counters live + // in a "bulk-only" frame, 0…(input_len - (D+1)). + total_vertices: vertices.len().saturating_sub(D + 1), progress_every, started, last_progress: started, - last_processed: D + 1, + last_processed: 0, } }); - let mut inserted_vertices = D + 1; + // Bulk-only counters: `inserted_vertices` and `skipped_vertices` track work done + // inside this loop and sum to `offset + 1` after each iteration, so the logged + // 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; match construction_stats { None => { @@ -3197,7 +3403,7 @@ where }); if trace_insertion && let Some(coords) = coords.as_ref() { - eprintln!("[bulk] start idx={index} uuid={uuid} coords={coords:?}"); + tracing::debug!(index, %uuid, coords = ?coords, "[bulk] start"); } let started = trace_insertion.then(std::time::Instant::now); @@ -3241,9 +3447,7 @@ where )) => { inserted_vertices = inserted_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { - eprintln!( - "[bulk] inserted idx={index} uuid={uuid} elapsed={elapsed:?}" - ); + tracing::debug!(index, %uuid, elapsed = ?elapsed, "[bulk] inserted"); } // Cache hint for faster subsequent insertions. self.insertion_state.last_inserted_cell = hint; @@ -3274,11 +3478,7 @@ where let seed_cells = self.collect_local_repair_seed_cells(v_key, &repair_seed_cells); if !seed_cells.is_empty() { - let max_flips = if D >= 4 { - (seed_cells.len() * (D + 1) * 2).max(8) - } else { - (seed_cells.len() * (D + 1) * 4).max(16) - }; + let max_flips = local_repair_flip_budget::(seed_cells.len()); let repair_result = { let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); repair_delaunay_local_single_pass( @@ -3314,21 +3514,69 @@ where self.canonicalize_after_bulk_repair()?; continue; } - tracing::debug!( - error = %repair_err, - idx = index, - "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()); + // 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. + 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" + ); + 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()); + } + } } } } } log_bulk_progress_if_due( BatchProgressSample { - processed: index + 1, + processed: offset + 1, inserted: inserted_vertices, skipped: skipped_vertices, cell_count: self.tri.tds.number_of_cells(), @@ -3340,9 +3588,13 @@ where Ok((InsertionOutcome::Skipped { error }, stats, _repair_seed_cells)) => { skipped_vertices = skipped_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { - eprintln!( - "[bulk] skipped idx={index} uuid={uuid} attempts={} elapsed={elapsed:?} err={error}", - stats.attempts + tracing::debug!( + index, + %uuid, + attempts = stats.attempts, + elapsed = ?elapsed, + error = %error, + "[bulk] skipped" ); } // Keep going: this vertex was intentionally skipped (e.g. duplicate/near-duplicate @@ -3359,7 +3611,7 @@ where } log_bulk_progress_if_due( BatchProgressSample { - processed: index + 1, + processed: offset + 1, inserted: inserted_vertices, skipped: skipped_vertices, cell_count: self.tri.tds.number_of_cells(), @@ -3370,8 +3622,12 @@ where } Err(e) => { if trace_insertion && let Some(elapsed) = elapsed { - eprintln!( - "[bulk] failed idx={index} uuid={uuid} elapsed={elapsed:?} err={e}" + tracing::debug!( + index, + %uuid, + elapsed = ?elapsed, + error = %e, + "[bulk] failed" ); } // Non-retryable failure: abort construction with a structured error. @@ -3396,7 +3652,7 @@ where }); if trace_insertion && let Some(coords) = coords.as_ref() { - eprintln!("[bulk] start idx={index} uuid={uuid} coords={coords:?}"); + tracing::debug!(index, %uuid, coords = ?coords, "[bulk] start"); } let started = trace_insertion.then(std::time::Instant::now); @@ -3440,9 +3696,12 @@ where )) => { inserted_vertices = inserted_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { - eprintln!( - "[bulk] inserted idx={index} uuid={uuid} attempts={} elapsed={elapsed:?}", - stats.attempts + tracing::debug!( + index, + %uuid, + attempts = stats.attempts, + elapsed = ?elapsed, + "[bulk] inserted" ); } construction_stats.record_insertion(&stats); @@ -3463,11 +3722,7 @@ where let seed_cells = self.collect_local_repair_seed_cells(v_key, &repair_seed_cells); if !seed_cells.is_empty() { - let max_flips = if D >= 4 { - (seed_cells.len() * (D + 1) * 2).max(8) - } else { - (seed_cells.len() * (D + 1) * 4).max(16) - }; + let max_flips = local_repair_flip_budget::(seed_cells.len()); let repair_result = { let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); repair_delaunay_local_single_pass( @@ -3503,21 +3758,69 @@ where self.canonicalize_after_bulk_repair()?; continue; } - tracing::debug!( - error = %repair_err, - idx = index, - "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()); + // 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. + 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" + ); + 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()); + } + } } } } } log_bulk_progress_if_due( BatchProgressSample { - processed: index + 1, + processed: offset + 1, inserted: inserted_vertices, skipped: skipped_vertices, cell_count: self.tri.tds.number_of_cells(), @@ -3529,9 +3832,13 @@ where Ok((InsertionOutcome::Skipped { error }, stats, _repair_seed_cells)) => { skipped_vertices = skipped_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { - eprintln!( - "[bulk] skipped idx={index} uuid={uuid} attempts={} elapsed={elapsed:?} err={error}", - stats.attempts + tracing::debug!( + index, + %uuid, + attempts = stats.attempts, + elapsed = ?elapsed, + error = %error, + "[bulk] skipped" ); } construction_stats.record_insertion(&stats); @@ -3565,7 +3872,7 @@ where } log_bulk_progress_if_due( BatchProgressSample { - processed: index + 1, + processed: offset + 1, inserted: inserted_vertices, skipped: skipped_vertices, cell_count: self.tri.tds.number_of_cells(), @@ -3576,8 +3883,12 @@ where } Err(e) => { if trace_insertion && let Some(elapsed) = elapsed { - eprintln!( - "[bulk] failed idx={index} uuid={uuid} elapsed={elapsed:?} err={e}" + tracing::debug!( + index, + %uuid, + elapsed = ?elapsed, + error = %e, + "[bulk] failed" ); } // Non-retryable failure: abort construction with a structured error. @@ -5595,10 +5906,22 @@ where // introduce PL-manifold violations (e.g., disconnected ridge links). Catch those // locally and surface an insertion error so the outer transactional guard can roll // back the insertion. + // + // The validation scope must match what repair actually touched: the inserted + // vertex star (which may have grown via flips) **plus** any still-alive cells + // from the pre-repair seed frontier. Otherwise a violation introduced in an + // `extra_seed_cells` cell that is no longer adjacent to the new vertex would + // slip past this safety-net. if topology.requires_ridge_links() { - let local_cells: Vec = self.tri.adjacent_cells(vertex_key).collect(); - if !local_cells.is_empty() - && let Err(err) = validate_ridge_links_for_cells(&self.tri.tds, &local_cells) + let mut validation_cells: Vec = self.tri.adjacent_cells(vertex_key).collect(); + let mut seen: FastHashSet = validation_cells.iter().copied().collect(); + for &cell_key in &seed_cells { + if self.tri.tds.contains_cell(cell_key) && seen.insert(cell_key) { + validation_cells.push(cell_key); + } + } + if !validation_cells.is_empty() + && let Err(err) = validate_ridge_links_for_cells(&self.tri.tds, &validation_cells) { return Err(InsertionError::TopologyValidationFailed { message: "Topology invalid after Delaunay repair".to_string(), diff --git a/tests/delaunay_edge_cases.rs b/tests/delaunay_edge_cases.rs index c43ab381..b25b58da 100644 --- a/tests/delaunay_edge_cases.rs +++ b/tests/delaunay_edge_cases.rs @@ -16,8 +16,13 @@ use rand::seq::SliceRandom; fn init_tracing() { static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { + let default_filter = if cfg!(feature = "test-debug") { + "info" + } else { + "warn" + }; let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")); + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter)); let _ = tracing_subscriber::fmt() .with_env_filter(filter) .with_test_writer() @@ -25,6 +30,26 @@ fn init_tracing() { }); } +#[cfg(feature = "test-debug")] +macro_rules! test_debug_info { + ($($arg:tt)*) => {{ + #[cfg(feature = "test-debug")] + { + tracing::info!($($arg)*); + } + }}; +} + +#[cfg(feature = "test-debug")] +macro_rules! test_debug_warn { + ($($arg:tt)*) => {{ + #[cfg(feature = "test-debug")] + { + tracing::warn!($($arg)*); + } + }}; +} + // ========================================================================= // Regression Tests - Known Failing Configurations // ========================================================================= @@ -163,13 +188,21 @@ fn debug_issue_120_empty_circumsphere_5d() { .unwrap_or_else(|err| panic!("5D debug configuration failed to construct: {err}")); match dt.repair_delaunay_with_flips() { Ok(stats) => { - eprintln!( + #[cfg(feature = "test-debug")] + test_debug_info!( "[Issue #120 debug] repair_delaunay_with_flips stats: checked={}, flips={}, max_queue={}", - stats.facets_checked, stats.flips_performed, stats.max_queue_len + stats.facets_checked, + stats.flips_performed, + stats.max_queue_len ); + #[cfg(not(feature = "test-debug"))] + let _ = &stats; } Err(err) => { - eprintln!("[Issue #120 debug] repair_delaunay_with_flips error: {err}"); + #[cfg(feature = "test-debug")] + test_debug_warn!("[Issue #120 debug] repair_delaunay_with_flips error: {err}"); + #[cfg(not(feature = "test-debug"))] + let _ = &err; } } let mut dt_robust: DelaunayTriangulation, (), (), 5> = @@ -180,17 +213,28 @@ fn debug_issue_120_empty_circumsphere_5d() { ); match dt_robust.repair_delaunay_with_flips() { Ok(stats) => { - eprintln!( + #[cfg(feature = "test-debug")] + test_debug_info!( "[Issue #120 debug] robust repair stats: checked={}, flips={}, max_queue={}", - stats.facets_checked, stats.flips_performed, stats.max_queue_len + stats.facets_checked, + stats.flips_performed, + stats.max_queue_len ); + #[cfg(not(feature = "test-debug"))] + let _ = &stats; } Err(err) => { - eprintln!("[Issue #120 debug] robust repair error: {err}"); + #[cfg(feature = "test-debug")] + test_debug_warn!("[Issue #120 debug] robust repair error: {err}"); + #[cfg(not(feature = "test-debug"))] + let _ = &err; } } if let Err(err) = dt_robust.is_valid() { - eprintln!("[Issue #120 debug] robust triangulation still invalid: {err:?}"); + #[cfg(feature = "test-debug")] + test_debug_warn!("[Issue #120 debug] robust triangulation still invalid: {err:?}"); + #[cfg(not(feature = "test-debug"))] + let _ = &err; } let mut rng = rand::rngs::StdRng::seed_from_u64(0x1200_5eed); for attempt in 0..20 { @@ -201,7 +245,8 @@ fn debug_issue_120_empty_circumsphere_5d() { TopologyGuarantee::PLManifold, ) { if dt_alt.is_valid().is_ok() { - eprintln!( + #[cfg(feature = "test-debug")] + test_debug_info!( "[Issue #120 debug] found valid triangulation after shuffle attempt {}", attempt + 1 ); @@ -209,21 +254,28 @@ fn debug_issue_120_empty_circumsphere_5d() { } } if attempt == 19 { - eprintln!("[Issue #120 debug] no valid triangulation found in 20 shuffles"); + #[cfg(feature = "test-debug")] + test_debug_warn!("[Issue #120 debug] no valid triangulation found in 20 shuffles"); } } for (cell_key, cell) in dt.cells() { - eprintln!("[Issue #120 debug] cell {cell_key:?}:"); + #[cfg(feature = "test-debug")] + test_debug_info!("[Issue #120 debug] cell {cell_key:?}:"); + #[cfg(not(feature = "test-debug"))] + let _ = cell_key; for &vkey in cell.vertices() { let vertex = dt .tds() .get_vertex_by_key(vkey) .expect("vertex key should exist"); - eprintln!( + #[cfg(feature = "test-debug")] + test_debug_info!( " vkey={vkey:?}, uuid={}, point={:?}", vertex.uuid(), vertex.point() ); + #[cfg(not(feature = "test-debug"))] + let _ = &vertex; } } diff --git a/tests/delaunay_repair_fallback.rs b/tests/delaunay_repair_fallback.rs index f37c0649..025ff3ad 100644 --- a/tests/delaunay_repair_fallback.rs +++ b/tests/delaunay_repair_fallback.rs @@ -6,6 +6,33 @@ use delaunay::prelude::triangulation::*; +#[cfg(feature = "test-debug")] +fn init_tracing() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_test_writer() + .try_init(); + }); +} + +#[cfg(not(feature = "test-debug"))] +const fn init_tracing() {} + +#[cfg(feature = "test-debug")] +macro_rules! test_debug_info { + ($($arg:tt)*) => {{ + #[cfg(feature = "test-debug")] + { + init_tracing(); + tracing::info!($($arg)*); + } + }}; +} + /// Test that construction succeeds even when flip-based repair might struggle. /// /// This test uses a configuration that historically triggered repair challenges, @@ -71,6 +98,7 @@ fn repair_fallback_produces_valid_triangulation() { /// the fallback mechanism maintains validity throughout. #[test] fn incremental_insertion_with_repair_fallback() { + init_tracing(); let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty_with_topology_guarantee(TopologyGuarantee::PLManifold); @@ -107,7 +135,10 @@ fn incremental_insertion_with_repair_fallback() { } Err(e) => { // Some insertions may be skipped (duplicates, degeneracies), which is fine - eprintln!("Vertex {} skipped: {}", i + 1, e); + #[cfg(feature = "test-debug")] + test_debug_info!("Vertex {} skipped: {}", i + 1, e); + #[cfg(not(feature = "test-debug"))] + let _ = &e; } } } @@ -154,6 +185,7 @@ fn repair_fallback_2d() { /// Test that explicit repair call works and validates properly. #[test] fn explicit_repair_call_validates_result() { + init_tracing(); // Build a triangulation let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -175,7 +207,10 @@ fn explicit_repair_call_validates_result() { .repair_delaunay_with_flips() .expect("Explicit repair should succeed"); - eprintln!("Explicit repair stats: {stats:?}"); + #[cfg(feature = "test-debug")] + test_debug_info!("Explicit repair stats: {stats:?}"); + #[cfg(not(feature = "test-debug"))] + let _ = &stats; // Verify triangulation is valid after explicit repair dt.validate() diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 97a85939..4cb06872 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -89,7 +89,9 @@ fn install_runtime_cap(max_secs: u64) -> std::sync::mpsc::SyncSender<()> { Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {} // Deadline exceeded — hard abort. Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - eprintln!("=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ==="); + tracing::error!( + "=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ===" + ); std::process::abort(); } } @@ -1151,7 +1153,10 @@ fn debug_large_scale_incremental_prefix_bisect( }; // Double-check the boundary so we can trust the replay command we print below. - if minimal_prefix > 4 { + // The guard must match the binary search's lower bound (`lo = D + 1`): if + // `minimal_prefix == D + 1`, probing `minimal_prefix - 1` would request a + // prefix shorter than the initial simplex, which the harness cannot build. + if minimal_prefix > D + 1 { assert!( run_probe(minimal_prefix - 1).is_some_and(|result| result.is_ok()), "internal bisect inconsistency: prefix {} should pass", diff --git a/tests/proptest_delaunay_triangulation.rs b/tests/proptest_delaunay_triangulation.rs index 434a41a8..df3a1db0 100644 --- a/tests/proptest_delaunay_triangulation.rs +++ b/tests/proptest_delaunay_triangulation.rs @@ -39,8 +39,13 @@ use proptest::prelude::*; fn init_tracing() { static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { + let default_filter = if cfg!(feature = "test-debug") { + "info" + } else { + "warn" + }; let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")); + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter)); let _ = tracing_subscriber::fmt() .with_env_filter(filter) .with_test_writer() @@ -256,7 +261,7 @@ fn has_no_cospherical_5_tuples_3d(vertices: &[Vertex]) -> bool { let in_sphere_calls: u128 = tuples * 5u128; if n > MAX_N && allow_slow { - eprintln!( + tracing::warn!( "has_no_cospherical_5_tuples_3d warning: n={n} > {MAX_N}; checking {tuples} 5-tuples (~{in_sphere_calls} in_sphere predicate calls)" ); } @@ -366,10 +371,10 @@ fn insert_vertices_3d_no_retry_or_skip( && let Err(err) = result { let points: Vec<_> = vertices.iter().map(|vertex| *vertex.point()).collect(); - eprintln!( + tracing::warn!( "3D insertion-order: non-retryable insertion error at index {idx}: {err}" ); - eprintln!("3D insertion-order: insertion order points: {points:?}"); + tracing::warn!("3D insertion-order: insertion order points: {points:?}"); } return InsertionOrder3dRunStatus::NonRetryableError; }; @@ -499,7 +504,7 @@ macro_rules! gen_incremental_insertion_validity { ); if let Err(err) = &dt { if std::env::var_os("DELAUNAY_PROPTEST_CONSTRUCTION_ERRORS").is_some() { - eprintln!( + tracing::warn!( "{}D: incremental insertion construction failed (treated as rejection): {err}", $dim ); @@ -511,7 +516,7 @@ macro_rules! gen_incremental_insertion_validity { let insert_result = dt.insert(additional_vertex); if let Err(e) = &insert_result { if std::env::var_os("DELAUNAY_PROPTEST_INSERT_ERRORS").is_some() { - eprintln!( + tracing::warn!( "{}D: incremental insertion error (treated as rejection): {e}", $dim ); @@ -564,7 +569,7 @@ macro_rules! gen_incremental_insertion_validity { ); if let Err(err) = &dt { if std::env::var_os("DELAUNAY_PROPTEST_CONSTRUCTION_ERRORS").is_some() { - eprintln!( + tracing::warn!( "{}D: incremental insertion construction failed (treated as rejection): {err}", $dim ); @@ -576,7 +581,7 @@ macro_rules! gen_incremental_insertion_validity { let insert_result = dt.insert(additional_vertex); if let Err(e) = &insert_result { if std::env::var_os("DELAUNAY_PROPTEST_INSERT_ERRORS").is_some() { - eprintln!( + tracing::warn!( "{}D: incremental insertion error (treated as rejection): {e}", $dim ); @@ -770,7 +775,7 @@ proptest! { ); if let Err(err) = &dt { if std::env::var_os("DELAUNAY_PROPTEST_CONSTRUCTION_ERRORS").is_some() { - eprintln!( + tracing::warn!( "{}D: empty-circumsphere construction failed (treated as rejection): {err}", $dim ); @@ -822,7 +827,7 @@ proptest! { ); if let Err(err) = &dt { if std::env::var_os("DELAUNAY_PROPTEST_CONSTRUCTION_ERRORS").is_some() { - eprintln!( + tracing::warn!( "{}D: empty-circumsphere construction failed (treated as rejection): {err}", $dim ); @@ -1026,6 +1031,8 @@ fn prop_insertion_order_robustness_3d() { rejected_new_b_invalid_levels_1_to_3: usize, } + init_tracing(); + let config = Config::default(); let target_cases = config.cases; let mut runner = TestRunner::new(config); @@ -1263,7 +1270,7 @@ fn prop_insertion_order_robustness_3d() { stats.accepted ); } else { - eprintln!( + tracing::warn!( "prop_insertion_order_robustness_3d: invalid DELAUNAY_PROPTEST_MIN_ACCEPTANCE_PCT={min_acceptance_pct_str:?} (expected integer percent, e.g. 10)" ); } @@ -1272,7 +1279,7 @@ fn prop_insertion_order_robustness_3d() { if print_stats { let rejected_total = stats.generated.saturating_sub(stats.accepted); - eprintln!( + tracing::warn!( "prop_insertion_order_robustness_3d reject stats: target_cases={target_cases} generated={} accepted={} acceptance_rate={}.{:02}% rejected_total={} too_few_unique={} nearly_coplanar={} cospherical={} run_a(retry={}, skip={}, err={}, invalid={}) run_b(retry={}, skip={}, err={}, invalid={}) new_a(fail={}, skip={}, invalid={}) new_b(fail={}, skip={}, invalid={})", stats.generated, stats.accepted, @@ -1334,6 +1341,8 @@ macro_rules! gen_insertion_order_robustness_high_dim_impl { rejected_new_b_invalid_levels_1_to_3: usize, } + init_tracing(); + let config = Config::default(); let target_cases = config.cases; let mut runner = TestRunner::new(config); @@ -1471,7 +1480,7 @@ macro_rules! gen_insertion_order_robustness_high_dim_impl { stats.accepted ); } else { - eprintln!( + tracing::warn!( "prop_insertion_order_robustness_{}d: invalid DELAUNAY_PROPTEST_MIN_ACCEPTANCE_PCT value {min_acceptance_pct_str:?} (expected integer percent, e.g. 10)", $dim ); @@ -1484,7 +1493,7 @@ macro_rules! gen_insertion_order_robustness_high_dim_impl { if print_stats { let rejected_total = stats.generated.saturating_sub(stats.accepted); - eprintln!( + tracing::warn!( "prop_insertion_order_robustness_{}d reject stats: target_cases={target_cases} generated={} accepted={} acceptance_rate={}.{:02}% rejected_total={} too_few_unique={} coord_hyperplane={} new_a(fail={}, invalid={}) new_b(fail={}, invalid={})", $dim, stats.generated, @@ -1604,7 +1613,7 @@ macro_rules! gen_duplicate_cloud_test { let build_elapsed = build_start.elapsed(); if let Err(err) = &dt { if std::env::var_os("DELAUNAY_PROPTEST_CONSTRUCTION_ERRORS").is_some() { - eprintln!( + tracing::warn!( "{}D: duplicate-cloud construction failed (treated as rejection): {err}", $dim ); @@ -1706,7 +1715,7 @@ macro_rules! gen_duplicate_cloud_test { let build_elapsed = build_start.elapsed(); if let Err(err) = &dt { if std::env::var_os("DELAUNAY_PROPTEST_CONSTRUCTION_ERRORS").is_some() { - eprintln!( + tracing::warn!( "{}D: duplicate-cloud construction failed (treated as rejection): {err}", $dim ); diff --git a/tests/regressions.rs b/tests/regressions.rs index 27ccf885..1d8b4c52 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -194,3 +194,66 @@ fn regression_issue_307_4d_bulk_repair_keeps_positive_orientation() { "bulk repair must leave the triangulation structurally and topologically valid", ); } + +/// The 4D 500-point seed `0xD225B8A07E274AE6` (ball radius 100) exhausted all +/// shuffled retries before #204: every attempt finished with skip-heavy output +/// (`inserted≈266–300`, `skipped≈200–234`) and the construction ultimately +/// failed with `Cell violates Delaunay property: cell contains vertex that is +/// inside circumsphere`. The dominant failure mode was a cascade of +/// `Ridge fan detected: 4 facets share ridge with 3 vertices` skips driven by +/// a per-insertion local-repair flip budget that was too tight for D≥4 +/// (50-flip ceiling vs. observed `max_queue` p95 = 312). +/// +/// Fix 2 of the #204 plan (see `docs/archive/issue_204_investigation.md`) +/// raised the D≥4 budget factor/floor (`LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4` +/// = 12, `LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4` = 96) and added one +/// escalation pass with a 4× budget and the full TDS as seed set before the +/// soft-fail path accepts a non-convergent repair. Post-fix, the same seed +/// inserts 500/500 vertices with zero skips and passes full Level 1–4 +/// validation. +/// +/// Gated behind `slow-tests` because batch insertion currently takes ~4 min +/// wall time in release mode (still well below the previous ~10 min retry +/// exhaustion); run with: +/// +/// ```bash +/// cargo test --release --test regressions --features slow-tests \ +/// regression_issue_204_4d_500_local_repair_budget -- --nocapture +/// ``` +#[cfg(feature = "slow-tests")] +#[test] +fn regression_issue_204_4d_500_local_repair_budget() { + let seed: u64 = 0xD225_B8A0_7E27_4AE6; + let ball_radius = 100.0; + let n_points: usize = 500; + + let points = generate_random_points_in_ball_seeded::(n_points, ball_radius, seed) + .expect("point generation should succeed"); + let vertices: Vec> = points.into_iter().map(|p| vertex!(p)).collect(); + + let (dt, stats) = + DelaunayTriangulation::<_, (), (), 4>::new_with_construction_statistics(&vertices) + .unwrap_or_else(|e| { + panic!( + "#204 regression: 4D {n_points}-point construction with seed 0x{seed:X} \ + (ball radius {ball_radius}) must succeed after Fix 2; got: {}", + e.error + ) + }); + + assert_eq!( + stats.inserted, n_points, + "#204 regression: all {n_points} vertices should insert with the raised \ + D≥4 local-repair budget (seed 0x{seed:X})", + ); + assert_eq!( + stats.total_skipped(), + 0, + "#204 regression: no vertex should be skipped (seed 0x{seed:X})", + ); + assert!( + dt.as_triangulation().validate().is_ok(), + "#204 regression: triangulation must pass Levels 1–4 validation \ + (seed 0x{seed:X})", + ); +} diff --git a/tests/storage_backend_compatibility.rs b/tests/storage_backend_compatibility.rs index 1a1fb34b..181e145e 100644 --- a/tests/storage_backend_compatibility.rs +++ b/tests/storage_backend_compatibility.rs @@ -53,6 +53,52 @@ use delaunay::core::util::extract_edge_set; use delaunay::geometry::kernel::AdaptiveKernel; use delaunay::prelude::triangulation::*; +#[cfg(feature = "test-debug")] +fn init_tracing() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_test_writer() + .try_init(); + }); +} + +#[cfg(not(feature = "test-debug"))] +const fn init_tracing() {} + +macro_rules! test_debug_info { + ($($arg:tt)*) => {{ + #[cfg(feature = "test-debug")] + { + init_tracing(); + tracing::info!($($arg)*); + } + }}; +} + +#[cfg(feature = "test-debug")] +macro_rules! test_debug_warn { + ($($arg:tt)*) => {{ + #[cfg(feature = "test-debug")] + { + init_tracing(); + tracing::warn!($($arg)*); + } + }}; +} + +#[cfg(feature = "test-debug")] +fn log_large_scale_skip(expected: &str) { + test_debug_warn!("Large-scale test skipped (set RUN_LARGE_SCALE_TESTS=1 to enable)"); + test_debug_info!("Expected: {expected}"); +} + +#[cfg(not(feature = "test-debug"))] +const fn log_large_scale_skip(_expected: &str) {} + // ============================================================================= // TEST GENERATION MACROS (reduces duplication across 2D-5D) // ============================================================================= @@ -422,8 +468,7 @@ test_neighbor_access!( fn test_storage_backend_large_scale_2d() { // Check environment gate (optional, for extra safety) if std::env::var("RUN_LARGE_SCALE_TESTS").ok().as_deref() != Some("1") { - eprintln!("⚠️ Large-scale test skipped (set RUN_LARGE_SCALE_TESTS=1 to enable)"); - eprintln!(" Expected: ~900 vertices, <1s runtime, ~10MB memory"); + log_large_scale_skip("~900 vertices, <1s runtime, ~10MB memory"); return; } @@ -453,8 +498,7 @@ fn test_storage_backend_large_scale_2d() { fn test_storage_backend_large_scale_3d() { // Check environment gate (optional, for extra safety) if std::env::var("RUN_LARGE_SCALE_TESTS").ok().as_deref() != Some("1") { - eprintln!("⚠️ Large-scale test skipped (set RUN_LARGE_SCALE_TESTS=1 to enable)"); - eprintln!(" Expected: ~900 vertices, ~2s runtime, ~50MB memory"); + log_large_scale_skip("~900 vertices, ~2s runtime, ~50MB memory"); return; } @@ -490,8 +534,7 @@ fn test_storage_backend_large_scale_3d() { fn test_storage_backend_large_scale_4d() { // Check environment gate (optional, for extra safety) if std::env::var("RUN_LARGE_SCALE_TESTS").ok().as_deref() != Some("1") { - eprintln!("⚠️ Large-scale test skipped (set RUN_LARGE_SCALE_TESTS=1 to enable)"); - eprintln!(" Expected: ~500 vertices, ~5s runtime, ~100MB memory"); + log_large_scale_skip("~500 vertices, ~5s runtime, ~100MB memory"); return; } @@ -528,8 +571,7 @@ fn test_storage_backend_large_scale_4d() { fn test_storage_backend_large_scale_5d() { // Check environment gate (optional, for extra safety) if std::env::var("RUN_LARGE_SCALE_TESTS").ok().as_deref() != Some("1") { - eprintln!("⚠️ Large-scale test skipped (set RUN_LARGE_SCALE_TESTS=1 to enable)"); - eprintln!(" Expected: ~256 vertices, ~10s runtime, ~150MB memory"); + log_large_scale_skip("~256 vertices, ~10s runtime, ~150MB memory"); return; } @@ -716,6 +758,7 @@ test_cell_data!( #[test] #[ignore = "Phase 4 storage backend evaluation test - run with: cargo test --test storage_backend_compatibility -- --ignored"] fn test_dense_slotmap_backend_active() { + init_tracing(); let vertices = vec![ vertex!([0.0, 0.0, 0.0, 0.0]), vertex!([1.0, 0.0, 0.0, 0.0]), @@ -734,13 +777,14 @@ fn test_dense_slotmap_backend_active() { assert_eq!(tds.number_of_vertices(), 5); assert_eq!(tds.number_of_cells(), 1); - eprintln!("✓ DenseSlotMap backend test passed (4D)"); + test_debug_info!("DenseSlotMap backend test passed (4D)"); } #[cfg(not(feature = "dense-slotmap"))] #[test] #[ignore = "Phase 4 storage backend evaluation test - run with: cargo test --test storage_backend_compatibility -- --ignored"] fn test_slotmap_backend_active() { + init_tracing(); let vertices = vec![ vertex!([0.0, 0.0, 0.0, 0.0]), vertex!([1.0, 0.0, 0.0, 0.0]), @@ -759,5 +803,5 @@ fn test_slotmap_backend_active() { assert_eq!(tds.number_of_vertices(), 5); assert_eq!(tds.number_of_cells(), 1); - eprintln!("✓ SlotMap backend test passed (4D)"); + test_debug_info!("SlotMap backend test passed (4D)"); } From fb23595fd664ef19bb3ea7ca134e725214dfeeca Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Fri, 24 Apr 2026 09:35:01 -0700 Subject: [PATCH 03/11] Changed: harden flip diagnostics and refine large-scale debug workflows Refactor flip snapshotting and cavity-reduction bookkeeping to ensure diagnostic reliability and accurate repair-seed collection. Update documentation and justfile recipes to reflect fixed historical repros and transition to monitoring active scalability investigations for 3D, 4D, and 5D datasets. - Move removed-cell vertex capturing into fallible internal helpers - Implement lazy evaluation for cavity-reduction diagnostic logs - Harden vertex deduplication with fallible epsilon validation - Update 4D known issues to reflect 100-point and 500-point fixes - Simplify the large-scale debug harness CLI and documentation Refs: #204, #340, #341, #342 --- benches/large_scale_performance.rs | 3 + docs/KNOWN_ISSUES_4D.md | 52 ++-- justfile | 32 +-- src/core/algorithms/flips.rs | 230 ++++++++++++------ src/core/algorithms/locate.rs | 102 ++++++++ src/core/triangulation.rs | 317 +++++++++++++++++++++---- src/core/util/deduplication.rs | 100 +++++++- src/triangulation/delaunay.rs | 118 +++++++++ tests/README.md | 15 +- tests/delaunay_edge_cases.rs | 35 +-- tests/delaunay_repair_fallback.rs | 8 +- tests/large_scale_debug.rs | 300 ----------------------- tests/storage_backend_compatibility.rs | 13 +- 13 files changed, 810 insertions(+), 515 deletions(-) diff --git a/benches/large_scale_performance.rs b/benches/large_scale_performance.rs index 448b9dca..36b1fe10 100644 --- a/benches/large_scale_performance.rs +++ b/benches/large_scale_performance.rs @@ -324,6 +324,9 @@ fn bench_memory_usage(c: &mut Criterion, dimension_name: &str, n ); } + // Prime the one-time memory-unit log outside Criterion's measured closure. + let _ = get_memory_usage(); + group.bench_function("construction_memory_delta", |b| { b.iter(|| { let mem_info = measure_construction_with_memory::(n_points, seed); diff --git a/docs/KNOWN_ISSUES_4D.md b/docs/KNOWN_ISSUES_4D.md index 88833d0e..e06f2079 100644 --- a/docs/KNOWN_ISSUES_4D.md +++ b/docs/KNOWN_ISSUES_4D.md @@ -165,40 +165,25 @@ Use the debug large-scale test to verify current behavior on a given branch. makes larger runs appear to hang. ```bash -# 3D minimal reproducer (35 vertices, fails at insertion 22) -DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE=new \ - DELAUNAY_LARGE_DEBUG_N_3D=35 \ - DELAUNAY_LARGE_DEBUG_CASE_SEED_3D=0xE30C78582376677C \ - cargo test --release --test large_scale_debug debug_large_scale_3d \ - -- --ignored --nocapture - -# 3D incremental-prefix bisect (finds minimal failing prefix) -DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL=1000 \ - cargo test --release --test large_scale_debug \ - debug_large_scale_3d_incremental_prefix_bisect -- --ignored --nocapture +# 3D historical 35-point seed check (now passes) +DELAUNAY_LARGE_DEBUG_CASE_SEED_3D=0xE30C78582376677C just debug-large-scale-3d 35 + +# 3D 10000-point scalability investigation (#341) +just debug-large-scale-3d # 4D 100-point — permissive (allows skips) -DELAUNAY_LARGE_DEBUG_N_4D=100 DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ - cargo test --release --test large_scale_debug debug_large_scale_4d \ - -- --ignored --nocapture +DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 just debug-large-scale-4d 100 # 4D 100-point — strict (no skips) -DELAUNAY_LARGE_DEBUG_N_4D=100 DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=0 \ - cargo test --release --test large_scale_debug debug_large_scale_4d \ - -- --ignored --nocapture - -# 4D 500-point seeded repro (all shuffled retries still fail) -DELAUNAY_BULK_PROGRESS_EVERY=50 \ - DELAUNAY_LARGE_DEBUG_N_4D=500 \ - DELAUNAY_LARGE_DEBUG_CASE_SEED_4D=0xD225B8A07E274AE6 \ +DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=0 just debug-large-scale-4d 100 + +# 4D 500-point seeded case — now passes (verified 2026-04-23) +# First attempt inserts 500/500 with zero skips and no RidgeFan / +# DisconnectedBoundary / postcondition k=2 retryable-skip traces. +DELAUNAY_LARGE_DEBUG_CASE_SEED_4D=0xD225B8A07E274AE6 \ DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ - cargo test --release --test large_scale_debug debug_large_scale_4d \ - -- --ignored --nocapture + just debug-large-scale-4d 500 -# 4D prefix bisect (targets the seeded 500-point repro by default) -DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ - cargo test --release --test large_scale_debug \ - debug_large_scale_4d_incremental_prefix_bisect -- --ignored --nocapture ``` ### Recommendations @@ -207,11 +192,12 @@ DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ - **3D:** the historical #306/#204 seed now passes in release mode; continue to use the large-scale harness as a monitoring tool rather than assuming a 35-point correctness failure still exists. -- **4D:** the historical 100-point skip repro is fixed, but seeded 500-point - and larger batch runs can still fail after all shuffled retries. Use release - mode for investigation, prefer smaller seeded probes to debug the - `Ridge fan detected` path, and use incremental insertion when you need more - predictable progress at large N. +- **4D:** the historical 100-point and 500-point seeded repros are fixed. Seed + `0xD225B8A07E274AE6` now inserts **500/500** on the first attempt with no + `RidgeFan`, `DisconnectedBoundary`, or local `k=2` retryable-skip traces. + The remaining concern is the 3000-point scale/observability path; use release + mode to investigate it, and prefer incremental insertion when you need more + predictable large-N progress. - **5D:** experimental; incremental insertion strongly recommended. Exact insphere predicates are available (5D uses a 7×7 matrix, within the stack limit). - **6D+:** exact insphere is not available (matrix exceeds stack limit); falls back diff --git a/justfile b/justfile index 9c5ebcac..8ffcf86a 100644 --- a/justfile +++ b/justfile @@ -214,23 +214,14 @@ coverage: coverage-ci: cargo tarpaulin {{_coverage_base_args}} --out Xml --output-dir coverage -- --skip prop_ -debug-large-scale-3d-100: - DELAUNAY_LARGE_DEBUG_N_3D=100 cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture +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-1000: - DELAUNAY_LARGE_DEBUG_N_3D=1000 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 -debug-large-scale-3d-incremental-bisect total="1000": - DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL={{total}} cargo test --release --test large_scale_debug debug_large_scale_3d_incremental_prefix_bisect -- --ignored --nocapture - -debug-large-scale-4d-incremental-bisect total="500": - DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL={{total}} cargo test --release --test large_scale_debug debug_large_scale_4d_incremental_prefix_bisect -- --ignored --nocapture - -debug-large-scale-4d: - DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 cargo test --release --test large_scale_debug debug_large_scale_4d -- --ignored --exact --nocapture - -debug-large-scale-4d-100: - DELAUNAY_LARGE_DEBUG_N_4D=100 DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 cargo test --release --test large_scale_debug debug_large_scale_4d -- --ignored --exact --nocapture +debug-large-scale-5d n="1000": + DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 DELAUNAY_LARGE_DEBUG_N_5D={{n}} cargo test --release --test large_scale_debug debug_large_scale_5d -- --ignored --exact --nocapture # Default recipe shows available commands default: @@ -268,14 +259,11 @@ help-workflows: @echo " just test-slow # Run slow/stress tests with --features slow-tests" @echo " just examples # Run all examples" @echo "" - @echo "Active large-scale debugging (keep until #307/#204 are resolved):" + @echo "Active large-scale debugging:" @echo " just test-debug # Run debug tools with output" - @echo " just debug-large-scale-3d-100 # Run large-scale 3D debug harness at 100 points" - @echo " just debug-large-scale-3d-1000 # Run large-scale 3D debug harness at 1000 points" - @echo " just debug-large-scale-3d-incremental-bisect [total] # Bisect failing 3D incremental prefix" - @echo " just debug-large-scale-4d-incremental-bisect [total] # Bisect failing 4D batch prefix" - @echo " just debug-large-scale-4d-100 # Run large-scale 4D debug harness at 100 points" - @echo " just debug-large-scale-4d # Run large-scale 4D debug harness" + @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-5d [n] # Issue #342: 5D feasibility (default n=1000)" @echo "" @echo "Benchmark workflows (explicit perf-profile runs):" @echo " just bench-smoke # Smoke-test benchmark harnesses (minimal samples)" diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 1d9669fc..202f5651 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -61,6 +61,9 @@ use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicUsize, Ordering}; use thiserror::Error; +type VertexKeyList = SmallBuffer; +type RemovedCellVertexSnapshot = SmallBuffer; + /// Bistellar flip kind descriptor. /// /// Access the move size with [`BistellarFlipKind::k`]. @@ -218,6 +221,27 @@ where } /// Apply a bistellar flip using explicit k and vertex/cell slices. +fn snapshot_removed_cell_vertices( + tds: &Tds, + removed_cells: &CellKeyBuffer, +) -> Result +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + removed_cells + .iter() + .copied() + .map(|cell_key| { + let cell = tds + .get_cell(cell_key) + .ok_or(FlipError::MissingCell { cell_key })?; + Ok(cell.vertices().iter().copied().collect()) + }) + .collect() +} + #[expect( clippy::too_many_lines, reason = "Keep flip construction, validation, and wiring together for clarity" @@ -230,7 +254,7 @@ fn apply_bistellar_flip_with_k( removed_cells: &CellKeyBuffer, direction: FlipDirection, orientation_policy: ReplacementOrientationPolicy, -) -> Result, FlipError> +) -> Result, FlipError> where T: CoordinateScalar, U: DataType, @@ -414,18 +438,7 @@ where // `tds.remove_cells_by_keys` runs, `tds.get_cell(removed_key)` returns // `None`, which would strip the most useful context from predecessor-flip // traces (see #204 investigation). - let removed_cell_vertices: SmallBuffer< - SmallBuffer, - MAX_PRACTICAL_DIMENSION_SIZE, - > = removed_cells - .iter() - .copied() - .map(|cell_key| { - tds.get_cell(cell_key) - .map(|cell| cell.vertices().iter().copied().collect()) - .unwrap_or_default() - }) - .collect(); + let removed_cell_vertices = snapshot_removed_cell_vertices(tds, removed_cells)?; tds.remove_cells_by_keys(removed_cells); @@ -434,13 +447,15 @@ where "TDS coherent orientation invariant violated after bistellar flip (k={k_move}, direction={direction:?})", ); - Ok(FlipInfo { - kind: BistellarFlipKind { k: k_move, d: D }, - direction, - removed_cells: removed_cells.iter().copied().collect(), - new_cells, - removed_face_vertices: removed_face_vertices.iter().copied().collect(), - inserted_face_vertices: inserted_face_vertices.iter().copied().collect(), + Ok(AppliedFlip { + info: FlipInfo { + kind: BistellarFlipKind { k: k_move, d: D }, + direction, + removed_cells: removed_cells.iter().copied().collect(), + new_cells, + removed_face_vertices: removed_face_vertices.iter().copied().collect(), + inserted_face_vertices: inserted_face_vertices.iter().copied().collect(), + }, removed_cell_vertices, }) } @@ -1174,7 +1189,7 @@ where U: DataType, V: DataType, { - apply_bistellar_flip_with_k( + Ok(apply_bistellar_flip_with_k( tds, K_MOVE, &context.removed_face_vertices, @@ -1182,7 +1197,8 @@ where &context.removed_cells, context.direction, ReplacementOrientationPolicy::AllowSigned, - ) + )? + .info) } /// Apply a generic k-move with runtime k (no Delaunay check). @@ -1202,7 +1218,7 @@ where U: DataType, V: DataType, { - apply_bistellar_flip_with_k( + Ok(apply_bistellar_flip_with_k( tds, k_move, &context.removed_face_vertices, @@ -1210,14 +1226,15 @@ where &context.removed_cells, context.direction, ReplacementOrientationPolicy::AllowSigned, - ) + )? + .info) } /// Apply a k=2 Delaunay-repair move with positive replacement geometry. fn apply_delaunay_flip_k2( tds: &mut Tds, context: &FlipContext, -) -> Result, FlipError> +) -> Result, FlipError> where T: CoordinateScalar, U: DataType, @@ -1238,7 +1255,7 @@ where fn apply_delaunay_flip_k3( tds: &mut Tds, context: &FlipContext, -) -> Result, FlipError> +) -> Result, FlipError> where T: CoordinateScalar, U: DataType, @@ -1260,7 +1277,7 @@ fn apply_delaunay_flip_dynamic( tds: &mut Tds, k_move: usize, context: &FlipContextDyn, -) -> Result, FlipError> +) -> Result, FlipError> where T: CoordinateScalar, U: DataType, @@ -1760,12 +1777,6 @@ pub enum FlipError { /// SmallBuffer::new(); /// inserted_face_vertices.push(VertexKey::from(KeyData::from_ffi(4))); /// -/// let mut removed_cell_vertices: SmallBuffer< -/// SmallBuffer, -/// MAX_PRACTICAL_DIMENSION_SIZE, -/// > = SmallBuffer::new(); -/// removed_cell_vertices.push(SmallBuffer::new()); -/// /// let info: FlipInfo<3> = FlipInfo { /// kind: BistellarFlipKind::k2(3), /// direction: FlipDirection::Forward, @@ -1773,7 +1784,6 @@ pub enum FlipError { /// new_cells, /// removed_face_vertices, /// inserted_face_vertices, -/// removed_cell_vertices, /// }; /// assert_eq!(info.kind.k(), 2); /// ``` @@ -1791,17 +1801,12 @@ pub struct FlipInfo { pub removed_face_vertices: SmallBuffer, /// The inserted-face simplex (complementary simplex). pub inserted_face_vertices: SmallBuffer, - /// Snapshot of each removed cell's vertex list, captured **before** the - /// flip's `remove_cells_by_keys` call. Entries correspond 1:1 with - /// `removed_cells`. An empty inner buffer indicates the cell was already - /// missing at snapshot time. - /// - /// This exists so postcondition diagnostics can reconstruct the removed - /// simplices after the keys in `removed_cells` are stale. - pub removed_cell_vertices: SmallBuffer< - SmallBuffer, - MAX_PRACTICAL_DIMENSION_SIZE, - >, +} + +#[derive(Debug, Clone)] +struct AppliedFlip { + info: FlipInfo, + removed_cell_vertices: RemovedCellVertexSnapshot, } /// Const-generic flip context for a k-move (forward or inverse). @@ -3200,8 +3205,8 @@ where )); } - let info = match apply_delaunay_flip_k2(tds, &context) { - Ok(info) => info, + let applied = match apply_delaunay_flip_k2(tds, &context) { + Ok(applied) => applied, Err( err @ (FlipError::DegenerateCell | FlipError::NegativeOrientation { .. } @@ -3230,7 +3235,8 @@ where }; stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - last_applied_flip = Some(LastAppliedFlip::from_info(&info)); + last_applied_flip = Some(LastAppliedFlip::from_applied_flip(&applied)); + let info = applied.info; for &cell_key in &info.new_cells { enqueue_cell_facets( @@ -4416,11 +4422,8 @@ struct LastAppliedFlip { new_cells: CellKeyBuffer, /// Snapshot of each removed cell's vertex list captured before the flip's /// `remove_cells_by_keys` call; pairs 1:1 with `removed_cells`. Empty - /// inner buffers indicate snapshot-time-missing cells. - removed_cell_vertices: SmallBuffer< - SmallBuffer, - MAX_PRACTICAL_DIMENSION_SIZE, - >, + /// inner buffers only appear in placeholder instances built via `Self::new`. + removed_cell_vertices: RemovedCellVertexSnapshot, } impl LastAppliedFlip { @@ -4448,7 +4451,8 @@ impl LastAppliedFlip { /// Preserves the concrete flip footprint so a later ridge snapshot can tell /// whether the immediately preceding move created the bad local star. - fn from_info(info: &FlipInfo) -> Self { + fn from_applied_flip(applied: &AppliedFlip) -> Self { + let info = &applied.info; let mut last = Self::new( info.kind.k(), &info.removed_face_vertices, @@ -4457,14 +4461,13 @@ impl LastAppliedFlip { last.removed_cells.clone_from(&info.removed_cells); last.new_cells.clone_from(&info.new_cells); last.removed_cell_vertices - .clone_from(&info.removed_cell_vertices); + .clone_from(&applied.removed_cell_vertices); last } /// Formats each removed cell as `CellKey(N): vertices=[...]` using the /// snapshot captured before the flip's cell removal. Falls back to - /// `missing-snapshot` when the snapshot row is empty (either the cell was - /// gone at snapshot time or `Self::new` produced the placeholder). + /// `missing-snapshot` only for placeholder rows created by `Self::new`. fn removed_cell_vertex_lines(&self) -> Vec { self.removed_cells .iter() @@ -4979,8 +4982,8 @@ where ); } }; - let info = match apply_delaunay_flip_k3(tds, &context) { - Ok(info) => info, + let applied = match apply_delaunay_flip_k3(tds, &context) { + Ok(applied) => applied, Err(err) if let FlipError::InsertedSimplexAlreadyExists { .. } = &err => { diagnostics.record_inserted_simplex_skip(|| { format!( @@ -5003,6 +5006,8 @@ where } Err(e) => return Err(e.into()), }; + *last_applied_flip = Some(LastAppliedFlip::from_applied_flip(&applied)); + let info = applied.info; if repair_trace_enabled() { tracing::debug!( "[repair] apply k=3 flip: kind={:?} direction={:?} removed_face={:?} inserted_face={:?} removed_cells={:?} new_cells={:?}", @@ -5016,7 +5021,6 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - *last_applied_flip = Some(LastAppliedFlip::from_info(&info)); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5165,8 +5169,8 @@ where ); } }; - let info = match apply_delaunay_flip_dynamic(tds, D, &context) { - Ok(info) => info, + let applied = match apply_delaunay_flip_dynamic(tds, D, &context) { + Ok(applied) => applied, Err(err) if let FlipError::InsertedSimplexAlreadyExists { .. } = &err => { diagnostics.record_inserted_simplex_skip(|| { format!( @@ -5189,6 +5193,8 @@ where } Err(e) => return Err(e.into()), }; + *last_applied_flip = Some(LastAppliedFlip::from_applied_flip(&applied)); + let info = applied.info; if repair_trace_enabled() { tracing::debug!( "[repair] apply inverse k=2 flip: kind={:?} direction={:?} removed_face={:?} inserted_face={:?} removed_cells={:?} new_cells={:?}", @@ -5202,7 +5208,6 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - *last_applied_flip = Some(LastAppliedFlip::from_info(&info)); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5343,8 +5348,8 @@ where ); } }; - let info = match apply_delaunay_flip_dynamic(tds, D - 1, &context) { - Ok(info) => info, + let applied = match apply_delaunay_flip_dynamic(tds, D - 1, &context) { + Ok(applied) => applied, Err(err) if let FlipError::InsertedSimplexAlreadyExists { .. } = &err => { diagnostics.record_inserted_simplex_skip(|| { format!( @@ -5367,6 +5372,8 @@ where } Err(e) => return Err(e.into()), }; + *last_applied_flip = Some(LastAppliedFlip::from_applied_flip(&applied)); + let info = applied.info; if repair_trace_enabled() { tracing::debug!( "[repair] apply inverse k=3 flip: kind={:?} direction={:?} removed_face={:?} inserted_face={:?} removed_cells={:?} new_cells={:?}", @@ -5380,7 +5387,6 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - *last_applied_flip = Some(LastAppliedFlip::from_info(&info)); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5525,8 +5531,8 @@ where ); } }; - let info = match apply_delaunay_flip_k2(tds, &context) { - Ok(info) => info, + let applied = match apply_delaunay_flip_k2(tds, &context) { + Ok(applied) => applied, Err(err) if let FlipError::InsertedSimplexAlreadyExists { .. } = &err => { diagnostics.record_inserted_simplex_skip(|| { format!( @@ -5549,6 +5555,8 @@ where } Err(e) => return Err(e.into()), }; + *last_applied_flip = Some(LastAppliedFlip::from_applied_flip(&applied)); + let info = applied.info; if repair_trace_enabled() { tracing::debug!( "[repair] apply k=2 flip: kind={:?} direction={:?} removed_face={:?} inserted_face={:?} removed_cells={:?} new_cells={:?}", @@ -5562,7 +5570,6 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - *last_applied_flip = Some(LastAppliedFlip::from_info(&info)); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -6593,6 +6600,7 @@ mod tests { use crate::vertex; use approx::assert_relative_eq; use rand::{RngExt, SeedableRng, rngs::StdRng}; + use slotmap::KeyData; use std::sync::atomic::{AtomicUsize, Ordering}; fn init_tracing() { @@ -6661,6 +6669,90 @@ mod tests { } } + #[test] + fn test_snapshot_removed_cell_vertices_captures_vertices_and_reports_missing_cell() { + let mut tds: Tds = Tds::empty(); + let vertices = insert_standard_simplex_vertices::<2>(&mut tds); + let cell_key = tds + .insert_cell_with_mapping(Cell::new(vertices.clone(), None).unwrap()) + .unwrap(); + + let removed_cells: CellKeyBuffer = std::iter::once(cell_key).collect(); + let snapshot = snapshot_removed_cell_vertices(&tds, &removed_cells).unwrap(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].iter().copied().collect::>(), vertices); + + let missing_cell = CellKey::from(KeyData::from_ffi(999_999)); + let missing_cells: CellKeyBuffer = std::iter::once(missing_cell).collect(); + let err = snapshot_removed_cell_vertices(&tds, &missing_cells).unwrap_err(); + assert!(matches!( + err, + FlipError::MissingCell { cell_key } if cell_key == missing_cell + )); + } + + #[test] + fn test_last_applied_flip_preserves_removed_cell_vertex_snapshots() { + let removed_cell = CellKey::from(KeyData::from_ffi(101)); + let new_cell = CellKey::from(KeyData::from_ffi(102)); + let v1 = VertexKey::from(KeyData::from_ffi(201)); + let v2 = VertexKey::from(KeyData::from_ffi(202)); + let v3 = VertexKey::from(KeyData::from_ffi(203)); + let v4 = VertexKey::from(KeyData::from_ffi(204)); + + let mut removed_cell_vertices = RemovedCellVertexSnapshot::new(); + removed_cell_vertices.push([v1, v2, v3].into_iter().collect::()); + + let applied = AppliedFlip::<3> { + info: FlipInfo { + kind: BistellarFlipKind::k2(3), + direction: FlipDirection::Forward, + removed_cells: std::iter::once(removed_cell).collect(), + new_cells: std::iter::once(new_cell).collect(), + removed_face_vertices: [v3, v1].into_iter().collect(), + inserted_face_vertices: [v4, v2].into_iter().collect(), + }, + removed_cell_vertices, + }; + + let last = LastAppliedFlip::from_applied_flip(&applied); + assert_eq!(last.k_move, 2); + assert_eq!( + last.removed_face_vertices + .iter() + .copied() + .collect::>(), + vec![v1, v3] + ); + assert_eq!( + last.inserted_face_vertices + .iter() + .copied() + .collect::>(), + vec![v2, v4] + ); + assert_eq!( + last.removed_cells.iter().copied().collect::>(), + vec![removed_cell] + ); + assert_eq!( + last.new_cells.iter().copied().collect::>(), + vec![new_cell] + ); + + let lines = last.removed_cell_vertex_lines(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains(&format!("{removed_cell:?}: vertices="))); + assert!(!lines[0].contains("missing-snapshot")); + + let mut placeholder = LastAppliedFlip::new(1, &[v1], &[v2]); + placeholder.removed_cells.push(removed_cell); + assert_eq!( + placeholder.removed_cell_vertex_lines(), + vec![format!("{removed_cell:?}: missing-snapshot")] + ); + } + fn facet_index_for_edge_2d( tds: &Tds, cell_key: CellKey, diff --git a/src/core/algorithms/locate.rs b/src/core/algorithms/locate.rs index d1f7799d..e9671350 100644 --- a/src/core/algorithms/locate.rs +++ b/src/core/algorithms/locate.rs @@ -136,6 +136,7 @@ pub enum LocateError { /// assert!(matches!(err, ConflictError::InvalidStartCell { .. })); /// ``` #[derive(Debug, Clone, thiserror::Error)] +#[non_exhaustive] pub enum ConflictError { /// Starting cell is invalid #[error("Invalid starting cell: {cell_key:?}")] @@ -1853,6 +1854,107 @@ mod tests { use crate::vertex; use slotmap::KeyData; + #[test] + fn test_internal_inconsistency_site_display_variants() { + let ridge_fan = InternalInconsistencySite::RidgeFanExtraFacetOutOfBounds { + index: 7, + boundary_facets_len: 5, + extra_facets_len: 3, + }; + assert_eq!( + ridge_fan.to_string(), + "RidgeFan extra_facets index 7 out of bounds \ + (boundary_facets.len()=5, extra_facets_len=3)" + ); + + let open_boundary = InternalInconsistencySite::OpenBoundaryMissingFirstFacet { + first_facet: 11, + boundary_facets_len: 9, + facet_count: 1, + ridge_vertex_count: 2, + }; + assert_eq!( + open_boundary.to_string(), + "OpenBoundary missing first_facet index 11 \ + (boundary_facets.len()=9, facet_count=1, ridge_vertex_count=2)" + ); + + let missing_second = InternalInconsistencySite::RidgeInfoMissingSecondFacet { + first_facet: 4, + boundary_facets_len: 6, + ridge_vertex_count: 3, + }; + assert_eq!( + missing_second.to_string(), + "RidgeInfo missing second_facet when facet_count == 2 \ + (first_facet=4, boundary_facets_len=6, ridge_vertex_count=3)" + ); + } + + #[test] + fn test_format_vertex_and_cell_references_include_missing_markers() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + ]; + let dt = DelaunayTriangulation::new(&vertices).unwrap(); + let tds = dt.tds(); + let cell_key = tds.cell_keys().next().unwrap(); + let cell = tds.get_cell(cell_key).unwrap(); + + let formatted_vertices = format_vertex_refs(tds, cell.vertices()); + assert!(formatted_vertices.contains("VertexKey")); + assert!(!formatted_vertices.contains("missing")); + + let missing_vertex = VertexKey::from(KeyData::from_ffi(999_999)); + let formatted_missing = format_vertex_refs(tds, &[missing_vertex]); + assert!(formatted_missing.contains("missing")); + + let facet = FacetHandle::new(cell_key, 0); + let formatted_facet = format_facet_vertices(tds, facet); + assert!(formatted_facet.contains("VertexKey")); + + let formatted_cell = format_cell_vertices(tds, cell_key); + assert!(formatted_cell.contains("VertexKey")); + + let missing_cell = CellKey::from(KeyData::from_ffi(999_999)); + assert_eq!( + format_facet_vertices(tds, FacetHandle::new(missing_cell, 0)), + "" + ); + assert_eq!(format_cell_vertices(tds, missing_cell), ""); + } + + #[test] + fn test_collect_ridge_fan_extra_cells_deduplicates_cells() { + let cell_a = CellKey::from(KeyData::from_ffi(1)); + let cell_b = CellKey::from(KeyData::from_ffi(2)); + let cell_c = CellKey::from(KeyData::from_ffi(3)); + let cell_d = CellKey::from(KeyData::from_ffi(4)); + let boundary_facets: CavityBoundaryBuffer = [ + FacetHandle::new(cell_a, 0), + FacetHandle::new(cell_b, 1), + FacetHandle::new(cell_c, 2), + FacetHandle::new(cell_c, 3), + FacetHandle::new(cell_d, 0), + ] + .into_iter() + .collect(); + + let info = RidgeInfo { + ridge_vertex_count: 2, + ridge_vertices: SmallBuffer::new(), + facet_count: 5, + first_facet: 0, + second_facet: Some(1), + extra_facets: vec![2, 3, 4], + }; + + let extra_cells = collect_ridge_fan_extra_cells(&boundary_facets, &info).unwrap(); + assert_eq!(extra_cells, vec![cell_c, cell_d]); + } + #[test] fn test_orientation_logic_manual() { // Manual test of orientation logic for 2D triangle diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 394f980f..d234ab35 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -313,17 +313,20 @@ fn cavity_conflict_error_summary(error: &ConflictError) -> String { /// Routed through `tracing::debug!`; enable with `RUST_LOG=debug` (the /// large-scale debug harness wires this up automatically when /// `DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE` is set). -fn log_cavity_reduction_event( +fn log_cavity_reduction_event( enabled: bool, iteration: usize, conflict_cells: &CellKeyBuffer, - event: &str, -) { + event: F, +) where + F: FnOnce() -> String, +{ if !enabled { return; } let conflict_preview: Vec = conflict_cells.iter().copied().take(12).collect(); + let event = event(); tracing::debug!( target: "delaunay::cavity_reduction", iteration, @@ -334,6 +337,34 @@ 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" @@ -4355,9 +4386,10 @@ where conflict_cells.push(start_cell); } - // Preserve every cell that participates in cavity shaping so callers can seed - // local Delaunay repair from cells that were shrunk out of the final cavity. - let mut repair_seed_cells = conflict_cells.clone(); + // 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(); // Extract cavity boundary. // @@ -4395,7 +4427,7 @@ where trace_cavity_reduction, iterations, &conflict_cells, - &format!("initial_ok boundary_facets={}", boundary.len()), + || format!("initial_ok boundary_facets={}", boundary.len()), ); } Err(err) => { @@ -4403,7 +4435,7 @@ where trace_cavity_reduction, iterations, &conflict_cells, - &format!("initial_err {}", cavity_conflict_error_summary(err)), + || format!("initial_err {}", cavity_conflict_error_summary(err)), ); } } @@ -4414,7 +4446,7 @@ where trace_cavity_reduction, iterations, &conflict_cells, - "budget_exhausted", + || "budget_exhausted".to_string(), ); break; } @@ -4435,12 +4467,16 @@ where trace_cavity_reduction, iterations, &conflict_cells, - &format!("ridge_fan_shrink remove_cells={extra_cells:?}"), + || format!("ridge_fan_shrink remove_cells={extra_cells:?}"), ); saw_ridge_fan_shrink = true; let remove_set: FastHashSet = extra_cells.iter().copied().collect(); - conflict_cells.retain(|k| !remove_set.contains(k)); + retain_conflict_cells_and_record_removed( + &mut conflict_cells, + &mut repair_seed_cells, + |cell_key| !remove_set.contains(&cell_key), + ); } // DisconnectedBoundary: EXPAND – add non-conflict neighbors of the @@ -4483,11 +4519,10 @@ where trace_cavity_reduction, iterations, &conflict_cells, - &format!("disconnected_boundary_expand add_cells={added:?}"), + || format!("disconnected_boundary_expand add_cells={added:?}"), ); for k in cells_to_add { conflict_cells.push(k); - repair_seed_cells.push(k); } } else if conflict_cells.len() > D + 1 { // SHRINK fallback: no non-conflict neighbors found. @@ -4501,19 +4536,25 @@ where trace_cavity_reduction, iterations, &conflict_cells, - &format!( - "disconnected_boundary_shrink remove_cells={disconnected_cells:?}" - ), + || { + format!( + "disconnected_boundary_shrink remove_cells={disconnected_cells:?}" + ) + }, ); let remove_set: FastHashSet = disconnected_cells.iter().copied().collect(); - conflict_cells.retain(|k| !remove_set.contains(k)); + retain_conflict_cells_and_record_removed( + &mut conflict_cells, + &mut repair_seed_cells, + |cell_key| !remove_set.contains(&cell_key), + ); } else { log_cavity_reduction_event( trace_cavity_reduction, iterations, &conflict_cells, - "disconnected_boundary_no_progress", + || "disconnected_boundary_no_progress".to_string(), ); break; } @@ -4533,10 +4574,14 @@ where trace_cavity_reduction, iterations, &conflict_cells, - &format!("open_boundary_shrink open_cell={open_cell:?}"), + || format!("open_boundary_shrink open_cell={open_cell:?}"), ); let open = *open_cell; - conflict_cells.retain(|k| *k != open); + retain_conflict_cells_and_record_removed( + &mut conflict_cells, + &mut repair_seed_cells, + |cell_key| cell_key != open, + ); } _ => { @@ -4544,7 +4589,7 @@ where trace_cavity_reduction, iterations, &conflict_cells, - "no_reduction_rule_matched", + || "no_reduction_rule_matched".to_string(), ); break; } @@ -4557,7 +4602,7 @@ where trace_cavity_reduction, iterations, &conflict_cells, - &format!("reextract_ok boundary_facets={}", boundary.len()), + || format!("reextract_ok boundary_facets={}", boundary.len()), ); } Err(err) => { @@ -4565,7 +4610,7 @@ where trace_cavity_reduction, iterations, &conflict_cells, - &format!("reextract_err {}", cavity_conflict_error_summary(err)), + || format!("reextract_err {}", cavity_conflict_error_summary(err)), ); } } @@ -4611,12 +4656,13 @@ where "Conflict region degeneracy ({err}); falling back to star-split of cell {start_cell:?}" ); - conflict_cells = { - let mut owned = CellKeyBuffer::new(); - owned.push(start_cell); - owned - }; - repair_seed_cells.push(start_cell); + let mut replacement = CellKeyBuffer::new(); + replacement.push(start_cell); + replace_conflict_cells_and_record_removed( + &mut conflict_cells, + &mut repair_seed_cells, + replacement, + ); Self::star_split_boundary_facets(start_cell) } else { @@ -4646,12 +4692,13 @@ where "Empty cavity boundary; falling back to splitting containing cell {start_cell:?}" ); - conflict_cells = { - let mut owned = CellKeyBuffer::new(); - owned.push(start_cell); - owned - }; - repair_seed_cells.push(start_cell); + let mut replacement = CellKeyBuffer::new(); + replacement.push(start_cell); + replace_conflict_cells_and_record_removed( + &mut conflict_cells, + &mut repair_seed_cells, + replacement, + ); boundary_facets = Self::star_split_boundary_facets(start_cell); } @@ -4718,13 +4765,9 @@ where Some(&conflict_cells), )?; - // Drop any repair-seed entries that are about to be deleted. Cavity - // reduction shrinks `conflict_cells` in place; the cells that were in - // the *initial* conflict region but remain in the final reduced set - // will be removed by `remove_cells_by_keys` below, so their keys - // become stale. Callers filter with `contains_cell` as a safety net, - // but the contract of `repair_seed_cells` is "cells that participated - // in cavity shaping and survived", so the filter belongs here. + // Drop any repair-seed entries that were removed earlier but later got + // reintroduced into the final conflict region. Those keys will be + // deleted by `remove_cells_by_keys` below, so they cannot seed repair. let dead_conflict_cells: FastHashSet = conflict_cells.iter().copied().collect(); repair_seed_cells.retain(|ck| !dead_conflict_cells.contains(ck)); @@ -6020,12 +6063,15 @@ where #[cfg(test)] mod tests { use super::*; + use crate::core::algorithms::locate::InternalInconsistencySite; use crate::core::collections::NeighborBuffer; use crate::core::collections::spatial_hash_grid::HashGridIndex; use crate::core::vertex::VertexBuilder; use crate::geometry::kernel::{AdaptiveKernel, FastKernel}; use crate::geometry::point::Point; - use crate::geometry::traits::coordinate::{Coordinate, CoordinateScalar}; + use crate::geometry::traits::coordinate::{ + Coordinate, CoordinateConversionError, CoordinateScalar, + }; use crate::topology::characteristics::validation::validate_triangulation_euler; use crate::topology::traits::topological_space::{GlobalTopology, ToroidalConstructionMode}; use crate::triangulation::delaunay::DelaunayTriangulation; @@ -6159,6 +6205,191 @@ mod tests { ); } + #[test] + fn test_retryable_conflict_trace_detail_formats_retryable_variants() { + let extra_cell = CellKey::from(KeyData::from_ffi(10)); + let disconnected_cell = CellKey::from(KeyData::from_ffi(11)); + let open_cell = CellKey::from(KeyData::from_ffi(12)); + + let non_manifold = InsertionError::ConflictRegion(ConflictError::NonManifoldFacet { + facet_hash: 0xABCD, + cell_count: 3, + }); + assert_eq!( + retryable_conflict_trace_detail(&non_manifold).as_deref(), + Some("kind=non_manifold_facet facet_hash=0xabcd cell_count=3") + ); + + let ridge_fan = InsertionError::ConflictRegion(ConflictError::RidgeFan { + facet_count: 4, + ridge_vertex_count: 2, + extra_cells: vec![extra_cell], + }); + assert_eq!( + retryable_conflict_trace_detail(&ridge_fan).as_deref(), + Some("kind=ridge_fan facet_count=4 ridge_vertex_count=2 extra_cells=1") + ); + + let disconnected = InsertionError::ConflictRegion(ConflictError::DisconnectedBoundary { + visited: 2, + total: 5, + disconnected_cells: vec![disconnected_cell], + }); + assert_eq!( + retryable_conflict_trace_detail(&disconnected).as_deref(), + Some("kind=disconnected_boundary visited=2 total=5 disconnected_cells=1") + ); + + let open = InsertionError::ConflictRegion(ConflictError::OpenBoundary { + facet_count: 1, + ridge_vertex_count: 2, + open_cell, + }); + assert_eq!( + retryable_conflict_trace_detail(&open).as_deref(), + Some("kind=open_boundary facet_count=1 ridge_vertex_count=2") + ); + + let not_retryable = InsertionError::CavityFilling { + message: "plain insertion failure".to_string(), + }; + assert!(retryable_conflict_trace_detail(¬_retryable).is_none()); + } + + #[test] + fn test_cavity_conflict_error_summary_formats_all_variants() { + let cell_key = CellKey::from(KeyData::from_ffi(21)); + + let cases = vec![ + ( + ConflictError::NonManifoldFacet { + facet_hash: 0xCAFE, + cell_count: 4, + }, + "non_manifold_facet facet_hash=0xcafe cell_count=4".to_string(), + ), + ( + ConflictError::RidgeFan { + facet_count: 5, + ridge_vertex_count: 3, + extra_cells: vec![cell_key], + }, + "ridge_fan facet_count=5 ridge_vertex_count=3 extra_cells=1".to_string(), + ), + ( + ConflictError::DisconnectedBoundary { + visited: 1, + total: 3, + disconnected_cells: vec![cell_key], + }, + "disconnected_boundary visited=1 total=3 disconnected_cells=1".to_string(), + ), + ( + ConflictError::OpenBoundary { + facet_count: 1, + ridge_vertex_count: 2, + open_cell: cell_key, + }, + format!("open_boundary facet_count=1 ridge_vertex_count=2 open_cell={cell_key:?}"), + ), + ( + ConflictError::InvalidStartCell { cell_key }, + format!("invalid_start_cell cell_key={cell_key:?}"), + ), + ( + ConflictError::CellDataAccessFailed { + cell_key, + message: "missing vertices".to_string(), + }, + format!("cell_data_access_failed cell_key={cell_key:?} message=missing vertices"), + ), + ]; + + for (error, expected) in cases { + assert_eq!(cavity_conflict_error_summary(&error), expected); + } + + let predicate = ConflictError::PredicateError { + source: CoordinateConversionError::ConversionFailed { + coordinate_index: 2, + coordinate_value: "NaN".to_string(), + from_type: "f64", + to_type: "f32", + }, + }; + assert!( + cavity_conflict_error_summary(&predicate) + .starts_with("predicate_error source=Failed to convert coordinate") + ); + + let internal = ConflictError::InternalInconsistency { + site: InternalInconsistencySite::RidgeInfoMissingSecondFacet { + first_facet: 4, + boundary_facets_len: 6, + ridge_vertex_count: 2, + }, + }; + 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(); + conflict_cells.push(CellKey::from(KeyData::from_ffi(41))); + + let mut called = false; + log_cavity_reduction_event(false, 0, &conflict_cells, || { + called = true; + "should not run".to_string() + }); + assert!(!called); + + log_cavity_reduction_event(true, 1, &conflict_cells, || { + called = true; + "ran".to_string() + }); + assert!(called); + } + #[test] fn test_triangulation_new_empty_and_new_with_tds_default_to_pl_manifold() { let tri: Triangulation, (), (), 2> = diff --git a/src/core/util/deduplication.rs b/src/core/util/deduplication.rs index b45359f5..23a6274e 100644 --- a/src/core/util/deduplication.rs +++ b/src/core/util/deduplication.rs @@ -5,6 +5,16 @@ use crate::core::traits::data_type::DataType; use crate::core::vertex::Vertex; use crate::geometry::traits::coordinate::CoordinateScalar; +use thiserror::Error; + +/// Errors returned by fallible vertex deduplication helpers. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum DeduplicationError { + /// Epsilon must be non-negative for distance-based deduplication. + #[error("epsilon must be non-negative")] + NegativeEpsilon, +} /// Filters vertices to remove exact coordinate duplicates. /// @@ -92,9 +102,9 @@ where /// A new vector containing vertices that are at least `epsilon` apart from each /// other (distance >= epsilon). The first occurrence of each cluster is kept. /// -/// # Panics -/// -/// Panics if `epsilon` is negative. +/// If `epsilon` is negative, the input is returned unchanged and a warning is +/// emitted. Use [`try_dedup_vertices_epsilon`] when callers should receive a +/// typed error for invalid epsilon values. /// /// # Examples /// @@ -124,13 +134,47 @@ where U: DataType, { if epsilon < T::zero() { - tracing::error!("dedup_vertices_epsilon received negative epsilon; enforcing contract"); + tracing::warn!( + epsilon = ?epsilon, + "dedup_vertices_epsilon received negative epsilon; returning input unchanged" + ); + return vertices.to_vec(); + } + + dedup_vertices_epsilon_nonnegative(vertices, epsilon) +} + +/// Fallible variant of [`dedup_vertices_epsilon`]. +/// +/// This function rejects negative epsilon values with a typed error instead of +/// falling back to returning the input unchanged. +/// +/// # Errors +/// +/// Returns [`DeduplicationError::NegativeEpsilon`] when `epsilon` is negative. +pub fn try_dedup_vertices_epsilon( + vertices: &[Vertex], + epsilon: T, +) -> Result>, DeduplicationError> +where + T: CoordinateScalar, + U: DataType, +{ + if epsilon < T::zero() { + return Err(DeduplicationError::NegativeEpsilon); } - assert!( - epsilon >= T::zero(), - "dedup_vertices_epsilon expects non-negative epsilon", - ); + Ok(dedup_vertices_epsilon_nonnegative(vertices, epsilon)) +} + +fn dedup_vertices_epsilon_nonnegative( + vertices: &[Vertex], + epsilon: T, +) -> Vec> +where + T: CoordinateScalar, + U: DataType, +{ let mut unique: Vec> = Vec::with_capacity(vertices.len()); 'outer: for &v in vertices { @@ -374,6 +418,46 @@ mod tests { ); } + #[test] + fn test_coords_within_epsilon_exact_boundary_logs_and_keeps_point() { + let a = [0.0, 0.0]; + let b = [1.0, 0.0]; + + assert!(!coords_within_epsilon(&a, &b, 1.0)); + } + + #[test] + fn test_dedup_vertices_epsilon_negative_epsilon_returns_input_unchanged() { + let vertices: Vec> = Vertex::from_points(&[ + Point::new([0.0, 0.0]), + Point::new([0.0, 0.0]), + Point::new([1.0, 0.0]), + ]); + + let unique = dedup_vertices_epsilon(&vertices, -1.0); + + assert_eq!(unique.len(), vertices.len()); + assert_eq!( + unique + .iter() + .map(<&Vertex<_, _, _> as Into<[f64; 2]>>::into) + .collect::>(), + vertices + .iter() + .map(<&Vertex<_, _, _> as Into<[f64; 2]>>::into) + .collect::>() + ); + } + + #[test] + fn test_try_dedup_vertices_epsilon_negative_epsilon_returns_error() { + let vertices: Vec> = Vertex::from_points(&[Point::new([0.0, 0.0])]); + + let err = try_dedup_vertices_epsilon(&vertices, -1.0).unwrap_err(); + + assert_eq!(err, DeduplicationError::NegativeEpsilon); + } + #[test] fn test_dedup_vertices_epsilon_preserves_first_occurrence() { // Verify that first occurrence is kept, later duplicates removed diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 0dd08018..2951bb57 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -6694,6 +6694,7 @@ mod tests { use crate::triangulation::flips::BistellarFlips; use crate::vertex; use rand::{RngExt, SeedableRng}; + use slotmap::KeyData; pub(super) fn force_repair_nonconvergent_enabled() -> bool { FORCE_REPAIR_NONCONVERGENT.with(std::cell::Cell::get) @@ -6728,6 +6729,123 @@ mod tests { }); } + #[test] + fn test_local_repair_flip_budget_uses_dimension_specific_floor_and_factor() { + assert_eq!( + local_repair_flip_budget::<3>(0), + LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4 + ); + assert_eq!( + local_repair_flip_budget::<4>(0), + LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4 + ); + + let seed_count = 10; + let raw_3d = seed_count * (3 + 1) * LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4; + let raw_4d = seed_count * (4 + 1) * LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4; + assert_eq!( + local_repair_flip_budget::<3>(seed_count), + raw_3d.max(LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4) + ); + assert_eq!( + local_repair_flip_budget::<4>(seed_count), + raw_4d.max(LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4) + ); + } + + #[test] + fn test_log_bulk_progress_if_due_updates_progress_state_only_when_due() { + let sample = BatchProgressSample { + processed: 5, + inserted: 4, + skipped: 1, + cell_count: 7, + perturbation_seed: 0xCAFE, + }; + + let mut disabled = None; + log_bulk_progress_if_due(sample, &mut disabled); + assert!(disabled.is_none()); + + let mut state = Some(BatchProgressState { + total_vertices: 10, + progress_every: 5, + started: Instant::now(), + last_progress: Instant::now(), + last_processed: 0, + }); + + log_bulk_progress_if_due( + BatchProgressSample { + processed: 0, + ..sample + }, + &mut state, + ); + assert_eq!(state.as_ref().unwrap().last_processed, 0); + + log_bulk_progress_if_due( + BatchProgressSample { + processed: 3, + ..sample + }, + &mut state, + ); + assert_eq!(state.as_ref().unwrap().last_processed, 0); + + log_bulk_progress_if_due(sample, &mut state); + assert_eq!(state.as_ref().unwrap().last_processed, 5); + + log_bulk_progress_if_due( + BatchProgressSample { + processed: 10, + inserted: 8, + skipped: 2, + cell_count: 11, + perturbation_seed: 0xBEEF, + }, + &mut state, + ); + assert_eq!(state.as_ref().unwrap().last_processed, 10); + } + + #[test] + fn test_collect_local_repair_seed_cells_merges_adjacent_extra_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(); + + let (vertex_key, adjacent, extra_cell) = dt + .vertices() + .find_map(|(vertex_key, _)| { + let adjacent: Vec = dt.tri.adjacent_cells(vertex_key).collect(); + all_cells + .iter() + .copied() + .find(|cell_key| !adjacent.contains(cell_key)) + .map(|extra_cell| (vertex_key, adjacent, extra_cell)) + }) + .expect("fixture should contain a cell outside at least one vertex star"); + + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let seeds = dt.collect_local_repair_seed_cells( + vertex_key, + &[adjacent[0], extra_cell, extra_cell, stale_cell], + ); + + assert_eq!(seeds.len(), adjacent.len() + 1); + assert_eq!(&seeds[..adjacent.len()], adjacent.as_slice()); + assert_eq!(seeds[adjacent.len()], extra_cell); + assert!(!seeds.contains(&stale_cell)); + } + struct ForceHeuristicRebuildGuard { prior: bool, } diff --git a/tests/README.md b/tests/README.md index c3016a34..137bec6a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -382,18 +382,21 @@ Integration tests for the `delaunayize_by_flips` workflow validating the public #### [`large_scale_debug.rs`](./large_scale_debug.rs) -Reproduction-oriented debug harnesses for larger 3D/4D datasets, including ignored tests and bisect-style workflows. +Reproduction-oriented debug harnesses for the active larger 3D/4D/5D +datasets tracked in issues #340, #341, and #342. -**Run with:** `cargo test --release --test large_scale_debug -- --ignored --nocapture` (or the `just debug-large-scale-*` helpers) +**Run with:** `cargo test --release --test large_scale_debug -- --ignored --nocapture` +or one of the active large-scale helpers: + +- `just debug-large-scale-4d [n]` — issue #340, default `n=3000` +- `just debug-large-scale-3d [n]` — issue #341, default `n=10000` +- `just debug-large-scale-5d [n]` — issue #342, default `n=1000` **Note:** Use `--release` for runs above roughly 30 vertices; debug-mode overhead makes large 3D/4D cases look hung even when the algorithm is making progress. For the `new`/batch path, set `DELAUNAY_BULK_PROGRESS_EVERY=` to emit periodic batch-construction -summaries. For minimal seeded repros, use the ignored -`debug_large_scale_3d_incremental_prefix_bisect` and -`debug_large_scale_4d_incremental_prefix_bisect` tests (or the matching -`just debug-large-scale-*-incremental-bisect` helpers). +summaries. #### [`conflict_region_verification.rs`](./conflict_region_verification.rs) diff --git a/tests/delaunay_edge_cases.rs b/tests/delaunay_edge_cases.rs index b25b58da..b7b6e90b 100644 --- a/tests/delaunay_edge_cases.rs +++ b/tests/delaunay_edge_cases.rs @@ -30,23 +30,31 @@ fn init_tracing() { }); } -#[cfg(feature = "test-debug")] macro_rules! test_debug_info { ($($arg:tt)*) => {{ #[cfg(feature = "test-debug")] { + init_tracing(); tracing::info!($($arg)*); } + #[cfg(not(feature = "test-debug"))] + { + let _ = format_args!($($arg)*); + } }}; } -#[cfg(feature = "test-debug")] macro_rules! test_debug_warn { ($($arg:tt)*) => {{ #[cfg(feature = "test-debug")] { + init_tracing(); tracing::warn!($($arg)*); } + #[cfg(not(feature = "test-debug"))] + { + let _ = format_args!($($arg)*); + } }}; } @@ -188,21 +196,15 @@ fn debug_issue_120_empty_circumsphere_5d() { .unwrap_or_else(|err| panic!("5D debug configuration failed to construct: {err}")); match dt.repair_delaunay_with_flips() { Ok(stats) => { - #[cfg(feature = "test-debug")] test_debug_info!( "[Issue #120 debug] repair_delaunay_with_flips stats: checked={}, flips={}, max_queue={}", stats.facets_checked, stats.flips_performed, stats.max_queue_len ); - #[cfg(not(feature = "test-debug"))] - let _ = &stats; } Err(err) => { - #[cfg(feature = "test-debug")] test_debug_warn!("[Issue #120 debug] repair_delaunay_with_flips error: {err}"); - #[cfg(not(feature = "test-debug"))] - let _ = &err; } } let mut dt_robust: DelaunayTriangulation, (), (), 5> = @@ -213,28 +215,19 @@ fn debug_issue_120_empty_circumsphere_5d() { ); match dt_robust.repair_delaunay_with_flips() { Ok(stats) => { - #[cfg(feature = "test-debug")] test_debug_info!( "[Issue #120 debug] robust repair stats: checked={}, flips={}, max_queue={}", stats.facets_checked, stats.flips_performed, stats.max_queue_len ); - #[cfg(not(feature = "test-debug"))] - let _ = &stats; } Err(err) => { - #[cfg(feature = "test-debug")] test_debug_warn!("[Issue #120 debug] robust repair error: {err}"); - #[cfg(not(feature = "test-debug"))] - let _ = &err; } } if let Err(err) = dt_robust.is_valid() { - #[cfg(feature = "test-debug")] test_debug_warn!("[Issue #120 debug] robust triangulation still invalid: {err:?}"); - #[cfg(not(feature = "test-debug"))] - let _ = &err; } let mut rng = rand::rngs::StdRng::seed_from_u64(0x1200_5eed); for attempt in 0..20 { @@ -245,7 +238,6 @@ fn debug_issue_120_empty_circumsphere_5d() { TopologyGuarantee::PLManifold, ) { if dt_alt.is_valid().is_ok() { - #[cfg(feature = "test-debug")] test_debug_info!( "[Issue #120 debug] found valid triangulation after shuffle attempt {}", attempt + 1 @@ -254,28 +246,21 @@ fn debug_issue_120_empty_circumsphere_5d() { } } if attempt == 19 { - #[cfg(feature = "test-debug")] test_debug_warn!("[Issue #120 debug] no valid triangulation found in 20 shuffles"); } } for (cell_key, cell) in dt.cells() { - #[cfg(feature = "test-debug")] test_debug_info!("[Issue #120 debug] cell {cell_key:?}:"); - #[cfg(not(feature = "test-debug"))] - let _ = cell_key; for &vkey in cell.vertices() { let vertex = dt .tds() .get_vertex_by_key(vkey) .expect("vertex key should exist"); - #[cfg(feature = "test-debug")] test_debug_info!( " vkey={vkey:?}, uuid={}, point={:?}", vertex.uuid(), vertex.point() ); - #[cfg(not(feature = "test-debug"))] - let _ = &vertex; } } diff --git a/tests/delaunay_repair_fallback.rs b/tests/delaunay_repair_fallback.rs index 025ff3ad..7ee341dd 100644 --- a/tests/delaunay_repair_fallback.rs +++ b/tests/delaunay_repair_fallback.rs @@ -22,7 +22,6 @@ fn init_tracing() { #[cfg(not(feature = "test-debug"))] const fn init_tracing() {} -#[cfg(feature = "test-debug")] macro_rules! test_debug_info { ($($arg:tt)*) => {{ #[cfg(feature = "test-debug")] @@ -30,6 +29,10 @@ macro_rules! test_debug_info { init_tracing(); tracing::info!($($arg)*); } + #[cfg(not(feature = "test-debug"))] + { + let _ = format_args!($($arg)*); + } }}; } @@ -135,10 +138,7 @@ fn incremental_insertion_with_repair_fallback() { } Err(e) => { // Some insertions may be skipped (duplicates, degeneracies), which is fine - #[cfg(feature = "test-debug")] test_debug_info!("Vertex {} skipped: {}", i + 1, e); - #[cfg(not(feature = "test-debug"))] - let _ = &e; } } } diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 4cb06872..66392215 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -897,299 +897,6 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz DebugOutcome::Success } -/// Failure detail captured for one prefix probe in the bisect harness. -#[derive(Debug, Clone)] -struct IncrementalFailure { - prefix_len: usize, - index: usize, - uuid: uuid::Uuid, - coords: [f64; D], - error: String, -} - -/// Runs one batch-construction probe against a deterministic prefix of the input. -/// -/// The bisect harness uses this helper so every probe exercises the same path as the -/// regular `debug_large_scale_*d` tests: batch/new construction, optional final repair, -/// then full validation. -fn run_incremental_prefix( - vertices: &[Vertex], - prefix_len: usize, - _repair_every: usize, -) -> Result<(), IncrementalFailure> { - let kernel = RobustKernel::::new(); - let prefix = &vertices[..prefix_len]; - let mut dt = match DelaunayTriangulation::, (), (), D>::with_topology_guarantee_and_options_with_construction_statistics( - &kernel, - prefix, - TopologyGuarantee::PLManifoldStrict, - ConstructionOptions::default(), - ) { - Ok((dt, _stats)) => dt, - Err(err) => { - let DelaunayTriangulationConstructionErrorWithStatistics { - error, statistics, .. - } = err; - // Attribute constructor failures to the last successfully processed prefix slot - // so the operator gets a stable replay anchor instead of an abstract count. - let idx = statistics - .inserted - .saturating_sub(1) - .min(prefix_len.saturating_sub(1)); - let (uuid, coords) = prefix.get(idx).copied().map_or_else( - || (uuid::Uuid::nil(), [0.0; D]), - |vertex| (vertex.uuid(), *vertex.point().coords()), - ); - return Err(IncrementalFailure { - prefix_len, - index: idx, - uuid, - coords, - error: format!( - "{} [inserted={} skipped_duplicate={} skipped_degeneracy={}]", - error, - statistics.inserted, - statistics.skipped_duplicate, - statistics.skipped_degeneracy - ), - }) - } - }; - - let skipped_total = prefix_len.saturating_sub(dt.number_of_vertices()); - if !env_flag("DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS") && skipped_total > 0 { - let idx = prefix_len.saturating_sub(1); - let (uuid, coords) = prefix.get(idx).copied().map_or_else( - || (uuid::Uuid::nil(), [0.0; D]), - |vertex| (vertex.uuid(), *vertex.point().coords()), - ); - return Err(IncrementalFailure { - prefix_len, - index: idx, - uuid, - coords, - error: format!( - "{skipped_total} vertices were skipped (set DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 to allow)" - ), - }); - } - - // Keep the bisect path aligned with the main debug harness by allowing an optional - // final repair pass before we classify the prefix as a validation failure. - if !env_flag("DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR") && dt.number_of_cells() > 0 { - let _ = dt.repair_delaunay_with_flips_advanced(DelaunayRepairHeuristicConfig::default()); - } - - if let Err(report) = dt.validation_report() { - let idx = prefix_len.saturating_sub(1); - let (uuid, coords) = prefix.get(idx).copied().map_or_else( - || (uuid::Uuid::nil(), [0.0; D]), - |vertex| (vertex.uuid(), *vertex.point().coords()), - ); - let detail = report.violations.first().map_or_else( - || "no violations captured".to_string(), - |violation| format!("{:?}: {}", violation.kind, violation.error), - ); - return Err(IncrementalFailure { - prefix_len, - index: idx, - uuid, - coords, - error: format!("validation_report failed: {detail}"), - }); - } - - Ok(()) -} - -#[expect( - clippy::too_many_lines, - reason = "Debug harness intentionally verbose for reproducibility and operator guidance" -)] -/// Binary-searches the smallest prefix that still reproduces a batch-construction failure. -/// -/// This is intentionally dimension-generic so 3D and 4D seeded repros can share the -/// same workflow while keeping their own defaults and replay commands. -fn debug_large_scale_incremental_prefix_bisect( - dimension_name: &str, - default_total_n: usize, - default_case_seed: Option, -) { - init_tracing(); - - let total_n = env_usize("DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL") - .unwrap_or(default_total_n) - .max(D + 1); - let base_seed = env_u64("DELAUNAY_LARGE_DEBUG_SEED").unwrap_or(42); - let case_seed = env_u64(&format!("DELAUNAY_LARGE_DEBUG_CASE_SEED_{D}D")) - .or_else(|| env_u64("DELAUNAY_LARGE_DEBUG_CASE_SEED")) - .or(default_case_seed) - .unwrap_or_else(|| seed_for_case::(base_seed, total_n)); - let ball_radius = env_f64("DELAUNAY_LARGE_DEBUG_BALL_RADIUS").unwrap_or(100.0); - let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(128); - let max_probes = env_usize("DELAUNAY_LARGE_DEBUG_PREFIX_MAX_PROBES"); - let max_runtime_secs = env_usize("DELAUNAY_LARGE_DEBUG_PREFIX_MAX_RUNTIME_SECS").unwrap_or(0); - - println!("============================================="); - println!("{dimension_name} incremental prefix bisect"); - println!("============================================="); - println!("Config:"); - println!(" D: {D}"); - println!(" total_n: {total_n}"); - println!(" base_seed: 0x{base_seed:X} ({base_seed})"); - println!(" case_seed: 0x{case_seed:X} ({case_seed})"); - println!(" ball_radius: {ball_radius}"); - println!(" repair_every: {repair_every} (ignored in batch/new mode)"); - println!(" probe_mode: new (batch, matches debug_large_scale_{D}d default)"); - println!(" max_probes: {max_probes:?}"); - println!(" max_runtime_secs:{max_runtime_secs}"); - println!(); - - let points = generate_random_points_in_ball_seeded::(total_n, ball_radius, case_seed) - .unwrap_or_else(|e| { - panic!("failed to generate deterministic {dimension_name} ball points for bisect: {e}") - }); - let vertices: Vec> = points.into_iter().map(|p| vertex!(p)).collect(); - - let t_bisect = Instant::now(); - let mut probe_count = 0usize; - - let mut run_probe = |prefix_len: usize| -> Option>> { - // The probe limiter keeps manual investigations bounded even when a single prefix - // is expensive, which matters for the 4D retry-collapse cases. - if let Some(limit) = max_probes - && probe_count >= limit - { - println!( - "Stopping early: reached DELAUNAY_LARGE_DEBUG_PREFIX_MAX_PROBES={limit} (elapsed {:?})", - t_bisect.elapsed() - ); - return None; - } - - if max_runtime_secs > 0 && t_bisect.elapsed().as_secs() >= max_runtime_secs as u64 { - println!( - "Stopping early: reached DELAUNAY_LARGE_DEBUG_PREFIX_MAX_RUNTIME_SECS={} (probes={probe_count}, elapsed {:?})", - max_runtime_secs, - t_bisect.elapsed() - ); - return None; - } - - probe_count = probe_count.saturating_add(1); - let t_probe = Instant::now(); - let result = run_incremental_prefix(&vertices, prefix_len, repair_every); - println!( - " probe #{probe_count}: prefix_len={prefix_len} -> {} ({:?})", - if result.is_err() { "FAIL" } else { "PASS" }, - t_probe.elapsed() - ); - Some(result) - }; - - let first_failure = match run_probe(total_n) { - None => return, - Some(Ok(())) => { - // Re-run the full prefix outside the closure to catch any accidental harness - // mismatch before we report that the seed no longer fails. - if let Err(mismatch) = run_incremental_prefix(&vertices, total_n, repair_every) { - println!( - "HARNESS MISMATCH: bisect full-prefix probe passed but canonical full-prefix recheck failed." - ); - println!( - " mismatch details: idx={} uuid={} coords={:?} error={}", - mismatch.index, mismatch.uuid, mismatch.coords, mismatch.error - ); - panic!("aborting: harness mismatch (bisect PASS vs canonical FAIL)"); - } - println!("Canonical full-prefix recheck: PASS"); - println!( - "No failure observed for full prefix total_n={total_n}; bisect skipped (likely fixed or total too small)." - ); - println!( - "Config recap: base_seed=0x{base_seed:X} case_seed=0x{case_seed:X} ball_radius={ball_radius} repair_every={repair_every} mode=new" - ); - println!( - "To force a failure, increase DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL or adjust DELAUNAY_LARGE_DEBUG_CASE_SEED_{D}D." - ); - return; - } - Some(Err(err)) => err, - }; - - let mut lo = D + 1; - let mut hi = first_failure.prefix_len.max(lo); - println!( - "Full-run first failure: idx={} (prefix_len={})", - first_failure.index, first_failure.prefix_len - ); - println!("Initial binary-search range: [{lo}, {hi}]"); - - // Standard binary search: shrink toward the first failing prefix while preserving - // the invariant that everything below `lo` is known-good and `hi` is known-bad. - while lo < hi { - let mid = lo + (hi - lo) / 2; - let Some(result) = run_probe(mid) else { - return; - }; - let failed = result.is_err(); - - if failed { - hi = mid; - } else { - lo = mid + 1; - } - } - - let minimal_prefix = lo; - let minimal_failure = match run_probe(minimal_prefix) { - None => return, - Some(Ok(())) => { - panic!( - "internal bisect inconsistency: expected failure at minimal_prefix={minimal_prefix}" - ) - } - Some(Err(err)) => err, - }; - - // Double-check the boundary so we can trust the replay command we print below. - // The guard must match the binary search's lower bound (`lo = D + 1`): if - // `minimal_prefix == D + 1`, probing `minimal_prefix - 1` would request a - // prefix shorter than the initial simplex, which the harness cannot build. - if minimal_prefix > D + 1 { - assert!( - run_probe(minimal_prefix - 1).is_some_and(|result| result.is_ok()), - "internal bisect inconsistency: prefix {} should pass", - minimal_prefix - 1 - ); - } - - println!(); - println!("Minimal failing prefix: {minimal_prefix}"); - println!( - "Failure details: idx={} uuid={} coords={:?}", - minimal_failure.index, minimal_failure.uuid, minimal_failure.coords - ); - println!("Error: {}", minimal_failure.error); - println!(); - println!("Replay command:"); - println!( - " DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE=new DELAUNAY_LARGE_DEBUG_N_{D}D={minimal_prefix} DELAUNAY_LARGE_DEBUG_CASE_SEED_{D}D=0x{case_seed:X} DELAUNAY_REPAIR_DEBUG_FACETS=1 cargo test --release --test large_scale_debug debug_large_scale_{D}d -- --ignored --nocapture" - ); -} - -#[test] -#[ignore = "large-scale debug harness (manual run)"] -fn debug_large_scale_3d_incremental_prefix_bisect() { - debug_large_scale_incremental_prefix_bisect::<3>("3D", 1000, None); -} - -#[test] -#[ignore = "large-scale debug harness (manual run)"] -fn debug_large_scale_4d_incremental_prefix_bisect() { - debug_large_scale_incremental_prefix_bisect::<4>("4D", 500, Some(0xD225_B8A0_7E27_4AE6)); -} - /// Regression test for issue #228: 3D 1000-point flip-repair non-convergence. /// /// Before the fix, `AdaptiveKernel`'s exact+SoS predicates were overridden by @@ -1287,13 +994,6 @@ fn regression_issue_230_4d_100_orientation() { ); } -#[test] -#[ignore = "large-scale debug harness (manual run)"] -fn debug_large_scale_2d() { - let outcome = debug_large_case::<2>("2D", 10_000); - assert!(matches!(outcome, DebugOutcome::Success), "{outcome}"); -} - #[test] #[ignore = "large-scale debug harness (manual run)"] fn debug_large_scale_3d() { diff --git a/tests/storage_backend_compatibility.rs b/tests/storage_backend_compatibility.rs index 181e145e..ea2eadff 100644 --- a/tests/storage_backend_compatibility.rs +++ b/tests/storage_backend_compatibility.rs @@ -76,10 +76,13 @@ macro_rules! test_debug_info { init_tracing(); tracing::info!($($arg)*); } + #[cfg(not(feature = "test-debug"))] + { + let _ = format_args!($($arg)*); + } }}; } -#[cfg(feature = "test-debug")] macro_rules! test_debug_warn { ($($arg:tt)*) => {{ #[cfg(feature = "test-debug")] @@ -87,18 +90,18 @@ macro_rules! test_debug_warn { init_tracing(); tracing::warn!($($arg)*); } + #[cfg(not(feature = "test-debug"))] + { + let _ = format_args!($($arg)*); + } }}; } -#[cfg(feature = "test-debug")] fn log_large_scale_skip(expected: &str) { test_debug_warn!("Large-scale test skipped (set RUN_LARGE_SCALE_TESTS=1 to enable)"); test_debug_info!("Expected: {expected}"); } -#[cfg(not(feature = "test-debug"))] -const fn log_large_scale_skip(_expected: &str) {} - // ============================================================================= // TEST GENERATION MACROS (reduces duplication across 2D-5D) // ============================================================================= From cff07db377414f8e0176d7e41d8fe6073c661576 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Fri, 24 Apr 2026 10:44:10 -0700 Subject: [PATCH 04/11] Fixed: normalize indented headings in changelog post-processing Update the changelog post-processing script to convert indented ATX headings from commit bodies into bold prose. This ensures the generated CHANGELOG.md complies with Markdownlint rule MD023 (headings must start at column 0) while preserving the visual hierarchy and readability of historical commit summaries. Additionally, internal diagnostic state for Delaunay repair was moved from global atomics to a per-attempt structure to ensure reliable rate-limiting across concurrent threads. Refs: #204 --- CHANGELOG.md | 139 ++++++++++++- Cargo.lock | 6 +- scripts/postprocess_changelog.py | 32 ++- scripts/tests/test_postprocess_changelog.py | 37 ++++ src/core/algorithms/flips.rs | 220 +++++++++++--------- src/core/triangulation.rs | 6 +- src/core/util/deduplication.rs | 62 +++++- src/triangulation/delaunay.rs | 117 +++++++++-- tests/large_scale_debug.rs | 6 +- 9 files changed, 483 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c368dd9..8d3d11c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Merged Pull Requests +- Orient Delaunay repair replacement cells [#307](https://github.com/acgetchell/delaunay/pull/307) [#336](https://github.com/acgetchell/delaunay/pull/336) +- Use dedicated perf profile for consistent benchmark measurement [#334](https://github.com/acgetchell/delaunay/pull/334) - Periodic-aware Delaunay verification (Level 4) for toroidal tria… [#333](https://github.com/acgetchell/delaunay/pull/333) - Adopt Rust 1.95.0 MSRV [#330](https://github.com/acgetchell/delaunay/pull/330) - Bump actions-rust-lang/setup-rust-toolchain [#328](https://github.com/acgetchell/delaunay/pull/328) @@ -53,6 +55,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add T² (3×3 grid) and T³ (3×3×3 Freudenthal) integration tests validating χ = 0 via explicit construction +- Instrument large-scale 4D debugging and widen local repair seeds + [`fd5dbf2`](https://github.com/acgetchell/delaunay/commit/fd5dbf211af14124db6cc21ceef0b821b53cdffe) + +- Thread cavity-touched cells through insertion as `repair_seed_cells` + so post-insertion local Delaunay repair widens its frontier beyond + the inserted vertex star; cells shrunk out of the conflict region + during cavity reduction now participate in the next repair pass. + + - Accumulate ridge-fan extras across every fan in a conflict region + before returning `RidgeFan`, letting one cavity-reduction step + shrink all detected fans at once instead of peeling them iteration + by iteration. + + - Add release-visible diagnostic hooks routed through `tracing::debug!`: + `DELAUNAY_BULK_PROGRESS_EVERY` for periodic batch-construction + progress, `DELAUNAY_DEBUG_RETRYABLE_SKIP` for retryable + conflict-region skip traces, `DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE` + for the first cavity-reduction chain, `DELAUNAY_DEBUG_RIDGE_FAN_ONCE` + for the first detected ridge fan, and + `DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET` / + `DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY` for repair + postcondition debugging. + + - Thread `last_applied_flip` through repair postcondition verification + so unresolved k=2 facet and ridge snapshots can relate the violating + local star to the immediately preceding flip. + + - Replace `ConflictError::InternalInconsistency { context: String }` + with a typed `InternalInconsistencySite` enum carrying structured + indices and counts, so callers can `matches!` on specific sites + instead of parsing prose. + + - Generalize the large-scale incremental prefix bisect over `const D`, + add a 4D counterpart targeting the seeded 500-point repro + (`0xD225_B8A0_7E27_4AE6`), and expose it via + `just debug-large-scale-4d-incremental-bisect`. + + - Switch the large-scale debug just recipes to `--release` and + document the 2026-04-23 re-verification: historical 35-point 3D and + 100-point 4D correctness repros from #306/#307 now pass, while a + 500-point 4D seed still fails all shuffled retries with + `Ridge fan detected: 4 facets share ridge with 3 vertices`. + + - Default the large-scale debug harness tracing filter to `debug` when + any of the new release-visible env vars are present so library-side + `tracing::debug!` events surface without extra `RUST_LOG` wiring. + + - Broaden `test_perturbation_retry_and_exhaustion_4d` and + `test_perturbation_retry_seeded_branch_4d` to iterate over 50 seeds + so the retry-path assertions stay robust to insertion-path + improvements that make individual well-conditioned seeds less likely + to trigger retries. + ### Changed - Rename tds file and move delaunay/builder into triangulation/ [#317](https://github.com/acgetchell/delaunay/pull/317) @@ -94,10 +149,12 @@ Perform a general dependency update, including a patch bump for `uuid`. updates, and merged pull requests. Add `.kilo/` to the ignored user configuration patterns. -- Use dedicated perf profile for consistent benchmark measurement - [`ebf9abf`](https://github.com/acgetchell/delaunay/commit/ebf9abf1571b397aeabd47196a101961c456c0c4) +- Use dedicated perf profile for consistent benchmark measurement [#334](https://github.com/acgetchell/delaunay/pull/334) + [`f527c0c`](https://github.com/acgetchell/delaunay/commit/f527c0cf37b76f09222800afcfc138e623957678) + +- Changed: use dedicated perf profile for consistent benchmark measurement -Introduce a `perf` Cargo profile that inherits from `release` but + Introduce a `perf` Cargo profile that inherits from `release` but restores ThinLTO and single codegen units. This ensures local, CI, and release benchmarks are generated with identical optimization settings. @@ -110,15 +167,58 @@ Introduce a `perf` Cargo profile that inherits from `release` but Also deniest warnings via the manifest lint policy to ensure consistent repository-wide enforcement. -- Standardize benchmark profiles and enhance SARIF analysis [`9acf503`](https://github.com/acgetchell/delaunay/commit/9acf503ad75f031a4c2c5978f0f353951623499f) + - Changed: standardize benchmark profiles and enhance SARIF analysis -Standardize benchmark workflows to use the `perf` profile by default + Standardize benchmark workflows to use the `perf` profile by default across local scripts and CI for consistent optimization settings. Add a dedicated CodeQL analysis workflow and refactor SARIF reporting for cargo-audit, Clippy, and Codacy to improve GitHub Code Scanning integration. Update manifest lints to comply with RFC 3389 priority requirements and fix the minimum sample size for benchmark smoke tests. + - Changed: track sampling metadata and standardize benchmark profiles + + Enhance performance regression testing by embedding sampling configuration + (Criterion settings and Cargo profile) into baseline files. This enables + automatic detection of configuration mismatches during comparisons. + Standardize benchmarking scripts on the trusted perf profile and update + developer guidelines for naming conventions and local imports. + + - Changed: enable debug line tables for perf profile and refine validation + + Include `debug = "line-tables-only"` in the perf Cargo profile to + enable source-level profiling. Update the benchmark comparison logic + to ensure that legacy baselines with missing or "Unknown" metadata + trigger configuration mismatch warnings. + + - Changed: expand benchmark metadata validation tests + + Update the benchmark utility tests to verify that differences or + omissions in Criterion measurement and warm-up time are correctly + reported in configuration mismatch warnings. + + - Changed: enable CodeRabbit request changes workflow + + Enable the request_changes_workflow in the CodeRabbit configuration to + allow the AI reviewer to formally request changes on pull requests. This + ensures that identified issues are explicitly addressed during the + review process rather than appearing as informational comments only. + +- Harden flip diagnostics and refine large-scale debug workflows + [`fb23595`](https://github.com/acgetchell/delaunay/commit/fb23595fd664ef19bb3ea7ca134e725214dfeeca) + +Refactor flip snapshotting and cavity-reduction bookkeeping to ensure + diagnostic reliability and accurate repair-seed collection. Update + documentation and justfile recipes to reflect fixed historical repros + and transition to monitoring active scalability investigations for 3D, + 4D, and 5D datasets. + +- Move removed-cell vertex capturing into fallible internal helpers +- Implement lazy evaluation for cavity-reduction diagnostic logs +- Harden vertex deduplication with fallible epsilon validation +- Update 4D known issues to reflect 100-point and 500-point fixes +- Simplify the large-scale debug harness CLI and documentation + ### Documentation - Sync documentation with post-v0.7.5 changes [skip ci] [`5fa36aa`](https://github.com/acgetchell/delaunay/commit/5fa36aa67cb99bb3a5781e4c2733c2acec3adea8) @@ -210,6 +310,27 @@ Standardize benchmark workflows to use the `perf` profile by default - Add unit tests for align_periodic_offset (identity, delta shifts, higher-dimension, overflow). +- Orient Delaunay repair replacement cells [#307](https://github.com/acgetchell/delaunay/pull/307) [#336](https://github.com/acgetchell/delaunay/pull/336) + [`68deb62`](https://github.com/acgetchell/delaunay/commit/68deb6212a0860cd85776744d29ba7e76f368579) + +- fix: orient Delaunay repair replacement cells [#307](https://github.com/acgetchell/delaunay/pull/307) + + - Build flip replacement cell order from oriented cavity-boundary constraints. + - Keep raw bistellar flips topology-oriented while requiring positive replacement geometry for Delaunay repair. + - Canonicalize bulk repair results before continuing construction. + - Add a 4D regression test for the issue #307 bulk construction failure. + - Document branch naming conventions for contributors and agents. +- Close the 4D bulk repair retry collapse [`8c110f3`](https://github.com/acgetchell/delaunay/commit/8c110f3d1eac51ca189eb608fd6f09715afde879) + +- Raise the D≥4 per-insertion repair budget, add a rate-limited escalation pass, and widen local post-repair validation so the 500-point #204 repro converges + without skipped vertices. + - Preserve removed-cell snapshots and predecessor context in flip diagnostics, drop stale repair seeds after cavity reduction, and re-export locate conflict + diagnostics from the prelude. + - Replace committed `eprintln!` diagnostics in production, tests, and benches with `tracing` , using `test-debug` and `bench-logging` gates and keeping logs + out of Criterion hot loops. + - Document the #204 investigation, refresh the 4D known-issues and TODO notes, and record the repository logging policy plus release-visible debug environment + variables. + ### Maintenance - Bump pytest in the uv group across 1 directory [#322](https://github.com/acgetchell/delaunay/pull/322) @@ -1979,7 +2100,7 @@ Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.2.0 to Implements correctness fixes, API improvements, and comprehensive testing for the Hilbert space-filling curve ordering utilities. - ## Correctness Fixes + **Correctness Fixes** - Add debug_assert guards in hilbert_index_from_quantized for parameter validation (bits range and overflow checks) @@ -1988,7 +2109,7 @@ Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.2.0 to to scaled.round().to_u32() for fairer spatial distribution across grid cells, improving point ordering quality - ## API Design + **API Design** - Add HilbertError enum with InvalidBitsParameter, IndexOverflow, and DimensionTooLarge variants for proper error handling @@ -1999,7 +2120,7 @@ Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.2.0 to - Bulk API avoids redundant quantization computation, significantly improving performance for large insertion batches - ## Testing + **Testing** - Add 4D continuity test verifying Hilbert curve property on 256-point grid (bits=2) @@ -2012,7 +2133,7 @@ Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.2.0 to - All 17 Hilbert-specific tests pass (11 existing + 6 new) - ## Known Issue + **Known Issue** Temporarily ignore repair_fallback_produces_valid_triangulation test as the rounding change affects insertion order, exposing a latent geometric diff --git a/Cargo.lock b/Cargo.lock index 7b53eb63..7a0b22b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -579,7 +579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.6", "serde", ] @@ -709,9 +709,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "rand_core 0.6.4", "serde", diff --git a/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py index 2fc9045f..7d48cb33 100644 --- a/scripts/postprocess_changelog.py +++ b/scripts/postprocess_changelog.py @@ -8,7 +8,8 @@ 2. Reflow long lines at word boundaries, preserving markdown links and code spans as atomic tokens (MD013). 3. Tag bare fenced code blocks with a language (MD040). - 4. Strip trailing blank lines (MD012). + 4. Normalize indented commit-body headings (MD023). + 5. Strip trailing blank lines (MD012). Usage: postprocess-changelog # default: CHANGELOG.md @@ -60,6 +61,9 @@ # Extra spaces after list marker: ``- `` → ``- `` (MD030). _LIST_MARKER_SPACE_RE = re.compile(r"^(\s*-)\s{2,}") +# Indented ATX headings from commit bodies: `` ## Title`` → `` **Title**``. +_INDENTED_ATX_HEADING_RE = re.compile(r"^(?P\s+)#{1,6}\s+(?P.*?)(?:\s+#+\s*)?$") + def _max_pr_number(entry: str) -> int: """ @@ -308,6 +312,28 @@ def _fix_typos(text: str) -> str: return text +def _normalize_indented_heading(line: str) -> str: + """ + Convert indented commit-body headings into bold prose. + + git-cliff indents commit bodies under each changelog entry. If a historical + commit body contains an ATX heading such as ``## Correctness Fixes``, the + rendered changelog contains `` ## Correctness Fixes``. Markdownlint still + treats that as a heading, but MD023 requires headings to start at column 0. + Keeping the text as bold prose preserves readability without changing the + generated changelog hierarchy. + """ + match = _INDENTED_ATX_HEADING_RE.match(line) + if match is None: + return line + + title = match.group("title").strip() + if not title: + return line + + return f"{match.group('indent')}**{title}**" + + def postprocess(path: Path) -> None: """Read *path*, apply hygiene fixes, and write it back.""" text = path.read_text(encoding="utf-8") @@ -355,6 +381,10 @@ def postprocess(path: Path) -> None: line = _deindent_orphan(line, lines, idx) stripped = line.lstrip() + # --- MD023: headings must start at the beginning of the line --- + line = _normalize_indented_heading(line) + stripped = line.lstrip() + # --- MD032: blank line before a list item that follows prose --- if _needs_blank_before(stripped, result): result.append("") diff --git a/scripts/tests/test_postprocess_changelog.py b/scripts/tests/test_postprocess_changelog.py index 2037fbfb..5d58d43b 100644 --- a/scripts/tests/test_postprocess_changelog.py +++ b/scripts/tests/test_postprocess_changelog.py @@ -9,6 +9,7 @@ _fix_typos, _inject_summary_sections, _max_pr_number, + _normalize_indented_heading, _reflow_line, postprocess, ) @@ -371,6 +372,42 @@ def test_no_blank_after_heading(self, tmp_path: Path) -> None: assert "\n\n- item" not in f.read_text(encoding="utf-8") +class TestIndentedHeadingNormalization: + """MD023: commit-body headings are rendered as prose, not nested headings.""" + + def test_indented_atx_heading_becomes_bold_prose(self) -> None: + assert _normalize_indented_heading(" ## Correctness Fixes") == " **Correctness Fixes**" + + def test_indented_atx_closing_sequence_becomes_bold_prose(self) -> None: + assert _normalize_indented_heading(" ### API Design ###") == " **API Design**" + + def test_column_zero_changelog_heading_is_preserved(self) -> None: + assert _normalize_indented_heading("### Added") == "### Added" + + def test_full_pipeline_normalizes_commit_body_headings(self, tmp_path: Path) -> None: + f = tmp_path / "CHANGELOG.md" + f.write_text( + "# Changelog\n\n" + "## [1.0.0]\n\n" + "### Performance\n\n" + "- perf: improve Hilbert curve correctness\n\n" + " ## Correctness Fixes\n\n" + " - Add debug_assert guards\n\n" + " ## API Design\n\n" + " - Add HilbertError enum\n", + encoding="utf-8", + ) + + postprocess(f) + + result = f.read_text(encoding="utf-8") + assert " ## Correctness Fixes" not in result + assert " ## API Design" not in result + assert " **Correctness Fixes**" in result + assert " **API Design**" in result + assert "### Performance" in result + + class TestCodeBlockLanguage: def test_adds_language_to_bare_fence(self, tmp_path: Path) -> None: f = tmp_path / "CHANGELOG.md" diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 202f5651..0e30ee8d 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -58,7 +58,6 @@ use std::collections::VecDeque; use std::env; use std::fmt; use std::hash::{Hash, Hasher}; -use std::sync::atomic::{AtomicUsize, Ordering}; use thiserror::Error; type VertexKeyList = SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>; @@ -845,13 +844,14 @@ fn debug_ridge_context<T, U, V, const D: usize>( tds: &Tds<T, U, V, D>, ridge: RidgeHandle, reported_multiplicity: Option<usize>, + diagnostics: &mut RepairDiagnostics, last_applied_flip: Option<&LastAppliedFlip>, ) where T: CoordinateScalar, U: DataType, V: DataType, { - if !should_emit_ridge_debug(reported_multiplicity) { + if !should_emit_ridge_debug(diagnostics, reported_multiplicity) { return; } let Some(cell) = tds.get_cell(ridge.cell_key()) else { @@ -1019,13 +1019,14 @@ fn debug_postcondition_facet_context<T, U, V, const D: usize>( tds: &Tds<T, U, V, D>, facet: FacetHandle, context: &FlipContext<D, 2>, + diagnostics: &mut RepairDiagnostics, last_applied_flip: Option<&LastAppliedFlip>, ) where T: CoordinateScalar, U: DataType, V: DataType, { - if !should_emit_postcondition_facet_debug() { + if !should_emit_postcondition_facet_debug(diagnostics) { return; } @@ -3856,7 +3857,13 @@ where "[repair] postcondition k=2 violation remains (facet={facet:?})" ); } - debug_postcondition_facet_context(tds, facet, &context, last_applied_flip); + debug_postcondition_facet_context( + tds, + facet, + &context, + diagnostics, + last_applied_flip, + ); let mut message = format!("local k=2 violation remains after repair (facet={facet:?})"); if std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { @@ -3978,7 +3985,7 @@ where // preceding flip, when available — so #204-style ridge // diagnostics carry the same predecessor-flip context as the // k=2 facet path via `debug_postcondition_facet_context`. - debug_ridge_context(tds, ridge, None, last_applied_flip); + debug_ridge_context(tds, ridge, None, diagnostics, last_applied_flip); return Err(DelaunayRepairError::PostconditionFailed { message: format!("local k=3 violation remains after repair (ridge={ridge:?})"), }); @@ -4201,6 +4208,8 @@ struct RepairDiagnostics { missing_cell_sample: Option<String>, flip_signature_window: VecDeque<u64>, flip_signature_counts: FastHashMap<u64, usize>, + ridge_debug_emitted: usize, + postcondition_facet_debug_emitted: usize, } impl RepairDiagnostics { @@ -4522,8 +4531,6 @@ fn repair_ridge_debug_enabled() -> bool { const RIDGE_DEBUG_LIMIT_DEFAULT: usize = 64; const RIDGE_DEBUG_MIN_MULTIPLICITY_DEFAULT: usize = 0; -static RIDGE_DEBUG_EMITTED: AtomicUsize = AtomicUsize::new(0); -static POSTCONDITION_FACET_DEBUG_EMITTED: AtomicUsize = AtomicUsize::new(0); /// Rate-limits ridge snapshots to keep pathological repair runs from flooding /// logs. @@ -4543,9 +4550,12 @@ fn ridge_debug_min_multiplicity() -> usize { .unwrap_or(RIDGE_DEBUG_MIN_MULTIPLICITY_DEFAULT) } -/// Applies the ridge debug limit atomically so concurrent tests still share a -/// bounded diagnostic budget. -fn should_emit_ridge_debug(reported_multiplicity: Option<usize>) -> bool { +/// Applies the ridge debug limit per repair attempt so independent repairs do +/// not consume each other's diagnostic budget. +fn should_emit_ridge_debug( + diagnostics: &mut RepairDiagnostics, + reported_multiplicity: Option<usize>, +) -> bool { let min_multiplicity = ridge_debug_min_multiplicity(); match reported_multiplicity { // Multiplicity-based skips dominate large 4D traces, so let callers suppress @@ -4561,7 +4571,8 @@ fn should_emit_ridge_debug(reported_multiplicity: Option<usize>) -> bool { if limit == 0 { return false; } - let current = RIDGE_DEBUG_EMITTED.fetch_add(1, Ordering::Relaxed); + let current = diagnostics.ridge_debug_emitted; + diagnostics.ridge_debug_emitted = diagnostics.ridge_debug_emitted.saturating_add(1); if current == limit { tracing::debug!( "repair: ridge debug output limit reached; suppressing further ridge snapshots" @@ -4577,13 +4588,17 @@ fn postcondition_facet_debug_enabled() -> bool { env::var_os("DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET").is_some() } -/// Emits at most one postcondition facet snapshot per process so the focused -/// #204 debug path stays readable. -fn should_emit_postcondition_facet_debug() -> bool { +/// Emits at most one postcondition facet snapshot per repair attempt so the +/// focused #204 debug path stays readable. +fn should_emit_postcondition_facet_debug(diagnostics: &mut RepairDiagnostics) -> bool { if !postcondition_facet_debug_enabled() { return false; } - POSTCONDITION_FACET_DEBUG_EMITTED.fetch_add(1, Ordering::Relaxed) == 0 + let current = diagnostics.postcondition_facet_debug_emitted; + diagnostics.postcondition_facet_debug_emitted = diagnostics + .postcondition_facet_debug_emitted + .saturating_add(1); + current == 0 } /// Computes a dimension-sensitive flip budget so non-convergent repair fails @@ -4894,11 +4909,17 @@ where // and the full global incidence so we can see whether repair is skipping // a stale handle or a genuinely overshared ridge. if repair_ridge_debug_enabled() { - debug_ridge_context(tds, ridge, Some(*found), last_applied_flip.as_ref()); + debug_ridge_context( + tds, + ridge, + Some(*found), + diagnostics, + last_applied_flip.as_ref(), + ); } } FlipError::InvalidRidgeAdjacency { .. } if repair_ridge_debug_enabled() => { - debug_ridge_context(tds, ridge, None, last_applied_flip.as_ref()); + debug_ridge_context(tds, ridge, None, diagnostics, last_applied_flip.as_ref()); } FlipError::MissingCell { cell_key } => { diagnostics.record_missing_cell_skip(|| { @@ -6669,90 +6690,101 @@ mod tests { } } - #[test] - fn test_snapshot_removed_cell_vertices_captures_vertices_and_reports_missing_cell() { - let mut tds: Tds<f64, (), (), 2> = Tds::empty(); - let vertices = insert_standard_simplex_vertices::<2>(&mut tds); - let cell_key = tds - .insert_cell_with_mapping(Cell::new(vertices.clone(), None).unwrap()) - .unwrap(); - - let removed_cells: CellKeyBuffer = std::iter::once(cell_key).collect(); - let snapshot = snapshot_removed_cell_vertices(&tds, &removed_cells).unwrap(); - assert_eq!(snapshot.len(), 1); - assert_eq!(snapshot[0].iter().copied().collect::<Vec<_>>(), vertices); + macro_rules! gen_removed_cell_snapshot_tests { + ($dim:literal) => { + pastey::paste! { + #[test] + fn [<test_snapshot_removed_cell_vertices_captures_vertices_and_reports_missing_cell_ $dim d>]() { + let mut tds: Tds<f64, (), (), $dim> = Tds::empty(); + let vertices = insert_standard_simplex_vertices::<$dim>(&mut tds); + let cell_key = tds + .insert_cell_with_mapping(Cell::new(vertices.clone(), None).unwrap()) + .unwrap(); - let missing_cell = CellKey::from(KeyData::from_ffi(999_999)); - let missing_cells: CellKeyBuffer = std::iter::once(missing_cell).collect(); - let err = snapshot_removed_cell_vertices(&tds, &missing_cells).unwrap_err(); - assert!(matches!( - err, - FlipError::MissingCell { cell_key } if cell_key == missing_cell - )); - } + let removed_cells: CellKeyBuffer = std::iter::once(cell_key).collect(); + let snapshot = snapshot_removed_cell_vertices(&tds, &removed_cells).unwrap(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].iter().copied().collect::<Vec<_>>(), vertices); + + let missing_cell = CellKey::from(KeyData::from_ffi(999_999 + $dim)); + let missing_cells: CellKeyBuffer = std::iter::once(missing_cell).collect(); + let err = snapshot_removed_cell_vertices(&tds, &missing_cells).unwrap_err(); + assert!(matches!( + err, + FlipError::MissingCell { cell_key } if cell_key == missing_cell + )); + } - #[test] - fn test_last_applied_flip_preserves_removed_cell_vertex_snapshots() { - let removed_cell = CellKey::from(KeyData::from_ffi(101)); - let new_cell = CellKey::from(KeyData::from_ffi(102)); - let v1 = VertexKey::from(KeyData::from_ffi(201)); - let v2 = VertexKey::from(KeyData::from_ffi(202)); - let v3 = VertexKey::from(KeyData::from_ffi(203)); - let v4 = VertexKey::from(KeyData::from_ffi(204)); - - let mut removed_cell_vertices = RemovedCellVertexSnapshot::new(); - removed_cell_vertices.push([v1, v2, v3].into_iter().collect::<VertexKeyList>()); - - let applied = AppliedFlip::<3> { - info: FlipInfo { - kind: BistellarFlipKind::k2(3), - direction: FlipDirection::Forward, - removed_cells: std::iter::once(removed_cell).collect(), - new_cells: std::iter::once(new_cell).collect(), - removed_face_vertices: [v3, v1].into_iter().collect(), - inserted_face_vertices: [v4, v2].into_iter().collect(), - }, - removed_cell_vertices, - }; + #[test] + fn [<test_last_applied_flip_preserves_removed_cell_vertex_snapshots_ $dim d>]() { + let removed_cell = CellKey::from(KeyData::from_ffi(101 + $dim)); + let new_cell = CellKey::from(KeyData::from_ffi(102 + $dim)); + let v1 = VertexKey::from(KeyData::from_ffi(201 + $dim)); + let v2 = VertexKey::from(KeyData::from_ffi(202 + $dim)); + let v3 = VertexKey::from(KeyData::from_ffi(203 + $dim)); + let v4 = VertexKey::from(KeyData::from_ffi(204 + $dim)); + + let mut removed_cell_vertices = RemovedCellVertexSnapshot::new(); + removed_cell_vertices.push([v1, v2, v3].into_iter().collect::<VertexKeyList>()); + + let applied = AppliedFlip::<$dim> { + info: FlipInfo { + kind: BistellarFlipKind::k2($dim), + direction: FlipDirection::Forward, + removed_cells: std::iter::once(removed_cell).collect(), + new_cells: std::iter::once(new_cell).collect(), + removed_face_vertices: [v3, v1].into_iter().collect(), + inserted_face_vertices: [v4, v2].into_iter().collect(), + }, + removed_cell_vertices, + }; - let last = LastAppliedFlip::from_applied_flip(&applied); - assert_eq!(last.k_move, 2); - assert_eq!( - last.removed_face_vertices - .iter() - .copied() - .collect::<Vec<_>>(), - vec![v1, v3] - ); - assert_eq!( - last.inserted_face_vertices - .iter() - .copied() - .collect::<Vec<_>>(), - vec![v2, v4] - ); - assert_eq!( - last.removed_cells.iter().copied().collect::<Vec<_>>(), - vec![removed_cell] - ); - assert_eq!( - last.new_cells.iter().copied().collect::<Vec<_>>(), - vec![new_cell] - ); + let last = LastAppliedFlip::from_applied_flip(&applied); + assert_eq!(last.k_move, 2); + assert_eq!( + last.removed_face_vertices + .iter() + .copied() + .collect::<Vec<_>>(), + vec![v1, v3] + ); + assert_eq!( + last.inserted_face_vertices + .iter() + .copied() + .collect::<Vec<_>>(), + vec![v2, v4] + ); + assert_eq!( + last.removed_cells.iter().copied().collect::<Vec<_>>(), + vec![removed_cell] + ); + assert_eq!( + last.new_cells.iter().copied().collect::<Vec<_>>(), + vec![new_cell] + ); - let lines = last.removed_cell_vertex_lines(); - assert_eq!(lines.len(), 1); - assert!(lines[0].contains(&format!("{removed_cell:?}: vertices="))); - assert!(!lines[0].contains("missing-snapshot")); + let lines = last.removed_cell_vertex_lines(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains(&format!("{removed_cell:?}: vertices="))); + assert!(!lines[0].contains("missing-snapshot")); - let mut placeholder = LastAppliedFlip::new(1, &[v1], &[v2]); - placeholder.removed_cells.push(removed_cell); - assert_eq!( - placeholder.removed_cell_vertex_lines(), - vec![format!("{removed_cell:?}: missing-snapshot")] - ); + let mut placeholder = LastAppliedFlip::new(1, &[v1], &[v2]); + placeholder.removed_cells.push(removed_cell); + assert_eq!( + placeholder.removed_cell_vertex_lines(), + vec![format!("{removed_cell:?}: missing-snapshot")] + ); + } + } + }; } + gen_removed_cell_snapshot_tests!(2); + gen_removed_cell_snapshot_tests!(3); + gen_removed_cell_snapshot_tests!(4); + gen_removed_cell_snapshot_tests!(5); + fn facet_index_for_edge_2d( tds: &Tds<f64, (), (), 2>, cell_key: CellKey, diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index d234ab35..298c4110 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -4417,8 +4417,8 @@ where { const MAX_CAVITY_ITERATIONS: usize = 32; let mut iterations: usize = 0; - let trace_cavity_reduction = cavity_reduction_trace_enabled() - && !CAVITY_REDUCTION_TRACE_EMITTED.swap(true, Ordering::Relaxed); + let trace_enabled = cavity_reduction_trace_enabled(); + let mut trace_cavity_reduction = false; let mut saw_ridge_fan_shrink = false; match &extraction_result { @@ -4431,6 +4431,8 @@ where ); } Err(err) => { + trace_cavity_reduction = trace_enabled + && !CAVITY_REDUCTION_TRACE_EMITTED.swap(true, Ordering::Relaxed); log_cavity_reduction_event( trace_cavity_reduction, iterations, diff --git a/src/core/util/deduplication.rs b/src/core/util/deduplication.rs index 23a6274e..001f3e25 100644 --- a/src/core/util/deduplication.rs +++ b/src/core/util/deduplication.rs @@ -14,6 +14,10 @@ pub enum DeduplicationError { /// Epsilon must be non-negative for distance-based deduplication. #[error("epsilon must be non-negative")] NegativeEpsilon, + + /// Epsilon must be finite for distance-based deduplication. + #[error("epsilon must be finite")] + NonFiniteEpsilon, } /// Filters vertices to remove exact coordinate duplicates. @@ -102,9 +106,9 @@ where /// A new vector containing vertices that are at least `epsilon` apart from each /// other (distance >= epsilon). The first occurrence of each cluster is kept. /// -/// If `epsilon` is negative, the input is returned unchanged and a warning is -/// emitted. Use [`try_dedup_vertices_epsilon`] when callers should receive a -/// typed error for invalid epsilon values. +/// If `epsilon` is negative, NaN, or infinite, the input is returned unchanged +/// and a warning is emitted. Use [`try_dedup_vertices_epsilon`] when callers +/// should receive a typed error for invalid epsilon values. /// /// # Examples /// @@ -133,10 +137,10 @@ where T: CoordinateScalar, U: DataType, { - if epsilon < T::zero() { + if !epsilon.is_finite_generic() || epsilon < T::zero() { tracing::warn!( epsilon = ?epsilon, - "dedup_vertices_epsilon received negative epsilon; returning input unchanged" + "dedup_vertices_epsilon received non-finite or negative epsilon; returning input unchanged" ); return vertices.to_vec(); } @@ -146,12 +150,14 @@ where /// Fallible variant of [`dedup_vertices_epsilon`]. /// -/// This function rejects negative epsilon values with a typed error instead of -/// falling back to returning the input unchanged. +/// This function rejects negative, NaN, and infinite epsilon values with a +/// typed error instead of falling back to returning the input unchanged. /// /// # Errors /// /// Returns [`DeduplicationError::NegativeEpsilon`] when `epsilon` is negative. +/// Returns [`DeduplicationError::NonFiniteEpsilon`] when `epsilon` is NaN or +/// infinite. pub fn try_dedup_vertices_epsilon<T, U, const D: usize>( vertices: &[Vertex<T, U, D>], epsilon: T, @@ -160,6 +166,10 @@ where T: CoordinateScalar, U: DataType, { + if !epsilon.is_finite_generic() { + return Err(DeduplicationError::NonFiniteEpsilon); + } + if epsilon < T::zero() { return Err(DeduplicationError::NegativeEpsilon); } @@ -419,7 +429,7 @@ mod tests { } #[test] - fn test_coords_within_epsilon_exact_boundary_logs_and_keeps_point() { + fn test_coords_within_epsilon_exact_boundary_keeps_point() { let a = [0.0, 0.0]; let b = [1.0, 0.0]; @@ -458,6 +468,42 @@ mod tests { assert_eq!(err, DeduplicationError::NegativeEpsilon); } + #[test] + fn test_dedup_vertices_epsilon_non_finite_epsilon_returns_input_unchanged() { + let vertices: Vec<Vertex<f64, (), 2>> = Vertex::from_points(&[ + Point::new([0.0, 0.0]), + Point::new([0.0, 0.0]), + Point::new([1.0, 0.0]), + ]); + + for epsilon in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let unique = dedup_vertices_epsilon(&vertices, epsilon); + + assert_eq!(unique.len(), vertices.len()); + assert_eq!( + unique + .iter() + .map(<&Vertex<_, _, _> as Into<[f64; 2]>>::into) + .collect::<Vec<_>>(), + vertices + .iter() + .map(<&Vertex<_, _, _> as Into<[f64; 2]>>::into) + .collect::<Vec<_>>() + ); + } + } + + #[test] + fn test_try_dedup_vertices_epsilon_non_finite_epsilon_returns_error() { + let vertices: Vec<Vertex<f64, (), 2>> = Vertex::from_points(&[Point::new([0.0, 0.0])]); + + for epsilon in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let err = try_dedup_vertices_epsilon(&vertices, epsilon).unwrap_err(); + + assert_eq!(err, DeduplicationError::NonFiniteEpsilon); + } + } + #[test] fn test_dedup_vertices_epsilon_preserves_first_occurrence() { // Verify that first occurrence is kept, later duplicates removed diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 2951bb57..ff7ce281 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -2497,6 +2497,7 @@ where } // Attempt 0: original order, no extra perturbation salt. + log_construction_retry_result(0, None, 0_u64, "started", None, None); let mut last_error: String = match Self::build_with_kernel_inner_seeded( <K as Clone>::clone(kernel), vertices, @@ -2508,14 +2509,26 @@ where ) { Ok(candidate) => match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => return Ok(candidate), + Ok(()) => { + log_construction_retry_result(0, None, 0_u64, "succeeded", None, None); + return Ok(candidate); + } Err(err) => format!("Delaunay property violated after construction: {err}"), }, Err(err) => { + let err_string = err.to_string(); if Self::is_non_retryable_construction_error(&err) { + log_construction_retry_result( + 0, + None, + 0_u64, + "failed", + Some(&err_string), + None, + ); return Err(err); } - err.to_string() + err_string } }; @@ -3512,6 +3525,16 @@ where &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 @@ -3535,6 +3558,19 @@ where "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 { @@ -3756,6 +3792,16 @@ where &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 @@ -3779,6 +3825,19 @@ where "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 { @@ -6729,30 +6788,42 @@ mod tests { }); } - #[test] - fn test_local_repair_flip_budget_uses_dimension_specific_floor_and_factor() { - assert_eq!( - local_repair_flip_budget::<3>(0), - LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4 - ); - assert_eq!( - local_repair_flip_budget::<4>(0), - LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4 - ); + macro_rules! gen_local_repair_flip_budget_tests { + ($dim:literal, $floor:ident, $factor:ident) => { + pastey::paste! { + #[test] + fn [<test_local_repair_flip_budget_uses_dimension_specific_floor_and_factor_ $dim d>]() { + assert_eq!(local_repair_flip_budget::<$dim>(0), $floor); - let seed_count = 10; - let raw_3d = seed_count * (3 + 1) * LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4; - let raw_4d = seed_count * (4 + 1) * LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4; - assert_eq!( - local_repair_flip_budget::<3>(seed_count), - raw_3d.max(LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4) - ); - assert_eq!( - local_repair_flip_budget::<4>(seed_count), - raw_4d.max(LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4) - ); + let seed_count = 10; + let raw = seed_count * ($dim + 1) * $factor; + assert_eq!(local_repair_flip_budget::<$dim>(seed_count), raw.max($floor)); + } + } + }; } + gen_local_repair_flip_budget_tests!( + 2, + LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4, + LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4 + ); + gen_local_repair_flip_budget_tests!( + 3, + LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4, + LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4 + ); + gen_local_repair_flip_budget_tests!( + 4, + LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4, + LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4 + ); + gen_local_repair_flip_budget_tests!( + 5, + LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4, + LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4 + ); + #[test] fn test_log_bulk_progress_if_due_updates_progress_state_only_when_due() { let sample = BatchProgressSample { diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 66392215..543b4661 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -250,13 +250,15 @@ fn init_tracing() { static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { // Debug-level tracing is needed to surface the release-visible diagnostic hooks - // (retryable-skip, cavity-reduction, ridge-fan-dump, bulk-progress, bulk-retry) - // that are emitted through `tracing::debug!` inside the library. + // (retryable-skip, cavity-reduction, ridge/postcondition repair debug, + // bulk-progress, bulk-retry) emitted through `tracing::debug!` inside the library. let debug_env_vars = [ "DELAUNAY_INSERT_TRACE", "DELAUNAY_DEBUG_RETRYABLE_SKIP", "DELAUNAY_DEBUG_CAVITY_REDUCTION_ONCE", "DELAUNAY_DEBUG_RIDGE_FAN_ONCE", + "DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET", + "DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY", "DELAUNAY_BULK_PROGRESS_EVERY", "DELAUNAY_DEBUG_SHUFFLE", ]; From c04ec0176a16221a376fd0d7f134a690f66b2696 Mon Sep 17 00:00:00 2001 From: Adam Getchell <adam@adamgetchell.org> Date: Fri, 24 Apr 2026 12:30:02 -0700 Subject: [PATCH 05/11] Fixed: harden 4D perturbation tests and enhance construction diagnostics Replace randomized seed sweeps with a deterministic 4D adversarial repro set to ensure retry paths remain covered. Deduplicate repair seeds during vertex insertion, instrument construction attempts with structured tracing, and document new debug environment variables for large-scale repair analysis. Refs: #204 --- CHANGELOG.md | 13 +- scripts/postprocess_changelog.py | 1 + scripts/tests/test_postprocess_changelog.py | 3 + src/core/triangulation.rs | 242 ++++++++++++++------ src/triangulation/delaunay.rs | 45 +++- tests/large_scale_debug.rs | 4 + typos.toml | 1 + 7 files changed, 226 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3d11c0..a7326010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,7 +164,7 @@ Perform a general dependency update, including a patch bump for `uuid`. A new `bench-smoke` target provides quick harness validation without the overhead of high-sample measurements. - Also deniest warnings via the manifest lint policy to ensure consistent + Also denies warnings via the manifest lint policy to ensure consistent repository-wide enforcement. - Changed: standardize benchmark profiles and enhance SARIF analysis @@ -330,6 +330,17 @@ Refactor flip snapshotting and cavity-reduction bookkeeping to ensure out of Criterion hot loops. - Document the #204 investigation, refresh the 4D known-issues and TODO notes, and record the repository logging policy plus release-visible debug environment variables. +- Normalize indented headings in changelog post-processing [`cff07db`](https://github.com/acgetchell/delaunay/commit/cff07db377414f8e0176d7e41d8fe6073c661576) + +Update the changelog post-processing script to convert indented ATX + headings from commit bodies into bold prose. This ensures the generated + CHANGELOG.md complies with Markdownlint rule MD023 (headings must start + at column 0) while preserving the visual hierarchy and readability of + historical commit summaries. + + Additionally, internal diagnostic state for Delaunay repair was moved + from global atomics to a per-attempt structure to ensure reliable + rate-limiting across concurrent threads. ### Maintenance diff --git a/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py index 7d48cb33..0f3c90cb 100644 --- a/scripts/postprocess_changelog.py +++ b/scripts/postprocess_changelog.py @@ -30,6 +30,7 @@ # Keys are whole-word patterns; values are their replacements. # Applied as word-boundary replacements so partial matches are avoided. _TYPO_MAP: dict[str, str] = { + "deniest": "denies", "varous": "various", "runtim": "runtime", } diff --git a/scripts/tests/test_postprocess_changelog.py b/scripts/tests/test_postprocess_changelog.py index 5d58d43b..105149e1 100644 --- a/scripts/tests/test_postprocess_changelog.py +++ b/scripts/tests/test_postprocess_changelog.py @@ -76,6 +76,9 @@ def test_empty_file(self, tmp_path: Path) -> None: class TestFixTypos: + def test_fixes_deniest(self) -> None: + assert _fix_typos("Also deniest warnings") == "Also denies warnings" + def test_fixes_varous(self) -> None: assert _fix_typos("Fix varous issues") == "Fix various issues" diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 298c4110..2e6040bb 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -4772,6 +4772,8 @@ where // deleted by `remove_cells_by_keys` below, so they cannot seed repair. let dead_conflict_cells: FastHashSet<CellKey> = conflict_cells.iter().copied().collect(); repair_seed_cells.retain(|ck| !dead_conflict_cells.contains(ck)); + let mut seen_repair_seed_cells = FastHashSet::default(); + repair_seed_cells.retain(|ck| seen_repair_seed_cells.insert(*ck)); // Remove conflict cells (now that new cells are wired up) let _removed_count = self.tds.remove_cells_by_keys(&conflict_cells); @@ -10706,55 +10708,162 @@ mod tests { // PROGRESSIVE PERTURBATION: RETRY PATH COVERAGE // ========================================================================= + #[expect( + clippy::too_many_lines, + reason = "Literal 4D repro point set keeps retry-path coverage deterministic" + )] + fn perturbation_retry_repro_points_4d() -> [Point<f64, 4>; 20] { + // Fixed adversarial insertion sequence captured from the former + // randomized sweep (seed 4, index 19). The final insertion exhausts + // perturbation retries in the current 4D path, so this keeps retry + // coverage deterministic without looping over random seeds. + [ + Point::new([ + 0.660_063_804_566_304_3, + 3.139_352_812_821_116, + 1.460_437_437_858_557_2, + 1.683_976_950_416_514_7, + ]), + Point::new([ + 2.451_966_162_957_145, + 9.547_229_335_697_903, + 3.306_128_696_560_687_5, + -3.722_166_730_957_705_6, + ]), + Point::new([ + -2.344_360_378_074_79, + -2.755_831_029_562_339, + -1.275_699_073_649_171_6, + 7.667_812_493_160_508, + ]), + Point::new([ + -8.633_692_230_033_44, + 1.995_093_685_275_964_6, + 7.993_316_108_703_105, + -3.310_780_098_197_376_7, + ]), + Point::new([ + 9.710_410_828_147_591, + -9.675_293_457_452_888, + -7.169_080_272_753_141, + 5.405_946_111_675_925_5, + ]), + Point::new([ + 2.266_246_031_487_613, + 2.481_673_939_102_995, + 3.039_413_140_674_462, + 4.441_464_307_622_285, + ]), + Point::new([ + 2.565_731_492_709_954, + 8.916_218_617_699_3, + -3.878_340_784_199_263_4, + -9.518_720_806_139_726, + ]), + Point::new([ + -2.067_801_258_479_087_2, + -5.739_002_626_992_522, + 7.554_154_642_458_165, + -2.983_334_995_469_171_2, + ]), + Point::new([ + 7.592_645_474_686_005, + -3.326_646_745_715_216, + -3.259_537_116_123_248, + -4.935_000_398_073_641, + ]), + Point::new([ + -5.931_807_896_262_18, + 8.897_268_005_841_394, + 0.324_049_126_782_281_15, + -8.328_532_028_712_647, + ]), + Point::new([ + -8.182_644_118_410_867, + 5.373_925_359_941_506, + -9.015_837_749_827_128, + -1.703_973_344_007_208, + ]), + Point::new([ + 1.455_467_619_488_706_2, + 9.869_985_381_801_74, + 8.605_618_759_378_327, + -1.050_236_122_559_873_3, + ]), + Point::new([ + -5.687_160_826_499_058, + 6.504_655_423_433_022, + 8.941_590_411_569_816, + 9.543_547_641_077_382, + ]), + Point::new([ + 8.975_549_245_653_312, + -8.089_655_037_805_944, + 9.936_284_142_216_682, + -7.816_992_427_475_977, + ]), + Point::new([ + 5.825_845_324_524_742, + -7.639_141_597_632_388, + 1.549_524_653_880_336_4, + 4.563_088_344_949_309, + ]), + Point::new([ + 7.387_141_055_690_918, + 6.194_972_387_680_284, + -5.764_015_058_796_046, + 9.298_338_336_238_999, + ]), + Point::new([ + -1.597_916_740_077_209_9, + -4.938_008_036_006_716, + 7.414_979_546_687_874, + -7.718_146_418_588_452, + ]), + Point::new([ + -2.414_045_007_912_424_3, + 8.888_648_260_600_007, + -5.859_329_894_512_815, + 3.268_096_825_406_147, + ]), + Point::new([ + -8.294_250_893_230_837, + 3.083_275_278_154_95, + 8.020_989_920_767_69, + 8.155_291_219_012_977, + ]), + Point::new([ + 6.718_748_825_685_814_6, + -4.640_634_945_941_695, + 2.283_644_483_657_752_7, + 0.837_537_687_473_188_8, + ]), + ] + } + /// Exercise the perturbation retry loop (`attempt > 0`) and exhaustion - /// path (`SkippedDegeneracy`) using 4D random points where orientation - /// degeneracies are common. + /// path (`SkippedDegeneracy`) using a deterministic 4D repro. /// /// Covers: progressive scale factor, perturbation coordinate generation /// with `perturbation_seed == 0`, retry decision, and retry exhaustion. - /// - /// Iterates over multiple seeds to remain robust to insertion-path - /// improvements (e.g. ridge-fan accumulation, widened local repair) that - /// make retries less common for any individual well-conditioned seed. - /// The test's contract is that the retry path is reachable for *some* - /// seed, not that every seed must exercise it. #[test] fn test_perturbation_retry_and_exhaustion_4d() { - // 50 seeds × 20 points = 1000 insertion attempts in 4D; empirically - // more than sufficient to trigger at least one retry or exhaustion - // from orientation/ridge-fan degeneracies regardless of improvements - // to the non-degenerate insertion path. - const SEED_COUNT: u64 = 50; - const POINTS_PER_SEED: usize = 20; - - for seed in 123..(123 + SEED_COUNT) { - let points = crate::geometry::util::generate_random_points_seeded::<f64, 4>( - POINTS_PER_SEED, - (-10.0, 10.0), - seed, - ) - .unwrap(); - - let mut tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = - Triangulation::new_empty(AdaptiveKernel::new()); + let mut tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = + Triangulation::new_empty(AdaptiveKernel::new()); - for point in points { - let v = VertexBuilder::default().point(point).build().unwrap(); - let (_outcome, stats) = tri.insert_with_statistics(v, None, None).unwrap(); + for point in perturbation_retry_repro_points_4d() { + let v = VertexBuilder::default().point(point).build().unwrap(); + let (_outcome, stats) = tri.insert_with_statistics(v, None, None).unwrap(); - if (stats.used_perturbation() && stats.success()) - || (stats.skipped() && stats.attempts > 1) - { - return; - } + if (stats.used_perturbation() && stats.success()) + || (stats.skipped() && stats.attempts > 1) + { + return; } } panic!( - "4D insertion with {SEED_COUNT} random seeds of {POINTS_PER_SEED} points each \ - did not trigger a perturbation retry or exhaustion; the retry path may be \ - unreachable from random well-conditioned input and needs a dedicated \ - adversarial repro." + "deterministic 4D adversarial repro did not trigger a perturbation retry or exhaustion" ); } @@ -10764,49 +10873,32 @@ mod tests { /// Covers: the `mix` computation and sign selection in the seeded path /// (lines using `perturbation_seed ^ ...`). /// - /// Uses the same multi-seed iteration as - /// [`test_perturbation_retry_and_exhaustion_4d`] for the same reason. + /// Uses the same deterministic 4D repro as + /// [`test_perturbation_retry_and_exhaustion_4d`]. #[test] fn test_perturbation_retry_seeded_branch_4d() { - const SEED_COUNT: u64 = 50; - const POINTS_PER_SEED: usize = 20; - - for seed in 123..(123 + SEED_COUNT) { - let points = crate::geometry::util::generate_random_points_seeded::<f64, 4>( - POINTS_PER_SEED, - (-10.0, 10.0), - seed, - ) - .unwrap(); - - let mut tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = - Triangulation::new_empty(AdaptiveKernel::new()); - - for point in points { - let v = VertexBuilder::default().point(point).build().unwrap(); - let (_outcome, stats) = tri - .insert_transactional( - v, - None, - None, - DEFAULT_PERTURBATION_RETRIES, - 0xDEAD_BEEF, - None, - None, - ) - .unwrap(); + let mut tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = + Triangulation::new_empty(AdaptiveKernel::new()); + + for point in perturbation_retry_repro_points_4d() { + let v = VertexBuilder::default().point(point).build().unwrap(); + let (_outcome, stats) = tri + .insert_transactional( + v, + None, + None, + DEFAULT_PERTURBATION_RETRIES, + 0xDEAD_BEEF, + None, + None, + ) + .unwrap(); - if stats.used_perturbation() { - return; - } + if stats.used_perturbation() && (stats.success() || stats.skipped()) { + return; } } - panic!( - "4D seeded insertion with {SEED_COUNT} random seeds of {POINTS_PER_SEED} points each \ - did not trigger a perturbation retry; the seeded retry branch \ - (perturbation_seed != 0) may be unreachable from random well-conditioned input and \ - needs a dedicated adversarial repro." - ); + panic!("deterministic 4D adversarial repro did not trigger the seeded perturbation branch"); } } diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index ff7ce281..03a1864d 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -1341,10 +1341,12 @@ fn log_bulk_progress_if_due(sample: BatchProgressSample, state: &mut Option<Batc let chunk_elapsed = state.last_progress.elapsed(); let chunk_processed = sample.processed.saturating_sub(state.last_processed); - let overall_rate = safe_usize_to_scalar::<f64>(sample.processed).unwrap_or(f64::NAN) - / elapsed.as_secs_f64().max(1e-9); - let chunk_rate = safe_usize_to_scalar::<f64>(chunk_processed).unwrap_or(f64::NAN) - / chunk_elapsed.as_secs_f64().max(1e-9); + let overall_rate = safe_usize_to_scalar::<f64>(sample.processed) + .ok() + .map(|processed| processed / elapsed.as_secs_f64().max(1e-9)); + let chunk_rate = safe_usize_to_scalar::<f64>(chunk_processed) + .ok() + .map(|processed| processed / chunk_elapsed.as_secs_f64().max(1e-9)); tracing::debug!( target: "delaunay::bulk_progress", @@ -1355,8 +1357,8 @@ fn log_bulk_progress_if_due(sample: BatchProgressSample, state: &mut Option<Batc skipped = sample.skipped, cells = sample.cell_count, elapsed = ?elapsed, - total_rate_pts_per_s = overall_rate, - recent_rate_pts_per_s = chunk_rate, + total_rate_pts_per_s = ?overall_rate, + recent_rate_pts_per_s = ?chunk_rate, "bulk-construction progress" ); @@ -2670,6 +2672,7 @@ where let mut last_stats: Option<ConstructionStatistics> = None; // Attempt 0: original order, no extra perturbation salt. + log_construction_retry_result(0, None, 0_u64, "started", None, None); let mut last_error: String = match Self::build_with_kernel_inner_seeded_with_construction_statistics( <K as Clone>::clone(kernel), @@ -2682,7 +2685,17 @@ where ) { Ok((candidate, stats)) => { match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => return Ok((candidate, stats)), + Ok(()) => { + log_construction_retry_result( + 0, + None, + 0_u64, + "succeeded", + None, + Some(&stats), + ); + return Ok((candidate, stats)); + } Err(err) => { last_stats.replace(stats); format!("Delaunay property violated after construction: {err}") @@ -2693,6 +2706,15 @@ where let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics } = err; if Self::is_non_retryable_construction_error(&error) { + let last_error = error.to_string(); + log_construction_retry_result( + 0, + None, + 0_u64, + "failed", + Some(&last_error), + Some(&statistics), + ); return Err(DelaunayTriangulationConstructionErrorWithStatistics { error, statistics, @@ -2780,6 +2802,15 @@ where let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics } = err; if Self::is_non_retryable_construction_error(&error) { + let last_error = error.to_string(); + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "failed", + Some(&last_error), + Some(&statistics), + ); return Err(DelaunayTriangulationConstructionErrorWithStatistics { error, statistics, diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 543b4661..e14a8233 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -55,6 +55,10 @@ //! DELAUNAY_DEBUG_RETRYABLE_SKIP=1 \ //! # Optional: dump the first detected ridge-fan cavity snapshot once per run //! DELAUNAY_DEBUG_RIDGE_FAN_ONCE=1 \ +//! # Optional: dump the first unresolved repair postcondition facet once per run +//! DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET=1 \ +//! # Optional: only emit ridge repair debug when multiplicity is at least N +//! DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY=4 \ //! cargo test --release --test large_scale_debug debug_large_scale_4d -- --ignored --nocapture //! ``` diff --git a/typos.toml b/typos.toml index 0f5bc1a1..798e0011 100644 --- a/typos.toml +++ b/typos.toml @@ -36,5 +36,6 @@ ND = "ND" Udo = "Udo" # Intentional misspellings used in the postprocess_changelog.py correction map # and its tests. Suppressing here avoids false positives on the map keys. +deniest = "deniest" runtim = "runtim" varous = "varous" From c89a5145216fd6ceadf08cb22007a0479b45192c Mon Sep 17 00:00:00 2001 From: Adam Getchell <adam@adamgetchell.org> Date: Fri, 24 Apr 2026 14:27:40 -0700 Subject: [PATCH 06/11] Fixed: harden 4D perturbation tests and improve construction diagnostics Introduce deterministic test hooks to force insertion retries, replacing unstable randomized seed sweeps in 4D fixtures. Refine construction tracing to avoid synthesizing NaN coordinates and ensure failure diagnostics include attempt metadata. Ensure wall-clock timeout messages are flushed to stderr before watchdog aborts during large-scale debugging. Refs: #204 --- CHANGELOG.md | 9 ++ src/core/triangulation.rs | 111 +++++++++++++++++--- src/triangulation/delaunay.rs | 189 ++++++++++++++++++++++------------ tests/large_scale_debug.rs | 28 ++++- 4 files changed, 254 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7326010..46c0ac12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -342,6 +342,15 @@ Update the changelog post-processing script to convert indented ATX from global atomics to a per-attempt structure to ensure reliable rate-limiting across concurrent threads. +- Harden 4D perturbation tests and enhance construction diagnostics + [`c04ec01`](https://github.com/acgetchell/delaunay/commit/c04ec0176a16221a376fd0d7f134a690f66b2696) + +Replace randomized seed sweeps with a deterministic 4D adversarial repro + set to ensure retry paths remain covered. Deduplicate repair seeds + during vertex insertion, instrument construction attempts with + structured tracing, and document new debug environment variables for + large-scale repair analysis. + ### Maintenance - Bump pytest in the uv group across 1 directory [#322](https://github.com/acgetchell/delaunay/pull/322) diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 2e6040bb..832ac90c 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -200,6 +200,27 @@ static DUPLICATE_DETECTION_FORCE_ENABLED: AtomicBool = AtomicBool::new(false); #[cfg(debug_assertions)] static VERTEX_TO_CELLS_SPILL_EVENTS: AtomicU64 = AtomicU64::new(0); +#[cfg(test)] +mod test_hooks { + use std::cell::Cell; + + thread_local! { + static FORCE_NEXT_INSERTION_RETRYABLE_FAILURE: Cell<bool> = const { Cell::new(false) }; + } + + pub(super) fn take_force_next_insertion_retryable_failure() -> bool { + FORCE_NEXT_INSERTION_RETRYABLE_FAILURE.replace(false) + } + + pub(super) fn set_force_next_insertion_retryable_failure(enabled: bool) -> bool { + FORCE_NEXT_INSERTION_RETRYABLE_FAILURE.replace(enabled) + } + + pub(super) fn restore_force_next_insertion_retryable_failure(prior: bool) { + FORCE_NEXT_INSERTION_RETRYABLE_FAILURE.set(prior); + } +} + fn duplicate_detection_metrics_enabled() -> bool { #[cfg(test)] if DUPLICATE_DETECTION_FORCE_ENABLED.load(Ordering::Relaxed) { @@ -3609,6 +3630,24 @@ where // Topology safety net: ensure we don't commit an insertion that breaks Level 3 topology. // If the cavity-based insertion produces an Euler/topology mismatch, roll back and retry a // conservative fallback (star-split of the containing cell) within the same transactional attempt. + #[cfg(test)] + // Test-only hook for deterministic coverage of the rollback + perturbation retry + // success path, which is otherwise rare under the adaptive SoS predicates. + let result = if test_hooks::take_force_next_insertion_retryable_failure() { + Err(InsertionError::NonManifoldTopology { + facet_hash: 0x000F_0CED, + cell_count: 3, + }) + } else { + self.try_insert_with_topology_safety_net( + current_vertex, + conflict_cells, + hint, + attempt, + &tds_snapshot, + ) + }; + #[cfg(not(test))] let result = self.try_insert_with_topology_safety_net( current_vertex, conflict_cells, @@ -6122,6 +6161,23 @@ mod tests { (tri, [v0, v1, v2, v3], ck) } + struct ForceNextRetryableInsertionFailureGuard { + prior: bool, + } + + impl ForceNextRetryableInsertionFailureGuard { + fn enable() -> Self { + let prior = test_hooks::set_force_next_insertion_retryable_failure(true); + Self { prior } + } + } + + impl Drop for ForceNextRetryableInsertionFailureGuard { + fn drop(&mut self) { + test_hooks::restore_force_next_insertion_retryable_failure(self.prior); + } + } + #[test] fn test_triangulation_validation_error_from_manifold_error_preserves_detail() { let tds_err = TdsError::InvalidNeighbors { @@ -10841,29 +10897,60 @@ mod tests { ] } - /// Exercise the perturbation retry loop (`attempt > 0`) and exhaustion - /// path (`SkippedDegeneracy`) using a deterministic 4D repro. + /// Exercise both successful perturbation retry (`attempt > 0`) and + /// exhaustion (`SkippedDegeneracy`) paths with deterministic 4D fixtures. /// /// Covers: progressive scale factor, perturbation coordinate generation - /// with `perturbation_seed == 0`, retry decision, and retry exhaustion. + /// with `perturbation_seed == 0`, retry decision, retry success, and + /// retry exhaustion. #[test] fn test_perturbation_retry_and_exhaustion_4d() { - let mut tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = + let initial_vertices: Vec<Vertex<f64, (), 4>> = 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]), + ]; + let tds = Triangulation::<AdaptiveKernel<f64>, (), (), 4>::build_initial_simplex( + &initial_vertices, + ) + .unwrap(); + let mut retry_success_tri = Triangulation::<AdaptiveKernel<f64>, (), (), 4>::new_with_tds( + AdaptiveKernel::new(), + tds, + ); + + let _guard = ForceNextRetryableInsertionFailureGuard::enable(); + let retry_success_vertex = VertexBuilder::default() + .point(Point::new([0.2, 0.2, 0.2, 0.2])) + .build() + .unwrap(); + let (_outcome, retry_success_stats) = retry_success_tri + .insert_with_statistics(retry_success_vertex, None, None) + .unwrap(); + let saw_retry = retry_success_stats.used_perturbation() && retry_success_stats.success(); + + let mut exhaustion_tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = Triangulation::new_empty(AdaptiveKernel::new()); + let mut saw_exhausted_skip = false; for point in perturbation_retry_repro_points_4d() { let v = VertexBuilder::default().point(point).build().unwrap(); - let (_outcome, stats) = tri.insert_with_statistics(v, None, None).unwrap(); + let (_outcome, stats) = exhaustion_tri + .insert_with_statistics(v, None, None) + .unwrap(); - if (stats.used_perturbation() && stats.success()) - || (stats.skipped() && stats.attempts > 1) - { - return; - } + saw_exhausted_skip |= stats.skipped() && stats.attempts > 1; } - panic!( - "deterministic 4D adversarial repro did not trigger a perturbation retry or exhaustion" + assert!( + saw_retry, + "deterministic 4D fixture did not trigger a successful perturbation retry" + ); + assert!( + saw_exhausted_skip, + "deterministic 4D adversarial repro did not trigger retry exhaustion" ); } diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 03a1864d..31ac53ec 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -171,9 +171,66 @@ thread_local! { } #[cfg(test)] -thread_local! { - static FORCE_HEURISTIC_REBUILD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; - static FORCE_REPAIR_NONCONVERGENT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; +mod test_hooks { + use crate::core::algorithms::flips::{ + DelaunayRepairDiagnostics, DelaunayRepairError, RepairQueueOrder, + }; + use std::cell::Cell; + + thread_local! { + static FORCE_HEURISTIC_REBUILD: Cell<bool> = const { Cell::new(false) }; + static FORCE_REPAIR_NONCONVERGENT: Cell<bool> = const { Cell::new(false) }; + } + + pub(super) fn force_heuristic_rebuild_enabled() -> bool { + FORCE_HEURISTIC_REBUILD.with(Cell::get) + } + + pub(super) fn set_force_heuristic_rebuild(enabled: bool) -> bool { + FORCE_HEURISTIC_REBUILD.with(|flag| { + let prior = flag.get(); + flag.set(enabled); + prior + }) + } + + pub(super) fn restore_force_heuristic_rebuild(prior: bool) { + FORCE_HEURISTIC_REBUILD.with(|flag| flag.set(prior)); + } + + pub(super) fn force_repair_nonconvergent_enabled() -> bool { + FORCE_REPAIR_NONCONVERGENT.with(Cell::get) + } + + pub(super) fn set_force_repair_nonconvergent(enabled: bool) -> bool { + FORCE_REPAIR_NONCONVERGENT.with(|flag| { + let prior = flag.get(); + flag.set(enabled); + prior + }) + } + + pub(super) fn restore_force_repair_nonconvergent(prior: bool) { + FORCE_REPAIR_NONCONVERGENT.with(|flag| flag.set(prior)); + } + + pub(super) fn synthetic_nonconvergent_error() -> DelaunayRepairError { + DelaunayRepairError::NonConvergent { + max_flips: 0, + 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: 0, + queue_order: RepairQueueOrder::Fifo, + }), + } + } } struct HeuristicRebuildRecursionGuard { @@ -1428,6 +1485,24 @@ fn log_construction_retry_result( } } +/// Converts vertex coordinates for diagnostics without synthesizing sentinel values. +/// +/// Returns `None` if any coordinate cannot be represented as `f64`, allowing +/// callers to omit diagnostic coordinates instead of hiding conversion failure +/// behind `NaN` or infinity. +fn vertex_coords_f64<T, U, const D: usize>(vertex: &Vertex<T, U, D>) -> Option<Vec<f64>> +where + T: CoordinateScalar, + U: DataType, +{ + vertex + .point() + .coords() + .iter() + .map(ToPrimitive::to_f64) + .collect() +} + /// Sort key for Hilbert ordering: `(hilbert_index, quantized_coords, vertex, input_index)`. type HilbertSortKey<T, U, const D: usize> = (u128, [u32; D], Vertex<T, U, D>, usize); @@ -2600,10 +2675,19 @@ where } } Err(err) => { + let err_string = err.to_string(); if Self::is_non_retryable_construction_error(&err) { + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "failed", + Some(&err_string), + None, + ); return Err(err); } - last_error = err.to_string(); + last_error = err_string; } } @@ -3437,14 +3521,7 @@ where for (offset, vertex) in vertices.iter().skip(D + 1).enumerate() { let index = (D + 1).saturating_add(offset); let uuid = vertex.uuid(); - let coords = trace_insertion.then(|| { - vertex - .point() - .coords() - .iter() - .map(|c| c.to_f64().unwrap_or(f64::NAN)) - .collect::<Vec<f64>>() - }); + let coords = trace_insertion.then(|| vertex_coords_f64(vertex)).flatten(); if trace_insertion && let Some(coords) = coords.as_ref() { tracing::debug!(index, %uuid, coords = ?coords, "[bulk] start"); @@ -3534,8 +3611,8 @@ where }; #[cfg(test)] let repair_result = - if tests::force_repair_nonconvergent_enabled() { - Err(tests::synthetic_nonconvergent_error()) + if test_hooks::force_repair_nonconvergent_enabled() { + Err(test_hooks::synthetic_nonconvergent_error()) } else { repair_result }; @@ -3709,14 +3786,7 @@ where for (offset, vertex) in vertices.iter().skip(D + 1).enumerate() { let index = (D + 1).saturating_add(offset); let uuid = vertex.uuid(); - let coords = trace_insertion.then(|| { - vertex - .point() - .coords() - .iter() - .map(|c| c.to_f64().unwrap_or(f64::NAN)) - .collect::<Vec<f64>>() - }); + let coords = trace_insertion.then(|| vertex_coords_f64(vertex)).flatten(); if trace_insertion && let Some(coords) = coords.as_ref() { tracing::debug!(index, %uuid, coords = ?coords, "[bulk] start"); @@ -3801,8 +3871,8 @@ where }; #[cfg(test)] let repair_result = - if tests::force_repair_nonconvergent_enabled() { - Err(tests::synthetic_nonconvergent_error()) + if test_hooks::force_repair_nonconvergent_enabled() { + Err(test_hooks::synthetic_nonconvergent_error()) } else { repair_result }; @@ -3934,12 +4004,7 @@ where construction_stats.record_insertion(&stats); // Keep the first few skip samples so we have concrete reproduction anchors. - let coords: Vec<f64> = vertex - .point() - .coords() - .iter() - .map(|c| c.to_f64().unwrap_or(f64::NAN)) - .collect(); + let coords = vertex_coords_f64(vertex).unwrap_or_default(); construction_stats.record_skip_sample(ConstructionSkipSample { index, uuid: vertex.uuid(), @@ -4733,8 +4798,8 @@ where K: ExactPredicates, { #[cfg(test)] - if tests::force_repair_nonconvergent_enabled() { - return Err(tests::synthetic_nonconvergent_error()); + if test_hooks::force_repair_nonconvergent_enabled() { + return Err(test_hooks::synthetic_nonconvergent_error()); } let operation = TopologicalOperation::FacetFlip; let topology = self.tri.topology_guarantee(); @@ -4808,7 +4873,7 @@ where fn force_heuristic_rebuild_enabled() -> bool { #[cfg(test)] { - FORCE_HEURISTIC_REBUILD.with(std::cell::Cell::get) + test_hooks::force_heuristic_rebuild_enabled() } #[cfg(not(test))] { @@ -5957,8 +6022,8 @@ where }; #[cfg(test)] - let repair_result = if tests::force_repair_nonconvergent_enabled() { - Err(tests::synthetic_nonconvergent_error()) + let repair_result = if test_hooks::force_repair_nonconvergent_enabled() { + Err(test_hooks::synthetic_nonconvergent_error()) } else { repair_result }; @@ -6786,27 +6851,6 @@ mod tests { use rand::{RngExt, SeedableRng}; use slotmap::KeyData; - pub(super) fn force_repair_nonconvergent_enabled() -> bool { - FORCE_REPAIR_NONCONVERGENT.with(std::cell::Cell::get) - } - - pub(super) fn synthetic_nonconvergent_error() -> DelaunayRepairError { - DelaunayRepairError::NonConvergent { - max_flips: 0, - 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: 0, - queue_order: RepairQueueOrder::Fifo, - }), - } - } fn init_tracing() { static INIT: std::sync::Once = std::sync::Once::new(); INIT.call_once(|| { @@ -6954,18 +6998,14 @@ mod tests { impl ForceHeuristicRebuildGuard { fn enable() -> Self { - let prior = FORCE_HEURISTIC_REBUILD.with(|flag| { - let prior = flag.get(); - flag.set(true); - prior - }); + let prior = test_hooks::set_force_heuristic_rebuild(true); Self { prior } } } impl Drop for ForceHeuristicRebuildGuard { fn drop(&mut self) { - FORCE_HEURISTIC_REBUILD.with(|flag| flag.set(self.prior)); + test_hooks::restore_force_heuristic_rebuild(self.prior); } } @@ -6975,18 +7015,14 @@ mod tests { impl ForceRepairNonconvergentGuard { fn enable() -> Self { - let prior = FORCE_REPAIR_NONCONVERGENT.with(|flag| { - let prior = flag.get(); - flag.set(true); - prior - }); + let prior = test_hooks::set_force_repair_nonconvergent(true); Self { prior } } } impl Drop for ForceRepairNonconvergentGuard { fn drop(&mut self) { - FORCE_REPAIR_NONCONVERGENT.with(|flag| flag.set(self.prior)); + test_hooks::restore_force_repair_nonconvergent(self.prior); } } @@ -7109,6 +7145,23 @@ mod tests { assert_eq!(sample.attempts, 1); assert!(sample.error.contains("Duplicate coordinates")); } + + #[test] + fn test_vertex_coords_f64_converts_f64_vertex_coords() { + init_tracing(); + let vertex: Vertex<f64, (), 3> = vertex!([1.25, -2.5, 3.75]); + + assert_eq!(vertex_coords_f64(&vertex), Some(vec![1.25, -2.5, 3.75])); + } + + #[test] + fn test_vertex_coords_f64_converts_f32_vertex_coords() { + init_tracing(); + let vertex: Vertex<f32, (), 3> = vertex!([1.25f32, -2.5f32, 3.75f32]); + + assert_eq!(vertex_coords_f64(&vertex), Some(vec![1.25, -2.5, 3.75])); + } + #[test] fn test_construction_statistics_record_insertion_tracks_inserted_common_fields() { init_tracing(); diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index e14a8233..19b26632 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -75,9 +75,18 @@ use delaunay::triangulation::delaunay::{ }; use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; use std::env; +use std::io::{self, Write}; use std::num::NonZeroUsize; use std::time::{Duration, Instant}; +/// Writes the timeout diagnostic synchronously so it survives the watchdog abort. +fn write_timeout_abort_message<W: Write>(mut writer: W, max_secs: u64) -> io::Result<()> { + let message = format!("=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ==="); + tracing::error!("{message}"); + writeln!(writer, "{message}")?; + writer.flush() +} + /// Installs a per-test wall-clock cap. /// /// Spawns a watchdog thread that calls [`std::process::abort`] if `max_secs` elapses. @@ -93,9 +102,9 @@ fn install_runtime_cap(max_secs: u64) -> std::sync::mpsc::SyncSender<()> { Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {} // Deadline exceeded — hard abort. Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - tracing::error!( - "=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ===" - ); + if let Err(err) = write_timeout_abort_message(std::io::stderr().lock(), max_secs) { + tracing::debug!(?err, "failed to flush timeout message before abort"); + } std::process::abort(); } } @@ -903,6 +912,19 @@ fn debug_large_case<const D: usize>(dimension_name: &str, default_n_points: usiz DebugOutcome::Success } +#[test] +fn test_write_timeout_abort_message_flushes_message() { + let mut output = Vec::new(); + + write_timeout_abort_message(&mut output, 17).expect("timeout diagnostic write should succeed"); + + let message = String::from_utf8(output).expect("timeout diagnostic should be UTF-8"); + assert_eq!( + message, + "=== TIMEOUT: wall time exceeded 17 seconds — aborting ===\n" + ); +} + /// Regression test for issue #228: 3D 1000-point flip-repair non-convergence. /// /// Before the fix, `AdaptiveKernel`'s exact+SoS predicates were overridden by From 549db4a37b11b13b38e8d2e1f06babcc9bb3739f Mon Sep 17 00:00:00 2001 From: Adam Getchell <adam@adamgetchell.org> Date: Fri, 24 Apr 2026 15:42:43 -0700 Subject: [PATCH 07/11] Fixed: broaden topology validation after Delaunay repair Expand post-repair topology validation to check the entire triangulation whenever flips are performed. This ensures that manifold violations introduced by non-local repair sequences are caught even if they occur outside the original seed frontier. Additionally, update skip diagnostics to treat unrepresentable f64 coordinates as None rather than empty vectors. Refs: #204 --- scripts/tests/test_postprocess_changelog.py | 6 + src/core/algorithms/flips.rs | 297 +++++++++++++++++++- src/core/triangulation.rs | 7 +- src/triangulation/delaunay.rs | 82 +++--- tests/large_scale_debug.rs | 27 +- 5 files changed, 370 insertions(+), 49 deletions(-) diff --git a/scripts/tests/test_postprocess_changelog.py b/scripts/tests/test_postprocess_changelog.py index 105149e1..be671c7e 100644 --- a/scripts/tests/test_postprocess_changelog.py +++ b/scripts/tests/test_postprocess_changelog.py @@ -387,6 +387,12 @@ def test_indented_atx_closing_sequence_becomes_bold_prose(self) -> None: def test_column_zero_changelog_heading_is_preserved(self) -> None: assert _normalize_indented_heading("### Added") == "### Added" + def test_normalized_heading_is_idempotent(self) -> None: + assert _normalize_indented_heading(" **Title**") == " **Title**" + + once = _normalize_indented_heading(" ## Correctness Fixes") + assert _normalize_indented_heading(once) == once + def test_full_pipeline_normalizes_commit_body_headings(self, tmp_path: Path) -> None: f = tmp_path / "CHANGELOG.md" f.write_text( diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 0e30ee8d..fb03ae96 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -219,7 +219,10 @@ where }) } -/// Apply a bistellar flip using explicit k and vertex/cell slices. +/// Captures each removed cell's vertex list before a flip deletes the cells. +/// +/// The snapshot lets later diagnostics describe removed simplices even after +/// their `CellKey`s no longer resolve in the TDS. fn snapshot_removed_cell_vertices<T, U, V, const D: usize>( tds: &Tds<T, U, V, D>, removed_cells: &CellKeyBuffer, @@ -241,6 +244,7 @@ where .collect() } +/// Apply a bistellar flip using explicit k and vertex/cell slices. #[expect( clippy::too_many_lines, reason = "Keep flip construction, validation, and wiring together for clarity" @@ -6785,6 +6789,297 @@ mod tests { gen_removed_cell_snapshot_tests!(4); gen_removed_cell_snapshot_tests!(5); + struct RidgeDiagnosticFixture3d { + tds: Tds<f64, (), (), 3>, + origin_vertex: VertexKey, + x_axis_vertex: VertexKey, + y_axis_vertex: VertexKey, + upper_apex_vertex: VertexKey, + lower_apex_vertex: VertexKey, + upper_tetrahedron: CellKey, + lower_neighbor: CellKey, + } + + impl RidgeDiagnosticFixture3d { + fn new() -> Self { + let mut tds: Tds<f64, (), (), 3> = Tds::empty(); + let origin_vertex = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, 0.0])) + .unwrap(); + let x_axis_vertex = tds + .insert_vertex_with_mapping(vertex!([1.0, 0.0, 0.0])) + .unwrap(); + let y_axis_vertex = tds + .insert_vertex_with_mapping(vertex!([0.0, 1.0, 0.0])) + .unwrap(); + let upper_apex_vertex = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, 1.0])) + .unwrap(); + let lower_apex_vertex = tds + .insert_vertex_with_mapping(vertex!([0.0, 0.0, -1.0])) + .unwrap(); + + let upper_tetrahedron = tds + .insert_cell_with_mapping( + Cell::new( + vec![ + origin_vertex, + x_axis_vertex, + y_axis_vertex, + upper_apex_vertex, + ], + None, + ) + .unwrap(), + ) + .unwrap(); + let lower_neighbor = tds + .insert_cell_with_mapping( + Cell::new( + vec![ + origin_vertex, + x_axis_vertex, + y_axis_vertex, + lower_apex_vertex, + ], + None, + ) + .unwrap(), + ) + .unwrap(); + repair_neighbor_pointers(&mut tds).unwrap(); + + Self { + tds, + origin_vertex, + x_axis_vertex, + y_axis_vertex, + upper_apex_vertex, + lower_apex_vertex, + upper_tetrahedron, + lower_neighbor, + } + } + + fn ridge_ab(&self) -> SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE> { + [self.origin_vertex, self.x_axis_vertex] + .into_iter() + .collect() + } + + fn ridge_handle_abcd(&self) -> RidgeHandle { + RidgeHandle::new(self.upper_tetrahedron, 2, 3) + } + + fn last_applied_flip(&self) -> LastAppliedFlip { + let mut removed_cell_vertices = RemovedCellVertexSnapshot::new(); + removed_cell_vertices.push( + [ + self.origin_vertex, + self.x_axis_vertex, + self.y_axis_vertex, + self.upper_apex_vertex, + ] + .into_iter() + .collect::<VertexKeyList>(), + ); + + let applied = AppliedFlip::<3> { + info: FlipInfo { + kind: BistellarFlipKind::k2(3), + direction: FlipDirection::Forward, + removed_cells: std::iter::once(self.upper_tetrahedron).collect(), + new_cells: std::iter::once(self.lower_neighbor).collect(), + removed_face_vertices: [ + self.origin_vertex, + self.x_axis_vertex, + self.y_axis_vertex, + ] + .into_iter() + .collect(), + inserted_face_vertices: [self.upper_apex_vertex, self.lower_apex_vertex] + .into_iter() + .collect(), + }, + removed_cell_vertices, + }; + + LastAppliedFlip::from_applied_flip(&applied) + } + } + + #[test] + fn test_ridge_diagnostic_helpers_format_valid_missing_and_invalid_cells() { + init_tracing(); + let fixture = RidgeDiagnosticFixture3d::new(); + let ridge = fixture.ridge_ab(); + let cell = fixture.tds.get_cell(fixture.upper_tetrahedron).unwrap(); + + let ridge_neighbors = ridge_neighbor_cells_for_cell(cell, &ridge); + assert!( + ridge_neighbors.contains(&fixture.lower_neighbor), + "shared-face neighbor should be visible from the ridge diagnostics" + ); + + let incident = ridge_incident_cell_summary(&fixture.tds, fixture.upper_tetrahedron, &ridge); + assert!(incident.contains(&format!("{:?}: extras=", fixture.upper_tetrahedron))); + assert!(incident.contains("ridge_neighbors=")); + assert!(incident.contains(&format!("{:?}", fixture.lower_neighbor))); + + let cell_summary = cell_vertex_summary(&fixture.tds, fixture.upper_tetrahedron); + assert!(cell_summary.contains("vertices=")); + + let facet_summary = facet_incident_cell_summary( + &fixture.tds, + fixture.upper_tetrahedron, + &[ + fixture.origin_vertex, + fixture.x_axis_vertex, + fixture.y_axis_vertex, + ], + ); + assert!(facet_summary.contains("opposite_vertices=")); + assert!(facet_summary.contains("neighbors=")); + + let missing_cell = CellKey::from(KeyData::from_ffi(999_901)); + assert_eq!( + ridge_incident_cell_summary(&fixture.tds, missing_cell, &ridge), + format!("{missing_cell:?}: missing") + ); + assert_eq!( + cell_vertex_summary(&fixture.tds, missing_cell), + format!("{missing_cell:?}: missing") + ); + assert_eq!( + facet_incident_cell_summary( + &fixture.tds, + missing_cell, + &[fixture.origin_vertex, fixture.x_axis_vertex], + ), + format!("{missing_cell:?}: missing") + ); + + let missing_vertex = VertexKey::from(KeyData::from_ffi(999_902)); + let invalid_ridge: SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE> = + [fixture.origin_vertex, missing_vertex] + .into_iter() + .collect(); + let invalid_summary = + ridge_incident_cell_summary(&fixture.tds, fixture.upper_tetrahedron, &invalid_ridge); + assert!(invalid_summary.contains("extras_error=")); + } + + #[test] + fn test_predecessor_diagnostic_summaries_include_flip_overlap() { + init_tracing(); + let fixture = RidgeDiagnosticFixture3d::new(); + let last = fixture.last_applied_flip(); + + let ridge_summary = predecessor_flip_summary( + &fixture.tds, + RidgeHandle::new(fixture.lower_neighbor, 2, 3), + &[fixture.lower_neighbor], + &last, + ); + assert!(ridge_summary.contains("ridge_cell_is_new=true")); + assert!(ridge_summary.contains("global_cells_in_new")); + assert!(ridge_summary.contains("predecessor_new_cell_vertices")); + + let postcondition_summary = postcondition_facet_predecessor_summary( + &fixture.tds, + &[fixture.upper_tetrahedron, fixture.lower_neighbor], + &last, + ); + assert!(postcondition_summary.contains("incident_cells_in_new")); + assert!(postcondition_summary.contains("incident_cells_in_removed")); + assert!(postcondition_summary.contains("predecessor_removed_cell_vertices")); + assert!(!postcondition_summary.contains("missing-snapshot")); + } + + #[test] + fn test_debug_ridge_context_exercises_valid_missing_and_invalid_paths() { + init_tracing(); + let fixture = RidgeDiagnosticFixture3d::new(); + let last = fixture.last_applied_flip(); + let mut diagnostics = RepairDiagnostics::default(); + + debug_ridge_context( + &fixture.tds, + fixture.ridge_handle_abcd(), + Some(2), + &mut diagnostics, + Some(&last), + ); + assert_eq!(diagnostics.ridge_debug_emitted, 1); + + let missing_cell = CellKey::from(KeyData::from_ffi(999_903)); + debug_ridge_context( + &fixture.tds, + RidgeHandle::new(missing_cell, 0, 1), + None, + &mut diagnostics, + None, + ); + assert_eq!(diagnostics.ridge_debug_emitted, 2); + + debug_ridge_context( + &fixture.tds, + RidgeHandle::new(fixture.upper_tetrahedron, 0, 0), + None, + &mut diagnostics, + None, + ); + assert_eq!(diagnostics.ridge_debug_emitted, 3); + } + + #[test] + fn test_ridge_debug_limit_suppresses_after_attempt_budget() { + let mut diagnostics = RepairDiagnostics { + ridge_debug_emitted: RIDGE_DEBUG_LIMIT_DEFAULT, + ..RepairDiagnostics::default() + }; + + assert!(!should_emit_ridge_debug(&mut diagnostics, Some(99))); + assert_eq!( + diagnostics.ridge_debug_emitted, + RIDGE_DEBUG_LIMIT_DEFAULT + 1 + ); + } + + #[test] + fn test_postcondition_facet_debug_context_is_noop_without_env_flag() { + init_tracing(); + let fixture = RidgeDiagnosticFixture3d::new(); + let last = fixture.last_applied_flip(); + let context = FlipContext::<3, 2> { + removed_face_vertices: [ + fixture.origin_vertex, + fixture.x_axis_vertex, + fixture.y_axis_vertex, + ] + .into_iter() + .collect(), + inserted_face_vertices: [fixture.upper_apex_vertex, fixture.lower_apex_vertex] + .into_iter() + .collect(), + removed_cells: [fixture.upper_tetrahedron, fixture.lower_neighbor] + .into_iter() + .collect(), + direction: FlipDirection::Forward, + }; + let mut diagnostics = RepairDiagnostics::default(); + + debug_postcondition_facet_context( + &fixture.tds, + FacetHandle::new(fixture.upper_tetrahedron, 3), + &context, + &mut diagnostics, + Some(&last), + ); + + assert_eq!(diagnostics.postcondition_facet_debug_emitted, 0); + } + fn facet_index_for_edge_2d( tds: &Tds<f64, (), (), 2>, cell_key: CellKey, diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 832ac90c..7e900660 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -10937,11 +10937,14 @@ mod tests { for point in perturbation_retry_repro_points_4d() { let v = VertexBuilder::default().point(point).build().unwrap(); - let (_outcome, stats) = exhaustion_tri + let (outcome, stats) = exhaustion_tri .insert_with_statistics(v, None, None) .unwrap(); - saw_exhausted_skip |= stats.skipped() && stats.attempts > 1; + saw_exhausted_skip |= stats.skipped() + && stats.attempts == DEFAULT_PERTURBATION_RETRIES + 1 + && matches!(stats.result, InsertionResult::SkippedDegeneracy) + && matches!(outcome, InsertionOutcome::Skipped { error } if error.is_retryable()); } assert!( diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 31ac53ec..a5638212 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -708,7 +708,15 @@ pub struct ConstructionSkipSample { /// UUID of the skipped vertex. pub uuid: Uuid, /// Coordinates of the skipped vertex, converted to `f64` for logging/debugging. + /// + /// Empty when [`coords_available`](Self::coords_available) is `false`. pub coords: Vec<f64>, + /// Whether [`coords`](Self::coords) contains a successfully converted coordinate vector. + /// + /// `false` means at least one coordinate could not be represented as `f64`; + /// callers should omit coordinates rather than treating an empty vector as + /// real geometry. + pub coords_available: bool, /// Number of insertion attempts for this vertex. pub attempts: usize, /// Human-readable error message describing why the vertex was skipped. @@ -4004,11 +4012,13 @@ where construction_stats.record_insertion(&stats); // Keep the first few skip samples so we have concrete reproduction anchors. - let coords = vertex_coords_f64(vertex).unwrap_or_default(); + let (coords, coords_available) = vertex_coords_f64(vertex) + .map_or_else(|| (Vec::new(), false), |coords| (coords, true)); construction_stats.record_skip_sample(ConstructionSkipSample { index, uuid: vertex.uuid(), coords, + coords_available, attempts: stats.attempts, error: error.to_string(), }); @@ -6018,7 +6028,7 @@ where let repair_result = { let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_with_flips_k2_k3(tds, kernel, seed_ref, topology, max_flips).map(|_| ()) + repair_delaunay_with_flips_k2_k3(tds, kernel, seed_ref, topology, max_flips) }; #[cfg(test)] @@ -6029,7 +6039,9 @@ where }; match repair_result { - Ok(()) => {} + Ok(stats) => { + self.validate_ridge_links_after_repair(topology, &stats)?; + } Err( e @ (DelaunayRepairError::NonConvergent { .. } | DelaunayRepairError::PostconditionFailed { .. }), @@ -6040,11 +6052,13 @@ where // If the robust pass also fails, return an error. Callers that need // the full heuristic rebuild (shuffled re-insertion) can invoke // `repair_delaunay_with_flips_advanced()` explicitly. - self.repair_delaunay_with_flips_robust(seed_ref, max_flips) + let robust_stats = self + .repair_delaunay_with_flips_robust(seed_ref, max_flips) .map_err(|robust_err| InsertionError::DelaunayRepairFailed { source: Box::new(robust_err), context: format!("local repair failed ({e}); robust fallback also failed"), })?; + self.validate_ridge_links_after_repair(topology, &robust_stats)?; } Err(e) => { return Err(InsertionError::DelaunayRepairFailed { @@ -6054,36 +6068,6 @@ where } } - // Topology safety-net: flip-based repair is a topological operation and must not - // violate the requested topology guarantee. - // - // In practice, higher-dimensional flip sequences can transiently (or permanently) - // introduce PL-manifold violations (e.g., disconnected ridge links). Catch those - // locally and surface an insertion error so the outer transactional guard can roll - // back the insertion. - // - // The validation scope must match what repair actually touched: the inserted - // vertex star (which may have grown via flips) **plus** any still-alive cells - // from the pre-repair seed frontier. Otherwise a violation introduced in an - // `extra_seed_cells` cell that is no longer adjacent to the new vertex would - // slip past this safety-net. - if topology.requires_ridge_links() { - let mut validation_cells: Vec<CellKey> = self.tri.adjacent_cells(vertex_key).collect(); - let mut seen: FastHashSet<CellKey> = validation_cells.iter().copied().collect(); - for &cell_key in &seed_cells { - if self.tri.tds.contains_cell(cell_key) && seen.insert(cell_key) { - validation_cells.push(cell_key); - } - } - if !validation_cells.is_empty() - && let Err(err) = validate_ridge_links_for_cells(&self.tri.tds, &validation_cells) - { - return Err(InsertionError::TopologyValidationFailed { - message: "Topology invalid after Delaunay repair".to_string(), - source: Box::new(TriangulationValidationError::from(err)), - }); - } - } // Flip-based repair mutates cell orderings; restore canonical positive geometric // orientation before exposing the updated triangulation state. self.tri.normalize_and_promote_positive_orientation()?; @@ -6093,6 +6077,34 @@ where Ok(()) } + /// Validates PL ridge links after a repair pass that actually performed flips. + /// + /// `repair_delaunay_with_flips_k2_k3` may retry internally with a full-TDS + /// reseed after local postcondition failure or non-convergence, so the caller + /// cannot infer the final mutation frontier from the original seed cells. + /// Validate all current cells in that case to preserve the topology invariant. + fn validate_ridge_links_after_repair( + &self, + topology: TopologyGuarantee, + stats: &DelaunayRepairStats, + ) -> Result<(), InsertionError> { + if !topology.requires_ridge_links() || stats.flips_performed == 0 { + return Ok(()); + } + + let validation_cells: Vec<CellKey> = self.tri.tds.cell_keys().collect(); + if validation_cells.is_empty() { + return Ok(()); + } + + validate_ridge_links_for_cells(&self.tri.tds, &validation_cells).map_err(|err| { + InsertionError::TopologyValidationFailed { + message: "Topology invalid after Delaunay repair".to_string(), + source: Box::new(TriangulationValidationError::from(err)), + } + }) + } + /// Merge the inserted vertex star with any cells that cavity reduction touched and /// left in place. Stale cells are ignored so callers can pass raw cavity-trace sets. fn collect_local_repair_seed_cells( @@ -7142,6 +7154,7 @@ mod tests { assert_eq!(sample.index, 4); assert_eq!(sample.uuid, duplicate_uuid); assert_eq!(sample.coords, vec![0.0, 0.0, 0.0]); + assert!(sample.coords_available); assert_eq!(sample.attempts, 1); assert!(sample.error.contains("Duplicate coordinates")); } @@ -7243,6 +7256,7 @@ mod tests { coordinate_base + 0.5, coordinate_base + 1.0, ], + coords_available: true, attempts: index + 1, error: format!("skip sample #{index}"), }); diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 19b26632..f5cb0932 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -116,7 +116,7 @@ fn install_runtime_cap(max_secs: u64) -> std::sync::mpsc::SyncSender<()> { struct SkipSample<const D: usize> { index: usize, uuid: uuid::Uuid, - coords: [f64; D], + coords: Option<[f64; D]>, attempts: usize, error: String, } @@ -193,17 +193,20 @@ impl<const D: usize> From<ConstructionStatistics> for InsertionSummary<D> { .skip_samples .iter() .filter_map(|s| { - let coords: [f64; D] = if let Ok(coords) = s.coords.as_slice().try_into() { - coords + let coords = if s.coords_available { + let Ok(coords) = s.coords.as_slice().try_into() else { + tracing::warn!( + index = s.index, + uuid = %s.uuid, + coords_len = s.coords.len(), + expected_dim = D, + "dropping skip sample due to coordinate dimension mismatch" + ); + return None; + }; + Some(coords) } else { - tracing::warn!( - index = s.index, - uuid = %s.uuid, - coords_len = s.coords.len(), - expected_dim = D, - "dropping skip sample due to coordinate dimension mismatch" - ); - return None; + None }; Some(SkipSample { index: s.index, @@ -756,7 +759,7 @@ fn debug_large_case<const D: usize>(dimension_name: &str, default_n_points: usiz let sample = SkipSample { index: idx, uuid, - coords, + coords: Some(coords), attempts: stats.attempts, error: error.to_string(), }; From 1b6e9bc8d4f3d368aee460a9e03df5285e5acb7f Mon Sep 17 00:00:00 2001 From: Adam Getchell <adam@adamgetchell.org> Date: Fri, 24 Apr 2026 17:56:18 -0700 Subject: [PATCH 08/11] Fixed: harden Delaunay repair error handling and flip snapshotting Distinguish between recoverable soft-fail repair errors and fatal topology/flip failures during bulk construction to prevent entering invalid states. Snapshot removed cell vertices before mutation in the flip algorithm to ensure unexpected failures abort without leaving dangling replacement cells. Additionally, improve diagnostic reliability by propagating writer failures in timeout handlers and lazily evaluating cavity-reduction logs. Refs: #204 --- src/core/algorithms/flips.rs | 42 +++++----- src/core/algorithms/locate.rs | 5 +- src/core/triangulation.rs | 7 +- src/triangulation/delaunay.rs | 150 +++++++++++++++++++++++++++++++++- tests/large_scale_debug.rs | 82 +++++++++++++++---- 5 files changed, 242 insertions(+), 44 deletions(-) diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index fb03ae96..bd6cd7d6 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -319,7 +319,7 @@ where && let Some(existing_cell) = find_cell_containing_simplex(tds, inserted_face_vertices, removed_cells) { - if repair_trace_enabled() || std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { + if repair_trace_enabled() || env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { tracing::debug!( "[repair] skip flip: inserted simplex already exists (k={k_move}, inserted_face={inserted_face_vertices:?}, existing_cell={existing_cell:?})" ); @@ -388,7 +388,7 @@ where }); } Ok(Orientation::DEGENERATE) => { - if std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { + if env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { tracing::debug!( k_move, direction = ?direction, @@ -416,6 +416,13 @@ where validate_replacement_orientation(tds, &new_cell_vertices)?; } + // Snapshot the removed cells' vertex lists before any TDS mutation so an + // unexpected missing cell aborts without leaving replacement cells behind. + // After `tds.remove_cells_by_keys` runs, `tds.get_cell(removed_key)` returns + // `None`, which would strip the most useful context from predecessor-flip + // traces (see #204 investigation). + let removed_cell_vertices = snapshot_removed_cell_vertices(tds, removed_cells)?; + for vertices in new_cell_vertices { let cell = Cell::new(vertices, None)?; let cell_key = tds @@ -436,13 +443,6 @@ where message: e.to_string(), })?; - // Snapshot the removed cells' vertex lists BEFORE removal so postcondition - // diagnostics can reconstruct the lost simplices. After - // `tds.remove_cells_by_keys` runs, `tds.get_cell(removed_key)` returns - // `None`, which would strip the most useful context from predecessor-flip - // traces (see #204 investigation). - let removed_cell_vertices = snapshot_removed_cell_vertices(tds, removed_cells)?; - tds.remove_cells_by_keys(removed_cells); debug_assert!( @@ -2543,7 +2543,7 @@ where } let violates = in_a > 0 || in_b > 0; - if std::env::var_os("DELAUNAY_REPAIR_DEBUG_PREDICATES").is_some() + if env::var_os("DELAUNAY_REPAIR_DEBUG_PREDICATES").is_some() && (violates || in_a == 0 || in_b == 0) { tracing::debug!( @@ -3220,7 +3220,7 @@ where | FlipError::InsertedSimplexAlreadyExists { .. } | FlipError::CellCreation(_)), ) => { - if std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { + if env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { tracing::debug!( "k=2 flip skipped in repair_delaunay_with_flips_k2_attempt (facet={facet:?}): {err}" ); @@ -3870,7 +3870,7 @@ where ); let mut message = format!("local k=2 violation remains after repair (facet={facet:?})"); - if std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { + if env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { let removed_details: Vec<_> = context .removed_face_vertices .iter() @@ -4345,7 +4345,7 @@ fn emit_repair_debug_summary( config: &RepairAttemptConfig, max_flips: usize, ) { - if std::env::var_os("DELAUNAY_REPAIR_DEBUG_SUMMARY").is_none() { + if env::var_os("DELAUNAY_REPAIR_DEBUG_SUMMARY").is_none() { return; } @@ -5536,7 +5536,7 @@ where // Shared trace tail for apply-k=2-facet skip arms below. let log_apply_skip = |err: &FlipError| { - if std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { + if env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { tracing::debug!( facet = ?facet, reason = %err, @@ -6278,7 +6278,7 @@ where return false; }; - if std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() || repair_trace_enabled() { + if env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() || repair_trace_enabled() { let mut target: SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE> = vertices.iter().copied().collect(); target.sort_unstable(); @@ -6290,7 +6290,7 @@ where v }); - if std::env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { + if env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { tracing::debug!( "k=2 flip would duplicate existing cell {cell_key:?}; target={target:?}; existing={existing_sorted:?}" ); @@ -6619,6 +6619,7 @@ mod tests { use super::*; use crate::core::algorithms::incremental_insertion::repair_neighbor_pointers; use crate::core::collections::Uuid; + use crate::core::triangulation::TopologyGuarantee; use crate::geometry::kernel::{AdaptiveKernel, FastKernel}; use crate::topology::traits::topological_space::ToroidalConstructionMode; use crate::triangulation::delaunay::DelaunayTriangulation; @@ -6626,10 +6627,13 @@ mod tests { use approx::assert_relative_eq; use rand::{RngExt, SeedableRng, rngs::StdRng}; use slotmap::KeyData; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{ + Once, + atomic::{AtomicUsize, Ordering}, + }; fn init_tracing() { - static INIT: std::sync::Once = std::sync::Once::new(); + static INIT: Once = Once::new(); INIT.call_once(|| { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")); @@ -9371,8 +9375,6 @@ mod tests { #[test] fn test_delaunay_repair_error_partial_eq() { - use crate::core::triangulation::TopologyGuarantee; - let post_test = DelaunayRepairError::PostconditionFailed { message: "test".to_string(), }; diff --git a/src/core/algorithms/locate.rs b/src/core/algorithms/locate.rs index e9671350..45637a1d 100644 --- a/src/core/algorithms/locate.rs +++ b/src/core/algorithms/locate.rs @@ -29,6 +29,7 @@ use crate::geometry::kernel::Kernel; use crate::geometry::point::Point; use crate::geometry::traits::coordinate::{CoordinateConversionError, CoordinateScalar}; use std::env; +use std::fmt; use std::hash::{Hash, Hasher}; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; @@ -349,8 +350,8 @@ pub enum InternalInconsistencySite { }, } -impl std::fmt::Display for InternalInconsistencySite { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for InternalInconsistencySite { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::RidgeFanExtraFacetOutOfBounds { index, diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index 7e900660..7369a8ac 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -4555,12 +4555,15 @@ where conflict_cells_before = conflict_cells.len(), "D={D}: cavity expansion (DisconnectedBoundary hole-fill)" ); - let added: Vec<CellKey> = cells_to_add.iter().copied().collect(); log_cavity_reduction_event( trace_cavity_reduction, iterations, &conflict_cells, - || format!("disconnected_boundary_expand add_cells={added:?}"), + || { + let added: Vec<CellKey> = + cells_to_add.iter().copied().collect(); + format!("disconnected_boundary_expand add_cells={added:?}") + }, ); for k in cells_to_add { conflict_cells.push(k); diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index a5638212..65433309 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -3471,6 +3471,29 @@ where } } + /// 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 { + matches!( + repair_err, + DelaunayRepairError::NonConvergent { .. } + | DelaunayRepairError::PostconditionFailed { .. } + ) + } + + /// Converts non-soft-fail local-repair errors into construction failures so + /// the bulk loop does not canonicalize or keep mutating after unexpected + /// topology/flip failures. + fn map_hard_repair_error( + index: usize, + repair_err: &DelaunayRepairError, + ) -> DelaunayTriangulationConstructionError { + TriangulationConstructionError::GeometricDegeneracy { + message: format!("per-insertion Delaunay repair failed at index {index}: {repair_err}"), + } + .into() + } + /// Inserts the non-simplex vertices under a fixed perturbation seed so bulk /// construction retries are reproducible. #[allow(clippy::too_many_lines)] @@ -3535,7 +3558,7 @@ where tracing::debug!(index, %uuid, coords = ?coords, "[bulk] start"); } - let started = trace_insertion.then(std::time::Instant::now); + let started = trace_insertion.then(Instant::now); let mut insert = || { // Pass the batch index through to transactional insertion so the // lower-layer retryable-skip trace can point back to this exact @@ -3657,6 +3680,12 @@ where // 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, @@ -3800,7 +3829,7 @@ where tracing::debug!(index, %uuid, coords = ?coords, "[bulk] start"); } - let started = trace_insertion.then(std::time::Instant::now); + let started = trace_insertion.then(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 @@ -3917,6 +3946,12 @@ where // 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, @@ -6862,9 +6897,12 @@ mod tests { use crate::vertex; use rand::{RngExt, SeedableRng}; use slotmap::KeyData; + use std::sync::Once; + + type TestDelaunay4 = DelaunayTriangulation<AdaptiveKernel<f64>, (), (), 4>; fn init_tracing() { - static INIT: std::sync::Once = std::sync::Once::new(); + static INIT: Once = Once::new(); INIT.call_once(|| { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")); @@ -7004,6 +7042,78 @@ 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, } @@ -9306,6 +9416,40 @@ mod tests { assert!(dt.validate().is_ok()); } + #[test] + fn test_repair_soft_fail_classification() { + let nonconvergent = test_hooks::synthetic_nonconvergent_error(); + assert!(TestDelaunay4::can_soft_fail(&nonconvergent)); + + let postcondition = DelaunayRepairError::PostconditionFailed { + message: "unresolved facet".to_string(), + }; + assert!(TestDelaunay4::can_soft_fail(&postcondition)); + + let flip_error = + DelaunayRepairError::Flip(FlipError::UnsupportedDimension { dimension: 1 }); + assert!(!TestDelaunay4::can_soft_fail(&flip_error)); + + let topology_error = DelaunayRepairError::InvalidTopology { + required: TopologyGuarantee::PLManifold, + found: TopologyGuarantee::Pseudomanifold, + message: "local repair requires manifold topology", + }; + assert!(!TestDelaunay4::can_soft_fail(&topology_error)); + + let mapped = TestDelaunay4::map_hard_repair_error(23, &flip_error); + assert!( + matches!( + mapped, + DelaunayTriangulationConstructionError::Triangulation( + TriangulationConstructionError::GeometricDegeneracy { ref message } + ) if message.contains("per-insertion Delaunay repair failed at index 23") + && message.contains("Bistellar flip not supported for D=1") + ), + "non-soft D>=4 repair failures should propagate as construction errors: {mapped:?}" + ); + } + // ========================================================================= // Tests for try_d_lt4_global_repair_fallback // ========================================================================= diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index f5cb0932..c3b8ac20 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -64,6 +64,7 @@ #![forbid(unsafe_code)] +use delaunay::core::triangulation::TopologyGuarantee; use delaunay::geometry::kernel::RobustKernel; use delaunay::geometry::util::{ generate_random_points_in_ball_seeded, generate_random_points_seeded, @@ -75,37 +76,43 @@ use delaunay::triangulation::delaunay::{ }; use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; use std::env; +use std::fmt; use std::io::{self, Write}; use std::num::NonZeroUsize; +use std::process; +use std::sync::{ + Once, + mpsc::{self, RecvTimeoutError, SyncSender}, +}; +use std::thread; use std::time::{Duration, Instant}; /// Writes the timeout diagnostic synchronously so it survives the watchdog abort. fn write_timeout_abort_message<W: Write>(mut writer: W, max_secs: u64) -> io::Result<()> { let message = format!("=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ==="); - tracing::error!("{message}"); writeln!(writer, "{message}")?; writer.flush() } /// Installs a per-test wall-clock cap. /// -/// Spawns a watchdog thread that calls [`std::process::abort`] if `max_secs` elapses. -/// Returns a [`std::sync::mpsc::SyncSender`] whose **drop** cancels the watchdog: when +/// Spawns a watchdog thread that calls [`process::abort`] if `max_secs` elapses. +/// Returns a [`SyncSender`] whose **drop** cancels the watchdog: when /// the sender is dropped (i.e. the test completes normally), the channel disconnects and /// the watchdog thread exits without aborting. This prevents a stale watchdog installed /// for one test from firing during a subsequent test. -fn install_runtime_cap(max_secs: u64) -> std::sync::mpsc::SyncSender<()> { - let (tx, rx) = std::sync::mpsc::sync_channel::<()>(0); - std::thread::spawn(move || { +fn install_runtime_cap(max_secs: u64) -> SyncSender<()> { + let (tx, rx) = mpsc::sync_channel::<()>(0); + thread::spawn(move || { match rx.recv_timeout(Duration::from_secs(max_secs)) { // Sender dropped (test finished) or explicit send — exit cleanly. - Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {} + Ok(()) | Err(RecvTimeoutError::Disconnected) => {} // Deadline exceeded — hard abort. - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - if let Err(err) = write_timeout_abort_message(std::io::stderr().lock(), max_secs) { + Err(RecvTimeoutError::Timeout) => { + if let Err(err) = write_timeout_abort_message(io::stderr().lock(), max_secs) { tracing::debug!(?err, "failed to flush timeout message before abort"); } - std::process::abort(); + process::abort(); } } }); @@ -263,7 +270,7 @@ fn env_flag(name: &str) -> bool { } fn init_tracing() { - static INIT: std::sync::Once = std::sync::Once::new(); + static INIT: Once = Once::new(); INIT.call_once(|| { // Debug-level tracing is needed to surface the release-visible diagnostic hooks // (retryable-skip, cavity-reduction, ridge/postcondition repair debug, @@ -442,8 +449,8 @@ enum DebugOutcome { }, } -impl std::fmt::Display for DebugOutcome { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for DebugOutcome { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Success => write!(f, "Success"), Self::ConstructionFailure { error } => { @@ -915,6 +922,35 @@ fn debug_large_case<const D: usize>(dimension_name: &str, default_n_points: usiz DebugOutcome::Success } +#[derive(Clone, Copy)] +enum FailingWriterMode { + Write, + Flush, +} + +struct FailingWriter { + mode: FailingWriterMode, + kind: io::ErrorKind, +} + +impl Write for FailingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + if matches!(self.mode, FailingWriterMode::Write) { + return Err(io::Error::new(self.kind, "synthetic write failure")); + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + if matches!(self.mode, FailingWriterMode::Flush) { + return Err(io::Error::new(self.kind, "synthetic flush failure")); + } + + Ok(()) + } +} + #[test] fn test_write_timeout_abort_message_flushes_message() { let mut output = Vec::new(); @@ -928,6 +964,22 @@ fn test_write_timeout_abort_message_flushes_message() { ); } +#[test] +fn test_write_timeout_abort_message_propagates_error() { + // `install_runtime_cap` aborts immediately after this helper returns, so the + // caller must be able to observe write or flush failures before aborting. + let cases = [ + (FailingWriterMode::Write, io::ErrorKind::BrokenPipe), + (FailingWriterMode::Flush, io::ErrorKind::WriteZero), + ]; + + for (mode, kind) in cases { + let err = write_timeout_abort_message(FailingWriter { mode, kind }, 17) + .expect_err("timeout diagnostic should propagate writer failures"); + assert_eq!(err.kind(), kind); + } +} + /// Regression test for issue #228: 3D 1000-point flip-repair non-convergence. /// /// Before the fix, `AdaptiveKernel`'s exact+SoS predicates were overridden by @@ -952,8 +1004,6 @@ fn test_write_timeout_abort_message_flushes_message() { #[test] #[ignore = "1000-point 3D construction exceeds CI timeout (~30min debug)"] fn regression_issue_228_3d_1000_flip_repair_convergence() { - use delaunay::core::triangulation::TopologyGuarantee; - let seed = seed_for_case::<3>(42, 1000); let points = generate_random_points_in_ball_seeded::<f64, 3>(1000, 100.0, seed) .expect("point generation should succeed"); @@ -995,8 +1045,6 @@ fn regression_issue_228_3d_1000_flip_repair_convergence() { #[test] #[ignore = "4D 100-point construction can take minutes in debug mode"] fn regression_issue_230_4d_100_orientation() { - use delaunay::core::triangulation::TopologyGuarantee; - let seed = seed_for_case::<4>(42, 100); let points = generate_random_points_in_ball_seeded::<f64, 4>(100, 100.0, seed) .expect("point generation should succeed"); From 918cb96556ef8de8c1a1ebf288b91e73626da3e2 Mon Sep 17 00:00:00 2001 From: Adam Getchell <adam@adamgetchell.org> Date: Fri, 24 Apr 2026 19:10:16 -0700 Subject: [PATCH 09/11] Fixed: track repair frontier to optimize post-flip topology validation Refine Delaunay repair to track the specific set of cells created by flips ("touched cells") and distinguish between local repairs and full-TDS reseeds. This allows topology validation to target only modified regions whenever possible, rather than scanning the entire triangulation. Additionally, harden internal consistency checks during cavity extraction, filter non-finite coordinates in vertex diagnostics, and ensure hard repair errors propagate during high-dimensional escalation rather than being suppressed as soft failures. Refs: #204 --- src/core/algorithms/flips.rs | 103 +++++++++++++++++++++++++++++---- src/core/algorithms/locate.rs | 104 ++++++++++++++++++++++++++++++++-- src/triangulation/delaunay.rs | 89 ++++++++++++++++++++--------- tests/large_scale_debug.rs | 16 ++++-- 4 files changed, 265 insertions(+), 47 deletions(-) diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index bd6cd7d6..946c0101 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -115,7 +115,8 @@ where let mut diagnostics = RepairDiagnostics::default(); let mut queues = RepairQueues::new(); let mut last_applied_flip: Option<LastAppliedFlip> = None; - seed_repair_queues(tds, seed_cells, &mut queues, &mut stats)?; + let used_full_reseed = seed_repair_queues(tds, seed_cells, &mut queues, &mut stats)?; + let mut touched_cells = CellKeyBuffer::new(); let mut prefer_secondary = false; @@ -130,6 +131,7 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, )? || process_edge_queue_step( tds, kernel, @@ -139,6 +141,7 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, )? || process_triangle_queue_step( tds, kernel, @@ -148,6 +151,7 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, )?) { prefer_secondary = false; @@ -163,6 +167,7 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, )? { prefer_secondary = true; continue; @@ -177,6 +182,7 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, )? || process_edge_queue_step( tds, kernel, @@ -186,6 +192,7 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, )? || process_triangle_queue_step( tds, kernel, @@ -195,6 +202,7 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, )? { prefer_secondary = false; } @@ -216,6 +224,8 @@ where Ok(RepairAttemptOutcome { stats, last_applied_flip, + touched_cells, + used_full_reseed, }) } @@ -1977,6 +1987,18 @@ pub struct DelaunayRepairStats { pub max_queue_len: usize, } +/// Crate-private repair result with the validation frontier for callers that +/// need post-repair topology checks without scanning the whole TDS. +#[derive(Debug, Clone)] +pub(crate) struct DelaunayRepairRun { + /// Public aggregate repair statistics. + pub stats: DelaunayRepairStats, + /// Cells created by successful flips in the final repair attempt. + pub touched_cells: CellKeyBuffer, + /// Whether the final attempt used full-TDS queue seeding. + pub used_full_reseed: bool, +} + /// Carries both aggregate attempt stats and the final flip context so /// postcondition diagnostics can relate the first unresolved local violation to /// the last repair move that modified the TDS. @@ -1984,6 +2006,26 @@ pub struct DelaunayRepairStats { struct RepairAttemptOutcome { stats: DelaunayRepairStats, last_applied_flip: Option<LastAppliedFlip>, + touched_cells: CellKeyBuffer, + used_full_reseed: bool, +} + +/// Adds newly-created cells to the repair mutation frontier without duplicates. +fn record_touched_cells(touched_cells: &mut CellKeyBuffer, new_cells: &[CellKey]) { + for &cell_key in new_cells { + if !touched_cells.contains(&cell_key) { + touched_cells.push(cell_key); + } + } +} + +/// Converts an attempt outcome into the crate-private repair run result. +fn repair_run_from_attempt(outcome: RepairAttemptOutcome) -> DelaunayRepairRun { + DelaunayRepairRun { + stats: outcome.stats, + touched_cells: outcome.touched_cells, + used_full_reseed: outcome.used_full_reseed, + } } /// Queue ordering policy for flip repair attempts. @@ -3099,6 +3141,8 @@ where let mut queued: FastHashSet<u64> = FastHashSet::default(); let mut facet_handles: FastHashMap<u64, FacetHandle> = FastHashMap::default(); let mut last_applied_flip: Option<LastAppliedFlip> = None; + let mut touched_cells = CellKeyBuffer::new(); + let used_full_reseed = seed_cells.is_none(); let topology_model = GlobalTopology::DEFAULT.model(); if let Some(seeds) = seed_cells { @@ -3242,6 +3286,7 @@ where diagnostics.record_flip_signature(signature); last_applied_flip = Some(LastAppliedFlip::from_applied_flip(&applied)); let info = applied.info; + record_touched_cells(&mut touched_cells, &info.new_cells); for &cell_key in &info.new_cells { enqueue_cell_facets( @@ -3271,6 +3316,8 @@ where Ok(RepairAttemptOutcome { stats, last_applied_flip, + touched_cells, + used_full_reseed, }) } @@ -3288,6 +3335,28 @@ pub(crate) fn repair_delaunay_with_flips_k2_k3<K, U, V, const D: usize>( topology: TopologyGuarantee, max_flips_override: Option<usize>, ) -> Result<DelaunayRepairStats, DelaunayRepairError> +where + K: Kernel<D>, + U: DataType, + V: DataType, +{ + repair_delaunay_with_flips_k2_k3_run(tds, kernel, seed_cells, topology, max_flips_override) + .map(|run| run.stats) +} + +/// Repair Delaunay violations and return the final validation frontier. +/// +/// # Errors +/// +/// Returns a [`DelaunayRepairError`] if the repair fails to converge or an underlying +/// flip operation encounters an unrecoverable error. +pub(crate) fn repair_delaunay_with_flips_k2_k3_run<K, U, V, const D: usize>( + tds: &mut Tds<K::Scalar, U, V, D>, + kernel: &K, + seed_cells: Option<&[CellKey]>, + topology: TopologyGuarantee, + max_flips_override: Option<usize>, +) -> Result<DelaunayRepairRun, DelaunayRepairError> where K: Kernel<D>, U: DataType, @@ -3341,7 +3410,7 @@ where ) .is_ok() { - return Ok(outcome.stats); + return Ok(repair_run_from_attempt(outcome)); } if repair_trace_enabled() { tracing::debug!( @@ -3364,7 +3433,7 @@ where retry_seed_cells, outcome2.last_applied_flip.as_ref(), )?; - Ok(outcome2.stats) + Ok(repair_run_from_attempt(outcome2)) } Err(DelaunayRepairError::NonConvergent { .. }) => { if repair_trace_enabled() { @@ -3387,7 +3456,7 @@ where retry_seed_cells, outcome2.last_applied_flip.as_ref(), )?; - Ok(outcome2.stats) + Ok(repair_run_from_attempt(outcome2)) } Err(err) => Err(err), } @@ -3705,7 +3774,7 @@ where let mut stats = DelaunayRepairStats::default(); let mut diagnostics = RepairDiagnostics::default(); let mut queues = RepairQueues::new(); - seed_repair_queues(tds, seed_cells, &mut queues, &mut stats)?; + let _ = seed_repair_queues(tds, seed_cells, &mut queues, &mut stats)?; if repair_trace_enabled() { let seed_count = seed_cells.map_or(0, <[CellKey]>::len); tracing::debug!( @@ -3985,11 +4054,11 @@ where "[repair] postcondition k=3 violation remains (ridge={ridge:?})" ); } - // Emit the ridge adjacency snapshot — including the immediately - // preceding flip, when available — so #204-style ridge - // diagnostics carry the same predecessor-flip context as the - // k=2 facet path via `debug_postcondition_facet_context`. - debug_ridge_context(tds, ridge, None, diagnostics, last_applied_flip); + // Emit the ridge adjacency snapshot only under the opt-in ridge + // debug flag; the helper performs global incidence scans. + if repair_ridge_debug_enabled() { + debug_ridge_context(tds, ridge, None, diagnostics, last_applied_flip); + } return Err(DelaunayRepairError::PostconditionFailed { message: format!("local k=3 violation remains after repair (ridge={ridge:?})"), }); @@ -4700,7 +4769,7 @@ fn seed_repair_queues<T, U, V, const D: usize>( seed_cells: Option<&[CellKey]>, queues: &mut RepairQueues, stats: &mut DelaunayRepairStats, -) -> Result<(), FlipError> +) -> Result<bool, FlipError> where T: CoordinateScalar, U: DataType, @@ -4770,6 +4839,7 @@ where ); } seed_repair_queues(tds, None, queues, stats)?; + return Ok(true); } } else { for facet in AllFacetsIter::new(tds) { @@ -4808,8 +4878,9 @@ where ); } stats.max_queue_len = stats.max_queue_len.max(queues.total_len()); + return Ok(true); } - Ok(()) + Ok(false) } /// Requeues the local neighborhood created by a flip so the repair loop follows @@ -4880,6 +4951,7 @@ fn process_ridge_queue_step<K, U, V, const D: usize>( config: &RepairAttemptConfig, diagnostics: &mut RepairDiagnostics, last_applied_flip: &mut Option<LastAppliedFlip>, + touched_cells: &mut CellKeyBuffer, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5046,6 +5118,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); + record_touched_cells(touched_cells, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5071,6 +5144,7 @@ fn process_edge_queue_step<K, U, V, const D: usize>( config: &RepairAttemptConfig, diagnostics: &mut RepairDiagnostics, last_applied_flip: &mut Option<LastAppliedFlip>, + touched_cells: &mut CellKeyBuffer, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5233,6 +5307,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); + record_touched_cells(touched_cells, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5258,6 +5333,7 @@ fn process_triangle_queue_step<K, U, V, const D: usize>( config: &RepairAttemptConfig, diagnostics: &mut RepairDiagnostics, last_applied_flip: &mut Option<LastAppliedFlip>, + touched_cells: &mut CellKeyBuffer, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5412,6 +5488,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); + record_touched_cells(touched_cells, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5437,6 +5514,7 @@ fn process_facet_queue_step<K, U, V, const D: usize>( config: &RepairAttemptConfig, diagnostics: &mut RepairDiagnostics, last_applied_flip: &mut Option<LastAppliedFlip>, + touched_cells: &mut CellKeyBuffer, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5595,6 +5673,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); + record_touched_cells(touched_cells, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; diff --git a/src/core/algorithms/locate.rs b/src/core/algorithms/locate.rs index 45637a1d..ec086f6d 100644 --- a/src/core/algorithms/locate.rs +++ b/src/core/algorithms/locate.rs @@ -458,7 +458,8 @@ where format_vertex_refs(tds, cell.vertices()) } -/// Emits a compact one-shot snapshot of the first detected ridge fan in a run. +/// Emits a compact one-shot snapshot of the first detected ridge fan in the +/// current process/test binary. /// /// Enabled via `DELAUNAY_DEBUG_RIDGE_FAN_ONCE`. Output is routed through /// `tracing::debug!` so it respects the configured tracing subscriber; @@ -1729,7 +1730,24 @@ where open_cell, }); } + } + + for info in ridge_map.values() { + // `second_facet` is populated by the same ridge-map update that increments + // `facet_count` to 2, so a `None` here is an internal invariant violation. + // Check this before accumulating ridge fans so error precedence is deterministic. + if info.facet_count == 2 && info.second_facet.is_none() { + return Err(ConflictError::InternalInconsistency { + site: InternalInconsistencySite::RidgeInfoMissingSecondFacet { + first_facet: info.first_facet, + boundary_facets_len: boundary_facets.len(), + ridge_vertex_count: info.ridge_vertex_count, + }, + }); + } + } + for info in ridge_map.values() { if info.facet_count >= 3 { #[cfg(debug_assertions)] if detail_enabled { @@ -1759,9 +1777,6 @@ where // facet_count == 2 let a = info.first_facet; - // `second_facet` is populated by the same ridge-map update that increments - // `facet_count` to 2, so a `None` here is an internal invariant violation. - // Report it as such instead of fabricating a cell key. let b = info .second_facet .ok_or_else(|| ConflictError::InternalInconsistency { @@ -1956,6 +1971,87 @@ mod tests { assert_eq!(extra_cells, vec![cell_c, cell_d]); } + #[test] + fn test_extract_cavity_boundary_accumulates_multiple_ridge_fans_2d() { + let mut tds: Tds<f64, (), (), 2> = Tds::empty(); + let center_a = tds.insert_vertex_with_mapping(vertex!([0.0, 0.0])).unwrap(); + let a0 = tds.insert_vertex_with_mapping(vertex!([1.0, 0.0])).unwrap(); + let a1 = tds.insert_vertex_with_mapping(vertex!([0.0, 1.0])).unwrap(); + let a2 = tds + .insert_vertex_with_mapping(vertex!([-1.0, 0.0])) + .unwrap(); + let a3 = tds + .insert_vertex_with_mapping(vertex!([0.0, -1.0])) + .unwrap(); + let a4 = tds.insert_vertex_with_mapping(vertex!([1.0, 1.0])).unwrap(); + let a5 = tds + .insert_vertex_with_mapping(vertex!([-1.0, -1.0])) + .unwrap(); + + let center_b = tds + .insert_vertex_with_mapping(vertex!([10.0, 0.0])) + .unwrap(); + let b0 = tds + .insert_vertex_with_mapping(vertex!([11.0, 0.0])) + .unwrap(); + let b1 = tds + .insert_vertex_with_mapping(vertex!([10.0, 1.0])) + .unwrap(); + let b2 = tds.insert_vertex_with_mapping(vertex!([9.0, 0.0])).unwrap(); + let b3 = tds + .insert_vertex_with_mapping(vertex!([10.0, -1.0])) + .unwrap(); + let b4 = tds + .insert_vertex_with_mapping(vertex!([11.0, 1.0])) + .unwrap(); + let b5 = tds + .insert_vertex_with_mapping(vertex!([9.0, -1.0])) + .unwrap(); + + let origin_cells = [ + tds.insert_cell_with_mapping(Cell::new(vec![center_a, a0, a1], None).unwrap()) + .unwrap(), + tds.insert_cell_with_mapping(Cell::new(vec![center_a, a2, a3], None).unwrap()) + .unwrap(), + tds.insert_cell_with_mapping(Cell::new(vec![center_a, a4, a5], None).unwrap()) + .unwrap(), + ]; + let shifted_cells = [ + tds.insert_cell_with_mapping(Cell::new(vec![center_b, b0, b1], None).unwrap()) + .unwrap(), + tds.insert_cell_with_mapping(Cell::new(vec![center_b, b2, b3], None).unwrap()) + .unwrap(), + tds.insert_cell_with_mapping(Cell::new(vec![center_b, b4, b5], None).unwrap()) + .unwrap(), + ]; + + let all_cells = [ + origin_cells[0], + origin_cells[1], + origin_cells[2], + shifted_cells[0], + shifted_cells[1], + shifted_cells[2], + ]; + let conflict_cells: CellKeyBuffer = all_cells.into_iter().collect(); + + match extract_cavity_boundary(&tds, &conflict_cells).unwrap_err() { + ConflictError::RidgeFan { + facet_count, + ridge_vertex_count, + extra_cells, + } => { + assert_eq!(facet_count, 6); + assert_eq!(ridge_vertex_count, 1); + let expected: FastHashSet<CellKey> = all_cells.into_iter().collect(); + let actual: FastHashSet<CellKey> = extra_cells.iter().copied().collect(); + assert_eq!(actual, expected); + assert_eq!(extra_cells.len(), expected.len()); + } + other => panic!("Expected RidgeFan, got {other:?}"), + } + } + #[test] fn test_orientation_logic_manual() { // Manual test of orientation logic for 2D triangle diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 65433309..71aef1c9 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -7,8 +7,9 @@ use crate::core::adjacency::{AdjacencyIndex, AdjacencyIndexBuildError}; use crate::core::algorithms::flips::{ - DelaunayRepairError, DelaunayRepairStats, FlipError, apply_bistellar_flip_k1_inverse, - repair_delaunay_local_single_pass, repair_delaunay_with_flips_k2_k3, + DelaunayRepairError, DelaunayRepairRun, DelaunayRepairStats, FlipError, + 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, }; use crate::core::algorithms::incremental_insertion::InsertionError; @@ -1507,7 +1508,7 @@ where .point() .coords() .iter() - .map(ToPrimitive::to_f64) + .map(|coord| coord.to_f64().filter(|value| value.is_finite())) .collect() } @@ -3393,7 +3394,6 @@ where /// 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 - /// canonicalization failures after a successful escalation, which are /// hard errors the bulk loop must propagate. fn try_local_repair_escalation_d_ge_4( &mut self, @@ -3459,6 +3459,9 @@ where 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, @@ -4882,10 +4885,20 @@ where seed_cells: Option<&[CellKey]>, max_flips: Option<usize>, ) -> Result<DelaunayRepairStats, DelaunayRepairError> { + self.repair_delaunay_with_flips_robust_run(seed_cells, max_flips) + .map(|run| run.stats) + } + + /// Replays repair with an exact-predicate kernel and returns the validation frontier. + fn repair_delaunay_with_flips_robust_run( + &mut self, + seed_cells: Option<&[CellKey]>, + max_flips: Option<usize>, + ) -> Result<DelaunayRepairRun, DelaunayRepairError> { let topology = self.tri.topology_guarantee(); let kernel = RobustKernel::<K::Scalar>::new(); let (tds, kernel) = (&mut self.tri.tds, &kernel); - repair_delaunay_with_flips_k2_k3(tds, kernel, seed_cells, topology, max_flips) + repair_delaunay_with_flips_k2_k3_run(tds, kernel, seed_cells, topology, max_flips) } /// Applies the repair policy only when the dimension and topology can @@ -6063,7 +6076,7 @@ where let repair_result = { let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_with_flips_k2_k3(tds, kernel, seed_ref, topology, max_flips) + repair_delaunay_with_flips_k2_k3_run(tds, kernel, seed_ref, topology, max_flips) }; #[cfg(test)] @@ -6074,8 +6087,8 @@ where }; match repair_result { - Ok(stats) => { - self.validate_ridge_links_after_repair(topology, &stats)?; + Ok(run) => { + self.validate_ridge_links_after_repair(topology, &run)?; } Err( e @ (DelaunayRepairError::NonConvergent { .. } @@ -6087,13 +6100,13 @@ where // If the robust pass also fails, return an error. Callers that need // the full heuristic rebuild (shuffled re-insertion) can invoke // `repair_delaunay_with_flips_advanced()` explicitly. - let robust_stats = self - .repair_delaunay_with_flips_robust(seed_ref, max_flips) + let robust_run = self + .repair_delaunay_with_flips_robust_run(seed_ref, max_flips) .map_err(|robust_err| InsertionError::DelaunayRepairFailed { source: Box::new(robust_err), context: format!("local repair failed ({e}); robust fallback also failed"), })?; - self.validate_ridge_links_after_repair(topology, &robust_stats)?; + self.validate_ridge_links_after_repair(topology, &robust_run)?; } Err(e) => { return Err(InsertionError::DelaunayRepairFailed { @@ -6114,30 +6127,36 @@ where /// Validates PL ridge links after a repair pass that actually performed flips. /// - /// `repair_delaunay_with_flips_k2_k3` may retry internally with a full-TDS - /// reseed after local postcondition failure or non-convergence, so the caller - /// cannot infer the final mutation frontier from the original seed cells. - /// Validate all current cells in that case to preserve the topology invariant. + /// `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. fn validate_ridge_links_after_repair( &self, topology: TopologyGuarantee, - stats: &DelaunayRepairStats, + run: &DelaunayRepairRun, ) -> Result<(), InsertionError> { - if !topology.requires_ridge_links() || stats.flips_performed == 0 { + if !topology.requires_ridge_links() || run.stats.flips_performed == 0 { return Ok(()); } - let validation_cells: Vec<CellKey> = self.tri.tds.cell_keys().collect(); - if validation_cells.is_empty() { - return Ok(()); + let validate_cells = |cells: &[CellKey]| { + if cells.is_empty() { + return Ok(()); + } + validate_ridge_links_for_cells(&self.tri.tds, cells).map_err(|err| { + InsertionError::TopologyValidationFailed { + message: "Topology invalid after Delaunay repair".to_string(), + source: Box::new(TriangulationValidationError::from(err)), + } + }) + }; + + if !run.used_full_reseed && !run.touched_cells.is_empty() { + return validate_cells(&run.touched_cells); } - validate_ridge_links_for_cells(&self.tri.tds, &validation_cells).map_err(|err| { - InsertionError::TopologyValidationFailed { - message: "Topology invalid after Delaunay repair".to_string(), - source: Box::new(TriangulationValidationError::from(err)), - } - }) + let validation_cells: Vec<CellKey> = self.tri.tds.cell_keys().collect(); + validate_cells(&validation_cells) } /// Merge the inserted vertex star with any cells that cavity reduction touched and @@ -6889,7 +6908,9 @@ mod tests { }; use crate::core::algorithms::locate::{ConflictError, LocateError}; use crate::core::tds::{EntityKind, GeometricError}; + 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::topology::characteristics::euler::TopologyClassification; use crate::topology::traits::topological_space::ToroidalConstructionMode; @@ -7285,6 +7306,22 @@ mod tests { assert_eq!(vertex_coords_f64(&vertex), Some(vec![1.25, -2.5, 3.75])); } + #[test] + fn test_vertex_coords_f64_rejects_non_finite_coords() { + init_tracing(); + let nan_vertex: Vertex<f64, (), 3> = VertexBuilder::default() + .point(Point::new([1.0, f64::NAN, 3.0])) + .build() + .unwrap(); + let infinite_vertex: Vertex<f64, (), 3> = VertexBuilder::default() + .point(Point::new([1.0, f64::INFINITY, 3.0])) + .build() + .unwrap(); + + assert_eq!(vertex_coords_f64(&nan_vertex), None); + assert_eq!(vertex_coords_f64(&infinite_vertex), None); + } + #[test] fn test_construction_statistics_record_insertion_tracks_inserted_common_fields() { init_tracing(); diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index c3b8ac20..43b03abe 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -89,8 +89,10 @@ use std::time::{Duration, Instant}; /// Writes the timeout diagnostic synchronously so it survives the watchdog abort. fn write_timeout_abort_message<W: Write>(mut writer: W, max_secs: u64) -> io::Result<()> { - let message = format!("=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ==="); - writeln!(writer, "{message}")?; + writeln!( + writer, + "=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ===" + )?; writer.flush() } @@ -110,7 +112,7 @@ fn install_runtime_cap(max_secs: u64) -> SyncSender<()> { // Deadline exceeded — hard abort. Err(RecvTimeoutError::Timeout) => { if let Err(err) = write_timeout_abort_message(io::stderr().lock(), max_secs) { - tracing::debug!(?err, "failed to flush timeout message before abort"); + tracing::warn!(?err, "failed to flush timeout message before abort"); } process::abort(); } @@ -538,9 +540,13 @@ fn print_insertion_summary<const D: usize>(summary: &InsertionSummary<D>, elapse println!(); println!(" skip_samples (first {}):", summary.skip_samples.len()); for s in &summary.skip_samples { + let coords = s.coords.as_ref().map_or_else( + || "<unavailable>".to_string(), + |coords| format!("{coords:?}"), + ); println!( - " idx={} uuid={} attempts={} coords={:?} error={}", - s.index, s.uuid, s.attempts, s.coords, s.error + " idx={} uuid={} attempts={} coords={} error={}", + s.index, s.uuid, s.attempts, coords, s.error ); } } From 5efa21f5318ce2e8deed9b46a5d74f8fed128403 Mon Sep 17 00:00:00 2001 From: Adam Getchell <adam@adamgetchell.org> Date: Fri, 24 Apr 2026 20:27:18 -0700 Subject: [PATCH 10/11] Fixed: improve Delaunay repair error classification and performance Optimize duplicate detection during repair by using a hash set for touched cells. Refine error handling to distinguish between retryable geometric degeneracies and fatal internal inconsistencies, ensuring that hard failures like orientation canonicalization or verification errors abort construction correctly. Additionally, expose a dedicated repair module in the prelude and fix a coordinate mismatch bug in large-scale debug diagnostics. Refs: #204 --- Cargo.lock | 4 +- Cargo.toml | 2 +- src/core/algorithms/flips.rs | 250 ++++++++++++++++------- src/lib.rs | 62 +++++- src/triangulation/delaunay.rs | 328 ++++++++++++++++++++++--------- src/triangulation/delaunayize.rs | 5 +- tests/delaunayize_workflow.rs | 2 +- tests/large_scale_debug.rs | 35 ++-- tests/triangulation_builder.rs | 3 +- 9 files changed, 492 insertions(+), 199 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a0b22b2..00d6687f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,9 +595,9 @@ dependencies = [ [[package]] name = "pastey" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" [[package]] name = "pin-project-lite" diff --git a/Cargo.toml b/Cargo.toml index d0787b05..648f4371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ uuid = { version = "1.23.1", features = [ "v4", "serde", "fast-rng" ] } [dev-dependencies] approx = "0.5.1" criterion = { version = "0.8.2", features = [ "html_reports" ] } -pastey = "0.2.1" +pastey = "0.2.2" proptest = "1.11.0" serde_json = "1.0.149" sysinfo = "0.38.4" # Process memory monitoring for benchmarks diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 946c0101..986bc925 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -117,6 +117,7 @@ where let mut last_applied_flip: Option<LastAppliedFlip> = None; let used_full_reseed = seed_repair_queues(tds, seed_cells, &mut queues, &mut stats)?; let mut touched_cells = CellKeyBuffer::new(); + let mut touched_cell_set = FastHashSet::<CellKey>::default(); let mut prefer_secondary = false; @@ -132,6 +133,7 @@ where &mut diagnostics, &mut last_applied_flip, &mut touched_cells, + &mut touched_cell_set, )? || process_edge_queue_step( tds, kernel, @@ -142,6 +144,7 @@ where &mut diagnostics, &mut last_applied_flip, &mut touched_cells, + &mut touched_cell_set, )? || process_triangle_queue_step( tds, kernel, @@ -152,6 +155,7 @@ where &mut diagnostics, &mut last_applied_flip, &mut touched_cells, + &mut touched_cell_set, )?) { prefer_secondary = false; @@ -168,6 +172,7 @@ where &mut diagnostics, &mut last_applied_flip, &mut touched_cells, + &mut touched_cell_set, )? { prefer_secondary = true; continue; @@ -183,6 +188,7 @@ where &mut diagnostics, &mut last_applied_flip, &mut touched_cells, + &mut touched_cell_set, )? || process_edge_queue_step( tds, kernel, @@ -193,6 +199,7 @@ where &mut diagnostics, &mut last_applied_flip, &mut touched_cells, + &mut touched_cell_set, )? || process_triangle_queue_step( tds, kernel, @@ -203,6 +210,7 @@ where &mut diagnostics, &mut last_applied_flip, &mut touched_cells, + &mut touched_cell_set, )? { prefer_secondary = false; } @@ -1972,7 +1980,7 @@ impl RidgeHandle { /// # Examples /// /// ```rust -/// use delaunay::core::algorithms::flips::DelaunayRepairStats; +/// use delaunay::prelude::triangulation::repair::DelaunayRepairStats; /// /// let stats = DelaunayRepairStats::default(); /// assert_eq!(stats.flips_performed, 0); @@ -2011,9 +2019,13 @@ struct RepairAttemptOutcome { } /// Adds newly-created cells to the repair mutation frontier without duplicates. -fn record_touched_cells(touched_cells: &mut CellKeyBuffer, new_cells: &[CellKey]) { +fn record_touched_cells( + touched_cells: &mut CellKeyBuffer, + touched_cell_set: &mut FastHashSet<CellKey>, + new_cells: &[CellKey], +) { for &cell_key in new_cells { - if !touched_cells.contains(&cell_key) { + if touched_cell_set.insert(cell_key) { touched_cells.push(cell_key); } } @@ -2033,7 +2045,7 @@ fn repair_run_from_attempt(outcome: RepairAttemptOutcome) -> DelaunayRepairRun { /// # Examples /// /// ```rust -/// use delaunay::core::algorithms::flips::RepairQueueOrder; +/// use delaunay::prelude::triangulation::repair::RepairQueueOrder; /// /// let order = RepairQueueOrder::Fifo; /// assert_eq!(order, RepairQueueOrder::Fifo); @@ -2051,7 +2063,9 @@ pub enum RepairQueueOrder { /// # Examples /// /// ```rust -/// use delaunay::core::algorithms::flips::{DelaunayRepairDiagnostics, RepairQueueOrder}; +/// use delaunay::prelude::triangulation::repair::{ +/// DelaunayRepairDiagnostics, RepairQueueOrder, +/// }; /// /// let diagnostics = DelaunayRepairDiagnostics { /// facets_checked: 0, @@ -2115,8 +2129,7 @@ impl fmt::Display for DelaunayRepairDiagnostics { /// # Examples /// /// ```rust -/// use delaunay::core::algorithms::flips::DelaunayRepairError; -/// use delaunay::core::triangulation::TopologyGuarantee; +/// use delaunay::prelude::triangulation::repair::{DelaunayRepairError, TopologyGuarantee}; /// /// let err = DelaunayRepairError::InvalidTopology { /// required: TopologyGuarantee::PLManifold, @@ -2137,12 +2150,27 @@ pub enum DelaunayRepairError { /// error enum small on the stack). diagnostics: Box<DelaunayRepairDiagnostics>, }, - /// Repair completed but left a Delaunay violation or otherwise could not be verified. + /// Repair completed but left a Delaunay violation. #[error("Delaunay repair postcondition failed: {message}")] PostconditionFailed { /// Additional context describing the postcondition failure. message: String, }, + /// Post-repair verification could not evaluate a local flip predicate. + #[error("Delaunay repair verification failed during {context}: {source}")] + VerificationFailed { + /// Verification phase that failed. + context: &'static str, + /// Underlying flip or predicate error. + #[source] + source: FlipError, + }, + /// Repair completed but orientation canonicalization failed. + #[error("Delaunay repair orientation canonicalization failed: {message}")] + OrientationCanonicalizationFailed { + /// Additional context describing the canonicalization failure. + message: String, + }, /// Flip-based repair is not admissible under the current topology guarantee. #[error("Delaunay repair requires {required:?} topology, found {found:?}: {message}")] InvalidTopology { @@ -3142,7 +3170,8 @@ where let mut facet_handles: FastHashMap<u64, FacetHandle> = FastHashMap::default(); let mut last_applied_flip: Option<LastAppliedFlip> = None; let mut touched_cells = CellKeyBuffer::new(); - let used_full_reseed = seed_cells.is_none(); + let mut touched_cell_set = FastHashSet::<CellKey>::default(); + let mut used_full_reseed = seed_cells.is_none(); let topology_model = GlobalTopology::DEFAULT.model(); if let Some(seeds) = seed_cells { @@ -3156,6 +3185,20 @@ where &mut stats, )?; } + if queue.is_empty() { + used_full_reseed = true; + for facet in AllFacetsIter::new(tds) { + let handle = FacetHandle::new(facet.cell_key(), facet.facet_index()); + enqueue_facet( + tds, + handle, + &mut queue, + &mut queued, + &mut facet_handles, + &mut stats, + ); + } + } } else { for facet in AllFacetsIter::new(tds) { let handle = FacetHandle::new(facet.cell_key(), facet.facet_index()); @@ -3286,7 +3329,7 @@ where diagnostics.record_flip_signature(signature); last_applied_flip = Some(LastAppliedFlip::from_applied_flip(&applied)); let info = applied.info; - record_touched_cells(&mut touched_cells, &info.new_cells); + record_touched_cells(&mut touched_cells, &mut touched_cell_set, &info.new_cells); for &cell_key in &info.new_cells { enqueue_cell_facets( @@ -3344,6 +3387,45 @@ where .map(|run| run.stats) } +fn run_full_reseed_retry<K, U, V, const D: usize>( + tds: &mut Tds<K::Scalar, U, V, D>, + kernel: &K, + config: &RepairAttemptConfig, + snapshot: Tds<K::Scalar, U, V, D>, +) -> Result<DelaunayRepairRun, DelaunayRepairError> +where + K: Kernel<D>, + U: DataType, + V: DataType, +{ + *tds = snapshot.clone(); + let retry_seed_cells = None; + let attempt_result = if D == 2 { + repair_delaunay_with_flips_k2_attempt(tds, kernel, retry_seed_cells, config) + } else { + repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, retry_seed_cells, config) + }; + + match attempt_result { + Ok(outcome) => match verify_repair_postcondition( + tds, + kernel, + retry_seed_cells, + outcome.last_applied_flip.as_ref(), + ) { + Ok(()) => Ok(repair_run_from_attempt(outcome)), + Err(err) => { + *tds = snapshot; + Err(err) + } + }, + Err(err) => { + *tds = snapshot; + Err(err) + } + } +} + /// Repair Delaunay violations and return the final validation frontier. /// /// # Errors @@ -3419,21 +3501,7 @@ where } // Postcondition verification failed: rerun with LIFO + full reseed. - *tds = tds_snapshot; - let retry_seed_cells = None; - let outcome2 = if D == 2 { - repair_delaunay_with_flips_k2_attempt(tds, kernel, retry_seed_cells, &attempt2) - } else { - repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, retry_seed_cells, &attempt2) - }?; - - verify_repair_postcondition( - tds, - kernel, - retry_seed_cells, - outcome2.last_applied_flip.as_ref(), - )?; - Ok(repair_run_from_attempt(outcome2)) + run_full_reseed_retry(tds, kernel, &attempt2, tds_snapshot) } Err(DelaunayRepairError::NonConvergent { .. }) => { if repair_trace_enabled() { @@ -3442,33 +3510,23 @@ where ); } // Retry with LIFO + full reseed. + run_full_reseed_retry(tds, kernel, &attempt2, tds_snapshot) + } + Err(err) => { *tds = tds_snapshot; - let retry_seed_cells = None; - let outcome2 = if D == 2 { - repair_delaunay_with_flips_k2_attempt(tds, kernel, retry_seed_cells, &attempt2) - } else { - repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, retry_seed_cells, &attempt2) - }?; - - verify_repair_postcondition( - tds, - kernel, - retry_seed_cells, - outcome2.last_applied_flip.as_ref(), - )?; - Ok(repair_run_from_attempt(outcome2)) + Err(err) } - Err(err) => Err(err), } } /// Run a seeded, bounded Delaunay repair capped to a specific set of cells. /// -/// Unlike [`repair_delaunay_with_flips_k2_k3`], this function **always reseeds from the -/// provided `seed_cells`** (never from `None` / all cells). This keeps the queue size +/// Unlike [`repair_delaunay_with_flips_k2_k3`], this function normally reseeds from the +/// provided `seed_cells` rather than `None` / all cells. This keeps the queue size /// bounded to `O(seed_cells × queues_per_cell)` regardless of the total triangulation size, /// which is critical for D≥4 where a full-triangulation seed would generate O(cells×30) -/// items (prohibitively expensive with robust predicates). +/// items (prohibitively expensive with robust predicates). In 2D, stale local seeds fall +/// back to all facets because the queue remains small and otherwise no repair work runs. /// /// Two attempts are made with alternating queue orders (FIFO → LIFO) to escape /// flip cycles — the same strategy as [`repair_delaunay_with_flips_k2_k3`], but without the @@ -3540,7 +3598,10 @@ where tracing::debug!("[repair] local attempt 1 non-convergent; retrying LIFO"); } } - Err(err) => return Err(err), + Err(err) => { + *tds = tds_snapshot; + return Err(err); + } } *tds = tds_snapshot.clone(); let attempt2_result = if D == 2 { @@ -3591,13 +3652,14 @@ where /// # Errors /// /// Returns [`DelaunayRepairError::PostconditionFailed`] if any flip predicate detects -/// a Delaunay violation. +/// a Delaunay violation, or [`DelaunayRepairError::VerificationFailed`] if a +/// local predicate cannot be evaluated. /// /// # Examples /// /// ``` /// use delaunay::prelude::triangulation::*; -/// use delaunay::core::algorithms::flips::verify_delaunay_via_flip_predicates; +/// use delaunay::prelude::triangulation::repair::verify_delaunay_via_flip_predicates; /// use delaunay::geometry::kernel::AdaptiveKernel; /// /// let vertices = vec![ @@ -3635,14 +3697,14 @@ where /// # Errors /// /// Returns [`DelaunayRepairError::PostconditionFailed`] if any flip predicate detects -/// a Delaunay violation, or another [`DelaunayRepairError`] if verification cannot -/// evaluate the local predicates. +/// a Delaunay violation, or [`DelaunayRepairError::VerificationFailed`] if +/// verification cannot evaluate the local predicates. /// /// # Examples /// /// ``` /// use delaunay::prelude::triangulation::*; -/// use delaunay::core::algorithms::flips::verify_delaunay_for_triangulation; +/// use delaunay::prelude::triangulation::repair::verify_delaunay_for_triangulation; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -3724,6 +3786,11 @@ enum PostconditionMode { Strict, } +/// Builds a verification failure that preserves the structured flip error. +const fn verification_failed(context: &'static str, source: FlipError) -> DelaunayRepairError { + DelaunayRepairError::VerificationFailed { context, source } +} + /// Adapts the public topology enum into the model used for lifted predicate /// evaluation. fn verify_repair_postcondition_with_topology<K, U, V, const D: usize>( @@ -3851,14 +3918,12 @@ where /// while remaining skippable during best-effort repair passes. fn handle_postcondition_predicate_failure( mode: PostconditionMode, - context: &str, + context: &'static str, error: &FlipError, ) -> Result<(), DelaunayRepairError> { match mode { PostconditionMode::Repair => Ok(()), - PostconditionMode::Strict => Err(DelaunayRepairError::PostconditionFailed { - message: format!("{context} predicate failed in strict mode: {error}"), - }), + PostconditionMode::Strict => Err(verification_failed(context, error.clone())), } } @@ -3911,9 +3976,7 @@ where continue; } Err(e) => { - return Err(DelaunayRepairError::PostconditionFailed { - message: format!("local k=2 verification failed after repair: {e}"), - }); + return Err(verification_failed("local k=2 degeneracy verification", e)); } }; @@ -3973,9 +4036,10 @@ where )?; } Err(e) => { - return Err(DelaunayRepairError::PostconditionFailed { - message: format!("local k=2 verification failed after repair: {e}"), - }); + return Err(verification_failed( + "local k=2 postcondition verification", + e, + )); } } } @@ -4035,9 +4099,7 @@ where continue; } Err(e) => { - return Err(DelaunayRepairError::PostconditionFailed { - message: format!("local k=3 verification failed after repair: {e}"), - }); + return Err(verification_failed("local k=3 degeneracy verification", e)); } }; @@ -4074,9 +4136,10 @@ where )?; } Err(e) => { - return Err(DelaunayRepairError::PostconditionFailed { - message: format!("local k=3 verification failed after repair: {e}"), - }); + return Err(verification_failed( + "local k=3 postcondition verification", + e, + )); } } } @@ -4143,9 +4206,10 @@ where continue; } Err(e) => { - return Err(DelaunayRepairError::PostconditionFailed { - message: format!("local inverse k=2 verification failed after repair: {e}"), - }); + return Err(verification_failed( + "local inverse k=2 postcondition verification", + e, + )); } }; @@ -4226,9 +4290,10 @@ where continue; } Err(e) => { - return Err(DelaunayRepairError::PostconditionFailed { - message: format!("local inverse k=3 verification failed after repair: {e}"), - }); + return Err(verification_failed( + "local inverse k=3 postcondition verification", + e, + )); } }; @@ -4418,7 +4483,7 @@ fn emit_repair_debug_summary( return; } - tracing::trace!( + tracing::debug!( label = %label, attempt = config.attempt, order = ?config.queue_order, @@ -4952,6 +5017,7 @@ fn process_ridge_queue_step<K, U, V, const D: usize>( diagnostics: &mut RepairDiagnostics, last_applied_flip: &mut Option<LastAppliedFlip>, touched_cells: &mut CellKeyBuffer, + touched_cell_set: &mut FastHashSet<CellKey>, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5118,7 +5184,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - record_touched_cells(touched_cells, &info.new_cells); + record_touched_cells(touched_cells, touched_cell_set, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5145,6 +5211,7 @@ fn process_edge_queue_step<K, U, V, const D: usize>( diagnostics: &mut RepairDiagnostics, last_applied_flip: &mut Option<LastAppliedFlip>, touched_cells: &mut CellKeyBuffer, + touched_cell_set: &mut FastHashSet<CellKey>, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5307,7 +5374,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - record_touched_cells(touched_cells, &info.new_cells); + record_touched_cells(touched_cells, touched_cell_set, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5334,6 +5401,7 @@ fn process_triangle_queue_step<K, U, V, const D: usize>( diagnostics: &mut RepairDiagnostics, last_applied_flip: &mut Option<LastAppliedFlip>, touched_cells: &mut CellKeyBuffer, + touched_cell_set: &mut FastHashSet<CellKey>, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5488,7 +5556,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - record_touched_cells(touched_cells, &info.new_cells); + record_touched_cells(touched_cells, touched_cell_set, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5515,6 +5583,7 @@ fn process_facet_queue_step<K, U, V, const D: usize>( diagnostics: &mut RepairDiagnostics, last_applied_flip: &mut Option<LastAppliedFlip>, touched_cells: &mut CellKeyBuffer, + touched_cell_set: &mut FastHashSet<CellKey>, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5673,7 +5742,7 @@ where } stats.flips_performed += 1; diagnostics.record_flip_signature(signature); - record_touched_cells(touched_cells, &info.new_cells); + record_touched_cells(touched_cells, touched_cell_set, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -9466,6 +9535,33 @@ mod tests { assert_eq!(post_test, post_test_copy); assert_ne!(post_test, post_other); + let verification_err = DelaunayRepairError::VerificationFailed { + context: "strict validation", + source: FlipError::DegenerateCell, + }; + let verification_err_copy = DelaunayRepairError::VerificationFailed { + context: "strict validation", + source: FlipError::DegenerateCell, + }; + let verification_other = DelaunayRepairError::VerificationFailed { + context: "strict validation", + source: FlipError::DuplicateCell, + }; + assert_eq!(verification_err, verification_err_copy); + assert_ne!(verification_err, verification_other); + + let canonicalization_err = DelaunayRepairError::OrientationCanonicalizationFailed { + message: "test".to_string(), + }; + let canonicalization_err_copy = DelaunayRepairError::OrientationCanonicalizationFailed { + message: "test".to_string(), + }; + let canonicalization_other = DelaunayRepairError::OrientationCanonicalizationFailed { + message: "other".to_string(), + }; + assert_eq!(canonicalization_err, canonicalization_err_copy); + assert_ne!(canonicalization_err, canonicalization_other); + let topo_err = DelaunayRepairError::InvalidTopology { required: TopologyGuarantee::PLManifold, found: TopologyGuarantee::Pseudomanifold, @@ -9480,6 +9576,8 @@ mod tests { // Different variants are never equal. assert_ne!(post_test, topo_err); + assert_ne!(post_test, verification_err); + assert_ne!(post_test, canonicalization_err); } macro_rules! gen_align_periodic_offset_tests { diff --git a/src/lib.rs b/src/lib.rs index a6b82108..884da99b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,6 +48,7 @@ //! | Read-only queries, traversal, convex hull | `use delaunay::prelude::query::*` | //! | Geometry helpers, predicates, points | `use delaunay::prelude::geometry::*` | //! | 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::*` | //! | Topology validation, Euler characteristic | `use delaunay::prelude::topology::validation::*` | //! | Collection types (`FastHashMap`, etc.) | `use delaunay::prelude::collections::*` | @@ -921,6 +922,23 @@ pub mod prelude { pub use crate::vertex; } + /// Flip-based Delaunay repair, diagnostics, and Level 4 validation. + pub mod repair { + pub use crate::core::algorithms::flips::{ + DelaunayRepairDiagnostics, DelaunayRepairError, DelaunayRepairStats, FlipError, + RepairQueueOrder, verify_delaunay_for_triangulation, + verify_delaunay_via_flip_predicates, + }; + pub use crate::core::triangulation::{TopologyGuarantee, Triangulation}; + pub use crate::triangulation::delaunay::{ + DelaunayCheckPolicy, DelaunayRepairHeuristicConfig, DelaunayRepairHeuristicSeeds, + DelaunayRepairOutcome, DelaunayRepairPolicy, DelaunayTriangulation, + }; + + // Convenience macro (commonly used in docs/examples). + pub use crate::vertex; + } + /// End-to-end "repair then delaunayize" workflow. /// /// Self-contained: a single `use delaunay::prelude::triangulation::delaunayize::*` @@ -1054,8 +1072,16 @@ mod tests { adjacency::AdjacencyIndex, cell::Cell, edge::EdgeKey, tds::Tds, triangulation::Triangulation, vertex::Vertex, }, - geometry::{Point, algorithms::convex_hull::ConvexHull, kernel::FastKernel}, + geometry::{ + Point, algorithms::convex_hull::ConvexHull, kernel::AdaptiveKernel, kernel::FastKernel, + }, is_normal, + prelude::triangulation::repair::{ + DelaunayCheckPolicy, DelaunayRepairError, DelaunayRepairOutcome, DelaunayRepairPolicy, + DelaunayRepairStats, DelaunayTriangulation as RepairDelaunayTriangulation, FlipError, + RepairQueueOrder, TopologyGuarantee, verify_delaunay_for_triangulation, + verify_delaunay_via_flip_predicates, vertex, + }, triangulation::delaunay::DelaunayTriangulation, }; @@ -1107,6 +1133,40 @@ mod tests { let _vertex_cells: VertexToCellsMap = VertexToCellsMap::default(); } + #[test] + fn test_prelude_triangulation_repair_exports() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + ]; + let dt: RepairDelaunayTriangulation<_, (), (), 2> = + RepairDelaunayTriangulation::new(&vertices).unwrap(); + let kernel = AdaptiveKernel::<f64>::new(); + + assert!(verify_delaunay_for_triangulation(dt.as_triangulation()).is_ok()); + assert!(verify_delaunay_via_flip_predicates(dt.tds(), &kernel).is_ok()); + + let stats = DelaunayRepairStats::default(); + let outcome = DelaunayRepairOutcome { + stats: stats.clone(), + heuristic: None, + }; + assert_eq!(outcome.stats.flips_performed, stats.flips_performed); + let order = RepairQueueOrder::Fifo; + assert!(matches!(order, RepairQueueOrder::Fifo)); + assert_eq!( + DelaunayRepairPolicy::default(), + DelaunayRepairPolicy::EveryInsertion + ); + assert_eq!(DelaunayCheckPolicy::default(), DelaunayCheckPolicy::EndOnly); + + let err = DelaunayRepairError::Flip(FlipError::DegenerateCell); + assert!(matches!(err, DelaunayRepairError::Flip(_))); + let topo = TopologyGuarantee::PLManifold; + assert!(matches!(topo, TopologyGuarantee::PLManifold)); + } + #[test] fn test_prelude_quality_exports() { use crate::prelude::*; diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index 71aef1c9..31867248 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -33,7 +33,7 @@ use crate::core::triangulation::{ }; use crate::core::util::{ coords_equal_exact, coords_within_epsilon, hilbert_indices_prequantized, hilbert_quantize, - stable_hash_u64_slice, + is_delaunay_property_only, stable_hash_u64_slice, }; use crate::core::vertex::Vertex; use crate::geometry::kernel::{AdaptiveKernel, ExactPredicates, Kernel, RobustKernel}; @@ -147,6 +147,35 @@ enum EscalationSkipReason { 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 { + match repair_err { + DelaunayRepairError::NonConvergent { .. } + | DelaunayRepairError::PostconditionFailed { .. } => true, + DelaunayRepairError::VerificationFailed { source, .. } + | DelaunayRepairError::Flip(source) => is_geometric_flip_error(source), + DelaunayRepairError::OrientationCanonicalizationFailed { .. } + | DelaunayRepairError::InvalidTopology { .. } + | DelaunayRepairError::HeuristicRebuildFailed { .. } => false, + } +} + +/// Returns true for flip errors caused by geometric predicates or degenerate +/// replacement cells rather than deterministic topology/cell-key failures. +const fn is_geometric_flip_error(error: &FlipError) -> bool { + matches!( + error, + FlipError::PredicateFailure { .. } + | FlipError::DegenerateCell + | FlipError::NegativeOrientation { .. } + | FlipError::CellCreation( + CellValidationError::DegenerateSimplex + | CellValidationError::CoordinateConversion { .. }, + ) + ) +} + /// Per-insertion local Delaunay repair flip budget. /// /// Computes `seeds * (D + 1) * FACTOR` with a minimum of `FLOOR`, using the @@ -2593,8 +2622,7 @@ where grid_cell_size, use_global_repair_fallback, ) { - Ok(candidate) => match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) - { + Ok(candidate) => match is_delaunay_property_only(&candidate.tri.tds) { Ok(()) => { log_construction_retry_result(0, None, 0_u64, "succeeded", None, None); return Ok(candidate); @@ -2664,25 +2692,23 @@ where grid_cell_size, use_global_repair_fallback, ) { - Ok(candidate) => { - match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => { - log_construction_retry_result( - attempt, - Some(attempt_seed), - perturbation_seed, - "succeeded", - None, - None, - ); - return Ok(candidate); - } - Err(err) => { - last_error = - format!("Delaunay property violated after construction: {err}"); - } + Ok(candidate) => match is_delaunay_property_only(&candidate.tri.tds) { + Ok(()) => { + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "succeeded", + None, + None, + ); + return Ok(candidate); } - } + Err(err) => { + last_error = + format!("Delaunay property violated after construction: {err}"); + } + }, Err(err) => { let err_string = err.to_string(); if Self::is_non_retryable_construction_error(&err) { @@ -2776,25 +2802,23 @@ where grid_cell_size, use_global_repair_fallback, ) { - Ok((candidate, stats)) => { - match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => { - log_construction_retry_result( - 0, - None, - 0_u64, - "succeeded", - None, - Some(&stats), - ); - return Ok((candidate, stats)); - } - Err(err) => { - last_stats.replace(stats); - format!("Delaunay property violated after construction: {err}") - } + Ok((candidate, stats)) => match is_delaunay_property_only(&candidate.tri.tds) { + Ok(()) => { + log_construction_retry_result( + 0, + None, + 0_u64, + "succeeded", + None, + Some(&stats), + ); + return Ok((candidate, stats)); } - } + Err(err) => { + last_stats.replace(stats); + format!("Delaunay property violated after construction: {err}") + } + }, Err(err) => { let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics } = err; @@ -2871,26 +2895,24 @@ where grid_cell_size, use_global_repair_fallback, ) { - Ok((candidate, stats)) => { - match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => { - log_construction_retry_result( - attempt, - Some(attempt_seed), - perturbation_seed, - "succeeded", - None, - Some(&stats), - ); - return Ok((candidate, stats)); - } - Err(err) => { - last_stats.replace(stats); - last_error = - format!("Delaunay property violated after construction: {err}"); - } + Ok((candidate, stats)) => match is_delaunay_property_only(&candidate.tri.tds) { + Ok(()) => { + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "succeeded", + None, + Some(&stats), + ); + return Ok((candidate, stats)); } - } + Err(err) => { + last_stats.replace(stats); + last_error = + format!("Delaunay property violated after construction: {err}"); + } + }, Err(err) => { let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics } = err; @@ -3246,7 +3268,7 @@ where if vertices.len() < D + 1 { return Err(TriangulationConstructionError::InsufficientVertices { dimension: D, - source: crate::core::cell::CellValidationError::InsufficientVertices { + source: CellValidationError::InsufficientVertices { actual: vertices.len(), expected: D + 1, dimension: D, @@ -3453,9 +3475,7 @@ where max_queue = stats.max_queue_len, "bulk D≥4: escalation succeeded" ); - if stats.flips_performed > 0 { - self.canonicalize_after_bulk_repair()?; - } + self.canonicalize_after_bulk_repair()?; Ok(LocalRepairEscalationOutcome::Succeeded { stats }) } Err(escalation_err) => { @@ -3491,10 +3511,13 @@ where index: usize, repair_err: &DelaunayRepairError, ) -> DelaunayTriangulationConstructionError { - TriangulationConstructionError::GeometricDegeneracy { - message: format!("per-insertion Delaunay repair failed at index {index}: {repair_err}"), + let message = + format!("per-insertion Delaunay repair failed at index {index}: {repair_err}"); + if is_geometric_repair_error(repair_err) { + TriangulationConstructionError::GeometricDegeneracy { message }.into() + } else { + TriangulationConstructionError::InternalInconsistency { message }.into() } - .into() } /// Inserts the non-simplex vertices under a fixed perturbation seed so bulk @@ -4259,13 +4282,23 @@ where ), } } + InsertionError::DelaunayRepairFailed { source, context } => { + let message = format!( + "Failed to canonicalize orientation after post-construction repair: \ + Delaunay repair failed ({context}): {source}" + ); + if is_geometric_repair_error(&source) { + TriangulationConstructionError::GeometricDegeneracy { message } + } else { + TriangulationConstructionError::InternalInconsistency { message } + } + } // Geometry-related failures (degenerate input, predicate issues). error @ (InsertionError::ConflictRegion(_) | InsertionError::Location(_) | InsertionError::NonManifoldTopology { .. } | InsertionError::HullExtension { .. } | InsertionError::DelaunayValidationFailed { .. } - | InsertionError::DelaunayRepairFailed { .. } | InsertionError::DuplicateCoordinates { .. }) => { TriangulationConstructionError::GeometricDegeneracy { message: format!( @@ -4302,6 +4335,14 @@ where InsertionError::DuplicateCoordinates { coordinates } => { TriangulationConstructionError::DuplicateCoordinates { coordinates } } + InsertionError::DelaunayRepairFailed { source, context } => { + let message = format!("Delaunay repair failed ({context}): {source}"); + if is_geometric_repair_error(&source) { + TriangulationConstructionError::GeometricDegeneracy { message } + } else { + TriangulationConstructionError::InternalInconsistency { message } + } + } // Insertion-layer failures that are best surfaced during construction as a // geometric degeneracy (e.g. numerical instability, hull visibility issues). @@ -4320,7 +4361,6 @@ where | InsertionError::NonManifoldTopology { .. } | InsertionError::HullExtension { .. } | InsertionError::DelaunayValidationFailed { .. } - | InsertionError::DelaunayRepairFailed { .. } | InsertionError::TopologyValidationFailed { .. }) => { TriangulationConstructionError::GeometricDegeneracy { message: insertion_error.to_string(), @@ -4868,13 +4908,13 @@ where Ok(stats) } - /// Canonicalize geometric orientation to the positive sign, mapping failures - /// to [`DelaunayRepairError::PostconditionFailed`]. + /// Canonicalize geometric orientation to the positive sign, preserving + /// canonicalization failures as their own repair error variant. fn ensure_positive_orientation(&mut self) -> Result<(), DelaunayRepairError> { self.tri .normalize_and_promote_positive_orientation() - .map_err(|e| DelaunayRepairError::PostconditionFailed { - message: format!("Orientation canonicalization failed after repair: {e}"), + .map_err(|e| DelaunayRepairError::OrientationCanonicalizationFailed { + message: format!("after flip repair: {e}"), }) } @@ -4974,8 +5014,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::triangulation::delaunay::DelaunayRepairHeuristicConfig; /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::repair::DelaunayRepairHeuristicConfig; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -6701,7 +6741,7 @@ where /// # Examples /// /// ```rust -/// use delaunay::triangulation::delaunay::DelaunayRepairPolicy; +/// use delaunay::prelude::triangulation::repair::DelaunayRepairPolicy; /// use std::num::NonZeroUsize; /// /// let policy = DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()); @@ -6742,7 +6782,7 @@ impl DelaunayRepairPolicy { /// # Examples /// /// ```rust -/// use delaunay::triangulation::delaunay::DelaunayRepairHeuristicConfig; +/// use delaunay::prelude::triangulation::repair::DelaunayRepairHeuristicConfig; /// /// let mut config = DelaunayRepairHeuristicConfig::default(); /// config.shuffle_seed = Some(7); @@ -6805,7 +6845,7 @@ impl DelaunayRepairHeuristicConfig { /// # Examples /// /// ```rust -/// use delaunay::triangulation::delaunay::DelaunayRepairHeuristicSeeds; +/// use delaunay::prelude::triangulation::repair::DelaunayRepairHeuristicSeeds; /// /// let seeds = DelaunayRepairHeuristicSeeds { /// shuffle_seed: 1, @@ -6826,8 +6866,9 @@ pub struct DelaunayRepairHeuristicSeeds { /// # Examples /// /// ```rust -/// use delaunay::core::algorithms::flips::DelaunayRepairStats; -/// use delaunay::triangulation::delaunay::DelaunayRepairOutcome; +/// use delaunay::prelude::triangulation::repair::{ +/// DelaunayRepairOutcome, DelaunayRepairStats, +/// }; /// /// let outcome = DelaunayRepairOutcome { /// stats: DelaunayRepairStats::default(), @@ -6864,7 +6905,7 @@ impl DelaunayRepairOutcome { /// # Examples /// /// ```rust -/// use delaunay::triangulation::delaunay::DelaunayCheckPolicy; +/// use delaunay::prelude::triangulation::repair::DelaunayCheckPolicy; /// use std::num::NonZeroUsize; /// /// let policy = DelaunayCheckPolicy::EveryN(NonZeroUsize::new(3).unwrap()); @@ -6907,6 +6948,7 @@ mod tests { HullExtensionReason, repair_neighbor_pointers, }; use crate::core::algorithms::locate::{ConflictError, LocateError}; + use crate::core::operations::InsertionResult; use crate::core::tds::{EntityKind, GeometricError}; use crate::core::vertex::VertexBuilder; use crate::geometry::kernel::{AdaptiveKernel, FastKernel, RobustKernel}; @@ -7330,7 +7372,7 @@ mod tests { let stats = InsertionStatistics { attempts: 3, cells_removed_during_repair: 4, - result: crate::core::operations::InsertionResult::Inserted, + result: InsertionResult::Inserted, }; summary.record_insertion(&stats); @@ -7347,10 +7389,7 @@ mod tests { // Borrowed API: caller retains ownership of insertion stats. assert_eq!(stats.attempts, 3); - assert!(matches!( - stats.result, - crate::core::operations::InsertionResult::Inserted - )); + assert!(matches!(stats.result, InsertionResult::Inserted)); } #[test] @@ -7361,12 +7400,12 @@ mod tests { let skipped_duplicate = InsertionStatistics { attempts: 1, cells_removed_during_repair: 0, - result: crate::core::operations::InsertionResult::SkippedDuplicate, + result: InsertionResult::SkippedDuplicate, }; let skipped_degeneracy = InsertionStatistics { attempts: 2, cells_removed_during_repair: 5, - result: crate::core::operations::InsertionResult::SkippedDegeneracy, + result: InsertionResult::SkippedDegeneracy, }; summary.record_insertion(&skipped_duplicate); @@ -7438,11 +7477,7 @@ mod tests { vertex!([0.0, 0.0, 0.0]), vertex!([1.0, 0.0, 0.0]), vertex!([0.0, 1.0, 0.0]), - Vertex::new_with_uuid( - crate::geometry::point::Point::new([f64::NAN, 0.0, 0.0]), - Uuid::new_v4(), - None, - ), + Vertex::new_with_uuid(Point::new([f64::NAN, 0.0, 0.0]), Uuid::new_v4(), None), ]; let result = select_balanced_simplex_indices(&vertices); @@ -9474,16 +9509,72 @@ mod tests { }; assert!(!TestDelaunay4::can_soft_fail(&topology_error)); - let mapped = TestDelaunay4::map_hard_repair_error(23, &flip_error); + let verification_error = DelaunayRepairError::VerificationFailed { + context: "local k=3 postcondition verification", + source: FlipError::InvalidFlipContext { + message: "bad ridge frame".to_string(), + }, + }; + assert!(!TestDelaunay4::can_soft_fail(&verification_error)); + + let canonicalization_error = DelaunayRepairError::OrientationCanonicalizationFailed { + message: "after flip repair: broken orientation".to_string(), + }; + assert!(!TestDelaunay4::can_soft_fail(&canonicalization_error)); + + let mapped_hard = TestDelaunay4::map_hard_repair_error(23, &flip_error); assert!( matches!( - mapped, + mapped_hard, DelaunayTriangulationConstructionError::Triangulation( - TriangulationConstructionError::GeometricDegeneracy { ref message } + TriangulationConstructionError::InternalInconsistency { ref message } ) if message.contains("per-insertion Delaunay repair failed at index 23") && message.contains("Bistellar flip not supported for D=1") ), - "non-soft D>=4 repair failures should propagate as construction errors: {mapped:?}" + "deterministic hard D>=4 repair failures should stop shuffled retries: {mapped_hard:?}" + ); + + let geometric_error = DelaunayRepairError::Flip(FlipError::DegenerateCell); + let mapped_geometric = TestDelaunay4::map_hard_repair_error(24, &geometric_error); + assert!( + matches!( + mapped_geometric, + DelaunayTriangulationConstructionError::Triangulation( + TriangulationConstructionError::GeometricDegeneracy { ref message } + ) if message.contains("per-insertion Delaunay repair failed at index 24") + && message.contains("degenerate cell") + ), + "geometric hard D>=4 repair failures should remain retryable degeneracies: {mapped_geometric:?}" + ); + + let mapped_verification = TestDelaunay4::map_hard_repair_error(25, &verification_error); + assert!( + matches!( + mapped_verification, + DelaunayTriangulationConstructionError::Triangulation( + TriangulationConstructionError::InternalInconsistency { ref message } + ) if message.contains("per-insertion Delaunay repair failed at index 25") + && message.contains("bad ridge frame") + ), + "verification context failures should stop shuffled retries: {mapped_verification:?}" + ); + + let predicate_verification = DelaunayRepairError::VerificationFailed { + context: "strict validation", + source: FlipError::PredicateFailure { + message: "in_sphere failed".to_string(), + }, + }; + let mapped_predicate = TestDelaunay4::map_hard_repair_error(26, &predicate_verification); + assert!( + matches!( + mapped_predicate, + DelaunayTriangulationConstructionError::Triangulation( + TriangulationConstructionError::GeometricDegeneracy { ref message } + ) if message.contains("per-insertion Delaunay repair failed at index 26") + && message.contains("in_sphere failed") + ), + "verification predicate failures should remain geometric: {mapped_predicate:?}" ); } @@ -9837,6 +9928,30 @@ mod tests { } } + #[test] + fn test_map_orientation_canonicalization_error_hard_repair_is_internal() { + let error = InsertionError::DelaunayRepairFailed { + source: Box::new(DelaunayRepairError::VerificationFailed { + context: "local k=3 postcondition verification", + source: FlipError::InvalidFlipContext { + message: "bad ridge frame".to_string(), + }, + }), + context: "orientation canonicalization".to_string(), + }; + let mapped = + DelaunayTriangulation::<AdaptiveKernel<f64>, (), (), 3>::map_orientation_canonicalization_error(error); + assert!( + matches!( + mapped, + TriangulationConstructionError::InternalInconsistency { ref message } + if message.contains("orientation canonicalization") + && message.contains("bad ridge frame") + ), + "hard repair errors during orientation canonicalization should be internal: {mapped:?}" + ); + } + // ---- map_insertion_error tests ---- #[test] @@ -9981,6 +10096,27 @@ mod tests { } } + #[test] + fn test_map_insertion_error_hard_repair_is_internal() { + let error = InsertionError::DelaunayRepairFailed { + source: Box::new(DelaunayRepairError::Flip(FlipError::UnsupportedDimension { + dimension: 1, + })), + context: "local repair".to_string(), + }; + let mapped = + DelaunayTriangulation::<AdaptiveKernel<f64>, (), (), 3>::map_insertion_error(error); + assert!( + matches!( + mapped, + TriangulationConstructionError::InternalInconsistency { ref message } + if message.contains("local repair") + && message.contains("Bistellar flip not supported for D=1") + ), + "hard repair errors during insertion should be internal: {mapped:?}" + ); + } + // ---- is_retryable refinement tests ---- #[test] @@ -10271,7 +10407,7 @@ mod tests { // builds it when all three stages fail. let primary_err = DelaunayRepairError::NonConvergent { max_flips: 1000, - diagnostics: Box::new(crate::core::algorithms::flips::DelaunayRepairDiagnostics { + diagnostics: Box::new(DelaunayRepairDiagnostics { facets_checked: 50, flips_performed: 1000, max_queue_len: 42, @@ -10281,7 +10417,7 @@ mod tests { cycle_detections: 0, cycle_signature_samples: Vec::new(), attempt: 1, - queue_order: crate::core::algorithms::flips::RepairQueueOrder::Fifo, + queue_order: RepairQueueOrder::Fifo, }), }; let robust_err = DelaunayRepairError::PostconditionFailed { diff --git a/src/triangulation/delaunayize.rs b/src/triangulation/delaunayize.rs index 8601fe00..f7ed3bf4 100644 --- a/src/triangulation/delaunayize.rs +++ b/src/triangulation/delaunayize.rs @@ -177,9 +177,8 @@ pub struct DelaunayizeOutcome<T, U, V, const D: usize> { /// # Examples /// /// ```rust -/// use delaunay::triangulation::delaunayize::DelaunayizeError; -/// use delaunay::core::algorithms::flips::DelaunayRepairError; -/// use delaunay::core::triangulation::TopologyGuarantee; +/// use delaunay::prelude::triangulation::delaunayize::DelaunayizeError; +/// use delaunay::prelude::triangulation::repair::{DelaunayRepairError, TopologyGuarantee}; /// /// let err = DelaunayizeError::DelaunayRepairFailed { /// source: DelaunayRepairError::InvalidTopology { diff --git a/tests/delaunayize_workflow.rs b/tests/delaunayize_workflow.rs index cb8a77fb..9015ce02 100644 --- a/tests/delaunayize_workflow.rs +++ b/tests/delaunayize_workflow.rs @@ -8,10 +8,10 @@ //! - Repeat-run determinism for outcome stats //! - Multi-dimensional coverage (2D–3D) -use delaunay::core::algorithms::flips::DelaunayRepairError; use delaunay::core::triangulation::TriangulationConstructionError; use delaunay::prelude::triangulation::delaunayize::*; use delaunay::prelude::triangulation::flips::BistellarFlips; +use delaunay::prelude::triangulation::repair::DelaunayRepairError; use delaunay::triangulation::delaunay::DelaunayTriangulationConstructionError; use delaunay::triangulation::flips::FacetHandle; use std::error::Error; diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index 43b03abe..fbad3278 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -67,7 +67,7 @@ use delaunay::core::triangulation::TopologyGuarantee; use delaunay::geometry::kernel::RobustKernel; use delaunay::geometry::util::{ - generate_random_points_in_ball_seeded, generate_random_points_seeded, + generate_random_points_in_ball_seeded, generate_random_points_seeded, safe_usize_to_scalar, }; use delaunay::prelude::triangulation::*; use delaunay::triangulation::delaunay::{ @@ -201,29 +201,31 @@ impl<const D: usize> From<ConstructionStatistics> for InsertionSummary<D> { let skip_samples: Vec<SkipSample<D>> = stats .skip_samples .iter() - .filter_map(|s| { + .map(|s| { let coords = if s.coords_available { - let Ok(coords) = s.coords.as_slice().try_into() else { - tracing::warn!( - index = s.index, - uuid = %s.uuid, - coords_len = s.coords.len(), - expected_dim = D, - "dropping skip sample due to coordinate dimension mismatch" - ); - return None; - }; - Some(coords) + s.coords.as_slice().try_into().map_or_else( + |_| { + tracing::warn!( + index = s.index, + uuid = %s.uuid, + coords_len = s.coords.len(), + expected_dim = D, + "preserving skip sample without coordinates due to coordinate dimension mismatch" + ); + None + }, + Some, + ) } else { None }; - Some(SkipSample { + SkipSample { index: s.index, uuid: s.uuid, coords, attempts: s.attempts, error: s.error.clone(), - }) + } }) .collect(); @@ -825,8 +827,7 @@ fn debug_large_case<const D: usize>(dimension_name: &str, default_n_points: usiz if (idx + 1) % progress_every == 0 { let chunk_elapsed = t_last_progress.elapsed(); let progress_f64: f64 = - delaunay::geometry::util::safe_usize_to_scalar(progress_every) - .unwrap_or(f64::NAN); + safe_usize_to_scalar(progress_every).unwrap_or(f64::NAN); let rate = progress_f64 / chunk_elapsed.as_secs_f64().max(1e-9); println!( "progress: {}/{} inserted={} skipped={} cells={} elapsed={:?} ({:.1} pts/s last {})", diff --git a/tests/triangulation_builder.rs b/tests/triangulation_builder.rs index 17689aa9..44330b41 100644 --- a/tests/triangulation_builder.rs +++ b/tests/triangulation_builder.rs @@ -8,12 +8,11 @@ use std::collections::HashMap; use std::f64::consts::TAU; -use delaunay::core::algorithms::flips::DelaunayRepairError; -use delaunay::core::triangulation::TopologyGuarantee; use delaunay::core::vertex::{Vertex, VertexBuilder}; use delaunay::geometry::kernel::RobustKernel; use delaunay::geometry::point::Point; use delaunay::geometry::traits::coordinate::Coordinate; +use delaunay::prelude::triangulation::repair::{DelaunayRepairError, TopologyGuarantee}; use delaunay::topology::characteristics::euler::{count_simplices, euler_characteristic}; use delaunay::topology::traits::topological_space::{GlobalTopology, ToroidalConstructionMode}; use delaunay::triangulation::builder::{DelaunayTriangulationBuilder, ExplicitConstructionError}; From a73e117c82f73043e329f2dd90fa46d60d6ea816 Mon Sep 17 00:00:00 2001 From: Adam Getchell <adam@adamgetchell.org> Date: Fri, 24 Apr 2026 21:08:11 -0700 Subject: [PATCH 11/11] Fixed: ensure touched cell tracking covers full triangulation during reseed Update Delaunay repair to correctly populate the touched cell buffer with every cell in the triangulation when a full reseed occurs. Previously, local repair attempts that fell back to a full reseed could return an incomplete frontier for post-repair validation. Additionally, remove the implicit fallback to full reseed when provided with empty local seeds, treating an empty seed slice as a bounded no-op. Refs: #204 --- src/core/algorithms/flips.rs | 120 ++++++++++++++++++++++++++++------- 1 file changed, 96 insertions(+), 24 deletions(-) diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 986bc925..4511a926 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -2001,7 +2001,11 @@ pub struct DelaunayRepairStats { pub(crate) struct DelaunayRepairRun { /// Public aggregate repair statistics. pub stats: DelaunayRepairStats, - /// Cells created by successful flips in the final repair attempt. + /// 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. pub touched_cells: CellKeyBuffer, /// Whether the final attempt used full-TDS queue seeding. pub used_full_reseed: bool, @@ -2032,11 +2036,26 @@ fn record_touched_cells( } /// Converts an attempt outcome into the crate-private repair run result. -fn repair_run_from_attempt(outcome: RepairAttemptOutcome) -> DelaunayRepairRun { +fn repair_run_from_attempt( + outcome: RepairAttemptOutcome, + current_cells: impl IntoIterator<Item = CellKey>, +) -> 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: outcome.stats, - touched_cells: outcome.touched_cells, - used_full_reseed: outcome.used_full_reseed, + stats, + touched_cells, + used_full_reseed, } } @@ -3171,7 +3190,7 @@ where let mut last_applied_flip: Option<LastAppliedFlip> = None; let mut touched_cells = CellKeyBuffer::new(); let mut touched_cell_set = FastHashSet::<CellKey>::default(); - let mut used_full_reseed = seed_cells.is_none(); + let used_full_reseed = seed_cells.is_none(); let topology_model = GlobalTopology::DEFAULT.model(); if let Some(seeds) = seed_cells { @@ -3185,20 +3204,6 @@ where &mut stats, )?; } - if queue.is_empty() { - used_full_reseed = true; - for facet in AllFacetsIter::new(tds) { - let handle = FacetHandle::new(facet.cell_key(), facet.facet_index()); - enqueue_facet( - tds, - handle, - &mut queue, - &mut queued, - &mut facet_handles, - &mut stats, - ); - } - } } else { for facet in AllFacetsIter::new(tds) { let handle = FacetHandle::new(facet.cell_key(), facet.facet_index()); @@ -3413,7 +3418,7 @@ where retry_seed_cells, outcome.last_applied_flip.as_ref(), ) { - Ok(()) => Ok(repair_run_from_attempt(outcome)), + Ok(()) => Ok(repair_run_from_attempt(outcome, tds.cell_keys())), Err(err) => { *tds = snapshot; Err(err) @@ -3492,7 +3497,7 @@ where ) .is_ok() { - return Ok(repair_run_from_attempt(outcome)); + return Ok(repair_run_from_attempt(outcome, tds.cell_keys())); } if repair_trace_enabled() { tracing::debug!( @@ -3525,8 +3530,8 @@ where /// provided `seed_cells` rather than `None` / all cells. This keeps the queue size /// bounded to `O(seed_cells × queues_per_cell)` regardless of the total triangulation size, /// which is critical for D≥4 where a full-triangulation seed would generate O(cells×30) -/// items (prohibitively expensive with robust predicates). In 2D, stale local seeds fall -/// back to all facets because the queue remains small and otherwise no repair work runs. +/// items (prohibitively expensive with robust predicates). An explicit empty seed slice +/// is a bounded no-op seed set; callers that want a whole-TDS repair pass `None`. /// /// Two attempts are made with alternating queue orders (FIFO → LIFO) to escape /// flip cycles — the same strategy as [`repair_delaunay_with_flips_k2_k3`], but without the @@ -10158,6 +10163,73 @@ mod tests { ); } + #[test] + fn test_repair_run_full_reseed_frontier_covers_all_cells() { + init_tracing(); + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + vertex!([1.0, 0.2]), + ]; + let dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let tds = dt.tds(); + let local_cell = tds.cell_keys().next().unwrap(); + let outcome = RepairAttemptOutcome { + stats: DelaunayRepairStats::default(), + last_applied_flip: None, + touched_cells: std::iter::once(local_cell).collect(), + used_full_reseed: true, + }; + + let run = repair_run_from_attempt(outcome, tds.cell_keys()); + let expected_cells: Vec<CellKey> = tds.cell_keys().collect(); + + assert!(run.used_full_reseed); + assert!( + expected_cells.len() > 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) }) + ); + } + + #[test] + fn test_repair_k2_empty_seed_does_not_full_reseed() { + init_tracing(); + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + vertex!([1.0, 0.2]), + ]; + let dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let mut tds = dt.tds().clone(); + let before = snapshot_topology(&tds); + let kernel = AdaptiveKernel::<f64>::new(); + let config = RepairAttemptConfig { + attempt: 1, + queue_order: RepairQueueOrder::Fifo, + max_flips_override: None, + }; + let empty_seeds: &[CellKey] = &[]; + + let outcome = + repair_delaunay_with_flips_k2_attempt(&mut tds, &kernel, Some(empty_seeds), &config) + .unwrap(); + + assert!(!outcome.used_full_reseed); + assert_eq!(outcome.stats.facets_checked, 0); + assert!(outcome.touched_cells.is_empty()); + assert_eq!(snapshot_topology(&tds), before); + } + #[test] fn test_repair_queue_k2_local_seed() { init_tracing();