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/CHANGELOG.md b/CHANGELOG.md index 0c368dd9..46c0ac12 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. @@ -107,18 +164,61 @@ Introduce a `perf` Cargo profile that inherits from `release` but 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. -- 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,47 @@ 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. +- 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. + +- 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) @@ -1979,7 +2120,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 +2129,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 +2140,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 +2153,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..00d6687f 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", ] @@ -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" @@ -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/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/benches/large_scale_performance.rs b/benches/large_scale_performance.rs index 87722849..36b1fe10 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,24 @@ 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 + ); + } + + // 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); - // 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 +566,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 7b264637..e06f2079 100644 --- a/docs/KNOWN_ISSUES_4D.md +++ b/docs/KNOWN_ISSUES_4D.md @@ -2,38 +2,97 @@ ## 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. - -**Severity:** High (correctness) -**Affects:** primarily large 4D bulk runs (typically 100+ vertices) -**Recommended workaround:** prefer incremental insertion for production 4D workloads - -**#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. +### Re-verified on 2026-04-23 (release mode) + +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 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 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 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) 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) 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) + +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). @@ -106,38 +165,39 @@ 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 +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 \ + just debug-large-scale-4d 500 + ``` ### 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 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/docs/TODO.md b/docs/TODO.md index d9eae71d..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* @@ -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,34 @@ 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 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. + --- ## 3 · Codebase Complexity 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 6bc845f4..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,9 +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_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 @@ -54,6 +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` | `[release]` One-shot dump of first detected ridge fan (ridge verts, boundary facets, extras). | ## Cavity & Hull @@ -82,9 +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` | `[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` | `[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/justfile b/justfile index 2a3fd608..8ffcf86a 100644 --- a/justfile +++ b/justfile @@ -214,20 +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 --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 --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 --test large_scale_debug debug_large_scale_3d_incremental_prefix_bisect -- --ignored --nocapture - -debug-large-scale-4d: - cargo test --test large_scale_debug debug_large_scale_4d -- --ignored --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 +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: @@ -265,13 +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-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/scripts/postprocess_changelog.py b/scripts/postprocess_changelog.py index 2fc9045f..0f3c90cb 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 @@ -29,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", } @@ -60,6 +62,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 +313,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 +382,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..be671c7e 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, ) @@ -75,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" @@ -371,6 +375,48 @@ 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_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( + "# 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 f3f01b79..4511a926 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -55,11 +55,14 @@ 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}; use thiserror::Error; +type VertexKeyList = SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>; +type RemovedCellVertexSnapshot = SmallBuffer<VertexKeyList, MAX_PRACTICAL_DIMENSION_SIZE>; + /// Bistellar flip kind descriptor. /// /// Access the move size with [`BistellarFlipKind::k`]. @@ -91,7 +94,7 @@ fn repair_delaunay_with_flips_k2_k3_attempt<K, U, V, const D: usize>( kernel: &K, seed_cells: Option<&[CellKey]>, config: &RepairAttemptConfig, -) -> Result<DelaunayRepairStats, DelaunayRepairError> +) -> Result<RepairAttemptOutcome, DelaunayRepairError> where K: Kernel<D>, U: DataType, @@ -112,7 +115,9 @@ 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 touched_cell_set = FastHashSet::<CellKey>::default(); let mut prefer_secondary = false; @@ -127,6 +132,8 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, )? || process_edge_queue_step( tds, kernel, @@ -136,6 +143,8 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, )? || process_triangle_queue_step( tds, kernel, @@ -145,6 +154,8 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, )?) { prefer_secondary = false; @@ -160,6 +171,8 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, )? { prefer_secondary = true; continue; @@ -174,6 +187,8 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, )? || process_edge_queue_step( tds, kernel, @@ -183,6 +198,8 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, )? || process_triangle_queue_step( tds, kernel, @@ -192,6 +209,8 @@ where config, &mut diagnostics, &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, )? { prefer_secondary = false; } @@ -210,7 +229,37 @@ where } emit_repair_debug_summary("attempt_done", &stats, &diagnostics, config, max_flips); - Ok(stats) + Ok(RepairAttemptOutcome { + stats, + last_applied_flip, + touched_cells, + used_full_reseed, + }) +} + +/// 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, +) -> Result<RemovedCellVertexSnapshot, FlipError> +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() } /// Apply a bistellar flip using explicit k and vertex/cell slices. @@ -226,7 +275,7 @@ fn apply_bistellar_flip_with_k<T, U, V, const D: usize>( removed_cells: &CellKeyBuffer, direction: FlipDirection, orientation_policy: ReplacementOrientationPolicy, -) -> Result<FlipInfo<D>, FlipError> +) -> Result<AppliedFlip<D>, FlipError> where T: CoordinateScalar, U: DataType, @@ -288,7 +337,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:?})" ); @@ -357,7 +406,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, @@ -385,6 +434,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 @@ -412,13 +468,16 @@ 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, }) } @@ -800,22 +859,27 @@ 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<T, U, V, const D: usize>( tds: &Tds<T, U, V, D>, ridge: RidgeHandle, - neighbor_walk_count: Option<usize>, + reported_multiplicity: Option<usize>, + diagnostics: &mut RepairDiagnostics, + last_applied_flip: Option<&LastAppliedFlip>, ) where T: CoordinateScalar, U: DataType, V: DataType, { - if !should_emit_ridge_debug() { + if !should_emit_ridge_debug(diagnostics, 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 +895,275 @@ fn debug_ridge_context<T, U, V, const D: usize>( 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::<Vec<_>>()); let global_cells = cells_containing_vertices(tds, &ridge_vertices); let neighbor_snapshot: Option<SmallBuffer<Option<CellKey>, MAX_PRACTICAL_DIMENSION_SIZE>> = cell.neighbors().map(|ns| ns.iter().copied().collect()); + let global_cell_details: Vec<String> = 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<T, U, V, const D: usize>( + tds: &Tds<T, U, V, D>, + cell_key: CellKey, + ridge_vertices: &SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>, +) -> 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<T, U, V, const D: usize>( + cell: &Cell<T, U, V, D>, + ridge_vertices: &SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>, +) -> SmallBuffer<CellKey, 2> +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let mut ridge_neighbors: SmallBuffer<CellKey, 2> = 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<T, U, V, const D: usize>( + tds: &Tds<T, U, V, D>, + ridge: RidgeHandle, + global_cells: &[CellKey], + last_applied_flip: &LastAppliedFlip, +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let global_cells_in_new: Vec<CellKey> = 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<String> = 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<T, U, V, const D: usize>(tds: &Tds<T, U, V, D>, 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<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(diagnostics) { + 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<String> = 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<T, U, V, const D: usize>( + tds: &Tds<T, U, V, D>, + 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<VertexKey> = cell + .vertices() + .iter() + .copied() + .filter(|vkey| !facet_vertices.contains(vkey)) + .collect(); + let neighbor_snapshot: Option<SmallBuffer<Option<CellKey>, 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<T, U, V, const D: usize>( + tds: &Tds<T, U, V, D>, + incident_cells: &[CellKey], + last_applied_flip: &LastAppliedFlip, +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let incident_cells_in_new: Vec<CellKey> = incident_cells + .iter() + .copied() + .filter(|cell_key| last_applied_flip.new_cells.contains(cell_key)) + .collect(); + let incident_cells_in_removed: Vec<CellKey> = incident_cells + .iter() + .copied() + .filter(|cell_key| last_applied_flip.removed_cells.contains(cell_key)) + .collect(); + let predecessor_new_cell_vertices: Vec<String> = last_applied_flip + .new_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<String> = + 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:?}", + 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 @@ -901,7 +1212,7 @@ where U: DataType, V: DataType, { - apply_bistellar_flip_with_k( + Ok(apply_bistellar_flip_with_k( tds, K_MOVE, &context.removed_face_vertices, @@ -909,7 +1220,8 @@ where &context.removed_cells, context.direction, ReplacementOrientationPolicy::AllowSigned, - ) + )? + .info) } /// Apply a generic k-move with runtime k (no Delaunay check). @@ -929,7 +1241,7 @@ where U: DataType, V: DataType, { - apply_bistellar_flip_with_k( + Ok(apply_bistellar_flip_with_k( tds, k_move, &context.removed_face_vertices, @@ -937,14 +1249,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<T, U, V, const D: usize>( tds: &mut Tds<T, U, V, D>, context: &FlipContext<D, 2>, -) -> Result<FlipInfo<D>, FlipError> +) -> Result<AppliedFlip<D>, FlipError> where T: CoordinateScalar, U: DataType, @@ -965,7 +1278,7 @@ where fn apply_delaunay_flip_k3<T, U, V, const D: usize>( tds: &mut Tds<T, U, V, D>, context: &FlipContext<D, 3>, -) -> Result<FlipInfo<D>, FlipError> +) -> Result<AppliedFlip<D>, FlipError> where T: CoordinateScalar, U: DataType, @@ -987,7 +1300,7 @@ fn apply_delaunay_flip_dynamic<T, U, V, const D: usize>( tds: &mut Tds<T, U, V, D>, k_move: usize, context: &FlipContextDyn<D>, -) -> Result<FlipInfo<D>, FlipError> +) -> Result<AppliedFlip<D>, FlipError> where T: CoordinateScalar, U: DataType, @@ -1513,6 +1826,12 @@ pub struct FlipInfo<const D: usize> { pub inserted_face_vertices: SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>, } +#[derive(Debug, Clone)] +struct AppliedFlip<const D: usize> { + info: FlipInfo<D>, + removed_cell_vertices: RemovedCellVertexSnapshot, +} + /// Const-generic flip context for a k-move (forward or inverse). #[derive(Debug, Clone)] pub(crate) struct FlipContext<const D: usize, const K: usize> { @@ -1661,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); @@ -1675,12 +1994,77 @@ pub struct DelaunayRepairStats { /// Maximum queue length observed. 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 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, +} + +/// 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<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, + touched_cell_set: &mut FastHashSet<CellKey>, + new_cells: &[CellKey], +) { + for &cell_key in new_cells { + if touched_cell_set.insert(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, + 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, + touched_cells, + used_full_reseed, + } +} + /// Queue ordering policy for flip repair attempts. /// /// # Examples /// /// ```rust -/// use delaunay::core::algorithms::flips::RepairQueueOrder; +/// use delaunay::prelude::triangulation::repair::RepairQueueOrder; /// /// let order = RepairQueueOrder::Fifo; /// assert_eq!(order, RepairQueueOrder::Fifo); @@ -1698,7 +2082,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, @@ -1762,8 +2148,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, @@ -1784,12 +2169,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 { @@ -2232,7 +2632,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!( @@ -2768,7 +3168,7 @@ fn repair_delaunay_with_flips_k2_attempt<K, U, V, const D: usize>( kernel: &K, seed_cells: Option<&[CellKey]>, config: &RepairAttemptConfig, -) -> Result<DelaunayRepairStats, DelaunayRepairError> +) -> Result<RepairAttemptOutcome, DelaunayRepairError> where K: Kernel<D>, U: DataType, @@ -2787,6 +3187,10 @@ where let mut queue: VecDeque<(FacetHandle, u64)> = VecDeque::new(); 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 mut touched_cell_set = FastHashSet::<CellKey>::default(); + let used_full_reseed = seed_cells.is_none(); let topology_model = GlobalTopology::DEFAULT.model(); if let Some(seeds) = seed_cells { @@ -2898,8 +3302,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 { .. } @@ -2908,7 +3312,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}" ); @@ -2928,6 +3332,9 @@ where }; stats.flips_performed += 1; diagnostics.record_flip_signature(signature); + last_applied_flip = Some(LastAppliedFlip::from_applied_flip(&applied)); + let info = applied.info; + record_touched_cells(&mut touched_cells, &mut touched_cell_set, &info.new_cells); for &cell_key in &info.new_cells { enqueue_cell_facets( @@ -2954,7 +3361,12 @@ where } emit_repair_debug_summary("attempt_done", &stats, &diagnostics, config, max_flips); - Ok(stats) + Ok(RepairAttemptOutcome { + stats, + last_applied_flip, + touched_cells, + used_full_reseed, + }) } /// Repair Delaunay violations using k=2 queues, k=3 queues in 3D, @@ -2971,6 +3383,67 @@ 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) +} + +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, tds.cell_keys())), + Err(err) => { + *tds = snapshot; + Err(err) + } + }, + Err(err) => { + *tds = snapshot; + Err(err) + } + } +} + +/// 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, @@ -3015,9 +3488,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(repair_run_from_attempt(outcome, tds.cell_keys())); } if repair_trace_enabled() { tracing::debug!( @@ -3026,16 +3506,7 @@ where } // Postcondition verification failed: rerun with LIFO + full reseed. - *tds = tds_snapshot; - let retry_seed_cells = None; - let stats2 = 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) + run_full_reseed_retry(tds, kernel, &attempt2, tds_snapshot) } Err(DelaunayRepairError::NonConvergent { .. }) => { if repair_trace_enabled() { @@ -3044,28 +3515,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 stats2 = 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) + 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). 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 @@ -3117,9 +3583,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"); @@ -3130,7 +3603,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 { @@ -3139,8 +3615,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. @@ -3176,13 +3657,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![ @@ -3220,14 +3702,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]), @@ -3276,6 +3758,7 @@ where None, global_topology, PostconditionMode::Strict, + None, ) } @@ -3285,6 +3768,7 @@ fn verify_repair_postcondition<K, U, V, const D: usize>( tds: &Tds<K::Scalar, U, V, D>, kernel: &K, seed_cells: Option<&[CellKey]>, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel<D>, @@ -3297,6 +3781,7 @@ where seed_cells, GlobalTopology::DEFAULT, PostconditionMode::Repair, + last_applied_flip, ) } @@ -3306,6 +3791,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>( @@ -3314,6 +3804,7 @@ fn verify_repair_postcondition_with_topology<K, U, V, const D: usize>( seed_cells: Option<&[CellKey]>, global_topology: GlobalTopology<D>, mode: PostconditionMode, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel<D>, @@ -3321,7 +3812,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 +3830,7 @@ fn verify_repair_postcondition_locally<K, U, V, const D: usize>( seed_cells: Option<&[CellKey]>, topology_model: &GlobalTopologyModelAdapter<D>, mode: PostconditionMode, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel<D>, @@ -3347,7 +3846,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!( @@ -3371,6 +3870,7 @@ where &config, &mut diagnostics, mode, + last_applied_flip, )?; verify_postcondition_k3_ridges( tds, @@ -3380,6 +3880,7 @@ where &config, &mut diagnostics, mode, + last_applied_flip, )?; verify_postcondition_inverse_k2_edges( tds, @@ -3422,19 +3923,21 @@ 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())), } } /// 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<K, U, V, const D: usize>( tds: &Tds<K::Scalar, U, V, D>, kernel: &K, @@ -3443,6 +3946,7 @@ fn verify_postcondition_k2_facets<K, U, V, const D: usize>( config: &RepairAttemptConfig, diagnostics: &mut RepairDiagnostics, mode: PostconditionMode, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel<D>, @@ -3477,9 +3981,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)); } }; @@ -3496,9 +3998,16 @@ where "[repair] postcondition k=2 violation remains (facet={facet:?})" ); } + 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() { + if env::var_os("DELAUNAY_REPAIR_DEBUG_FACETS").is_some() { let removed_details: Vec<_> = context .removed_face_vertices .iter() @@ -3532,9 +4041,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, + )); } } } @@ -3544,6 +4054,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<K, U, V, const D: usize>( tds: &Tds<K::Scalar, U, V, D>, kernel: &K, @@ -3552,6 +4066,7 @@ fn verify_postcondition_k3_ridges<K, U, V, const D: usize>( config: &RepairAttemptConfig, diagnostics: &mut RepairDiagnostics, mode: PostconditionMode, + last_applied_flip: Option<&LastAppliedFlip>, ) -> Result<(), DelaunayRepairError> where K: Kernel<D>, @@ -3589,9 +4104,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)); } }; @@ -3608,6 +4121,11 @@ where "[repair] postcondition k=3 violation remains (ridge={ridge:?})" ); } + // 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:?})"), }); @@ -3623,9 +4141,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, + )); } } } @@ -3692,9 +4211,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, + )); } }; @@ -3775,9 +4295,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, + )); } }; @@ -3830,6 +4351,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 { @@ -3961,11 +4484,11 @@ 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; } - tracing::trace!( + tracing::debug!( label = %label, attempt = config.attempt, order = ?config.queue_order, @@ -4047,11 +4570,18 @@ struct LastAppliedFlip { k_move: usize, removed_face_vertices: SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>, inserted_face_vertices: SmallBuffer<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>, + 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 only appear in placeholder instances built via `Self::new`. + removed_cell_vertices: RemovedCellVertexSnapshot, } 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<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE> = removed.iter().copied().collect(); @@ -4065,8 +4595,46 @@ impl LastAppliedFlip { k_move, removed_face_vertices, inserted_face_vertices, + removed_cells: CellKeyBuffer::new(), + new_cells: CellKeyBuffer::new(), + removed_cell_vertices: SmallBuffer::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_applied_flip<const D: usize>(applied: &AppliedFlip<D>) -> Self { + let info = &applied.info; + 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.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` only for placeholder rows created by `Self::new`. + fn removed_cell_vertex_lines(&self) -> Vec<String> { + 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 @@ -4094,36 +4662,60 @@ fn would_immediately_reverse_last_flip<const D: usize>( /// 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; -static RIDGE_DEBUG_EMITTED: AtomicUsize = AtomicUsize::new(0); +const RIDGE_DEBUG_MIN_MULTIPLICITY_DEFAULT: usize = 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::<usize>().ok()) .unwrap_or(RIDGE_DEBUG_LIMIT_DEFAULT) } -/// Applies the ridge debug limit atomically so concurrent tests still share a -/// bounded diagnostic budget. -fn should_emit_ridge_debug() -> bool { +/// 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::<usize>().ok()) + .unwrap_or(RIDGE_DEBUG_MIN_MULTIPLICITY_DEFAULT) +} + +/// 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 + // 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; } - 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" @@ -4132,6 +4724,26 @@ 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 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; + } + 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 /// predictably instead of running unbounded. fn default_max_flips<const D: usize>(cell_count: usize) -> usize { @@ -4227,7 +4839,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, @@ -4297,6 +4909,7 @@ where ); } seed_repair_queues(tds, None, queues, stats)?; + return Ok(true); } } else { for facet in AllFacetsIter::new(tds) { @@ -4335,8 +4948,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 @@ -4407,6 +5021,8 @@ 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, + touched_cell_set: &mut FastHashSet<CellKey>, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -4436,12 +5052,21 @@ 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), + diagnostics, + last_applied_flip.as_ref(), + ); } } FlipError::InvalidRidgeAdjacency { .. } if repair_ridge_debug_enabled() => { - debug_ridge_context(tds, ridge, None); + debug_ridge_context(tds, ridge, None, diagnostics, last_applied_flip.as_ref()); } FlipError::MissingCell { cell_key } => { diagnostics.record_missing_cell_skip(|| { @@ -4525,8 +5150,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!( @@ -4549,6 +5174,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={:?}", @@ -4562,11 +5189,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, - )); + record_touched_cells(touched_cells, touched_cell_set, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -4592,6 +5215,8 @@ 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, + touched_cell_set: &mut FastHashSet<CellKey>, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -4715,8 +5340,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!( @@ -4739,6 +5364,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={:?}", @@ -4752,11 +5379,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, - )); + record_touched_cells(touched_cells, touched_cell_set, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -4782,6 +5405,8 @@ 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, + touched_cell_set: &mut FastHashSet<CellKey>, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -4897,8 +5522,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!( @@ -4921,6 +5546,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={:?}", @@ -4934,11 +5561,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, - )); + record_touched_cells(touched_cells, touched_cell_set, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -4964,6 +5587,8 @@ 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, + touched_cell_set: &mut FastHashSet<CellKey>, ) -> Result<bool, DelaunayRepairError> where K: Kernel<D>, @@ -5063,7 +5688,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, @@ -5083,8 +5708,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!( @@ -5107,6 +5732,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={:?}", @@ -5120,11 +5747,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, - )); + record_touched_cells(touched_cells, touched_cell_set, &info.new_cells); enqueue_new_cells_for_repair(tds, &info.new_cells, queues, stats)?; @@ -5808,7 +6431,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(); @@ -5820,7 +6443,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:?}" ); @@ -6149,16 +6772,21 @@ 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; use crate::vertex; use approx::assert_relative_eq; use rand::{RngExt, SeedableRng, rngs::StdRng}; - use std::sync::atomic::{AtomicUsize, Ordering}; + use slotmap::KeyData; + 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")); @@ -6223,6 +6851,392 @@ mod tests { } } + 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 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_ $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 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")] + ); + } + } + }; + } + + gen_removed_cell_snapshot_tests!(2); + gen_removed_cell_snapshot_tests!(3); + 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, @@ -8514,8 +9528,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(), }; @@ -8528,6 +9540,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, @@ -8542,6 +9581,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 { @@ -9122,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(); 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..ec086f6d 100644 --- a/src/core/algorithms/locate.rs +++ b/src/core/algorithms/locate.rs @@ -28,7 +28,11 @@ 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::fmt; 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 +43,12 @@ struct ConflictDebugConfig { #[cfg(debug_assertions)] fn conflict_debug_config() -> &'static ConflictDebugConfig { - static CONFIG: std::sync::OnceLock<ConflictDebugConfig> = std::sync::OnceLock::new(); + static CONFIG: OnceLock<ConflictDebugConfig> = 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::<usize>().ok()) .filter(|value| *value > 0) @@ -52,6 +56,14 @@ fn conflict_debug_config() -> &'static ConflictDebugConfig { }) } +static RIDGE_FAN_DUMP_ENABLED: OnceLock<bool> = 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 @@ -125,6 +137,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:?}")] @@ -142,6 +155,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 +169,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 +207,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<CellKey>, }, @@ -216,10 +280,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 fmt::Display for InternalInconsistencySite { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 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<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>, facet_count: usize, first_facet: usize, second_facet: Option<usize>, @@ -228,6 +401,185 @@ struct RidgeInfo { extra_facets: Vec<usize>, } +fn format_vertex_refs<T, U, V, const D: usize>( + tds: &Tds<T, U, V, D>, + 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::<Vec<_>>() + .join(", ") +} + +fn format_facet_vertices<T, U, V, const D: usize>( + tds: &Tds<T, U, V, D>, + handle: FacetHandle, +) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let Some(cell) = tds.get_cell(handle.cell_key()) else { + return String::from("<missing-cell>"); + }; + + let facet_index = usize::from(handle.facet_index()); + let vertex_keys: Vec<VertexKey> = 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<T, U, V, const D: usize>(tds: &Tds<T, U, V, D>, cell_key: CellKey) -> String +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let Some(cell) = tds.get_cell(cell_key) else { + return String::from("<missing-cell>"); + }; + format_vertex_refs(tds, cell.vertices()) +} + +/// 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; +/// 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<T, U, V, const D: usize>( + tds: &Tds<T, U, V, D>, + 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<CellKey> = conflict_cells.iter().copied().take(16).collect(); + let ridge_vertices = format_vertex_refs(tds, info.ridge_vertices.as_slice()); + + let participating_facets: Vec<String> = participating_indices + .iter() + .copied() + .map(|boundary_index| { + boundary_facets.get(boundary_index).copied().map_or_else( + || format!("boundary_idx={boundary_index} <missing-boundary-facet>"), + |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<String> = 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<Vec<CellKey>, 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::<CellKey>::default(); + let mut extra_cells: Vec<CellKey> = 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 +1460,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 +1577,12 @@ where let ridge_vertex_count = facet_vkeys.len() - 1; for ridge_idx in 0..facet_vkeys.len() { + let mut ridge_vertices = + SmallBuffer::<VertexKey, MAX_PRACTICAL_DIMENSION_SIZE>::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 +1601,7 @@ where }) .or_insert(RidgeInfo { ridge_vertex_count, + ridge_vertices, facet_count: 1, first_facet: boundary_facet_idx, second_facet: None, @@ -1334,6 +1690,9 @@ where if !boundary_facets.is_empty() { let boundary_len = boundary_facets.len(); let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); boundary_len]; + let mut first_ridge_fan: Option<(usize, usize)> = None; + let mut ridge_fan_extra_cells: Vec<CellKey> = Vec::new(); + let mut ridge_fan_seen_cells = FastHashSet::<CellKey>::default(); for info in ridge_map.values() { // Closed manifold boundary requires exactly 2 incident facets per ridge. @@ -1350,19 +1709,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 { @@ -1371,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 { @@ -1385,72 +1761,43 @@ 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::<CellKey>::default(); - let mut extra_cells: Vec<CellKey> = 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() + 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. @@ -1523,6 +1870,188 @@ 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)), + "<missing-cell>" + ); + assert_eq!(format_cell_vertices(tds, missing_cell), "<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_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/core/triangulation.rs b/src/core/triangulation.rs index e04dac60..7369a8ac 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,21 +190,248 @@ 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<bool> = OnceLock::new(); +static RETRYABLE_SKIP_TRACE_ENABLED: OnceLock<bool> = OnceLock::new(); +static CAVITY_REDUCTION_TRACE_ENABLED: OnceLock<bool> = 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); +#[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) { 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<String> { + 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<F>( + enabled: bool, + iteration: usize, + conflict_cells: &CellKeyBuffer, + event: F, +) where + F: FnOnce() -> String, +{ + if !enabled { + return; + } + + let conflict_preview: Vec<CellKey> = conflict_cells.iter().copied().take(12).collect(); + let event = event(); + tracing::debug!( + target: "delaunay::cavity_reduction", + iteration, + conflict_cells = conflict_cells.len(), + event, + conflict_preview = ?conflict_preview, + "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<CellKey> = 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" +)] +/// 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<usize>, + 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 +756,30 @@ impl From<ManifoldError> for InvariantError { } } -type TryInsertImplOk = ((VertexKey, Option<CellKey>), usize, SuspicionFlags); +struct TryInsertImplOk { + /// Inserted vertex key plus an optional locate hint for the caller. + inserted: (VertexKey, Option<CellKey>), + /// 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 +3376,7 @@ where DEFAULT_PERTURBATION_RETRIES, 0, None, + None, )?; match outcome { InsertionOutcome::Inserted { vertex_key, hint } => Ok((vertex_key, hint)), @@ -3161,29 +3413,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<K::Scalar, U, D>, conflict_cells: Option<&CellKeyBuffer>, hint: Option<CellKey>, perturbation_seed: u64, index: Option<&mut HashGridIndex<K::Scalar, D>>, - ) -> Result<(InsertionOutcome, InsertionStatistics), InsertionError> { - self.insert_transactional( + bulk_index: Option<usize>, + ) -> Result<DetailedInsertionResult, InsertionError> { + self.insert_transactional_detailed( vertex, conflict_cells, hint, DEFAULT_PERTURBATION_RETRIES, perturbation_seed, index, + bulk_index, ) } @@ -3199,11 +3455,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<K::Scalar, U, D>, + conflict_cells: Option<&CellKeyBuffer>, + hint: Option<CellKey>, + max_perturbation_attempts: usize, + perturbation_seed: u64, + index: Option<&mut HashGridIndex<K::Scalar, D>>, + bulk_index: Option<usize>, + ) -> 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<K::Scalar, U, D>, conflict_cells: Option<&CellKeyBuffer>, @@ -3211,13 +3500,19 @@ where max_perturbation_attempts: usize, perturbation_seed: u64, mut index: Option<&mut HashGridIndex<K::Scalar, D>>, - ) -> Result<(InsertionOutcome, InsertionStatistics), InsertionError> { + bulk_index: Option<usize>, + ) -> Result<DetailedInsertionResult, InsertionError> { 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<InsertionError> = 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 +3520,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 +3538,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 +3565,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 +3612,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(); @@ -3321,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, @@ -3330,7 +3657,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 +3672,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 +3696,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 +3752,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 +4051,18 @@ where attempt: usize, tds_snapshot: &Tds<K::Scalar, U, V, D>, ) -> Result<TryInsertImplOk, InsertionError> { - 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 +4073,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 +4100,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 +4123,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 +4410,7 @@ where mut conflict_cells: CellKeyBuffer, fallback_cell: Option<CellKey>, suspicion: &mut SuspicionFlags, - ) -> Result<(Option<CellKey>, usize), InsertionError> { + ) -> Result<(Option<CellKey>, usize, CellKeyBuffer), InsertionError> { #[cfg(not(debug_assertions))] let _ = point; @@ -4057,6 +4425,11 @@ where conflict_cells.push(start_cell); } + // 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. // // Iteratively resolve cavity-boundary errors rather than immediately falling back to a @@ -4083,9 +4456,39 @@ where { const MAX_CAVITY_ITERATIONS: usize = 32; let mut iterations: usize = 0; + let trace_enabled = cavity_reduction_trace_enabled(); + let mut trace_cavity_reduction = false; + 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) => { + trace_cavity_reduction = trace_enabled + && !CAVITY_REDUCTION_TRACE_EMITTED.swap(true, Ordering::Relaxed); + 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".to_string(), + ); break; } iterations += 1; @@ -4101,9 +4504,20 @@ 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<CellKey> = 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 @@ -4117,15 +4531,17 @@ where let conflict_set: FastHashSet<CellKey> = conflict_cells.iter().copied().collect(); let mut cells_to_add: FastHashSet<CellKey> = 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,6 +4555,16 @@ where conflict_cells_before = conflict_cells.len(), "D={D}: cavity expansion (DisconnectedBoundary hole-fill)" ); + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + || { + 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); } @@ -4150,10 +4576,30 @@ 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<CellKey> = 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".to_string(), + ); break; } } @@ -4168,14 +4614,50 @@ 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); + retain_conflict_cells_and_record_removed( + &mut conflict_cells, + &mut repair_seed_cells, + |cell_key| cell_key != open, + ); } - _ => break, + _ => { + log_cavity_reduction_event( + trace_cavity_reduction, + iterations, + &conflict_cells, + || "no_reduction_rule_matched".to_string(), + ); + 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)), + ); + } + } } } @@ -4218,11 +4700,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 - }; + 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 { @@ -4252,11 +4736,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 - }; + 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); } @@ -4323,6 +4809,14 @@ where Some(&conflict_cells), )?; + // 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<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); @@ -4474,7 +4968,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 +5061,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 +5089,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 +5285,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 +5316,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 +5396,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 +5616,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, @@ -5584,12 +6109,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; @@ -5636,6 +6164,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 { @@ -5723,6 +6268,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<_>>(), + vec![a, c] + ); + assert_eq!( + repair_seed_cells.iter().copied().collect::<Vec<_>>(), + 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<_>>(), + vec![c, d] + ); + assert_eq!( + repair_seed_cells.iter().copied().collect::<Vec<_>>(), + 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<FastKernel<f64>, (), (), 2> = @@ -10037,41 +10767,196 @@ mod tests { // PROGRESSIVE PERTURBATION: RETRY PATH COVERAGE // ========================================================================= - /// Exercise the perturbation retry loop (`attempt > 0`) and exhaustion - /// path (`SkippedDegeneracy`) using 4D random points where orientation - /// degeneracies are common. + #[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 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 points = - crate::geometry::util::generate_random_points_seeded::<f64, 4>(20, (-10.0, 10.0), 123) - .unwrap(); + 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 mut tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = - Triangulation::new_empty(AdaptiveKernel::new()); + 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 any_retried = false; - let mut any_exhausted = false; + let mut exhaustion_tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = + Triangulation::new_empty(AdaptiveKernel::new()); + let mut saw_exhausted_skip = false; - for point in points { + 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() { - any_retried = true; - } - if stats.skipped() && stats.attempts > 1 { - any_exhausted = true; - } + 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()); } - // 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" + 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" ); } @@ -10080,18 +10965,15 @@ mod tests { /// /// Covers: the `mix` computation and sign selection in the seeded path /// (lines using `perturbation_seed ^ ...`). + /// + /// Uses the same deterministic 4D repro as + /// [`test_perturbation_retry_and_exhaustion_4d`]. #[test] fn test_perturbation_retry_seeded_branch_4d() { - let points = - crate::geometry::util::generate_random_points_seeded::<f64, 4>(20, (-10.0, 10.0), 123) - .unwrap(); - let mut tri: Triangulation<AdaptiveKernel<f64>, (), (), 4> = Triangulation::new_empty(AdaptiveKernel::new()); - let mut any_retried = false; - - for point in points { + for point in perturbation_retry_repro_points_4d() { let v = VertexBuilder::default().point(point).build().unwrap(); let (_outcome, stats) = tri .insert_transactional( @@ -10101,19 +10983,15 @@ mod tests { DEFAULT_PERTURBATION_RETRIES, 0xDEAD_BEEF, None, + None, ) .unwrap(); - if stats.used_perturbation() { - any_retried = true; + if stats.used_perturbation() && (stats.success() || stats.skipped()) { + 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!("deterministic 4D adversarial repro did not trigger the seeded perturbation branch"); } } diff --git a/src/core/util/deduplication.rs b/src/core/util/deduplication.rs index 44f46a6d..001f3e25 100644 --- a/src/core/util/deduplication.rs +++ b/src/core/util/deduplication.rs @@ -5,6 +5,20 @@ 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, + + /// Epsilon must be finite for distance-based deduplication. + #[error("epsilon must be finite")] + NonFiniteEpsilon, +} /// Filters vertices to remove exact coordinate duplicates. /// @@ -92,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. /// -/// # Panics -/// -/// Panics if `epsilon` is negative. +/// 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 /// @@ -123,14 +137,54 @@ where T: CoordinateScalar, U: DataType, { + if !epsilon.is_finite_generic() || epsilon < T::zero() { + tracing::warn!( + epsilon = ?epsilon, + "dedup_vertices_epsilon received non-finite or 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, 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, +) -> Result<Vec<Vertex<T, U, D>>, DeduplicationError> +where + T: CoordinateScalar, + U: DataType, +{ + if !epsilon.is_finite_generic() { + return Err(DeduplicationError::NonFiniteEpsilon); + } + if epsilon < T::zero() { - eprintln!("dedup_vertices_epsilon received negative epsilon; enforcing contract"); + 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<T, U, const D: usize>( + vertices: &[Vertex<T, U, D>], + epsilon: T, +) -> Vec<Vertex<T, U, D>> +where + T: CoordinateScalar, + U: DataType, +{ let mut unique: Vec<Vertex<T, U, D>> = Vec::with_capacity(vertices.len()); 'outer: for &v in vertices { @@ -240,8 +294,9 @@ pub(crate) fn coords_within_epsilon<T: CoordinateScalar, const D: usize>( .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)" ); } @@ -373,6 +428,82 @@ mod tests { ); } + #[test] + fn test_coords_within_epsilon_exact_boundary_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<f64, (), 2>> = 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::<Vec<_>>(), + vertices + .iter() + .map(<&Vertex<_, _, _> as Into<[f64; 2]>>::into) + .collect::<Vec<_>>() + ); + } + + #[test] + fn test_try_dedup_vertices_epsilon_negative_epsilon_returns_error() { + let vertices: Vec<Vertex<f64, (), 2>> = 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_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/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<T: CoordinateScalar + SampleUniform, const D: usiz ) -> Result<Vec<Point<T, D>>, 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<T: CoordinateScalar + SampleUniform, const #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_UNUSED_IMPORTS").is_some() { - eprintln!( - "point_generation::generate_random_points_seeded called (n_points={n_points}, D={D}, seed={seed})" + tracing::debug!( + n_points, + dimension = D, + seed, + "point_generation::generate_random_points_seeded called" ); } diff --git a/src/geometry/util/triangulation_generation.rs b/src/geometry/util/triangulation_generation.rs index 28c23a8e..cf2c476b 100644 --- a/src/geometry/util/triangulation_generation.rs +++ b/src/geometry/util/triangulation_generation.rs @@ -306,8 +306,11 @@ where { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_UNUSED_IMPORTS").is_some() { - eprintln!( - "triangulation_generation::generate_random_triangulation called (n_points={n_points}, D={D}, seed={seed:?})" + tracing::debug!( + n_points, + dimension = D, + seed = ?seed, + "triangulation_generation::generate_random_triangulation called" ); } generate_random_triangulation_with_topology_guarantee( @@ -413,8 +416,10 @@ where for attempt in 0..RANDOM_TRIANGULATION_MAX_POINTSET_ATTEMPTS { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_RANDOM_POINTSET_RETRIES").is_some() { - eprintln!( - "random_triangulation: pointset attempt {attempt} of {RANDOM_TRIANGULATION_MAX_POINTSET_ATTEMPTS} (0-based)" + tracing::debug!( + attempt, + max_attempts = RANDOM_TRIANGULATION_MAX_POINTSET_ATTEMPTS, + "random_triangulation: pointset attempt" ); } let point_seed = seed.map(|base| { @@ -700,13 +705,13 @@ where // Build triangulation with configured options #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_RANDOM_BUILDER").is_some() { - eprintln!( - "random_triangulation_builder: single call to with_topology_guarantee_and_options with n_points={}, topology_guarantee={:?}, insertion_order={:?}, dedup_policy={:?}, retry_policy={:?}", - self.n_points, - self.topology_guarantee, - self.construction_options.insertion_order(), - self.construction_options.dedup_policy(), - self.construction_options.retry_policy(), + tracing::debug!( + n_points = self.n_points, + topology_guarantee = ?self.topology_guarantee, + insertion_order = ?self.construction_options.insertion_order(), + dedup_policy = ?self.construction_options.dedup_policy(), + retry_policy = ?self.construction_options.retry_policy(), + "random_triangulation_builder: single call to with_topology_guarantee_and_options" ); } let dt = DelaunayTriangulation::with_topology_guarantee_and_options( diff --git a/src/lib.rs b/src/lib.rs index 9bdb055b..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::*` | @@ -860,8 +861,8 @@ pub mod prelude { // Re-export point location algorithms from core::algorithms pub use crate::core::algorithms::locate::{ - LocateError, LocateFallback, LocateFallbackReason, LocateResult, LocateStats, locate, - locate_with_stats, + ConflictError, InternalInconsistencySite, LocateError, LocateFallback, + LocateFallbackReason, LocateResult, LocateStats, locate, locate_with_stats, }; // Re-export incremental insertion types @@ -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::*` @@ -958,8 +976,8 @@ pub mod prelude { /// Focused exports for core algorithms. pub mod algorithms { pub use crate::core::algorithms::locate::{ - LocateError, LocateFallback, LocateFallbackReason, LocateResult, LocateStats, locate, - locate_with_stats, + ConflictError, InternalInconsistencySite, LocateError, LocateFallback, + LocateFallbackReason, LocateResult, LocateStats, locate, locate_with_stats, }; } @@ -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 282a90d3..31867248 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -7,14 +7,15 @@ 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; 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::{ @@ -32,11 +33,12 @@ 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}; 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 +48,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; @@ -59,14 +62,205 @@ const DELAUNAY_SHUFFLE_SEED_SALT: u64 = 0x9E37_79B9_7F4A_7C15; // release-only construction failures (see #306). const HEURISTIC_REBUILD_ATTEMPTS: usize = 6; +// Per-insertion local-repair flip-budget tunables. +// +// Budget formula: `seed_cells.len() * (D + 1) * FACTOR` with a minimum of +// `FLOOR`. Two regimes so that D≥4's higher queue demand does not force a +// global budget increase. +// +// The D≥4 constants are sized from the measured `max_queue` distribution on +// the 500-point 4D seeded repro (seed `0xD225B8A07E274AE6`, ball radius 100) +// captured in `docs/archive/issue_204_investigation.md`: +// +// max_queue samples min=91 p50=207 p90=281 p95=312 p99=409 max=416 +// +// `FACTOR = 12` with `FLOOR = 96` yields a typical 300-flip budget (5-cell seed +// set), covering p50–p90 and brushing p95. The p95–p99 tail is intentionally +// left to the escalation pass (see `LOCAL_REPAIR_ESCALATION_*`) rather than +// paid for on every insertion. +pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4: usize = 12; +pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4: usize = 96; +pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4: usize = 4; +pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4: usize = 16; + +// Escalation tunables for D≥4. When the base local repair hits its budget, +// the soft-fail path reruns the repair once with `BASE_BUDGET * ESCALATION_FACTOR` +// and the full TDS as seed set before giving up. The escalation is rate-limited +// so every insertion does not pay for a near-global flip pass. +pub(crate) const LOCAL_REPAIR_ESCALATION_BUDGET_FACTOR_D_GE_4: usize = 4; +pub(crate) const LOCAL_REPAIR_ESCALATION_MIN_GAP: usize = 8; + +/// Outcome of a per-insertion D≥4 local-repair escalation attempt. +/// +/// Three orthogonal cases so the caller and any telemetry downstream can match +/// on the outcome without string parsing: +/// +/// - [`Skipped`](Self::Skipped) — the escalation did not run. The caller +/// should fall through to the soft-fail path using the original +/// [`DelaunayRepairError`] that triggered escalation. +/// - [`Succeeded`](Self::Succeeded) — the escalation converged. The caller +/// has already canonicalized the triangulation and should continue to the +/// next insertion. +/// - [`FailedAlso`](Self::FailedAlso) — the escalation ran but also hit its +/// budget or postcondition. The typed `DelaunayRepairError` is preserved so +/// downstream diagnostics can correlate it with the original error; the +/// caller should fall through to the soft-fail path. +/// +/// [`DelaunayRepairError`]: crate::core::algorithms::flips::DelaunayRepairError +#[derive(Clone, Debug)] +enum LocalRepairEscalationOutcome { + /// The escalation was not attempted. + Skipped { + /// Why the escalation was skipped. + reason: EscalationSkipReason, + }, + /// The escalation ran and successfully converged. + Succeeded { + /// Repair diagnostics from the successful escalation attempt. + stats: DelaunayRepairStats, + }, + /// The escalation ran but also failed to converge or satisfy its + /// postcondition. + FailedAlso { + /// Typed repair error produced by the escalation attempt. Preserved + /// by value so callers can match on the variant instead of parsing + /// the display form. + escalation_error: DelaunayRepairError, + }, +} + +/// Why a [`LocalRepairEscalationOutcome::Skipped`] escalation attempt did not +/// run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EscalationSkipReason { + /// The previous escalation ran within the `min_gap` insertion window, so + /// this attempt was rate-limited. + RateLimited { + /// Insertion index of the previous escalation. + last_escalation_idx: usize, + /// Configured minimum gap between escalations. + min_gap: usize, + }, + /// The triangulation had no cells to seed repair with. This is an edge + /// case for early insertions where the initial simplex has not been + /// committed; escalation there has nothing to escalate against. + EmptyTds, +} + +/// Returns true when a repair error represents input geometry or predicate +/// instability that shuffled construction may be able to resolve. +const fn is_geometric_repair_error(repair_err: &DelaunayRepairError) -> bool { + 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 +/// dimension-aware constants above. +const fn local_repair_flip_budget<const D: usize>(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<usize> = const { std::cell::Cell::new(0) }; } #[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 { @@ -544,7 +738,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. @@ -1162,6 +1364,183 @@ fn hilbert_bits_per_coord<const D: usize>() -> Option<u32> { 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<usize> { + [ + "DELAUNAY_BULK_PROGRESS_EVERY", + "DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY", + ] + .into_iter() + .find_map(|name| { + env::var(name) + .ok() + .and_then(|raw| raw.trim().parse::<usize>().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<BatchProgressState>) { + 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::<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", + 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<u64>, + 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" + ); + } +} + +/// 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(|coord| coord.to_f64().filter(|value| value.is_finite())) + .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); @@ -2220,7 +2599,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 { @@ -2233,6 +2612,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, @@ -2242,16 +2622,27 @@ where grid_cell_size, use_global_repair_fallback, ) { - Ok(candidate) => match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) - { - Ok(()) => return Ok(candidate), + 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); + } 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 } }; @@ -2264,6 +2655,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 +2681,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( <K as Clone>::clone(kernel), @@ -2299,20 +2692,37 @@ where grid_cell_size, use_global_repair_fallback, ) { - Ok(candidate) => { - match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => 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) { + 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; } } @@ -2326,6 +2736,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 +2776,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 { @@ -2373,6 +2791,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), @@ -2383,19 +2802,36 @@ where grid_cell_size, use_global_repair_fallback, ) { - Ok((candidate, stats)) => { - match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => 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; 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, @@ -2415,6 +2851,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 +2884,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( <K as Clone>::clone(kernel), @@ -2450,20 +2895,37 @@ where grid_cell_size, use_global_repair_fallback, ) { - Ok((candidate, stats)) => { - match crate::core::util::is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => 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; 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, @@ -2484,6 +2946,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 @@ -2798,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, @@ -2931,6 +3401,125 @@ where Ok(()) } + /// Attempt one D≥4 local-repair escalation before the soft-fail path + /// continues. + /// + /// Reruns `repair_delaunay_local_single_pass` with + /// `base_budget * LOCAL_REPAIR_ESCALATION_BUDGET_FACTOR_D_GE_4` and the + /// full TDS as seed set. Rate-limited by `LOCAL_REPAIR_ESCALATION_MIN_GAP` + /// so only every Nth insertion pays the (near-global) flip pass cost. + /// + /// Returns a typed [`LocalRepairEscalationOutcome`] so the caller can + /// distinguish `Skipped { reason }` (rate-limited or empty TDS) from + /// `Succeeded { stats }` (caller has already canonicalized and should + /// continue normally) from `FailedAlso { escalation_error }` (the + /// escalation ran but also hit its budget; the caller should fall through + /// to the soft-fail path, and the typed `DelaunayRepairError` is + /// preserved for downstream diagnostics). `Err(...)` is reserved for + /// hard errors the bulk loop must propagate. + fn try_local_repair_escalation_d_ge_4( + &mut self, + index: usize, + base_budget: usize, + last_escalation_idx: &mut Option<usize>, + original_err: &DelaunayRepairError, + ) -> Result<LocalRepairEscalationOutcome, DelaunayTriangulationConstructionError> { + // 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<CellKey> = 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" + ); + self.canonicalize_after_bulk_repair()?; + Ok(LocalRepairEscalationOutcome::Succeeded { stats }) + } + Err(escalation_err) => { + if !Self::can_soft_fail(&escalation_err) { + return Err(Self::map_hard_repair_error(index, &escalation_err)); + } + tracing::debug!( + idx = index, + error = %escalation_err, + "bulk D≥4: escalation also non-convergent; falling through to soft-fail" + ); + Ok(LocalRepairEscalationOutcome::FailedAlso { + escalation_error: escalation_err, + }) + } + } + } + + /// Identifies D≥4 local-repair failures that can safely try escalation and + /// then enter the bounded soft-fail path. + const fn can_soft_fail(repair_err: &DelaunayRepairError) -> bool { + 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 { + 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() + } + } + /// Inserts the non-simplex vertices under a fixed perturbation seed so bulk /// construction retries are reproducible. #[allow(clippy::too_many_lines)] @@ -2956,7 +3545,29 @@ 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 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: 0, + } + }); + // 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<usize> = None; match construction_stats { None => { @@ -2967,27 +3578,24 @@ 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() { - 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); + let started = trace_insertion.then(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 +3610,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,11 +3621,11 @@ 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:?}" - ); + tracing::debug!(index, %uuid, elapsed = ?elapsed, "[bulk] inserted"); } // Cache hint for faster subsequent insertions. self.insertion_state.last_inserted_cell = hint; @@ -3041,14 +3653,10 @@ where && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - let seed_cells: Vec<CellKey> = - 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) - } else { - (seed_cells.len() * (D + 1) * 4).max(16) - }; + let max_flips = local_repair_flip_budget::<D>(seed_cells.len()); let repair_result = { let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); repair_delaunay_local_single_pass( @@ -3060,8 +3668,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 }; @@ -3082,26 +3690,118 @@ 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; } - 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. + if !Self::can_soft_fail(&repair_err) { + return Err(Self::map_hard_repair_error( + index, + &repair_err, + )); + } + let outcome = self.try_local_repair_escalation_d_ge_4( + index, + max_flips, + &mut last_escalation_idx, + &repair_err, + )?; + match outcome { + LocalRepairEscalationOutcome::Succeeded { + stats, + } => { + tracing::debug!( + idx = index, + flips = stats.flips_performed, + max_queue = stats.max_queue_len, + "bulk D≥4: escalation closed the \ + non-convergence; continuing" + ); + log_bulk_progress_if_due( + BatchProgressSample { + processed: offset + 1, + inserted: inserted_vertices, + skipped: skipped_vertices, + cell_count: self + .tri + .tds + .number_of_cells(), + perturbation_seed, + }, + &mut batch_progress, + ); + continue; + } + LocalRepairEscalationOutcome::Skipped { + reason, + } => { + tracing::debug!( + idx = index, + error = %repair_err, + escalation_outcome = "skipped", + skip_reason = ?reason, + "bulk D≥4: per-insertion repair \ + non-convergent; continuing \ + (both_positive_artifact handled)" + ); + self.canonicalize_after_bulk_repair()?; + soft_fail_seeds + .extend(seed_cells.iter().copied()); + } + LocalRepairEscalationOutcome::FailedAlso { + escalation_error, + } => { + tracing::debug!( + idx = index, + error = %repair_err, + escalation_outcome = "failed_also", + escalation_error = %escalation_error, + "bulk D≥4: per-insertion repair \ + non-convergent; continuing \ + (both_positive_artifact handled)" + ); + self.canonicalize_after_bulk_repair()?; + soft_fail_seeds + .extend(seed_cells.iter().copied()); + } + } } } } } + 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, + ); } - 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}", - 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 @@ -3116,11 +3816,25 @@ where { let _ = (error, stats); } + 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, + ); } 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. @@ -3135,27 +3849,24 @@ 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() { - 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); + let started = trace_insertion.then(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 +3881,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,11 +3892,16 @@ 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:?}", - stats.attempts + tracing::debug!( + index, + %uuid, + attempts = stats.attempts, + elapsed = ?elapsed, + "[bulk] inserted" ); } construction_stats.record_insertion(&stats); @@ -3199,14 +3919,10 @@ where && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - let seed_cells: Vec<CellKey> = - 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) - } else { - (seed_cells.len() * (D + 1) * 4).max(16) - }; + let max_flips = local_repair_flip_budget::<D>(seed_cells.len()); let repair_result = { let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); repair_delaunay_local_single_pass( @@ -3218,8 +3934,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 }; @@ -3240,41 +3956,130 @@ 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; } - 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. + if !Self::can_soft_fail(&repair_err) { + return Err(Self::map_hard_repair_error( + index, + &repair_err, + )); + } + let outcome = self.try_local_repair_escalation_d_ge_4( + index, + max_flips, + &mut last_escalation_idx, + &repair_err, + )?; + match outcome { + LocalRepairEscalationOutcome::Succeeded { + stats, + } => { + tracing::debug!( + idx = index, + flips = stats.flips_performed, + max_queue = stats.max_queue_len, + "bulk D≥4: escalation closed the \ + non-convergence; continuing" + ); + log_bulk_progress_if_due( + BatchProgressSample { + processed: offset + 1, + inserted: inserted_vertices, + skipped: skipped_vertices, + cell_count: self + .tri + .tds + .number_of_cells(), + perturbation_seed, + }, + &mut batch_progress, + ); + continue; + } + LocalRepairEscalationOutcome::Skipped { + reason, + } => { + tracing::debug!( + idx = index, + error = %repair_err, + escalation_outcome = "skipped", + skip_reason = ?reason, + "bulk D≥4: per-insertion repair \ + non-convergent; continuing \ + (both_positive_artifact handled)" + ); + self.canonicalize_after_bulk_repair()?; + soft_fail_seeds + .extend(seed_cells.iter().copied()); + } + LocalRepairEscalationOutcome::FailedAlso { + escalation_error, + } => { + tracing::debug!( + idx = index, + error = %repair_err, + escalation_outcome = "failed_also", + escalation_error = %escalation_error, + "bulk D≥4: per-insertion repair \ + non-convergent; continuing \ + (both_positive_artifact handled)" + ); + self.canonicalize_after_bulk_repair()?; + soft_fail_seeds + .extend(seed_cells.iter().copied()); + } + } } } } } + 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, + ); } - 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}", - stats.attempts + tracing::debug!( + index, + %uuid, + attempts = stats.attempts, + elapsed = ?elapsed, + error = %error, + "[bulk] skipped" ); } 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, 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(), }); @@ -3291,11 +4096,25 @@ where { let _ = (error, stats); } + 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, + ); } 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. @@ -3463,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!( @@ -3506,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). @@ -3524,7 +4361,6 @@ where | InsertionError::NonManifoldTopology { .. } | InsertionError::HullExtension { .. } | InsertionError::DelaunayValidationFailed { .. } - | InsertionError::DelaunayRepairFailed { .. } | InsertionError::TopologyValidationFailed { .. }) => { TriangulationConstructionError::GeometricDegeneracy { message: insertion_error.to_string(), @@ -4050,8 +4886,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(); @@ -4072,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}"), }) } @@ -4089,10 +4925,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 @@ -4125,7 +4971,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))] { @@ -4168,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]), @@ -4329,15 +5175,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 +5192,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 +5206,7 @@ where .maybe_repair_after_insertion_capped( vertex_key, hint, + &repair_seed_cells, max_flips_override, ) .map_err(|e| DelaunayRepairError::HeuristicRebuildFailed { @@ -5056,18 +5905,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 +5928,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 +6004,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 +6054,23 @@ where &mut self, vertex_key: VertexKey, hint: Option<CellKey>, + 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<CellKey>, + extra_seed_cells: &[CellKey], max_flips: Option<usize>, ) -> Result<(), InsertionError> { let topology = self.tri.topology_guarantee(); @@ -5220,10 +6081,12 @@ where return Ok(()); } - let seed_cells: Vec<CellKey> = 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 +6099,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:?})" ); @@ -5253,18 +6116,20 @@ 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_run(tds, kernel, seed_ref, topology, max_flips) }; #[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 }; match repair_result { - Ok(()) => {} + Ok(run) => { + self.validate_ridge_links_after_repair(topology, &run)?; + } Err( e @ (DelaunayRepairError::NonConvergent { .. } | DelaunayRepairError::PostconditionFailed { .. }), @@ -5275,11 +6140,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_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_run)?; } Err(e) => { return Err(InsertionError::DelaunayRepairFailed { @@ -5289,24 +6156,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. - if topology.requires_ridge_links() { - let local_cells: Vec<CellKey> = 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) - { - 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()?; @@ -5316,6 +6165,69 @@ where Ok(()) } + /// Validates PL ridge links after a repair pass that actually performed flips. + /// + /// `repair_delaunay_with_flips_k2_k3_run` reports whether the final attempt + /// used a full-TDS reseed. Full reseeds validate every current cell; local + /// repairs validate only cells created by flips in the final attempt. + fn validate_ridge_links_after_repair( + &self, + topology: TopologyGuarantee, + run: &DelaunayRepairRun, + ) -> Result<(), InsertionError> { + if !topology.requires_ridge_links() || run.stats.flips_performed == 0 { + 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); + } + + 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 + /// 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<CellKey> { + let mut seen: FastHashSet<CellKey> = 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> { @@ -5829,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()); @@ -5870,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); @@ -5933,7 +6845,7 @@ impl DelaunayRepairHeuristicConfig { /// # Examples /// /// ```rust -/// use delaunay::triangulation::delaunay::DelaunayRepairHeuristicSeeds; +/// use delaunay::prelude::triangulation::repair::DelaunayRepairHeuristicSeeds; /// /// let seeds = DelaunayRepairHeuristicSeeds { /// shuffle_seed: 1, @@ -5954,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(), @@ -5992,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()); @@ -6035,38 +6948,24 @@ 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}; + use crate::geometry::point::Point; use crate::geometry::traits::coordinate::Coordinate; use crate::topology::characteristics::euler::TopologyClassification; use crate::topology::traits::topological_space::ToroidalConstructionMode; use crate::triangulation::flips::BistellarFlips; use crate::vertex; use rand::{RngExt, SeedableRng}; + use slotmap::KeyData; + use std::sync::Once; - pub(super) fn force_repair_nonconvergent_enabled() -> bool { - FORCE_REPAIR_NONCONVERGENT.with(std::cell::Cell::get) - } + type TestDelaunay4 = DelaunayTriangulation<AdaptiveKernel<f64>, (), (), 4>; - 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(); + 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")); @@ -6077,24 +6976,221 @@ mod tests { }); } + 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 = 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 { + 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<CellKey> = dt.cells().map(|(cell_key, _)| cell_key).collect(); + + let (vertex_key, adjacent, extra_cell) = dt + .vertices() + .find_map(|(vertex_key, _)| { + let adjacent: Vec<CellKey> = 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)); + } + + #[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, } 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); } } @@ -6104,18 +7200,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); } } @@ -6235,9 +7327,43 @@ 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")); } + + #[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_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(); @@ -6246,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); @@ -6263,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] @@ -6277,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); @@ -6319,6 +7442,7 @@ mod tests { coordinate_base + 0.5, coordinate_base + 1.0, ], + coords_available: true, attempts: index + 1, error: format!("skip sample #{index}"), }); @@ -6353,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); @@ -8368,6 +9488,96 @@ 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 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_hard, + DelaunayTriangulationConstructionError::Triangulation( + TriangulationConstructionError::InternalInconsistency { ref message } + ) if message.contains("per-insertion Delaunay repair failed at index 23") + && message.contains("Bistellar flip not supported for D=1") + ), + "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:?}" + ); + } + // ========================================================================= // Tests for try_d_lt4_global_repair_fallback // ========================================================================= @@ -8718,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] @@ -8862,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] @@ -9152,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, @@ -9162,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/README.md b/tests/README.md index 7effb123..137bec6a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -382,9 +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 --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=<N>` to emit periodic batch-construction +summaries. #### [`conflict_region_verification.rs`](./conflict_region_verification.rs) diff --git a/tests/delaunay_edge_cases.rs b/tests/delaunay_edge_cases.rs index c43ab381..b7b6e90b 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,34 @@ fn init_tracing() { }); } +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)*); + } + }}; +} + +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)*); + } + }}; +} + // ========================================================================= // Regression Tests - Known Failing Configurations // ========================================================================= @@ -163,13 +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) => { - eprintln!( + 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 ); } Err(err) => { - eprintln!("[Issue #120 debug] repair_delaunay_with_flips error: {err}"); + test_debug_warn!("[Issue #120 debug] repair_delaunay_with_flips error: {err}"); } } let mut dt_robust: DelaunayTriangulation<RobustKernel<f64>, (), (), 5> = @@ -180,17 +215,19 @@ fn debug_issue_120_empty_circumsphere_5d() { ); match dt_robust.repair_delaunay_with_flips() { Ok(stats) => { - eprintln!( + 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 ); } Err(err) => { - eprintln!("[Issue #120 debug] robust repair error: {err}"); + test_debug_warn!("[Issue #120 debug] robust repair error: {err}"); } } if let Err(err) = dt_robust.is_valid() { - eprintln!("[Issue #120 debug] robust triangulation still invalid: {err:?}"); + test_debug_warn!("[Issue #120 debug] robust triangulation still invalid: {err:?}"); } let mut rng = rand::rngs::StdRng::seed_from_u64(0x1200_5eed); for attempt in 0..20 { @@ -201,7 +238,7 @@ fn debug_issue_120_empty_circumsphere_5d() { TopologyGuarantee::PLManifold, ) { if dt_alt.is_valid().is_ok() { - eprintln!( + test_debug_info!( "[Issue #120 debug] found valid triangulation after shuffle attempt {}", attempt + 1 ); @@ -209,17 +246,17 @@ fn debug_issue_120_empty_circumsphere_5d() { } } if attempt == 19 { - eprintln!("[Issue #120 debug] no valid triangulation found in 20 shuffles"); + 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:?}:"); + test_debug_info!("[Issue #120 debug] cell {cell_key:?}:"); for &vkey in cell.vertices() { let vertex = dt .tds() .get_vertex_by_key(vkey) .expect("vertex key should exist"); - eprintln!( + test_debug_info!( " vkey={vkey:?}, uuid={}, point={:?}", vertex.uuid(), vertex.point() diff --git a/tests/delaunay_repair_fallback.rs b/tests/delaunay_repair_fallback.rs index f37c0649..7ee341dd 100644 --- a/tests/delaunay_repair_fallback.rs +++ b/tests/delaunay_repair_fallback.rs @@ -6,6 +6,36 @@ 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(not(feature = "test-debug"))] + { + let _ = format_args!($($arg)*); + } + }}; +} + /// Test that construction succeeds even when flip-based repair might struggle. /// /// This test uses a configuration that historically triggered repair challenges, @@ -71,6 +101,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 +138,7 @@ fn incremental_insertion_with_repair_fallback() { } Err(e) => { // Some insertions may be skipped (duplicates, degeneracies), which is fine - eprintln!("Vertex {} skipped: {}", i + 1, e); + test_debug_info!("Vertex {} skipped: {}", i + 1, 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/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 2c439347..fbad3278 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,14 +47,27 @@ //! 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 \ +//! # 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 //! ``` #![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, + generate_random_points_in_ball_seeded, generate_random_points_seeded, safe_usize_to_scalar, }; use delaunay::prelude::triangulation::*; use delaunay::triangulation::delaunay::{ @@ -62,26 +75,46 @@ use delaunay::triangulation::delaunay::{ DelaunayTriangulationConstructionErrorWithStatistics, }; 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<()> { + writeln!( + writer, + "=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ===" + )?; + 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) => { - eprintln!("=== TIMEOUT: wall time exceeded {max_secs} seconds — aborting ==="); - std::process::abort(); + Err(RecvTimeoutError::Timeout) => { + if let Err(err) = write_timeout_abort_message(io::stderr().lock(), max_secs) { + tracing::warn!(?err, "failed to flush timeout message before abort"); + } + process::abort(); } } }); @@ -92,7 +125,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, } @@ -168,26 +201,31 @@ impl<const D: usize> From<ConstructionStatistics> for InsertionSummary<D> { let skip_samples: Vec<SkipSample<D>> = stats .skip_samples .iter() - .filter_map(|s| { - let coords: [f64; D] = if let Ok(coords) = s.coords.as_slice().try_into() { - coords + .map(|s| { + let coords = if s.coords_available { + 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 { - 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 { + SkipSample { index: s.index, uuid: s.uuid, coords, attempts: s.attempts, error: s.error.clone(), - }) + } }) .collect(); @@ -214,11 +252,11 @@ fn parse_u64(s: &str) -> Option<u64> { } fn env_u64(name: &str) -> Option<u64> { - 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<usize> { - 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,17 +267,38 @@ fn env_usize(name: &str) -> Option<usize> { } 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" }) } 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, + // 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", + ]; + 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 +356,7 @@ impl PointDistribution { } fn env_f64(name: &str) -> Option<f64> { - let Ok(raw) = std::env::var(name) else { + let Ok(raw) = env::var(name) else { return None; }; @@ -313,7 +372,7 @@ fn env_f64(name: &str) -> Option<f64> { } 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 +389,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 +408,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; }; @@ -394,8 +453,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 } => { @@ -483,9 +542,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 ); } } @@ -711,7 +774,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(), }; @@ -764,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 {})", @@ -867,257 +929,62 @@ fn debug_large_case<const D: usize>(dimension_name: &str, default_n_points: usiz DebugOutcome::Success } -#[derive(Debug, Clone)] -struct IncrementalFailure3d { - prefix_len: usize, - index: usize, - uuid: uuid::Uuid, - coords: [f64; 3], - error: String, +#[derive(Clone, Copy)] +enum FailingWriterMode { + Write, + Flush, } -fn run_incremental_prefix_3d( - vertices: &[Vertex<f64, (), 3>], - prefix_len: usize, - _repair_every: usize, -) -> Result<(), IncrementalFailure3d> { - let kernel = RobustKernel::<f64>::new(); - let prefix = &vertices[..prefix_len]; - let mut dt = match DelaunayTriangulation::<RobustKernel<f64>, (), (), 3>::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; - 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]), - |vertex| (vertex.uuid(), *vertex.point().coords()), - ); - return Err(IncrementalFailure3d { - prefix_len, - index: idx, - uuid, - coords, - error: format!( - "{} [inserted={} skipped_duplicate={} skipped_degeneracy={}]", - error, - statistics.inserted, - statistics.skipped_duplicate, - statistics.skipped_degeneracy - ), - }) +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")); } - }; - 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; 3]), - |vertex| (vertex.uuid(), *vertex.point().coords()), - ); - return Err(IncrementalFailure3d { - prefix_len, - index: idx, - uuid, - coords, - error: format!( - "{skipped_total} vertices were skipped (set DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 to allow)" - ), - }); + Ok(buf.len()) } - if !env_flag("DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR") && dt.number_of_cells() > 0 { - let _ = dt.repair_delaunay_with_flips_advanced(DelaunayRepairHeuristicConfig::default()); - } + fn flush(&mut self) -> io::Result<()> { + if matches!(self.mode, FailingWriterMode::Flush) { + return Err(io::Error::new(self.kind, "synthetic flush failure")); + } - 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]), - |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 { - prefix_len, - index: idx, - uuid, - coords, - error: format!("validation_report failed: {detail}"), - }); + Ok(()) } - - 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() { - init_tracing(); +fn test_write_timeout_abort_message_flushes_message() { + let mut output = Vec::new(); - let total_n = env_usize("DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL") - .unwrap_or(1000) - .max(4); - let base_seed = env_u64("DELAUNAY_LARGE_DEBUG_SEED").unwrap_or(42); - let case_seed = env_u64("DELAUNAY_LARGE_DEBUG_CASE_SEED_3D") - .or_else(|| env_u64("DELAUNAY_LARGE_DEBUG_CASE_SEED")) - .unwrap_or_else(|| seed_for_case::<3>(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!("============================================="); - println!("Config:"); - 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!(" max_probes: {max_probes:?}"); - println!(" max_runtime_secs:{max_runtime_secs}"); - println!(); + write_timeout_abort_message(&mut output, 17).expect("timeout diagnostic write should succeed"); - let points = generate_random_points_in_ball_seeded::<f64, 3>(total_n, ball_radius, case_seed) - .unwrap_or_else(|e| { - panic!("failed to generate deterministic 3D ball points for bisect: {e}") - }); - let vertices: Vec<Vertex<f64, (), 3>> = 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<Result<(), IncrementalFailure3d>> { - 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_3d(&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(())) => { - if let Err(mismatch) = run_incremental_prefix_3d(&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_3D." - ); - return; - } - Some(Err(err)) => err, - }; - - let mut lo = 4; - let mut hi = first_failure.prefix_len.max(lo); - println!( - "Full-run first failure: idx={} (prefix_len={})", - first_failure.index, first_failure.prefix_len + let message = String::from_utf8(output).expect("timeout diagnostic should be UTF-8"); + assert_eq!( + message, + "=== TIMEOUT: wall time exceeded 17 seconds — aborting ===\n" ); - println!("Initial binary-search range: [{lo}, {hi}]"); - - 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, - }; +} - if minimal_prefix > 4 { - assert!( - run_probe(minimal_prefix - 1).is_some_and(|result| result.is_ok()), - "internal bisect inconsistency: prefix {} should pass", - minimal_prefix - 1 - ); +#[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); } - - 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_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" - ); } /// Regression test for issue #228: 3D 1000-point flip-repair non-convergence. @@ -1144,8 +1011,6 @@ fn debug_large_scale_3d_incremental_prefix_bisect() { #[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"); @@ -1187,8 +1052,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"); @@ -1217,13 +1080,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/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<f64, (), 3>]) -> 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::<f64, 4>(n_points, ball_radius, seed) + .expect("point generation should succeed"); + let vertices: Vec<Vertex<f64, (), 4>> = 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..ea2eadff 100644 --- a/tests/storage_backend_compatibility.rs +++ b/tests/storage_backend_compatibility.rs @@ -53,6 +53,55 @@ 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(not(feature = "test-debug"))] + { + let _ = format_args!($($arg)*); + } + }}; +} + +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)*); + } + }}; +} + +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}"); +} + // ============================================================================= // TEST GENERATION MACROS (reduces duplication across 2D-5D) // ============================================================================= @@ -422,8 +471,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 +501,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 +537,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 +574,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 +761,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 +780,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 +806,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)"); } 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}; 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"