diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21c626f3..3dd57c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1018,6 +1018,15 @@ PRs are evaluated on: - **Style**: Does the code follow project conventions? - **Mathematical Accuracy**: Are geometric algorithms correct? +### Non-Substantive Changes + +PRs that only introduce whitespace churn, blank-line changes, formatting noise, or other +non-substantive edits may be declined unless they are part of a clearly justified cleanup or +required by project tooling. + +Accepted contributions should materially improve correctness, numerical robustness, topology +invariants, performance, documentation clarity, tests, maintainability, or user-facing behavior. + ### Handling Feedback - **Respond to all comments**: Address each piece of feedback diff --git a/README.md b/README.md index f5ea3e1d..fa74e9ed 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,16 @@ Choose the smallest prelude that matches the task: | Task | Import | |---|---| -| Build, configure, insert, or remove vertices | `use delaunay::prelude::triangulation::*` | +| Construct/configure a Delaunay triangulation | `use delaunay::prelude::triangulation::construction::*` | | Read-only traversal, adjacency, convex hulls, and comparison helpers | `use delaunay::prelude::query::*` | | Points, kernels, predicates, and geometric measures | `use delaunay::prelude::geometry::*` | | Random points or triangulations for examples, tests, and benchmarks | `use delaunay::prelude::generators::*` | +| Low-level incremental insertion building blocks | `use delaunay::prelude::triangulation::insertion::*` | | Bistellar flips / Edit API | `use delaunay::prelude::triangulation::flips::*` | | Delaunay repair diagnostics and policies | `use delaunay::prelude::triangulation::repair::*` | | Delaunayize workflow | `use delaunay::prelude::triangulation::delaunayize::*` | +| Construction telemetry diagnostics | `use delaunay::prelude::triangulation::diagnostics::*` | +| Construction validation cadence/policy | `use delaunay::prelude::triangulation::validation::*` | | Hilbert ordering and quantization utilities | `use delaunay::prelude::ordering::*` | | Low-level TDS cells, facets, keys, and validation reports | `use delaunay::prelude::tds::*` | | Collection aliases and small buffers | `use delaunay::prelude::collections::*` | @@ -107,9 +110,11 @@ Choose the smallest prelude that matches the task: `use delaunay::prelude::*` remains available for quick experiments, but examples and benchmarks in this repository prefer focused preludes so imports document intent. +The broad `delaunay::prelude::triangulation::*` import is retained for compatibility, +but new docs and tests should prefer the narrow workflow preludes above. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; // Create a 4D Delaunay triangulation from a set of vertices (uses AdaptiveKernel by default). let vertices = vec![ @@ -138,7 +143,9 @@ assert!(dt.is_valid().is_ok()); For periodic boundary conditions, use `DelaunayTriangulationBuilder`: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulationBuilder, TopologyKind, vertex, +}; // Phase 1: Canonicalization (wraps coordinates into [0, 1)²) let vertices = vec![ @@ -196,7 +203,11 @@ The construction pipeline exposes deterministic controls for experiments and reg - Explicit topology/validation configuration via `TopologyGuarantee` and `ValidationPolicy` ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DedupPolicy, DelaunayTriangulationBuilder, InsertionOrderStrategy, + RetryPolicy, TopologyGuarantee, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0]), diff --git a/benches/ci_performance_suite.rs b/benches/ci_performance_suite.rs index b03ec1e6..cbf34aa6 100644 --- a/benches/ci_performance_suite.rs +++ b/benches/ci_performance_suite.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! CI Performance Suite - optimized performance regression testing for CI/CD //! //! This benchmark is the small, durable performance contract for the delaunay @@ -36,12 +38,12 @@ use delaunay::prelude::geometry::{ AdaptiveKernel, Coordinate, Point, RobustKernel, simplex_volume, }; use delaunay::prelude::query::ConvexHull; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, RetryPolicy, Vertex, +}; use delaunay::prelude::triangulation::flips::{ BistellarFlips, CellKey, EdgeKey, FacetHandle, RidgeHandle, TopologyGuarantee, TriangleHandle, }; -use delaunay::prelude::triangulation::{ - ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, RetryPolicy, Vertex, -}; use delaunay::vertex; use std::{env, hint::black_box, num::NonZeroUsize, sync::Once}; #[cfg(feature = "bench-logging")] diff --git a/benches/large_scale_performance.rs b/benches/large_scale_performance.rs index 94a85ccf..c83d14b4 100644 --- a/benches/large_scale_performance.rs +++ b/benches/large_scale_performance.rs @@ -79,7 +79,7 @@ use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main}; use delaunay::prelude::generators::generate_random_points_seeded; use delaunay::prelude::geometry::AdaptiveKernel; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ ConstructionOptions, DelaunayTriangulation, RetryPolicy, Vertex, }; use delaunay::vertex; diff --git a/benches/profiling_suite.rs b/benches/profiling_suite.rs index bee63b42..bb843eaa 100644 --- a/benches/profiling_suite.rs +++ b/benches/profiling_suite.rs @@ -64,7 +64,7 @@ use delaunay::prelude::generators::{ }; use delaunay::prelude::geometry::{Coordinate, safe_usize_to_scalar}; use delaunay::prelude::query::*; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ ConstructionOptions, DelaunayTriangulationBuilder, RetryPolicy, }; use delaunay::vertex; diff --git a/benches/topology_guarantee_construction.rs b/benches/topology_guarantee_construction.rs index c8bd9a47..3d3fa4a0 100644 --- a/benches/topology_guarantee_construction.rs +++ b/benches/topology_guarantee_construction.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Benchmark: construction cost vs topology guarantee (2D–5D) //! //! This benchmark compares `TopologyGuarantee::Pseudomanifold`, `TopologyGuarantee::PLManifold` @@ -14,9 +16,9 @@ use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use delaunay::prelude::generators::generate_random_points_seeded; -use delaunay::prelude::triangulation::{ - DelaunayRepairPolicy, DelaunayTriangulation, TopologyGuarantee, ValidationPolicy, -}; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, TopologyGuarantee}; +use delaunay::prelude::triangulation::repair::DelaunayRepairPolicy; +use delaunay::prelude::triangulation::validation::ValidationPolicy; use delaunay::vertex; use std::hint::black_box; use std::time::Duration; diff --git a/docs/api_design.md b/docs/api_design.md index 8a5de6ea..b02c5a09 100644 --- a/docs/api_design.md +++ b/docs/api_design.md @@ -60,7 +60,7 @@ The library provides two distinct APIs for different use cases: For most use cases, the simple constructor is sufficient: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; // Simple construction from vertices (Euclidean space, default options) let vertices = vec![ @@ -87,7 +87,7 @@ For advanced configuration (toroidal topology, custom validation policies, etc.) use `DelaunayTriangulationBuilder`: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; // Toroidal (periodic) triangulation in 2D let vertices = vec![ @@ -148,7 +148,7 @@ for topology guarantee and validation policy details. The Edit API is exposed through the `BistellarFlips` trait in `prelude::triangulation::flips`: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::flips::*; // Start with a valid triangulation @@ -257,7 +257,7 @@ After applying flips, you should: You can mix both APIs in the same workflow: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::flips::*; // 1. Build initial triangulation (Builder API) diff --git a/docs/code_organization.md b/docs/code_organization.md index 353e2c72..c0d22abd 100644 --- a/docs/code_organization.md +++ b/docs/code_organization.md @@ -220,7 +220,10 @@ delaunay/ │ │ ├── builder.rs │ │ ├── delaunay.rs │ │ ├── delaunayize.rs -│ │ └── flips.rs +│ │ ├── flips.rs +│ │ ├── locality.rs +│ │ └── validation.rs +│ ├── triangulation.rs │ └── lib.rs ├── tests/ │ ├── semgrep/ @@ -431,6 +434,12 @@ The `benchmark-utils` CLI provides integrated benchmark workflow functionality, - `delaunayize.rs` - End-to-end "repair then delaunayize" workflow (`delaunayize_by_flips`); bounded topology repair + flip-based Delaunay repair + optional fallback rebuild - `flips.rs` - High-level bistellar flip (Pachner move) trait and supporting public types; delegates to `core::algorithms::flips` +- `locality.rs` - Local seed/frontier helpers for Hilbert-local construction and repair +- `validation.rs` - Construction validation cadence and scheduling helpers + +**`src/triangulation.rs`** - Public facade for triangulation-facing workflows. +It keeps the module namespace stable while the implementation is split across +orthogonal files under `src/triangulation/`. **`src/topology/`** - Topology analysis and validation: diff --git a/docs/dev/debug_env_vars.md b/docs/dev/debug_env_vars.md index 72de08b0..b6c1ad1e 100644 --- a/docs/dev/debug_env_vars.md +++ b/docs/dev/debug_env_vars.md @@ -129,15 +129,18 @@ and release builds. | `DELAUNAY_LARGE_DEBUG_BALL_RADIUS` | **value** | Radius for ball distribution | | `DELAUNAY_LARGE_DEBUG_BOX_HALF_WIDTH` | **value** | Half-width for box distribution | | `DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE` | **value** | `new` (batch) or `incremental` | -| `DELAUNAY_LARGE_DEBUG_DEBUG_MODE` | **value** | `cadenced` or `strict` | +| `DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX` | **value** | Batch initial simplex strategy: `max-volume` (default), `balanced`, or `first` | +| `DELAUNAY_LARGE_DEBUG_DEBUG_MODE` | **value** | `cadenced` (ridge-link) or `strict` (per-insertion vertex-link) | | `DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED` | **value** | Vertex shuffle seed | | `DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY` | **value** | Progress logging interval | | `DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY` | **value** | Validation interval | -| `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY` | **value** | Repair interval | +| `DELAUNAY_LARGE_DEBUG_REPAIR_EVERY` | **value** | Batch/incremental repair interval (default: 1) | | `DELAUNAY_LARGE_DEBUG_REPAIR_MAX_FLIPS` | **value** | Flip budget override | | `DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS` | **value** | Timeout (0 = no cap) | -| `DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS` | presence | Allow vertex insertion skips | +| `DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT` | **value** | Maximum skipped-vertex percentage before failing (default: 5.0) | +| `DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS` | presence | Allow any number of vertex insertion skips | | `DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR` | presence | Skip final global repair pass | +| `DELAUNAY_BATCH_REPAIR_TRACE` | presence | Trace cadenced batch-repair seed counts, flips, queues, and elapsed time | | `DELAUNAY_LARGE_DEBUG_PREFIX_TOTAL` | **value** | Total prefix probes for bisect mode | | `DELAUNAY_LARGE_DEBUG_PREFIX_MAX_PROBES` | **value** | Max probes per bisect run | | `DELAUNAY_LARGE_DEBUG_PREFIX_MAX_RUNTIME_SECS` | **value** | Bisect probe timeout | diff --git a/docs/dev/rust.md b/docs/dev/rust.md index 1a85a050..bcb4eb40 100644 --- a/docs/dev/rust.md +++ b/docs/dev/rust.md @@ -563,9 +563,12 @@ Examples: ```text delaunay::prelude::triangulation +delaunay::prelude::triangulation::construction delaunay::prelude::triangulation::flips +delaunay::prelude::triangulation::insertion delaunay::prelude::triangulation::repair delaunay::prelude::triangulation::delaunayize +delaunay::prelude::triangulation::validation delaunay::prelude::query delaunay::prelude::algorithms delaunay::prelude::geometry @@ -600,7 +603,7 @@ Example: /// # Examples /// /// ```rust -/// # use delaunay::prelude::triangulation::*; +/// # use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// # fn main() -> Result<(), Box> { /// let mut triangulation = DelaunayTriangulation::<_, _, _, 2>::default(); /// let key = triangulation.insert_vertex([0.0, 0.0])?; diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 3850fa40..5f7859ae 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -43,7 +43,7 @@ Use `delaunay_violation_report` when you want data instead of only log output: ```rust use delaunay::prelude::diagnostics::delaunay_violation_report; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), diff --git a/docs/invariants.md b/docs/invariants.md index 5c87d346..07869060 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -245,9 +245,11 @@ identifying *visible* boundary facets and retriangulating the visible region. ### Degenerate input and initial simplex construction -Construction begins by creating an initial simplex from the first `D+1` affinely independent -vertices. If no non-degenerate simplex can be formed (e.g., collinear points in 2D, coplanar in 3D), -construction fails with a geometric degeneracy error. +Construction begins by creating an initial simplex from `D+1` affinely independent real input +vertices. The default batch constructor searches a bounded pool of extreme vertices for a +large-volume simplex before falling back to the selected insertion order. If no non-degenerate +simplex can be formed (e.g., collinear points in 2D, coplanar in 3D), construction fails with a +geometric degeneracy error. This early degeneracy detection is intentional: it prevents building a combinatorial structure whose geometric interpretation is undefined. diff --git a/docs/numerical_robustness_guide.md b/docs/numerical_robustness_guide.md index 00bef919..33258394 100644 --- a/docs/numerical_robustness_guide.md +++ b/docs/numerical_robustness_guide.md @@ -88,7 +88,7 @@ The convenience constructors (`DelaunayTriangulation::new()`, `::empty()`, etc.) ```rust use delaunay::prelude::geometry::RobustKernel; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let kernel = RobustKernel::::new(); @@ -165,7 +165,8 @@ cases involve cavity/topology failures rather than predicate degeneracies. Use `insert_with_statistics()` to observe this behavior: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -319,10 +320,12 @@ and per-insertion checks handle any remaining cases. This handles near-degenerate configurations correctly out of the box. - If you need explicit `BOUNDARY`/`DEGENERATE` signals (e.g. to detect and handle cospherical configurations yourself), switch to `RobustKernel`. -- If you use `FastKernel` for 2D performance, consider setting - `DelaunayRepairPolicy::EveryN(n)` (e.g. `n = 10`) instead of the default `EveryInsertion`. - This reduces the frequency of the automatic robust-fallback repair pass while still - maintaining the Delaunay property periodically. Note that the explicit repair methods +- If you use `FastKernel` for direct incremental insertion, consider setting + `DelaunayRepairPolicy::EveryN(n)` (e.g. `n = 10`) instead of the incremental default + `EveryInsertion`. Batch construction already uses a cadenced `ConstructionOptions` + repair default with final repair/validation. This reduces the frequency of the automatic + robust-fallback repair pass while still maintaining the Delaunay property periodically. + Note that the explicit repair methods (`repair_delaunay_with_flips`, etc.) are not available with `FastKernel` — use `AdaptiveKernel` or `RobustKernel` if you need manual repair control. - If you see retryable insertion errors, frequent perturbation retries, or skipped vertices, diff --git a/docs/topology.md b/docs/topology.md index 0cc02809..21040258 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -177,7 +177,7 @@ Toroidal (periodic) triangulations are **fully implemented and functional**. You construct toroidal triangulations using `DelaunayTriangulationBuilder`: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; // 2D periodic triangulation let vertices = vec![ diff --git a/docs/validation.md b/docs/validation.md index 51d5908f..9fefa487 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -79,7 +79,10 @@ insertion deviates from the happy-path and trips internal **suspicion flags**, e ### Example: configuring validation policy ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -121,7 +124,10 @@ PL-manifoldness. You can trigger that final certification via `Triangulation::validate_at_completion()` (or `Triangulation::validate()`). ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -210,7 +216,10 @@ Validates basic data integrity of individual vertices and cells. ### Example ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let v = vertex!([0.0, 0.0, 0.0]); assert!(v.is_valid().is_ok()); @@ -263,7 +272,10 @@ Validates the combinatorial structure of the Triangulation Data Structure. ### Example ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -341,7 +353,10 @@ Validates that the triangulation forms a valid topological manifold. ### Example ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -404,7 +419,10 @@ Validates the geometric optimality of the triangulation. ### Example ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), diff --git a/docs/workflows.md b/docs/workflows.md index f13f5c6b..5869ee37 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -14,7 +14,7 @@ For the theoretical background and rationale behind the invariants, see [`invari For most use cases, construction is a single call: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -39,7 +39,8 @@ Two knobs are commonly used for insertion-time safety vs performance: See [`validation.md`](validation.md) for details. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, TopologyGuarantee}; +use delaunay::prelude::triangulation::validation::ValidationPolicy; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -67,16 +68,18 @@ levels. ## Builder API: flip-based Delaunay repair (details) The Builder API is designed to construct Delaunay triangulations, and (by default) schedules local -flip-based repair passes after insertions. - -Automatic repair scheduling is controlled by `DelaunayRepairPolicy` (default: `EveryInsertion`). +flip-based repair passes during construction. Batch construction uses `ConstructionOptions`, whose +default repair cadence is `DelaunayRepairPolicy::EveryInsertion` plus final repair/validation. That +cadence reflects the current #341 proxy sweeps at 1000 and 3000 vertices; 10000-vertex runs remain +the scalability acceptance check. Direct incremental insertion keeps the lower-level +`DelaunayRepairPolicy` default at `EveryInsertion`. The explicit repair methods (`repair_delaunay_with_flips`, `repair_delaunay_with_flips_advanced`, `rebuild_with_heuristic`) require `K: ExactPredicates` at compile time. `AdaptiveKernel` and `RobustKernel` implement this trait; `FastKernel` does not. See [`numerical_robustness_guide.md`](numerical_robustness_guide.md) for kernel selection guidance. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayRepairPolicy, DelaunayTriangulation}; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -90,7 +93,7 @@ dt.set_delaunay_repair_policy(DelaunayRepairPolicy::Never); You can also run a global repair pass manually: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -137,8 +140,8 @@ If repair fails to converge within the flip budget, you get detections, etc.). ```rust +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::repair::DelaunayRepairError; -use delaunay::prelude::triangulation::*; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -178,7 +181,7 @@ You can provide explicit seeds for reproducibility; otherwise deterministic defa from the current vertex set. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::repair::DelaunayRepairHeuristicConfig; let vertices = vec![ @@ -205,7 +208,7 @@ Toroidal triangulations handle periodic boundary conditions. Use `DelaunayTriangulationBuilder` to construct them: ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; // 2D periodic triangulation with unit square domain let vertices = vec![ @@ -243,7 +246,9 @@ Data is attached at construction time via `VertexBuilder::data()`, read via the and modified post-construction via `set_vertex_data` / `set_cell_data`. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulationBuilder, Vertex, vertex, +}; // Attach integer labels at construction time let vertices: [Vertex; 3] = [ @@ -280,7 +285,8 @@ If you need observability (or you want to handle skipped vertices explicitly), u `insert_with_statistics()`. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -311,7 +317,7 @@ possible and fan retriangulation otherwise, then runs flip-based Delaunay repair the operation rolls back to the pre-removal triangulation. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; let vertices = vec![ vertex!([0.0, 0.0, 0.0]), @@ -354,7 +360,7 @@ After using flips, you typically: See [`api_design.md`](api_design.md) for the full Builder vs Edit API design. ```rust -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; use delaunay::prelude::triangulation::flips::*; let vertices = vec![ diff --git a/examples/delaunayize_repair.rs b/examples/delaunayize_repair.rs index 009f4c25..bb0e02bf 100644 --- a/examples/delaunayize_repair.rs +++ b/examples/delaunayize_repair.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! # Delaunayize-by-Flips Repair Example //! //! This example demonstrates the **delaunayize-by-flips** workflow that @@ -18,11 +20,10 @@ //! cargo run --example delaunayize_repair //! ``` +use delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError; use delaunay::prelude::triangulation::delaunayize::*; use delaunay::prelude::triangulation::flips::*; -use delaunay::prelude::triangulation::{ - DelaunayTriangulationConstructionError, DelaunayTriangulationValidationError, -}; +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; // For the generic print_outcome helper. use delaunay::prelude::DataType; diff --git a/examples/diagnostics.rs b/examples/diagnostics.rs index 38d40720..9f555579 100644 --- a/examples/diagnostics.rs +++ b/examples/diagnostics.rs @@ -17,11 +17,14 @@ use delaunay::prelude::diagnostics::{ #[cfg(feature = "diagnostics")] use delaunay::prelude::geometry::AdaptiveKernel; #[cfg(feature = "diagnostics")] -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, DelaunayTriangulationConstructionError, - DelaunayTriangulationValidationError, flips::*, }; #[cfg(feature = "diagnostics")] +use delaunay::prelude::triangulation::flips::*; +#[cfg(feature = "diagnostics")] +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; +#[cfg(feature = "diagnostics")] use delaunay::vertex; #[cfg(feature = "diagnostics")] diff --git a/examples/numerical_robustness.rs b/examples/numerical_robustness.rs index bb77048a..6c74fb94 100644 --- a/examples/numerical_robustness.rs +++ b/examples/numerical_robustness.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! # Numerical Robustness Example //! //! This example accompanies `docs/numerical_robustness_guide.md`. @@ -9,10 +11,10 @@ use delaunay::prelude::geometry::{ AdaptiveKernel, CircumcenterError, Coordinate, CoordinateConversionError, FastKernel, Kernel, Point, RobustKernel, robust_insphere, robust_orientation, }; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, DelaunayTriangulationConstructionError, - DelaunayTriangulationValidationError, }; +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; use delaunay::vertex; #[derive(Debug, thiserror::Error)] diff --git a/examples/pachner_roundtrip_4d.rs b/examples/pachner_roundtrip_4d.rs index 0becc8eb..2fdccee9 100644 --- a/examples/pachner_roundtrip_4d.rs +++ b/examples/pachner_roundtrip_4d.rs @@ -15,11 +15,11 @@ use ::uuid::Uuid; use delaunay::prelude::geometry::RobustKernel; -use delaunay::prelude::triangulation::flips::*; -use delaunay::prelude::triangulation::{ - ConstructionOptions, DelaunayTriangulationConstructionError, - DelaunayTriangulationValidationError, InsertionOrderStrategy, Vertex, +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayTriangulationConstructionError, InsertionOrderStrategy, Vertex, }; +use delaunay::prelude::triangulation::flips::*; +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; use std::time::Instant; type Dt4 = DelaunayTriangulation, (), (), 4>; diff --git a/examples/topology_editing_2d_3d.rs b/examples/topology_editing_2d_3d.rs index 9db04a6b..b13684c2 100644 --- a/examples/topology_editing_2d_3d.rs +++ b/examples/topology_editing_2d_3d.rs @@ -26,8 +26,13 @@ use delaunay::prelude::geometry::{ CircumcenterError, Coordinate, Kernel, Point, circumcenter, hypot, }; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, + vertex, +}; use delaunay::prelude::triangulation::flips::*; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::insertion::InsertionError; +use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; use delaunay::prelude::{TdsError, VertexKey}; type ExampleResult = Result; diff --git a/examples/zero_allocation_iterator_demo.rs b/examples/zero_allocation_iterator_demo.rs index 57bd4ced..c2ab1644 100644 --- a/examples/zero_allocation_iterator_demo.rs +++ b/examples/zero_allocation_iterator_demo.rs @@ -7,7 +7,7 @@ use delaunay::prelude::generators::generate_random_triangulation; use delaunay::prelude::tds::CellValidationError; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, DelaunayTriangulationConstructionError, }; use std::hint::black_box; diff --git a/justfile b/justfile index 9cf231f2..2e3c7e8d 100644 --- a/justfile +++ b/justfile @@ -233,8 +233,8 @@ coverage-ci: _ensure-cargo-llvm-cov mkdir -p coverage cargo llvm-cov {{_coverage_base_args}} --cobertura --output-path coverage/cobertura.xml -- --skip prop_ -debug-large-scale-3d n="10000": - DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 DELAUNAY_LARGE_DEBUG_N_3D={{n}} cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture +debug-large-scale-3d n="10000" repair_every="1": + DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 DELAUNAY_LARGE_DEBUG_N_3D={{n}} DELAUNAY_LARGE_DEBUG_REPAIR_EVERY={{repair_every}} cargo test --release --test large_scale_debug debug_large_scale_3d -- --ignored --exact --nocapture debug-large-scale-4d n="3000": DELAUNAY_BULK_PROGRESS_EVERY=100 DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=1800 DELAUNAY_LARGE_DEBUG_N_4D={{n}} cargo test --release --test large_scale_debug debug_large_scale_4d -- --ignored --exact --nocapture @@ -281,7 +281,7 @@ help-workflows: @echo "Active large-scale debugging:" @echo " just test-diagnostics # Run diagnostics tools with output" @echo " just debug-large-scale-4d [n] # Issue #340: 4D large-scale runtime (default n=3000)" - @echo " just debug-large-scale-3d [n] # Issue #341: 3D scalability (default n=10000)" + @echo " just debug-large-scale-3d [n] [repair_every] # Issue #341: 3D scalability (defaults n=10000, repair_every=1)" @echo " just debug-large-scale-5d [n] # Issue #342: 5D feasibility (default n=1000)" @echo "" @echo "Benchmark workflows:" diff --git a/src/core/algorithms/flips.rs b/src/core/algorithms/flips.rs index 09f366c4..4b92f477 100644 --- a/src/core/algorithms/flips.rs +++ b/src/core/algorithms/flips.rs @@ -60,6 +60,7 @@ use std::collections::VecDeque; use std::env; use std::fmt; use std::hash::{Hash, Hasher}; +use std::time::{Duration, Instant}; use thiserror::Error; type VertexKeyList = SmallBuffer; @@ -87,15 +88,31 @@ pub struct BistellarFlipKind { pub d: usize, } /// Run a single flip-repair attempt using k=2 (and k=3 in 3D+). +fn repair_delaunay_with_flips_k2_k3_attempt( + tds: &mut Tds, + kernel: &K, + seed_cells: Option<&[CellKey]>, + config: &RepairAttemptConfig, +) -> Result +where + K: Kernel, + U: DataType, + V: DataType, +{ + repair_delaunay_with_flips_k2_k3_attempt_timed(tds, kernel, seed_cells, config, None) +} + +/// Run a single flip-repair attempt while reporting queue-family timings. #[expect( clippy::too_many_lines, reason = "Repair loop contains inline tracing and queue handling for diagnostics" )] -fn repair_delaunay_with_flips_k2_k3_attempt( +fn repair_delaunay_with_flips_k2_k3_attempt_timed( tds: &mut Tds, kernel: &K, seed_cells: Option<&[CellKey]>, config: &RepairAttemptConfig, + mut timing: Option<&mut LocalRepairPhaseTiming>, ) -> Result where K: Kernel, @@ -117,26 +134,90 @@ where let mut diagnostics = RepairDiagnostics::default(); let mut queues = RepairQueues::new(); let mut last_applied_flip: Option = None; + let seed_started = timing.is_some().then(Instant::now); let used_full_reseed = seed_repair_queues(tds, seed_cells, &mut queues, &mut stats)?; + if let (Some(timing), Some(seed_started)) = (timing.as_deref_mut(), seed_started) { + timing.record_attempt_seed(seed_started.elapsed()); + } let mut touched_cells = CellKeyBuffer::new(); let mut touched_cell_set = FastHashSet::::default(); let mut prefer_secondary = false; + macro_rules! timed_step { + ($recorder:ident, $step:expr) => {{ + if timing.is_some() { + let started = Instant::now(); + let processed = $step?; + if let Some(timing) = timing.as_deref_mut() { + timing.$recorder(started.elapsed()); + } + processed + } else { + $step? + } + }}; + } + while queues.has_work() { - if prefer_secondary - && (process_ridge_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? || process_edge_queue_step( + if prefer_secondary { + let processed_ridge = timed_step!( + record_attempt_ridge, + process_ridge_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + let processed_edge = !processed_ridge + && timed_step!( + record_attempt_edge, + process_edge_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + let processed_triangle = !processed_ridge + && !processed_edge + && timed_step!( + record_attempt_triangle, + process_triangle_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + if processed_ridge || processed_edge || processed_triangle { + prefer_secondary = false; + continue; + } + } + + if timed_step!( + record_attempt_facet, + process_facet_queue_step( tds, kernel, &mut queues, @@ -147,7 +228,15 @@ where &mut last_applied_flip, &mut touched_cells, &mut touched_cell_set, - )? || process_triangle_queue_step( + ) + ) { + prefer_secondary = true; + continue; + } + + let processed_ridge = timed_step!( + record_attempt_ridge, + process_ridge_queue_step( tds, kernel, &mut queues, @@ -158,62 +247,42 @@ where &mut last_applied_flip, &mut touched_cells, &mut touched_cell_set, - )?) - { - prefer_secondary = false; - continue; - } - - if process_facet_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? { - prefer_secondary = true; - continue; - } - - if process_ridge_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? || process_edge_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? || process_triangle_queue_step( - tds, - kernel, - &mut queues, - &mut stats, - max_flips, - config, - &mut diagnostics, - &mut last_applied_flip, - &mut touched_cells, - &mut touched_cell_set, - )? { + ) + ); + let processed_edge = !processed_ridge + && timed_step!( + record_attempt_edge, + process_edge_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + let processed_triangle = !processed_ridge + && !processed_edge + && timed_step!( + record_attempt_triangle, + process_triangle_queue_step( + tds, + kernel, + &mut queues, + &mut stats, + max_flips, + config, + &mut diagnostics, + &mut last_applied_flip, + &mut touched_cells, + &mut touched_cell_set, + ) + ); + if processed_ridge || processed_edge || processed_triangle { prefer_secondary = false; } } @@ -232,6 +301,7 @@ where emit_repair_debug_summary("attempt_done", &stats, &diagnostics, config, max_flips); Ok(RepairAttemptOutcome { + postcondition_required: repair_postcondition_required(&stats, &diagnostics), stats, last_applied_flip, touched_cells, @@ -248,7 +318,6 @@ fn snapshot_removed_cell_vertices( removed_cells: &CellKeyBuffer, ) -> Result where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -508,7 +577,6 @@ fn find_cell_containing_simplex( removed_cells: &[CellKey], ) -> Option where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -906,7 +974,7 @@ fn debug_ridge_context( } let ridge_vertices = ridge_vertices_from_cell(cell, omit_a, omit_b); - let neighbor_walk = collect_cells_around_ridge(tds, ridge.cell_key(), &ridge_vertices) + let neighbor_walk = collect_cells_around_ridge(tds, ridge.cell_key(), &ridge_vertices, None) .map(|cells| cells.into_iter().collect::>()); let global_cells = cells_containing_vertices(tds, &ridge_vertices); let neighbor_snapshot: Option, MAX_PRACTICAL_DIMENSION_SIZE>> = @@ -2930,6 +2998,108 @@ pub struct DelaunayRepairStats { pub max_queue_len: usize, } +/// Wall-clock phase timing for one batch-local repair pass. +#[expect( + clippy::struct_field_names, + reason = "phase timing telemetry keeps units explicit on every exported field" +)] +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct LocalRepairPhaseTiming { + /// Nanoseconds spent cloning the TDS snapshot used for rollback. + pub(crate) snapshot_nanos: u64, + /// Nanoseconds spent applying flip-repair attempts. + pub(crate) attempt_nanos: u64, + /// Nanoseconds spent seeding repair attempt queues. + pub(crate) attempt_seed_nanos: u64, + /// Nanoseconds spent processing k=2 facet queue items. + pub(crate) attempt_facet_nanos: u64, + /// Nanoseconds spent processing k=3 ridge queue items. + pub(crate) attempt_ridge_nanos: u64, + /// Nanoseconds spent processing inverse k=2 edge queue items. + pub(crate) attempt_edge_nanos: u64, + /// Nanoseconds spent processing inverse k=3 triangle queue items. + pub(crate) attempt_triangle_nanos: u64, + /// Nanoseconds spent replaying postcondition predicates. + pub(crate) postcondition_nanos: u64, + /// Nanoseconds spent restoring the TDS from a saved snapshot. + pub(crate) restore_nanos: u64, +} + +impl LocalRepairPhaseTiming { + /// Adds rollback snapshot-clone time so setup cost stays separate from repair work. + fn record_snapshot(&mut self, elapsed: Duration) { + self.snapshot_nanos = self + .snapshot_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds total flip-attempt time across queue seeding and queue processing. + fn record_attempt(&mut self, elapsed: Duration) { + self.attempt_nanos = self + .attempt_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent building the queue for one repair attempt. + fn record_attempt_seed(&mut self, elapsed: Duration) { + self.attempt_seed_nanos = self + .attempt_seed_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent processing k=2 facet queue items. + fn record_attempt_facet(&mut self, elapsed: Duration) { + self.attempt_facet_nanos = self + .attempt_facet_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent processing k=3 ridge queue items. + fn record_attempt_ridge(&mut self, elapsed: Duration) { + self.attempt_ridge_nanos = self + .attempt_ridge_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent processing inverse k=2 edge queue items. + fn record_attempt_edge(&mut self, elapsed: Duration) { + self.attempt_edge_nanos = self + .attempt_edge_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent processing inverse k=3 triangle queue items. + fn record_attempt_triangle(&mut self, elapsed: Duration) { + self.attempt_triangle_nanos = self + .attempt_triangle_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent replaying local Delaunay postconditions after repair attempts. + fn record_postcondition(&mut self, elapsed: Duration) { + self.postcondition_nanos = self + .postcondition_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } + + /// Adds time spent restoring the saved TDS after a failed repair attempt. + fn record_restore(&mut self, elapsed: Duration) { + self.restore_nanos = self + .restore_nanos + .saturating_add(duration_nanos_saturating(elapsed)); + } +} + +/// Publishes one local repair pass's phase timing when the caller requested telemetry. +fn publish_local_repair_phase_timing( + timing: &mut Option<&mut LocalRepairPhaseTiming>, + phase_timing: LocalRepairPhaseTiming, +) { + if let Some(timing) = timing.as_deref_mut() { + *timing = phase_timing; + } +} + /// Crate-private repair result with the validation frontier for callers that /// need post-repair topology checks without scanning the whole TDS. #[derive(Debug, Clone)] @@ -2951,12 +3121,21 @@ pub(crate) struct DelaunayRepairRun { /// the last repair move that modified the TDS. #[derive(Debug)] struct RepairAttemptOutcome { + postcondition_required: bool, stats: DelaunayRepairStats, last_applied_flip: Option, touched_cells: CellKeyBuffer, used_full_reseed: bool, } +/// Determines whether repair changed or observed enough local state to require postcondition replay. +const fn repair_postcondition_required( + stats: &DelaunayRepairStats, + diagnostics: &RepairDiagnostics, +) -> bool { + stats.flips_performed > 0 || diagnostics.saw_applicable_repair_site +} + /// Adds newly-created cells to the repair mutation frontier without duplicates. fn record_touched_cells( touched_cells: &mut CellKeyBuffer, @@ -2970,6 +3149,22 @@ fn record_touched_cells( } } +/// Builds the local postcondition frontier from the caller's seed cells plus +/// cells created by successful flips. +fn local_postcondition_frontier( + seed_cells: &[CellKey], + touched_cells: &[CellKey], +) -> CellKeyBuffer { + let mut frontier = CellKeyBuffer::new(); + let mut seen = FastHashSet::::default(); + for &cell_key in seed_cells.iter().chain(touched_cells) { + if seen.insert(cell_key) { + frontier.push(cell_key); + } + } + frontier +} + /// Converts an attempt outcome into the crate-private repair run result. fn repair_run_from_attempt( outcome: RepairAttemptOutcome, @@ -3511,6 +3706,10 @@ where clippy::too_many_arguments, reason = "local predicate evaluation threads topology, source cells, and diagnostics explicitly" )] +#[expect( + clippy::too_many_lines, + reason = "local predicate evaluation keeps frame alignment, diagnostics, and exact predicate calls together" +)] /// Evaluate the k=2 facet flip predicate for a local Delaunay violation. fn delaunay_violation_k2_for_facet( tds: &Tds, @@ -3557,27 +3756,51 @@ where cell_vertices[0].sort_unstable_by_key(|v| v.data().as_ffi()); cell_vertices[1].sort_unstable_by_key(|v| v.data().as_ffi()); - let source_a = matching_source_cell(tds, &cell_vertices[0], source_cells).or(frame_cell); - let source_b = matching_source_cell(tds, &cell_vertices[1], source_cells).or(frame_cell); - let points_a = vertices_to_points_with_optional_lift( - tds, - topology_model, - &cell_vertices[0], - source_a, - source_cells, - )?; - let points_b = vertices_to_points_with_optional_lift( - tds, - topology_model, - &cell_vertices[1], - source_b, - source_cells, - )?; - - let opposite_point_a = - vertex_point_lifted_into_cell(tds, topology_model, opposite_a, source_b, source_cells)?; - let opposite_point_b = - vertex_point_lifted_into_cell(tds, topology_model, opposite_b, source_a, source_cells)?; + let (points_a, points_b, opposite_point_a, opposite_point_b) = + if matches!(topology_model, GlobalTopologyModelAdapter::Euclidean(_)) { + let mut point_cache = EuclideanPointCache::new(); + ( + point_cache.points_for_vertices(tds, &cell_vertices[0])?, + point_cache.points_for_vertices(tds, &cell_vertices[1])?, + point_cache.point(tds, opposite_a)?, + point_cache.point(tds, opposite_b)?, + ) + } else { + let source_a = + matching_source_cell(tds, &cell_vertices[0], source_cells).or(frame_cell); + let source_b = + matching_source_cell(tds, &cell_vertices[1], source_cells).or(frame_cell); + ( + vertices_to_points_with_optional_lift( + tds, + topology_model, + &cell_vertices[0], + source_a, + source_cells, + )?, + vertices_to_points_with_optional_lift( + tds, + topology_model, + &cell_vertices[1], + source_b, + source_cells, + )?, + vertex_point_lifted_into_cell( + tds, + topology_model, + opposite_a, + source_b, + source_cells, + )?, + vertex_point_lifted_into_cell( + tds, + topology_model, + opposite_b, + source_a, + source_cells, + )?, + ) + }; let in_a = match kernel.in_sphere(&points_a, &opposite_point_b) { Ok(value) => value, Err(e) => { @@ -3774,7 +3997,31 @@ pub(crate) fn build_k3_flip_context( ridge: RidgeHandle, ) -> Result, FlipError> where - T: CoordinateScalar, + U: DataType, + V: DataType, +{ + build_k3_flip_context_with_star_limit(tds, ridge, None) +} + +/// Builds k=3 repair context only for true three-cell ridge stars. +fn build_k3_flip_context_for_repair( + tds: &Tds, + ridge: RidgeHandle, +) -> Result, FlipError> +where + U: DataType, + V: DataType, +{ + build_k3_flip_context_with_star_limit(tds, ridge, Some(3)) +} + +/// Builds k=3 flip context while optionally rejecting ridge stars above a caller limit. +fn build_k3_flip_context_with_star_limit( + tds: &Tds, + ridge: RidgeHandle, + max_cells: Option, +) -> Result, FlipError> +where U: DataType, V: DataType, { @@ -3804,7 +4051,7 @@ where return Err(FlipError::InvalidRidgeAdjacency { cell_key }); } - let cells = collect_cells_around_ridge(tds, cell_key, &ridge_vertices)?; + let cells = collect_cells_around_ridge(tds, cell_key, &ridge_vertices, max_cells)?; if cells.len() != 3 { return Err(FlipError::InvalidRidgeMultiplicity { found: cells.len() }); } @@ -4003,6 +4250,9 @@ where .into()); } + let is_euclidean_topology = matches!(topology_model, GlobalTopologyModelAdapter::Euclidean(_)); + let mut euclidean_point_cache = EuclideanPointCache::new(); + for &missing in triangle_vertices { let mut cell_vertices: SmallBuffer = SmallBuffer::with_capacity(D + 1); @@ -4016,16 +4266,31 @@ where // Sort by VertexKey for canonical SoS perturbation ordering cell_vertices.sort_unstable_by_key(|v| v.data().as_ffi()); - let source_cell = matching_source_cell(tds, &cell_vertices, source_cells).or(frame_cell); - let points = vertices_to_points_with_optional_lift( - tds, - topology_model, - &cell_vertices, - source_cell, - source_cells, - )?; - let missing_point = - vertex_point_lifted_into_cell(tds, topology_model, missing, source_cell, source_cells)?; + let (points, missing_point) = if is_euclidean_topology { + ( + euclidean_point_cache.points_for_vertices(tds, &cell_vertices)?, + euclidean_point_cache.point(tds, missing)?, + ) + } else { + let source_cell = + matching_source_cell(tds, &cell_vertices, source_cells).or(frame_cell); + ( + vertices_to_points_with_optional_lift( + tds, + topology_model, + &cell_vertices, + source_cell, + source_cells, + )?, + vertex_point_lifted_into_cell( + tds, + topology_model, + missing, + source_cell, + source_cells, + )?, + ) + }; let in_sphere = match kernel.in_sphere(&points, &missing_point) { Ok(value) => value, @@ -4260,6 +4525,7 @@ where if !violates { continue; } + diagnostics.record_applicable_repair_site(); let signature = flip_signature( 2, @@ -4352,6 +4618,7 @@ where emit_repair_debug_summary("attempt_done", &stats, &diagnostics, config, max_flips); Ok(RepairAttemptOutcome { + postcondition_required: repair_postcondition_required(&stats, &diagnostics), stats, last_applied_flip, touched_cells, @@ -4551,6 +4818,27 @@ where U: DataType, V: DataType, { + repair_delaunay_local_single_pass_timed(tds, kernel, seed_cells, max_flips, None) +} + +/// Run a seeded, bounded repair pass while reporting phase timing to the caller. +#[expect( + clippy::too_many_lines, + reason = "bounded two-attempt repair keeps rollback, retry, and postcondition timing together" +)] +pub(crate) fn repair_delaunay_local_single_pass_timed( + tds: &mut Tds, + kernel: &K, + seed_cells: &[CellKey], + max_flips: usize, + mut timing: Option<&mut LocalRepairPhaseTiming>, +) -> Result +where + K: Kernel, + U: DataType, + V: DataType, +{ + let mut phase_timing = LocalRepairPhaseTiming::default(); // Two-attempt strategy: FIFO then LIFO queue ordering. // Predicate correctness depends on the caller supplying a kernel with // exact predicates (e.g. `AdaptiveKernel` or `RobustKernel`); @@ -4566,22 +4854,42 @@ where max_flips_override: Some(max_flips), }; // Snapshot so a failed attempt does not leave the TDS in a partially-modified state. + let snapshot_started = Instant::now(); let tds_snapshot = tds.clone(); + phase_timing.record_snapshot(snapshot_started.elapsed()); + + let attempt_started = Instant::now(); let attempt1_result = if D == 2 { repair_delaunay_with_flips_k2_attempt(tds, kernel, Some(seed_cells), &attempt1) } else { - repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, Some(seed_cells), &attempt1) + repair_delaunay_with_flips_k2_k3_attempt_timed( + tds, + kernel, + Some(seed_cells), + &attempt1, + Some(&mut phase_timing), + ) }; + phase_timing.record_attempt(attempt_started.elapsed()); + match attempt1_result { Ok(outcome) => { - if verify_repair_postcondition( + if !outcome.postcondition_required { + publish_local_repair_phase_timing(&mut timing, phase_timing); + return Ok(outcome.stats); + } + let postcondition_frontier = + local_postcondition_frontier(seed_cells, &outcome.touched_cells); + let postcondition_started = Instant::now(); + let postcondition_result = verify_local_repair_postcondition( tds, kernel, - Some(seed_cells), + &postcondition_frontier, outcome.last_applied_flip.as_ref(), - ) - .is_ok() - { + ); + phase_timing.record_postcondition(postcondition_started.elapsed()); + if postcondition_result.is_ok() { + publish_local_repair_phase_timing(&mut timing, phase_timing); return Ok(outcome.stats); } if repair_trace_enabled() { @@ -4594,36 +4902,71 @@ where } } Err(err) => { + let restore_started = Instant::now(); *tds = tds_snapshot; + phase_timing.record_restore(restore_started.elapsed()); + publish_local_repair_phase_timing(&mut timing, phase_timing); return Err(err); } } + let restore_started = Instant::now(); *tds = tds_snapshot.clone(); + phase_timing.record_restore(restore_started.elapsed()); + + let attempt_started = Instant::now(); let attempt2_result = if D == 2 { repair_delaunay_with_flips_k2_attempt(tds, kernel, Some(seed_cells), &attempt2) } else { - repair_delaunay_with_flips_k2_k3_attempt(tds, kernel, Some(seed_cells), &attempt2) - }; - match attempt2_result { - Ok(outcome) => match verify_repair_postcondition( + repair_delaunay_with_flips_k2_k3_attempt_timed( tds, kernel, Some(seed_cells), - outcome.last_applied_flip.as_ref(), - ) { - Ok(()) => Ok(outcome.stats), - Err(verifier_err) => { - // Postcondition failed: restore the TDS so callers that - // soft-fail receive a structurally valid triangulation. - *tds = tds_snapshot; - Err(verifier_err) + &attempt2, + Some(&mut phase_timing), + ) + }; + phase_timing.record_attempt(attempt_started.elapsed()); + + match attempt2_result { + Ok(outcome) => { + if !outcome.postcondition_required { + publish_local_repair_phase_timing(&mut timing, phase_timing); + return Ok(outcome.stats); } - }, + let postcondition_frontier = + local_postcondition_frontier(seed_cells, &outcome.touched_cells); + let postcondition_started = Instant::now(); + let postcondition_result = verify_local_repair_postcondition( + tds, + kernel, + &postcondition_frontier, + outcome.last_applied_flip.as_ref(), + ); + phase_timing.record_postcondition(postcondition_started.elapsed()); + match postcondition_result { + Ok(()) => { + publish_local_repair_phase_timing(&mut timing, phase_timing); + Ok(outcome.stats) + } + Err(verifier_err) => { + // Postcondition failed: restore the TDS so callers that + // soft-fail receive a structurally valid triangulation. + let restore_started = Instant::now(); + *tds = tds_snapshot; + phase_timing.record_restore(restore_started.elapsed()); + publish_local_repair_phase_timing(&mut timing, phase_timing); + Err(verifier_err) + } + } + } Err(err) => { // On failure, restore the TDS to the pre-repair snapshot so callers that // soft-fail (e.g. D≥4 bulk construction) receive a structurally valid // triangulation rather than a partially-modified one. + let restore_started = Instant::now(); *tds = tds_snapshot; + phase_timing.record_restore(restore_started.elapsed()); + publish_local_repair_phase_timing(&mut timing, phase_timing); Err(err) } } @@ -4749,6 +5092,7 @@ where global_topology, PostconditionMode::Strict, None, + ConnectivityPostcondition::Check, ) } @@ -4772,6 +5116,30 @@ where GlobalTopology::DEFAULT, PostconditionMode::Repair, last_applied_flip, + ConnectivityPostcondition::Check, + ) +} + +/// Replays local repair postconditions without forcing the full connectivity check. +fn verify_local_repair_postcondition( + tds: &Tds, + kernel: &K, + seed_cells: &[CellKey], + last_applied_flip: Option<&LastAppliedFlip>, +) -> Result<(), DelaunayRepairError> +where + K: Kernel, + U: DataType, + V: DataType, +{ + verify_repair_postcondition_with_topology( + tds, + kernel, + Some(seed_cells), + GlobalTopology::DEFAULT, + PostconditionMode::Repair, + last_applied_flip, + ConnectivityPostcondition::Defer, ) } @@ -4781,6 +5149,12 @@ enum PostconditionMode { Strict, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConnectivityPostcondition { + Check, + Defer, +} + /// Builds a verification failure that preserves the structured flip error. fn verification_failed(context: &'static str, source: FlipError) -> DelaunayRepairError { DelaunayRepairError::VerificationFailed { @@ -4798,6 +5172,7 @@ fn verify_repair_postcondition_with_topology( global_topology: GlobalTopology, mode: PostconditionMode, last_applied_flip: Option<&LastAppliedFlip>, + connectivity: ConnectivityPostcondition, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -4812,6 +5187,7 @@ where &topology_model, mode, last_applied_flip, + connectivity, ) } @@ -4824,6 +5200,7 @@ fn verify_repair_postcondition_locally( topology_model: &GlobalTopologyModelAdapter, mode: PostconditionMode, last_applied_flip: Option<&LastAppliedFlip>, + connectivity: ConnectivityPostcondition, ) -> Result<(), DelaunayRepairError> where K: Kernel, @@ -4894,12 +5271,12 @@ where mode, )?; - // After all flip predicates pass, verify that the repair did not disconnect the - // neighbor graph. Individual flips can transiently clear stale external neighbor - // pointers (which subsequent flips re-establish); checking here — after the - // complete repair pass — catches any genuine disconnection that the flip sequence - // failed to reconnect. - if !tds.is_connected() { + // After all flip predicates pass, full repair checks that the repair did not + // disconnect the neighbor graph. Batch-local construction repair defers this + // whole-TDS check to the construction finalization topology validation; doing + // it after every small local repair dominates large 3D runs without adding a + // stronger boundary guarantee than final validation already enforces. + if connectivity == ConnectivityPostcondition::Check && !tds.is_connected() { return Err(DelaunayRepairError::PostconditionFailed { message: format!( "repair pass disconnected the triangulation \ @@ -5336,6 +5713,7 @@ struct RepairDiagnostics { invalid_ridge_multiplicity_sample: Option, missing_cell_skips: usize, missing_cell_sample: Option, + saw_applicable_repair_site: bool, flip_signature_window: VecDeque, flip_signature_counts: FastHashMap, ridge_debug_emitted: usize, @@ -5526,6 +5904,11 @@ impl RepairDiagnostics { self.missing_cell_sample = Some(sample); } } + + /// Marks that a repair predicate found an applicable flip even if no mutation followed. + const fn record_applicable_repair_site(&mut self) { + self.saw_applicable_repair_site = true; + } } #[derive(Debug, Clone, Copy)] @@ -5564,6 +5947,12 @@ fn non_convergent_error( } } +/// Converts a measured duration to nanoseconds while saturating pathological +/// values that exceed telemetry counter width. +fn duration_nanos_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) +} + /// Gates the expensive repair summary behind an environment variable while /// keeping all attempts logged in a uniform shape. fn emit_repair_debug_summary( @@ -6128,7 +6517,7 @@ where }; stats.facets_checked += 1; - let context = match build_k3_flip_context(tds, ridge) { + let context = match build_k3_flip_context_for_repair(tds, ridge) { Ok(ctx) => ctx, Err( err @ (FlipError::InvalidRidgeIndex { .. } @@ -6190,6 +6579,7 @@ where if !violates { return Ok(true); } + diagnostics.record_applicable_repair_site(); if would_immediately_reverse_last_flip::( last_applied_flip.as_ref(), @@ -6382,6 +6772,7 @@ where if violates && !allow_exploratory_inverse { return Ok(true); } + diagnostics.record_applicable_repair_site(); if would_immediately_reverse_last_flip::( last_applied_flip.as_ref(), @@ -6564,6 +6955,7 @@ where if violates { return Ok(true); } + diagnostics.record_applicable_repair_site(); if would_immediately_reverse_last_flip::( last_applied_flip.as_ref(), @@ -6740,6 +7132,7 @@ where if !violates { return Ok(true); } + diagnostics.record_applicable_repair_site(); if would_immediately_reverse_last_flip::( last_applied_flip.as_ref(), @@ -6877,7 +7270,6 @@ fn ridge_vertices_from_cell( omit_b: usize, ) -> SmallBuffer where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -6899,7 +7291,6 @@ fn cell_extras_for_ridge( ridge: &SmallBuffer, ) -> Result, FlipError> where - T: CoordinateScalar, U: DataType, V: DataType, { @@ -6930,26 +7321,36 @@ fn missing_opposite_for_cell( /// Walks the neighbor graph around a ridge so k=3 context construction uses the /// local star rather than a global incidence scan. +/// +/// When `max_cells` is set, the walk stops after discovering more than that +/// many incident cells. Repair uses this to reject non-k=3 edge stars as soon +/// as they are known to be too large, while public flip construction leaves the +/// value unset to preserve exact multiplicity diagnostics. fn collect_cells_around_ridge( tds: &Tds, start_cell: CellKey, ridge: &SmallBuffer, + max_cells: Option, ) -> Result where - T: CoordinateScalar, U: DataType, V: DataType, { - let mut queue: VecDeque = VecDeque::new(); - let mut visited: FastHashSet = FastHashSet::default(); + let mut queue: CellKeyBuffer = CellKeyBuffer::new(); + let mut visited: CellKeyBuffer = CellKeyBuffer::new(); let mut cells: CellKeyBuffer = CellKeyBuffer::new(); + let mut queue_cursor = 0usize; + + queue.push(start_cell); - queue.push_back(start_cell); + while queue_cursor < queue.len() { + let cell_key = queue[queue_cursor]; + queue_cursor += 1; - while let Some(cell_key) = queue.pop_front() { - if !visited.insert(cell_key) { + if visited.contains(&cell_key) { continue; } + visited.push(cell_key); let cell = tds .cell(cell_key) @@ -6969,20 +7370,20 @@ where } cells.push(cell_key); + if max_cells.is_some_and(|limit| cells.len() > limit) { + return Ok(cells); + } if let Some(neighbors) = cell.neighbors() { for &omit_idx in &omit_indices { if let Some(neighbor_key) = neighbors.get(omit_idx).copied().flatten() { - if !tds.contains_cell(neighbor_key) { + let Some(neighbor_cell) = tds.cell(neighbor_key) else { continue; - } - let neighbor_cell = tds.cell(neighbor_key).ok_or(FlipError::MissingCell { - cell_key: neighbor_key, - })?; + }; if !ridge.iter().all(|v| neighbor_cell.contains_vertex(*v)) { return Err(FlipError::InvalidRidgeAdjacency { cell_key }); } - queue.push_back(neighbor_key); + queue.push(neighbor_key); } } } @@ -6991,6 +7392,78 @@ where Ok(cells) } +/// Returns a vertex's Euclidean point without applying topology-frame lifting. +fn vertex_point( + tds: &Tds, + vertex_key: VertexKey, +) -> Result, FlipError> +where + T: CoordinateScalar, + U: DataType, + V: DataType, +{ + let vertex = tds + .vertex(vertex_key) + .ok_or(FlipError::MissingVertex { vertex_key })?; + Ok(*vertex.point()) +} + +/// Small per-predicate cache for Euclidean vertex coordinates. +struct EuclideanPointCache { + points: SmallBuffer<(VertexKey, Point), MAX_PRACTICAL_DIMENSION_SIZE>, +} + +impl EuclideanPointCache { + /// Starts an empty cache for one local predicate evaluation. + fn new() -> Self { + Self { + points: SmallBuffer::new(), + } + } +} + +impl EuclideanPointCache +where + T: CoordinateScalar, +{ + /// Returns a cached Euclidean point, loading it from the TDS on first use. + fn point( + &mut self, + tds: &Tds, + vertex_key: VertexKey, + ) -> Result, FlipError> + where + U: DataType, + V: DataType, + { + if let Some((_key, point)) = self.points.iter().find(|(key, _point)| *key == vertex_key) { + return Ok(*point); + } + + let point = vertex_point(tds, vertex_key)?; + self.points.push((vertex_key, point)); + Ok(point) + } + + /// Converts a small vertex-key slice into Euclidean points while sharing cache hits. + fn points_for_vertices( + &mut self, + tds: &Tds, + vertices: &[VertexKey], + ) -> Result, MAX_PRACTICAL_DIMENSION_SIZE>, FlipError> + where + U: DataType, + V: DataType, + { + let mut points: SmallBuffer, MAX_PRACTICAL_DIMENSION_SIZE> = + SmallBuffer::with_capacity(vertices.len()); + for &vertex_key in vertices { + points.push(self.point(tds, vertex_key)?); + } + Ok(points) + } +} + /// Converts vertex keys to Euclidean points for predicates that do not need a /// periodic frame. fn vertices_to_points( @@ -7005,10 +7478,7 @@ where let mut points: SmallBuffer, MAX_PRACTICAL_DIMENSION_SIZE> = SmallBuffer::with_capacity(vertices.len()); for &vkey in vertices { - let vertex = tds - .vertex(vkey) - .ok_or(FlipError::MissingVertex { vertex_key: vkey })?; - points.push(*vertex.point()); + points.push(vertex_point(tds, vkey)?); } Ok(points) } @@ -9841,6 +10311,37 @@ mod tests { CellKey::from(KeyData::from_ffi(index)) } + #[test] + fn test_local_postcondition_frontier_deduplicates_seed_and_touched_cells() { + let seed_a = synthetic_cell_key(1); + let seed_b = synthetic_cell_key(2); + let touched_a = synthetic_cell_key(3); + let frontier = local_postcondition_frontier( + &[seed_a, seed_b, seed_a], + &[seed_b, touched_a, touched_a], + ); + + assert_eq!(frontier.len(), 3); + assert_eq!(frontier[0], seed_a); + assert_eq!(frontier[1], seed_b); + assert_eq!(frontier[2], touched_a); + } + + #[test] + fn test_repair_postcondition_required_tracks_mutation_or_applicable_site() { + let mut stats = DelaunayRepairStats::default(); + let mut diagnostics = RepairDiagnostics::default(); + + assert!(!repair_postcondition_required(&stats, &diagnostics)); + + diagnostics.record_applicable_repair_site(); + assert!(repair_postcondition_required(&stats, &diagnostics)); + + diagnostics = RepairDiagnostics::default(); + stats.flips_performed = 1; + assert!(repair_postcondition_required(&stats, &diagnostics)); + } + fn dynamic_flip_rejects_bad_context_for_dimension() { init_tracing(); let mut tds: Tds = Tds::empty(); @@ -12106,9 +12607,10 @@ mod tests { let tds = dt.tds(); let local_cell = tds.cell_keys().next().unwrap(); let outcome = RepairAttemptOutcome { + postcondition_required: false, stats: DelaunayRepairStats::default(), last_applied_flip: None, - touched_cells: std::iter::once(local_cell).collect(), + touched_cells: once(local_cell).collect(), used_full_reseed: true, }; diff --git a/src/core/algorithms/locate.rs b/src/core/algorithms/locate.rs index fbc4378e..12c1bc81 100644 --- a/src/core/algorithms/locate.rs +++ b/src/core/algorithms/locate.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Point location algorithms for triangulations. //! //! Implements facet-walking point location for finding the cell containing @@ -661,6 +663,21 @@ impl LocateStats { } } +/// Internal locate result that also records the final cell reached by the walk. +/// +/// Exterior insertion uses `terminal_cell` as a local conflict-region seed so it +/// can avoid a full triangulation scan while still repairing cells near the hull +/// facet where point location exited the triangulation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LocateTrace { + /// Public location classification. + pub(crate) result: LocateResult, + /// Public locate diagnostics. + pub(crate) stats: LocateStats, + /// Last cell visited by the facet walk before returning or falling back. + pub(crate) terminal_cell: CellKey, +} + /// Locate a point in the triangulation using facet walking (correctness-first). /// /// This function attempts a fast facet-walking traversal starting from `hint` (when provided). @@ -802,6 +819,26 @@ pub fn locate_with_stats( point: &Point, hint: Option, ) -> Result<(LocateResult, LocateStats), LocateError> +where + K: Kernel, + U: DataType, + V: DataType, +{ + let trace = locate_with_trace(tds, kernel, point, hint)?; + Ok((trace.result, trace.stats)) +} + +/// Locate a point and keep the final walked cell for local exterior repair. +/// +/// This mirrors [`locate_with_stats`] but also exposes the last facet-walk cell +/// before the algorithm concluded. For [`LocateResult::Outside`] without a scan +/// fallback, that cell is adjacent to the hull facet crossed by the query point. +pub(crate) fn locate_with_trace( + tds: &Tds, + kernel: &K, + point: &Point, + hint: Option, +) -> Result where K: Kernel, U: DataType, @@ -842,7 +879,11 @@ where steps: stats.walk_steps, }); let result = locate_by_scan(tds, kernel, point)?; - return Ok((result, stats)); + return Ok(LocateTrace { + result, + stats, + terminal_cell: current_cell, + }); } let cell = tds.cell(current_cell).ok_or(LocateError::InvalidCell { @@ -863,12 +904,20 @@ where found_outside_facet = true; break; } - return Ok((LocateResult::Outside, stats)); + return Ok(LocateTrace { + result: LocateResult::Outside, + stats, + terminal_cell: current_cell, + }); } } if !found_outside_facet { - return Ok((LocateResult::InsideCell(current_cell), stats)); + return Ok(LocateTrace { + result: LocateResult::InsideCell(current_cell), + stats, + terminal_cell: current_cell, + }); } } @@ -877,7 +926,11 @@ where steps: stats.walk_steps, }); let result = locate_by_scan(tds, kernel, point)?; - Ok((result, stats)) + Ok(LocateTrace { + result, + stats, + terminal_cell: current_cell, + }) } pub(crate) fn locate_by_scan( diff --git a/src/core/cell.rs b/src/core/cell.rs index 5c1df1bb..a2f8a70d 100644 --- a/src/core/cell.rs +++ b/src/core/cell.rs @@ -1648,10 +1648,17 @@ impl Hash for Cell { #[cfg(test)] mod tests { use super::*; + use crate::core::triangulation::TopologyGuarantee; use crate::core::vertex::vertex; + use crate::geometry::kernel::AdaptiveKernel; use crate::geometry::point::Point; use crate::geometry::predicates::insphere; use crate::geometry::util::{circumcenter, circumradius, circumradius_with_center}; + use crate::prelude::DelaunayTriangulation; + use crate::triangulation::builder::DelaunayTriangulationBuilder; + use crate::triangulation::delaunay::{ + ConstructionOptions, InitialSimplexStrategy, InsertionOrderStrategy, + }; use approx::assert_relative_eq; use std::{cmp, collections::hash_map::DefaultHasher, hash::Hasher}; @@ -1659,10 +1666,6 @@ mod tests { type TestVertex3D = Vertex; type TestVertex2D = Vertex; - use crate::geometry::kernel::AdaptiveKernel; - use crate::prelude::DelaunayTriangulation; - use crate::triangulation::builder::DelaunayTriangulationBuilder; - struct NonDataType(String); fn cell_with_non_data_type_metadata( @@ -2546,8 +2549,17 @@ mod tests { // Note: DelaunayTriangulation::new() creates AdaptiveKernel by default; // use with_kernel to get AdaptiveKernel for f32 vertices + let options = ConstructionOptions::default() + .with_insertion_order(InsertionOrderStrategy::Input) + .with_initial_simplex_strategy(InitialSimplexStrategy::First); let dt: DelaunayTriangulation, (), (), 3> = - DelaunayTriangulation::with_kernel(&AdaptiveKernel::new(), &vertices).unwrap(); + DelaunayTriangulation::with_topology_guarantee_and_options( + &AdaptiveKernel::new(), + &vertices, + TopologyGuarantee::DEFAULT, + options, + ) + .unwrap(); let cell_key = dt.cells().next().unwrap().0; let cell = &dt.tds().cell(cell_key).unwrap(); @@ -2555,14 +2567,27 @@ mod tests { let vertex_uuids = cell.vertex_uuids(dt.tds()).unwrap(); assert_eq!(cell.vertex_uuid_iter(dt.tds()).count(), 4); - // Verify coordinate type is preserved - let first_vertex_key = cell.vertices()[0]; - let first_vertex = &dt.tds().vertex(first_vertex_key).unwrap(); - assert_relative_eq!( - first_vertex.point().coords()[0], - 0.0f32, - epsilon = f32::EPSILON - ); + // Verify coordinate type is preserved without assuming cell-internal vertex order. + for expected_coords in [ + [0.0f32, 0.0f32, 0.0f32], + [1.0f32, 0.0f32, 0.0f32], + [0.0f32, 1.0f32, 0.0f32], + [0.0f32, 0.0f32, 1.0f32], + ] { + assert!( + cell.vertices().iter().any(|&vertex_key| { + dt.tds() + .vertex(vertex_key) + .unwrap() + .point() + .coords() + .iter() + .zip(expected_coords) + .all(|(actual, expected)| (*actual - expected).abs() <= f32::EPSILON) + }), + "Expected cell coordinates {expected_coords:?} not found" + ); + } // Verify UUIDs match the cell's vertices using iterator for (expected_uuid, returned_uuid) in diff --git a/src/core/facet.rs b/src/core/facet.rs index 1241c30f..4cdc2d59 100644 --- a/src/core/facet.rs +++ b/src/core/facet.rs @@ -1123,7 +1123,12 @@ pub fn facet_key_from_vertices(vertices: &[VertexKey]) -> u64 { mod tests { use super::*; use crate::core::tds::VertexKey; - use crate::triangulation::delaunay::DelaunayTriangulation; + use crate::core::triangulation::TopologyGuarantee; + use crate::core::vertex::Vertex; + use crate::geometry::kernel::AdaptiveKernel; + use crate::triangulation::delaunay::{ + ConstructionOptions, DelaunayTriangulation, InitialSimplexStrategy, InsertionOrderStrategy, + }; use crate::vertex; // ============================================================================= @@ -1371,16 +1376,23 @@ mod tests { #[test] fn facet_with_typed_data() { // Create 3D triangulation with typed vertex data - use crate::core::vertex::Vertex; - use crate::geometry::kernel::AdaptiveKernel; let vertices: Vec> = vec![ vertex!([0.0, 0.0, 0.0], 1), vertex!([1.0, 0.0, 0.0], 2), vertex!([0.0, 1.0, 0.0], 3), vertex!([0.0, 0.0, 1.0], 4), ]; + let options = ConstructionOptions::default() + .with_insertion_order(InsertionOrderStrategy::Input) + .with_initial_simplex_strategy(InitialSimplexStrategy::First); let dt: DelaunayTriangulation, i32, (), 3> = - DelaunayTriangulation::with_kernel(&AdaptiveKernel::new(), &vertices).unwrap(); + DelaunayTriangulation::with_topology_guarantee_and_options( + &AdaptiveKernel::new(), + &vertices, + TopologyGuarantee::DEFAULT, + options, + ) + .unwrap(); let cell_key = dt.cells().next().unwrap().0; // Create facet view for facet 0 (excludes vertex 0) @@ -1388,9 +1400,14 @@ mod tests { let facet_vertices: Vec<_> = facet.vertices().unwrap().collect(); assert_eq!(facet_vertices.len(), 3); // 3D facet should have 3 vertices (D) - assert!(facet_vertices.iter().any(|v| v.data == Some(2))); - assert!(facet_vertices.iter().any(|v| v.data == Some(3))); - assert!(facet_vertices.iter().any(|v| v.data == Some(4))); + let cell = dt.tds().cell(cell_key).expect("cell exists"); + for &vertex_key in cell.vertices().iter().skip(1) { + let expected_data = dt.tds().vertex(vertex_key).unwrap().data; + assert!( + facet_vertices.iter().any(|v| v.data == expected_data), + "Expected facet vertex data {expected_data:?} not found" + ); + } } /// Macro to generate dimension-specific facet tests for dimensions 2D-5D. @@ -1525,7 +1542,10 @@ mod tests { fn facet_1d_edge() { // Create 1D triangulation (edge with 2 vertices) let vertices = vec![vertex!([0.0]), vertex!([1.0])]; - let dt = DelaunayTriangulation::new(&vertices).unwrap(); + let options = ConstructionOptions::default() + .with_insertion_order(InsertionOrderStrategy::Input) + .with_initial_simplex_strategy(InitialSimplexStrategy::First); + let dt = DelaunayTriangulation::new_with_options(&vertices, options).unwrap(); let cell_key = dt.cells().next().unwrap().0; // Create facet view for facet 0 (excludes vertex 0) @@ -1770,9 +1790,11 @@ mod tests { let facet_vertices: Vec<_> = facet_view.vertices().unwrap().collect(); assert_eq!(facet_vertices.len(), 3); - // Get original vertices for comparison - let original_vertices = vertices; - let opposite_vertex = &original_vertices[0]; + let cell = dt.tds().cell(cell_key).expect("cell exists"); + let opposite_vertex = dt + .tds() + .vertex(cell.vertices()[0]) + .expect("opposite vertex exists"); // Facet vertices should not include the opposite vertex assert!( diff --git a/src/core/operations.rs b/src/core/operations.rs index 1e52c313..e23d2286 100644 --- a/src/core/operations.rs +++ b/src/core/operations.rs @@ -230,6 +230,74 @@ impl InsertionStatistics { } } +/// Release-visible telemetry for one transactional insertion. +/// +/// These counters are intended for aggregate diagnostics rather than stable performance +/// benchmarking. They let batch construction report whether insertion time is dominated by +/// point location, scan fallbacks, local conflict regions, or global exterior-point scans. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct InsertionTelemetry { + /// Number of point-location calls performed for this insertion. + pub locate_calls: usize, + /// Total facet-walk steps across all point-location calls. + pub locate_walk_steps_total: usize, + /// Maximum facet-walk steps taken by a single point-location call. + pub locate_walk_steps_max: usize, + /// Number of point-location calls that used the caller-provided hint. + pub locate_hint_uses: usize, + /// Number of point-location calls that fell back to a brute-force scan. + pub locate_scan_fallbacks: usize, + /// Number of point-location calls that ended inside a cell. + pub located_inside: usize, + /// Number of point-location calls that ended outside the convex hull. + pub located_outside: usize, + /// Number of point-location calls that ended on a lower-dimensional feature. + pub located_on_boundary: usize, + + /// Number of local conflict-region computations observed by insertion. + pub conflict_region_calls: usize, + /// Total number of cells in local conflict regions. + pub conflict_region_cells_total: usize, + /// Maximum number of cells in a single local conflict region. + pub conflict_region_cells_max: usize, + /// Wall-clock nanoseconds spent computing local conflict regions. + pub conflict_region_nanos: u64, + /// Maximum wall-clock nanoseconds spent computing one local conflict region. + pub conflict_region_nanos_max: u64, + + /// Number of cavity insertion attempts observed by insertion. + pub cavity_insertion_calls: usize, + /// Wall-clock nanoseconds spent filling cavities and wiring neighbors. + pub cavity_insertion_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one cavity insertion attempt. + pub cavity_insertion_nanos_max: u64, + + /// Number of hull extension attempts observed by insertion. + pub hull_extension_calls: usize, + /// Wall-clock nanoseconds spent extending the convex hull. + pub hull_extension_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one hull extension attempt. + pub hull_extension_nanos_max: u64, + + /// Number of post-insertion topology validations observed by insertion. + pub topology_validation_calls: usize, + /// Wall-clock nanoseconds spent in post-insertion topology validation. + pub topology_validation_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one post-insertion validation. + pub topology_validation_nanos_max: u64, + + /// Number of global exterior-point conflict scans. + pub global_conflict_scans: usize, + /// Total cells scanned by global exterior-point conflict scans. + pub global_conflict_cells_scanned: usize, + /// Total cells found by global exterior-point conflict scans. + pub global_conflict_cells_found_total: usize, + /// Maximum cells found by a single global exterior-point conflict scan. + pub global_conflict_cells_found_max: usize, + /// Wall-clock nanoseconds spent in global exterior-point conflict scans. + pub global_conflict_scan_nanos: u64, +} + /// Ephemeral insertion state used by Delaunay triangulations. #[derive(Clone, Copy, Debug)] pub(crate) struct DelaunayInsertionState { @@ -400,6 +468,15 @@ mod tests { DelaunayRepairPolicy::EveryInsertion.decide(1, TopologyGuarantee::PLManifold, op); assert!(matches!(decision, RepairDecision::Proceed)); + let decision = + DelaunayRepairPolicy::EveryInsertion.decide(0, TopologyGuarantee::PLManifold, op); + assert!(matches!( + decision, + RepairDecision::Skip { + reason: RepairSkipReason::PolicyDisabled + } + )); + let decision = DelaunayRepairPolicy::EveryInsertion.decide(1, TopologyGuarantee::Pseudomanifold, op); assert!(matches!(decision, RepairDecision::Proceed)); diff --git a/src/core/triangulation.rs b/src/core/triangulation.rs index ce7eab58..d1b1cb16 100644 --- a/src/core/triangulation.rs +++ b/src/core/triangulation.rs @@ -114,12 +114,12 @@ use crate::core::algorithms::incremental_insertion::{ repair_neighbor_pointers_local, wire_cavity_neighbors, }; #[cfg(debug_assertions)] -use crate::core::algorithms::locate::locate_with_stats; +use crate::core::algorithms::locate::locate; #[cfg(feature = "diagnostics")] use crate::core::algorithms::locate::verify_conflict_region_completeness; use crate::core::algorithms::locate::{ - ConflictError, LocateError, LocateResult, extract_cavity_boundary, find_conflict_region, - locate, locate_by_scan, + ConflictError, LocateError, LocateResult, LocateStats, extract_cavity_boundary, + find_conflict_region, locate_by_scan, locate_with_stats, locate_with_trace, }; use crate::core::cell::{Cell, CellValidationError}; use crate::core::collections::spatial_hash_grid::HashGridIndex; @@ -129,15 +129,18 @@ use crate::core::collections::{ fast_hash_map_with_capacity, fast_hash_set_with_capacity, }; use crate::core::edge::EdgeKey; -use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter, FacetHandle, facet_key_from_vertices}; +#[cfg(test)] +use crate::core::facet::facet_key_from_vertices; +use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter, FacetHandle}; use crate::core::operations::{ - InsertionOutcome, InsertionResult, InsertionStatistics, SuspicionFlags, + InsertionOutcome, InsertionResult, InsertionStatistics, InsertionTelemetry, SuspicionFlags, }; use crate::core::tds::{ CellKey, GeometricError, InvariantError, InvariantKind, InvariantViolation, Tds, TdsConstructionError, TdsError, TriangulationValidationReport, VertexKey, }; use crate::core::traits::data_type::DataType; +#[cfg(test)] use crate::core::util::canonical_points::sorted_cell_points; use crate::core::vertex::Vertex; use crate::geometry::kernel::Kernel; @@ -156,6 +159,10 @@ use crate::topology::manifold::{ use crate::topology::traits::global_topology_model::GlobalTopologyModel; use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; use crate::triangulation::delaunay::DelaunayTriangulationValidationError; +use crate::triangulation::locality::{ + append_live_unique_cell_seeds, collect_local_exterior_conflict_seed_cells, + replace_cells_and_record_removed, retain_cells_and_record_removed, +}; use core::ops::Div; use num_traits::{NumCast, One, Zero}; use std::borrow::Cow; @@ -166,6 +173,7 @@ use std::sync::{ OnceLock, atomic::{AtomicBool, AtomicU64, Ordering}, }; +use std::time::{Duration, Instant}; use thiserror::Error; use uuid::Uuid; @@ -369,34 +377,6 @@ fn log_cavity_reduction_event( ); } -fn retain_conflict_cells_and_record_removed( - conflict_cells: &mut CellKeyBuffer, - repair_seed_cells: &mut CellKeyBuffer, - mut keep_cell: impl FnMut(CellKey) -> bool, -) { - conflict_cells.retain(|cell_key| { - let keep = keep_cell(*cell_key); - if !keep { - repair_seed_cells.push(*cell_key); - } - keep - }); -} - -fn replace_conflict_cells_and_record_removed( - conflict_cells: &mut CellKeyBuffer, - repair_seed_cells: &mut CellKeyBuffer, - replacement: CellKeyBuffer, -) { - let replacement_set: FastHashSet = replacement.iter().copied().collect(); - for &cell_key in conflict_cells.iter() { - if !replacement_set.contains(&cell_key) { - repair_seed_cells.push(cell_key); - } - } - *conflict_cells = replacement; -} - #[expect( clippy::too_many_arguments, reason = "Diagnostic helper keeps retryable skip instrumentation centralized" @@ -837,11 +817,24 @@ struct TryInsertImplOk { cells_removed: usize, /// Suspicion flags observed during the insertion attempt. suspicion: SuspicionFlags, - /// Cells touched while shaping the cavity that should seed follow-up local repair. + /// Cells touched by insertion that should seed follow-up local repair. /// - /// This retains cells that were shrunk out of the final conflict region so higher - /// layers can still revisit them if the insertion leaves a nearby Delaunay violation. + /// This includes live cells created by the insertion plus cells that were shrunk + /// out of the final conflict region so higher layers can revisit nearby + /// Delaunay violations without rediscovering the inserted vertex star globally. repair_seed_cells: CellKeyBuffer, + /// Whether the insertion path can leave local Delaunay work for the caller. + /// + /// Clean interior Bowyer-Watson insertions preserve the Delaunay property. + /// Exterior hull extensions and suspicious fallback/repair paths still need + /// a local flip-repair pass. + delaunay_repair_required: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InsertionValidationWork { + FullValidation, + RequiredTopologyLinks, } /// Internal result from over-shared-facet repair, including the surviving frontier @@ -862,6 +855,19 @@ struct LocalFacetRepairOutcome { frontier_cells: CellKeyBuffer, } +/// Result of filling one insertion cavity, including the follow-up Delaunay +/// repair requirements that depend on how the cavity was shaped. +struct CavityInsertionOutcome { + /// Locate hint for the next insertion. + hint: Option, + /// Number of cells removed during local non-manifold repair. + cells_removed: usize, + /// Cells touched by insertion that should seed follow-up local repair. + repair_seed_cells: CellKeyBuffer, + /// Whether this cavity path can leave Delaunay work for the caller. + delaunay_repair_required: bool, +} + enum InsertionSite<'a> { Interior { start_cell: CellKey, @@ -869,6 +875,7 @@ enum InsertionSite<'a> { }, Exterior { conflict_cells: Option>, + repair_seed_cells: CellKeyBuffer, }, } @@ -877,10 +884,14 @@ enum InsertionSite<'a> { pub(crate) struct DetailedInsertionResult { /// Public insertion outcome returned to higher layers. pub outcome: InsertionOutcome, - /// Telemetry collected while attempting the insertion. + /// Public statistics collected while attempting the insertion. pub stats: InsertionStatistics, - /// Extra cells that should widen the caller's local repair seed set. + /// Internal path telemetry collected while attempting the insertion. + pub telemetry: InsertionTelemetry, + /// Local cells that should seed the caller's Delaunay repair set. pub repair_seed_cells: CellKeyBuffer, + /// Whether callers should run Delaunay repair over `repair_seed_cells`. + pub delaunay_repair_required: bool, } /// Policy controlling when the triangulation runs global validation passes. @@ -3608,6 +3619,7 @@ where bulk_index: Option, ) -> Result { let mut stats = InsertionStatistics::default(); + let mut telemetry = InsertionTelemetry::default(); let original_coords = *vertex.point().coords(); let original_uuid = vertex.uuid(); let mut current_vertex = vertex; @@ -3674,7 +3686,9 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Skipped { error }, stats, + telemetry, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); }; @@ -3721,7 +3735,9 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Skipped { error }, stats, + telemetry, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } @@ -3751,6 +3767,7 @@ where hint, attempt, &tds_snapshot, + &mut telemetry, ) }; #[cfg(not(test))] @@ -3760,6 +3777,7 @@ where hint, attempt, &tds_snapshot, + &mut telemetry, ); match result { @@ -3767,6 +3785,7 @@ where inserted, cells_removed, repair_seed_cells, + delaunay_repair_required, .. }) => { stats.cells_removed_during_repair = cells_removed; @@ -3790,7 +3809,9 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Inserted { vertex_key, hint }, stats, + telemetry, repair_seed_cells, + delaunay_repair_required, }); } Err(e) => { @@ -3805,7 +3826,9 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Skipped { error: e }, stats, + telemetry, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } @@ -3861,9 +3884,11 @@ where return Ok(DetailedInsertionResult { outcome: InsertionOutcome::Skipped { error: e }, stats, + telemetry, // Skipped insertions do not mutate the triangulation, so any // intermediate cavity-seed hints are irrelevant to callers. repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } else { // Non-retryable structural error (e.g., duplicate UUID) @@ -4128,9 +4153,12 @@ where Ok(()) } - fn validate_after_insertion(&self, suspicion: SuspicionFlags) -> Result<(), InvariantError> { + fn validation_after_insertion_work( + &self, + suspicion: SuspicionFlags, + ) -> Option { if self.tds.number_of_cells() == 0 { - return Ok(()); + return None; } let should_validate = self.validation_policy.should_validate(suspicion); @@ -4139,15 +4167,26 @@ where .topology_guarantee .requires_vertex_links_during_insertion(); - if !should_validate && !requires_link_checks { - return Ok(()); + if should_validate { + Some(InsertionValidationWork::FullValidation) + } else if requires_link_checks { + Some(InsertionValidationWork::RequiredTopologyLinks) + } else { + None } + } + + fn validate_after_insertion(&self, suspicion: SuspicionFlags) -> Result<(), InvariantError> { + let Some(work) = self.validation_after_insertion_work(suspicion) else { + return Ok(()); + }; self.log_validation_trigger_if_enabled(suspicion); - if should_validate { - self.is_valid() - } else { - self.validate_required_topology_links() + match work { + InsertionValidationWork::FullValidation => self.is_valid(), + InsertionValidationWork::RequiredTopologyLinks => { + self.validate_required_topology_links() + } } } @@ -4225,19 +4264,34 @@ where hint: Option, attempt: usize, tds_snapshot: &Tds, + telemetry: &mut InsertionTelemetry, ) -> Result { - let mut insert_ok = self.try_insert_impl(vertex, conflict_cells, hint)?; + let mut insert_ok = self.try_insert_impl(vertex, conflict_cells, hint, telemetry)?; if attempt > 0 { insert_ok.suspicion.perturbation_used = true; } + if insert_ok.suspicion.is_suspicious() { + insert_ok.delaunay_repair_required = true; + } // Skip Level 3 validation during bootstrap (vertices but no cells yet). if self.tds.number_of_cells() == 0 { return Ok(insert_ok); } - if let Err(validation_err) = self.validate_after_insertion(insert_ok.suspicion) { + let validation_work = self.validation_after_insertion_work(insert_ok.suspicion); + let validation_started = validation_work.map(|_| Instant::now()); + let validation_result = self.validate_after_insertion(insert_ok.suspicion); + if let (Some(InsertionValidationWork::FullValidation), Some(validation_started)) = + (validation_work, validation_started) + { + Self::record_topology_validation_telemetry( + telemetry, + Self::duration_nanos_saturating(validation_started.elapsed()), + ); + } + if let Err(validation_err) = validation_result { // Roll back to snapshot and attempt a star-split fallback for interior points. self.tds = tds_snapshot.clone(); return self.try_star_split_fallback_after_topology_failure( @@ -4245,6 +4299,7 @@ where hint, attempt, validation_err, + telemetry, ); } @@ -4263,9 +4318,16 @@ where hint: Option, attempt: usize, validation_err: InvariantError, + telemetry: &mut InsertionTelemetry, ) -> Result { let point = *vertex.point(); - let location = locate(&self.tds, &self.kernel, &point, hint); + let location = match locate_with_stats(&self.tds, &self.kernel, &point, hint) { + Ok((location, stats)) => { + Self::record_locate_telemetry(telemetry, location, &stats); + Ok(location) + } + Err(error) => Err(error), + }; let Ok(LocateResult::InsideCell(start_cell)) = location else { return Err(Self::invariant_error_to_insertion_error(validation_err)); @@ -4274,16 +4336,26 @@ where let mut star_conflict = CellKeyBuffer::new(); star_conflict.push(start_cell); - match self.try_insert_impl(vertex, Some(&star_conflict), Some(start_cell)) { + match self.try_insert_impl(vertex, Some(&star_conflict), Some(start_cell), telemetry) { Ok(mut fallback_ok) => { fallback_ok.suspicion.fallback_star_split = true; if attempt > 0 { fallback_ok.suspicion.perturbation_used = true; } + fallback_ok.delaunay_repair_required = true; - if let Err(fallback_validation_err) = - self.validate_after_insertion(fallback_ok.suspicion) + let validation_work = self.validation_after_insertion_work(fallback_ok.suspicion); + let validation_started = validation_work.map(|_| Instant::now()); + let validation_result = self.validate_after_insertion(fallback_ok.suspicion); + if let (Some(InsertionValidationWork::FullValidation), Some(validation_started)) = + (validation_work, validation_started) { + Self::record_topology_validation_telemetry( + telemetry, + Self::duration_nanos_saturating(validation_started.elapsed()), + ); + } + if let Err(fallback_validation_err) = validation_result { return Err(Self::invariant_error_to_insertion_error( fallback_validation_err, )); @@ -4438,8 +4510,12 @@ where /// Find all conflict cells by scanning the entire triangulation. /// - /// This is used for exterior points where the standard BFS conflict-region - /// search lacks a guaranteed seed cell inside the conflict region. + /// Test-only global conflict scanner for malformed-cell error coverage. + /// + /// Exterior production insertion deliberately avoids this path: hull + /// extension is the local topological mutation, and Delaunay violations are + /// left to the cadenced or final repair layers. + #[cfg(test)] fn find_conflict_region_global( &self, point: &Point, @@ -4523,6 +4599,7 @@ where } /// Returns true if any conflict cell has a facet on the hull boundary. + #[cfg(test)] fn conflict_region_touches_boundary( &self, conflict_cells: &CellKeyBuffer, @@ -4585,7 +4662,7 @@ where mut conflict_cells: CellKeyBuffer, fallback_cell: Option, suspicion: &mut SuspicionFlags, - ) -> Result<(Option, usize, CellKeyBuffer), InsertionError> { + ) -> Result { #[cfg(not(debug_assertions))] let _ = point; @@ -4599,12 +4676,16 @@ where suspicion.empty_conflict_region = true; suspicion.fallback_star_split = true; conflict_cells.push(start_cell); + // The fallback star-split is topologically safe but not a full + // Bowyer-Watson conflict-region replacement, so local Delaunay + // repair must revisit it. } // Preserve every cell that participates in cavity shaping and is later // removed from the final cavity so callers can seed local Delaunay // repair from the surviving fringe. let mut repair_seed_cells = CellKeyBuffer::new(); + let mut delaunay_repair_required = suspicion.fallback_star_split; // Extract cavity boundary. // @@ -4687,9 +4768,10 @@ where || format!("ridge_fan_shrink remove_cells={extra_cells:?}"), ); saw_ridge_fan_shrink = true; + delaunay_repair_required = true; let remove_set: FastHashSet = extra_cells.iter().copied().collect(); - retain_conflict_cells_and_record_removed( + retain_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, |cell_key| !remove_set.contains(&cell_key), @@ -4725,6 +4807,7 @@ where if !cells_to_add.is_empty() { // EXPAND: add the hole-filling cells. + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::debug!( add_count = cells_to_add.len(), @@ -4746,6 +4829,7 @@ where } } else if conflict_cells.len() > D + 1 { // SHRINK fallback: no non-conflict neighbors found. + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::debug!( remove_count = disconnected_cells.len(), @@ -4764,7 +4848,7 @@ where ); let remove_set: FastHashSet = disconnected_cells.iter().copied().collect(); - retain_conflict_cells_and_record_removed( + retain_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, |cell_key| !remove_set.contains(&cell_key), @@ -4784,6 +4868,7 @@ where Err(ConflictError::OpenBoundary { open_cell, .. }) if conflict_cells.len() > D + 1 => { + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::debug!( ?open_cell, @@ -4797,7 +4882,7 @@ where || format!("open_boundary_shrink open_cell={open_cell:?}"), ); let open = *open_cell; - retain_conflict_cells_and_record_removed( + retain_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, |cell_key| cell_key != open, @@ -4870,6 +4955,7 @@ where }; suspicion.fallback_star_split = true; + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::warn!( @@ -4878,7 +4964,7 @@ where let mut replacement = CellKeyBuffer::new(); replacement.push(start_cell); - replace_conflict_cells_and_record_removed( + replace_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, replacement, @@ -4907,6 +4993,7 @@ where suspicion.empty_conflict_region = true; suspicion.fallback_star_split = true; + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::warn!( @@ -4915,7 +5002,7 @@ where let mut replacement = CellKeyBuffer::new(); replacement.push(start_cell); - replace_conflict_cells_and_record_removed( + replace_cells_and_record_removed( &mut conflict_cells, &mut repair_seed_cells, replacement, @@ -5020,6 +5107,7 @@ where // Only mark this as "suspicious" if we *actually* detected local facet issues // and entered the repair path. suspicion.repair_loop_entered = true; + delaunay_repair_required = true; #[cfg(debug_assertions)] tracing::debug!( @@ -5053,6 +5141,7 @@ where if removed > 0 { suspicion.cells_removed = true; + delaunay_repair_required = true; } #[cfg(debug_assertions)] @@ -5092,6 +5181,7 @@ where let repaired = self .repair_neighbors_after_local_cell_removal(&new_cells, &neighbor_repair_frontier)?; suspicion.neighbor_pointers_rebuilt = repaired > 0; + delaunay_repair_required = true; } // Canonicalize cell ordering and geometric orientation invariants. @@ -5139,8 +5229,17 @@ where // Connectedness guard (STRUCTURAL SAFETY, NOT Level 3 validation) self.validate_connectedness(&new_cells)?; + // Seed follow-up Delaunay repair from the local insertion product. Higher layers + // use these cells to avoid rediscovering the inserted vertex star with a global scan. + append_live_unique_cell_seeds(&self.tds, &new_cells, &mut repair_seed_cells); + // Return hint for next insertion - Ok((hint, total_removed, repair_seed_cells)) + Ok(CavityInsertionOutcome { + hint, + cells_removed: total_removed, + repair_seed_cells, + delaunay_repair_required: delaunay_repair_required || suspicion.is_suspicious(), + }) } /// Repair stale incident-cell pointers and detect truly isolated vertices. @@ -5194,6 +5293,119 @@ where Ok(()) } + /// Records one point-location result into insertion telemetry. + #[inline] + fn record_locate_telemetry( + telemetry: &mut InsertionTelemetry, + location: LocateResult, + stats: &LocateStats, + ) { + telemetry.locate_calls = telemetry.locate_calls.saturating_add(1); + telemetry.locate_walk_steps_total = telemetry + .locate_walk_steps_total + .saturating_add(stats.walk_steps); + telemetry.locate_walk_steps_max = telemetry.locate_walk_steps_max.max(stats.walk_steps); + + if stats.used_hint { + telemetry.locate_hint_uses = telemetry.locate_hint_uses.saturating_add(1); + } + + if stats.fell_back_to_scan() { + telemetry.locate_scan_fallbacks = telemetry.locate_scan_fallbacks.saturating_add(1); + } + + match location { + LocateResult::InsideCell(_) => { + telemetry.located_inside = telemetry.located_inside.saturating_add(1); + } + LocateResult::Outside => { + telemetry.located_outside = telemetry.located_outside.saturating_add(1); + } + LocateResult::OnFacet(_, _) | LocateResult::OnEdge(_) | LocateResult::OnVertex(_) => { + telemetry.located_on_boundary = telemetry.located_on_boundary.saturating_add(1); + } + } + } + + #[inline] + fn record_conflict_region_telemetry(telemetry: &mut InsertionTelemetry, cells: usize) { + telemetry.conflict_region_calls = telemetry.conflict_region_calls.saturating_add(1); + telemetry.conflict_region_cells_total = + telemetry.conflict_region_cells_total.saturating_add(cells); + telemetry.conflict_region_cells_max = telemetry.conflict_region_cells_max.max(cells); + } + + #[inline] + fn record_conflict_region_timing(telemetry: &mut InsertionTelemetry, elapsed_nanos: u64) { + telemetry.conflict_region_nanos = telemetry + .conflict_region_nanos + .saturating_add(elapsed_nanos); + telemetry.conflict_region_nanos_max = + telemetry.conflict_region_nanos_max.max(elapsed_nanos); + } + + #[inline] + fn record_cavity_insertion_telemetry(telemetry: &mut InsertionTelemetry, elapsed_nanos: u64) { + telemetry.cavity_insertion_calls = telemetry.cavity_insertion_calls.saturating_add(1); + telemetry.cavity_insertion_nanos = telemetry + .cavity_insertion_nanos + .saturating_add(elapsed_nanos); + telemetry.cavity_insertion_nanos_max = + telemetry.cavity_insertion_nanos_max.max(elapsed_nanos); + } + + #[inline] + fn record_hull_extension_telemetry(telemetry: &mut InsertionTelemetry, elapsed_nanos: u64) { + telemetry.hull_extension_calls = telemetry.hull_extension_calls.saturating_add(1); + telemetry.hull_extension_nanos = + telemetry.hull_extension_nanos.saturating_add(elapsed_nanos); + telemetry.hull_extension_nanos_max = telemetry.hull_extension_nanos_max.max(elapsed_nanos); + } + + #[inline] + fn record_topology_validation_telemetry( + telemetry: &mut InsertionTelemetry, + elapsed_nanos: u64, + ) { + telemetry.topology_validation_calls = telemetry.topology_validation_calls.saturating_add(1); + telemetry.topology_validation_nanos = telemetry + .topology_validation_nanos + .saturating_add(elapsed_nanos); + telemetry.topology_validation_nanos_max = + telemetry.topology_validation_nanos_max.max(elapsed_nanos); + } + + #[inline] + fn duration_nanos_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) + } + + fn collect_exterior_repair_seed_cells( + &self, + point: &Point, + terminal_cell: CellKey, + locate_stats: &LocateStats, + telemetry: &mut InsertionTelemetry, + ) -> Result { + if locate_stats.fell_back_to_scan() || !self.tds.contains_cell(terminal_cell) { + return Ok(CellKeyBuffer::new()); + } + + let conflict_started = Instant::now(); + let local_seed_cells = collect_local_exterior_conflict_seed_cells( + &self.tds, + &self.kernel, + point, + terminal_cell, + )?; + Self::record_conflict_region_telemetry(telemetry, local_seed_cells.conflict_cells_found); + Self::record_conflict_region_timing( + telemetry, + Self::duration_nanos_saturating(conflict_started.elapsed()), + ); + Ok(local_seed_cells.seed_cells) + } + /// Internal implementation of insert without retry logic. /// Returns the result and the number of cells removed during repair. /// @@ -5208,6 +5420,7 @@ where vertex: Vertex, conflict_cells: Option<&CellKeyBuffer>, hint: Option, + telemetry: &mut InsertionTelemetry, ) -> Result { let mut suspicion = SuspicionFlags::default(); @@ -5238,6 +5451,7 @@ where cells_removed: 0, suspicion, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } else if num_vertices == D + 1 { // Build initial simplex from all D+1 vertices @@ -5265,57 +5479,36 @@ where cells_removed: 0, suspicion, repair_seed_cells: CellKeyBuffer::new(), + delaunay_repair_required: false, }); } - // 3. Locate containing cell (for vertex D+2 and beyond) - #[cfg(debug_assertions)] - let (location, locate_stats) = { - #[cfg(debug_assertions)] - { - let log_locate = std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() - || std::env::var_os("DELAUNAY_DEBUG_LOCATE").is_some(); - if log_locate { - let (location, stats) = - locate_with_stats(&self.tds, &self.kernel, &point, hint)?; - (location, Some(stats)) - } else { - (locate(&self.tds, &self.kernel, &point, hint)?, None) - } - } - #[cfg(not(debug_assertions))] - { - (locate(&self.tds, &self.kernel, &point, hint)?, None) - } - }; - - #[cfg(not(debug_assertions))] - let location = locate(&self.tds, &self.kernel, &point, hint)?; + // 3. Locate containing cell (for vertex D+2 and beyond). + // + // `locate()` delegates to `locate_with_stats()`, so collecting the stats here keeps + // the same point-location algorithm while making release-mode batch diagnostics useful. + let locate_trace = locate_with_trace(&self.tds, &self.kernel, &point, hint)?; + let location = locate_trace.result; + let locate_stats = locate_trace.stats; + Self::record_locate_telemetry(telemetry, location, &locate_stats); #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() || std::env::var_os("DELAUNAY_DEBUG_LOCATE").is_some() { - if let Some(stats) = locate_stats { - tracing::debug!( - point = ?point, - location = ?location, - start_cell = ?stats.start_cell, - used_hint = stats.used_hint, - walk_steps = stats.walk_steps, - fallback = ?stats.fallback, - "try_insert_impl: locate stats" - ); - } else { - tracing::debug!( - point = ?point, - location = ?location, - "try_insert_impl: locate result" - ); - } + tracing::debug!( + point = ?point, + location = ?location, + start_cell = ?locate_stats.start_cell, + used_hint = locate_stats.used_hint, + walk_steps = locate_stats.walk_steps, + fallback = ?locate_stats.fallback, + "try_insert_impl: locate stats" + ); } // 4. Determine the supported insertion site and any conflict cells it needs. + let caller_provided_conflict_cells = conflict_cells.is_some(); let insertion_site = match (location, conflict_cells) { (LocateResult::InsideCell(start_cell), None) => { // Interior point: compute conflict region automatically. @@ -5332,7 +5525,13 @@ where // // Fallback: treat the containing cell as the conflict region, effectively performing // a star-split of that cell to keep the simplicial complex connected. + let conflict_started = Instant::now(); let computed = find_conflict_region(&self.tds, &self.kernel, &point, start_cell)?; + Self::record_conflict_region_telemetry(telemetry, computed.len()); + Self::record_conflict_region_timing( + telemetry, + Self::duration_nanos_saturating(conflict_started.elapsed()), + ); #[cfg(feature = "diagnostics")] if std::env::var_os("DELAUNAY_DEBUG_CONFLICT_VERIFY").is_some() { @@ -5385,53 +5584,26 @@ where } } (LocateResult::Outside, None) => { - // 2D exterior insertions skip the global conflict-region scan and go straight to - // hull extension, which is cheaper and more reliable in 2D. For D>2 we attempt - // cavity insertion first using a global conflict scan. - if D == 2 { - #[cfg(debug_assertions)] + // Exterior insertion is the hull-extension case. Avoid the old + // full-TDS conflict scan here; it was O(number_of_cells) per + // exterior point, often only to rediscover that the hull path + // was required anyway. Cadenced and final Delaunay repair own + // any local empty-circumsphere cleanup after the hull mutation. + #[cfg(debug_assertions)] + if env::var_os("DELAUNAY_DEBUG_HULL").is_some() { tracing::debug!( - "Outside insertion in 2D: skipping global conflict-region scan; using hull extension" + "Outside insertion: skipping global conflict-region scan; using hull extension" ); - InsertionSite::Exterior { - conflict_cells: None, - } - } else { - #[cfg(debug_assertions)] - if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { - tracing::debug!("Outside insertion: starting global conflict-region scan"); - } - let computed = self.find_conflict_region_global(&point)?; - if computed.is_empty() { - #[cfg(debug_assertions)] - if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { - tracing::debug!( - "Outside insertion: global conflict region empty; will use hull extension" - ); - } - InsertionSite::Exterior { - conflict_cells: None, - } - } else if self.conflict_region_touches_boundary(&computed)? { - #[cfg(debug_assertions)] - tracing::debug!( - "Outside insertion (D={D}) conflict region touches hull; skipping cavity insertion" - ); - // Avoid cavity insertion when the conflict region touches the hull. - // These mixed boundaries can yield ridge-link singularities in higher dimensions. - InsertionSite::Exterior { - conflict_cells: None, - } - } else { - #[cfg(debug_assertions)] - tracing::debug!( - "Outside insertion (D={D}) using global conflict region with {} cells", - computed.len() - ); - InsertionSite::Exterior { - conflict_cells: Some(Cow::Owned(computed)), - } - } + } + let repair_seed_cells = self.collect_exterior_repair_seed_cells( + &point, + locate_trace.terminal_cell, + &locate_stats, + telemetry, + )?; + InsertionSite::Exterior { + conflict_cells: None, + repair_seed_cells, } } (LocateResult::Outside, Some(cells)) => { @@ -5442,8 +5614,15 @@ where "Outside insertion: caller provided empty conflict region; will use hull extension" ); } + let repair_seed_cells = self.collect_exterior_repair_seed_cells( + &point, + locate_trace.terminal_cell, + &locate_stats, + telemetry, + )?; InsertionSite::Exterior { conflict_cells: None, + repair_seed_cells, } } else { #[cfg(debug_assertions)] @@ -5455,6 +5634,7 @@ where } InsertionSite::Exterior { conflict_cells: Some(Cow::Borrowed(cells)), + repair_seed_cells: cells.iter().copied().collect(), } } } @@ -5471,21 +5651,32 @@ where conflict_cells, } => { let conflict_cells = conflict_cells.into_owned(); - let (hint, total_removed, repair_seed_cells) = self.insert_with_conflict_region( + let cavity_started = Instant::now(); + let insertion_result = self.insert_with_conflict_region( v_key, &point, conflict_cells, Some(start_cell), &mut suspicion, - )?; + ); + Self::record_cavity_insertion_telemetry( + telemetry, + Self::duration_nanos_saturating(cavity_started.elapsed()), + ); + let outcome = insertion_result?; Ok(TryInsertImplOk { - inserted: (v_key, hint), - cells_removed: total_removed, + inserted: (v_key, outcome.hint), + cells_removed: outcome.cells_removed, suspicion, - repair_seed_cells, + repair_seed_cells: outcome.repair_seed_cells, + delaunay_repair_required: outcome.delaunay_repair_required + || caller_provided_conflict_cells, }) } - InsertionSite::Exterior { conflict_cells } => { + InsertionSite::Exterior { + conflict_cells, + repair_seed_cells: exterior_repair_seed_cells, + } => { if let Some(conflict_cells) = conflict_cells { let conflict_cells = conflict_cells.into_owned(); #[cfg(debug_assertions)] @@ -5494,6 +5685,7 @@ where tracing::debug!( "Outside insertion attempting cavity insertion with conflict region size {conflict_len}" ); + let cavity_started = Instant::now(); let result = self.insert_with_conflict_region( v_key, &point, @@ -5501,13 +5693,18 @@ where None, &mut suspicion, ); + Self::record_cavity_insertion_telemetry( + telemetry, + Self::duration_nanos_saturating(cavity_started.elapsed()), + ); match result { - Ok((hint, total_removed, repair_seed_cells)) => { + Ok(outcome) => { return Ok(TryInsertImplOk { - inserted: (v_key, hint), - cells_removed: total_removed, + inserted: (v_key, outcome.hint), + cells_removed: outcome.cells_removed, suspicion, - repair_seed_cells, + repair_seed_cells: outcome.repair_seed_cells, + delaunay_repair_required: true, }); } Err(err) => { @@ -5558,7 +5755,13 @@ where "Outside insertion: proceeding to hull extension" ); } - let new_cells = match extend_hull(&mut self.tds, &self.kernel, v_key, &point) { + let hull_started = Instant::now(); + let hull_result = extend_hull(&mut self.tds, &self.kernel, v_key, &point); + Self::record_hull_extension_telemetry( + telemetry, + Self::duration_nanos_saturating(hull_started.elapsed()), + ); + let new_cells = match hull_result { Ok(cells) => cells, Err(err) => { let retry_inside = matches!( @@ -5570,6 +5773,26 @@ where if retry_inside { let fallback_location = locate_by_scan(&self.tds, &self.kernel, &point)?; + // This retry starts as a scan, so account for the fallback + // explicitly and let the common recorder handle the outcome. + telemetry.locate_scan_fallbacks = + telemetry.locate_scan_fallbacks.saturating_add(1); + let scan_start_cell = self + .tds + .cell_keys() + .next() + .ok_or(LocateError::EmptyTriangulation)?; + let scan_stats = LocateStats { + start_cell: scan_start_cell, + used_hint: false, + walk_steps: 0, + fallback: None, + }; + Self::record_locate_telemetry( + telemetry, + fallback_location, + &scan_stats, + ); if let LocateResult::InsideCell(start_cell) = fallback_location { #[cfg(debug_assertions)] if std::env::var_os("DELAUNAY_DEBUG_HULL").is_some() { @@ -5582,19 +5805,25 @@ where suspicion.fallback_star_split = true; let mut star_conflict = CellKeyBuffer::new(); star_conflict.push(start_cell); - let (hint, total_removed, repair_seed_cells) = self - .insert_with_conflict_region( - v_key, - &point, - star_conflict, - Some(start_cell), - &mut suspicion, - )?; + let cavity_started = Instant::now(); + let insertion_result = self.insert_with_conflict_region( + v_key, + &point, + star_conflict, + Some(start_cell), + &mut suspicion, + ); + Self::record_cavity_insertion_telemetry( + telemetry, + Self::duration_nanos_saturating(cavity_started.elapsed()), + ); + let outcome = insertion_result?; return Ok(TryInsertImplOk { - inserted: (v_key, hint), - cells_removed: total_removed, + inserted: (v_key, outcome.hint), + cells_removed: outcome.cells_removed, suspicion, - repair_seed_cells, + repair_seed_cells: outcome.repair_seed_cells, + delaunay_repair_required: true, }); } } @@ -5800,11 +6029,19 @@ where self.validate_connectedness(&new_cells)?; // Return vertex key and hint for next insertion + let mut repair_seed_cells = CellKeyBuffer::new(); + append_live_unique_cell_seeds(&self.tds, &new_cells, &mut repair_seed_cells); + append_live_unique_cell_seeds( + &self.tds, + &exterior_repair_seed_cells, + &mut repair_seed_cells, + ); Ok(TryInsertImplOk { inserted: (v_key, hint), cells_removed: total_removed, suspicion, - repair_seed_cells: CellKeyBuffer::new(), + repair_seed_cells, + delaunay_repair_required: true, }) } } @@ -6666,45 +6903,6 @@ mod tests { assert!(cavity_conflict_error_summary(&internal).contains("internal_inconsistency site=")); } - #[test] - fn test_cavity_reduction_cell_bookkeeping_records_removed_cells() { - let a = CellKey::from(KeyData::from_ffi(31)); - let b = CellKey::from(KeyData::from_ffi(32)); - let c = CellKey::from(KeyData::from_ffi(33)); - let d = CellKey::from(KeyData::from_ffi(34)); - - let mut conflict_cells: CellKeyBuffer = [a, b, c].into_iter().collect(); - let mut repair_seed_cells = CellKeyBuffer::new(); - retain_conflict_cells_and_record_removed( - &mut conflict_cells, - &mut repair_seed_cells, - |ck| ck != b, - ); - assert_eq!( - conflict_cells.iter().copied().collect::>(), - vec![a, c] - ); - assert_eq!( - repair_seed_cells.iter().copied().collect::>(), - vec![b] - ); - - let replacement: CellKeyBuffer = [c, d].into_iter().collect(); - replace_conflict_cells_and_record_removed( - &mut conflict_cells, - &mut repair_seed_cells, - replacement, - ); - assert_eq!( - conflict_cells.iter().copied().collect::>(), - vec![c, d] - ); - assert_eq!( - repair_seed_cells.iter().copied().collect::>(), - vec![b, a] - ); - } - #[test] fn test_log_cavity_reduction_event_only_evaluates_when_enabled() { let mut conflict_cells = CellKeyBuffer::new(); @@ -7177,6 +7375,26 @@ mod tests { .unwrap(); } + #[test] + fn test_validation_after_insertion_will_run_matches_policy_and_link_requirements() { + let tds = build_disconnected_two_triangles_tds_2d(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + + tri.set_validation_policy(ValidationPolicy::OnSuspicion); + tri.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); + assert_eq!( + tri.validation_after_insertion_work(SuspicionFlags::default()), + None + ); + + tri.set_topology_guarantee(TopologyGuarantee::PLManifold); + assert_eq!( + tri.validation_after_insertion_work(SuspicionFlags::default()), + Some(InsertionValidationWork::RequiredTopologyLinks) + ); + } + #[test] fn test_select_locate_hint_from_hash_grid_returns_incident_cell() { let vertices = vec![ @@ -9329,6 +9547,121 @@ mod tests { assert_eq!(tri.number_of_cells(), 1); } + #[test] + fn triangulation_exterior_insert_3d_uses_hull_extension_without_global_conflict_scan() { + let mut tri: Triangulation, (), (), 3> = + Triangulation::new_empty(FastKernel::new()); + + for coords in [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] { + tri.insert_with_statistics(vertex!(coords), None, None) + .unwrap(); + } + + let hint = tri.cells().next().map(|(cell_key, _)| cell_key); + let detail = tri + .insert_with_statistics_seeded_indexed_detailed( + vertex!([2.0, 2.0, 2.0]), + None, + hint, + 0, + None, + None, + ) + .unwrap(); + + assert!(matches!( + detail.outcome, + InsertionOutcome::Inserted { hint: Some(_), .. } + )); + assert_eq!(detail.telemetry.global_conflict_scans, 0); + assert_eq!(detail.telemetry.global_conflict_cells_scanned, 0); + assert_eq!(detail.telemetry.global_conflict_cells_found_total, 0); + assert_eq!(detail.telemetry.global_conflict_scan_nanos, 0); + assert_eq!(detail.telemetry.cavity_insertion_calls, 0); + assert_eq!(detail.telemetry.hull_extension_calls, 1); + assert!( + !detail.repair_seed_cells.is_empty(), + "hull extension should return local repair seeds" + ); + assert!(tri.is_valid().is_ok()); + } + + #[test] + fn triangulation_exterior_insert_with_empty_conflicts_uses_local_repair_seeds() { + let mut tri: Triangulation, (), (), 3> = + Triangulation::new_empty(FastKernel::new()); + + for coords in [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ] { + tri.insert_with_statistics(vertex!(coords), None, None) + .unwrap(); + } + + let hint = tri.cells().next().map(|(cell_key, _)| cell_key); + let empty_conflicts = CellKeyBuffer::new(); + let detail = tri + .insert_with_statistics_seeded_indexed_detailed( + vertex!([2.0, 2.0, 2.0]), + Some(&empty_conflicts), + hint, + 0, + None, + None, + ) + .unwrap(); + + assert!(matches!( + detail.outcome, + InsertionOutcome::Inserted { hint: Some(_), .. } + )); + assert_eq!(detail.telemetry.global_conflict_scans, 0); + assert_eq!(detail.telemetry.hull_extension_calls, 1); + assert!( + !detail.repair_seed_cells.is_empty(), + "empty caller conflicts should still use terminal-cell local repair seeds" + ); + assert!(tri.is_valid().is_ok()); + } + + #[test] + fn triangulation_policy_skipped_validation_does_not_increment_telemetry() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + ]; + let tds = + Triangulation::, (), (), 2>::build_initial_simplex(&vertices).unwrap(); + let mut tri = + Triangulation::, (), (), 2>::new_with_tds(FastKernel::new(), tds); + tri.set_validation_policy(ValidationPolicy::OnSuspicion); + tri.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); + + let hint = tri.cells().next().map(|(cell_key, _)| cell_key); + let detail = tri + .insert_with_statistics_seeded_indexed_detailed( + vertex!([0.25, 0.25]), + None, + hint, + 0, + None, + None, + ) + .unwrap(); + + assert!(matches!(detail.outcome, InsertionOutcome::Inserted { .. })); + assert_eq!(detail.telemetry.topology_validation_calls, 0); + } + #[test] fn triangulation_insert_with_statistics_hint_usage_4d() { let mut tri: Triangulation, (), (), 4> = @@ -11013,8 +11346,8 @@ mod tests { /// This validates that perturbation is proportional to local feature size. #[test] fn test_perturbation_scale_invariance_3d() { - const EXPECTED_VERTEX_COUNT: usize = 7; - const EXPECTED_CELL_COUNT: usize = 8; + const EXPECTED_VERTEX_COUNT: usize = 8; + const EXPECTED_CELL_COUNT: usize = 10; fn build_at_scale(scale: f64) -> (usize, usize) { let base_coords: [[f64; 3]; 8] = [ diff --git a/src/lib.rs b/src/lib.rs index 5c0d5c14..30134f62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,7 +49,7 @@ //! //! | Task | Import | //! |---|---| -//! | Build a triangulation, insert/remove vertices | `use delaunay::prelude::triangulation::*` | +//! | Construct/configure a Delaunay triangulation | `use delaunay::prelude::triangulation::construction::*` | //! | Low-level incremental insertion building blocks | `use delaunay::prelude::triangulation::insertion::*` | //! | Read-only queries, traversal, convex hull | `use delaunay::prelude::query::*` | //! | Point location and conflict-region algorithms | `use delaunay::prelude::algorithms::*` | @@ -59,10 +59,13 @@ //! | Bistellar flips (Pachner moves) | `use delaunay::prelude::triangulation::flips::*` | //! | Delaunay repair and flip-based Level 4 validation | `use delaunay::prelude::triangulation::repair::*` | //! | Delaunayize workflow (repair + flip) | `use delaunay::prelude::triangulation::delaunayize::*` | +//! | Construction telemetry diagnostics | `use delaunay::prelude::triangulation::diagnostics::*` | +//! | Construction validation cadence/policy | `use delaunay::prelude::triangulation::validation::*` | //! | Topology validation, Euler characteristic | `use delaunay::prelude::topology::validation::*` | //! | Topological spaces and topology traits | `use delaunay::prelude::topology::spaces::*` | //! | Low-level TDS cells, facets, keys | `use delaunay::prelude::tds::*` | //! | Collection types (`FastHashMap`, etc.) | `use delaunay::prelude::collections::*` | +//! | Legacy broad triangulation import | `use delaunay::prelude::triangulation::*` | //! | Everything (kitchen sink) | `use delaunay::prelude::*` | //! //! ## Examples (contract-oriented) @@ -70,7 +73,10 @@ //! ### Validation hierarchy (Levels 1–4) //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; +//! use delaunay::prelude::triangulation::insertion::InsertionError; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -99,7 +105,11 @@ //! ### Topology guarantees and insertion-time validation (`TopologyGuarantee`, `ValidationPolicy`) //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, TopologyGuarantee, +//! vertex, +//! }; +//! use delaunay::prelude::triangulation::validation::ValidationPolicy; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -125,7 +135,10 @@ //! ### Transactional operations and duplicate rejection //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; +//! use delaunay::prelude::triangulation::insertion::InsertionError; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -227,7 +240,11 @@ //! This automatic pass only runs Level 3 (`Triangulation::is_valid()`). It does **not** run Level 4. //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulation, DelaunayTriangulationConstructionError, vertex, +//! }; +//! use delaunay::prelude::triangulation::insertion::InsertionError; +//! use delaunay::prelude::triangulation::validation::ValidationPolicy; //! //! # #[derive(Debug, thiserror::Error)] //! # enum ExampleError { @@ -281,7 +298,9 @@ //! you may want to validate the Delaunay property explicitly for near-degenerate inputs. //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -300,7 +319,9 @@ //! ``` //! //! ```rust -//! use delaunay::prelude::triangulation::*; +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; //! //! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { //! let vertices = vec![ @@ -333,8 +354,10 @@ //! - **Explicit verification**: Use `dt.validate()` for cumulative verification (Levels 1–4), or //! `dt.is_valid()` for Level 4 only. -// Allow multiple crate versions due to transitive dependencies -#![expect(clippy::multiple_crate_versions)] +#![expect( + clippy::multiple_crate_versions, + reason = "transitive dependency versions are controlled by upstream crates" +)] // Temporarily allow deprecated warnings during API migrations. // - Historical Facet -> FacetView and Tds construction migrations // - DelaunayTriangulation::as_triangulation_mut() removal planned for v0.8.0 @@ -782,25 +805,7 @@ pub mod geometry { pub use util::*; } -/// Triangulation-facing APIs. -/// -/// This module groups public APIs that operate on triangulations, such as explicit -/// bistellar (Pachner) flip operations. -pub mod triangulation { - /// Fluent builder for [`DelaunayTriangulation`] with optional toroidal topology. - pub mod builder; - /// Delaunay triangulation layer with incremental insertion. - pub mod delaunay; - /// End-to-end "repair then delaunayize" workflow. - pub mod delaunayize; - /// Triangulation editing operations (bistellar flips). - pub mod flips; - - // Re-export commonly used triangulation types for discoverability. - pub use crate::core::triangulation::Triangulation; - pub use crate::triangulation::builder::DelaunayTriangulationBuilder; - pub use crate::triangulation::delaunay::DelaunayTriangulation; -} +pub mod triangulation; /// Topology analysis and validation for triangulated spaces. /// @@ -823,7 +828,9 @@ pub mod triangulation { /// # Example /// /// ```rust -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// DelaunayTriangulation, DelaunayTriangulationConstructionError, vertex, +/// }; /// use delaunay::prelude::topology::validation; /// /// # #[derive(Debug, thiserror::Error)] @@ -976,6 +983,57 @@ pub mod prelude { pub use crate::triangulation::builder::*; pub use crate::triangulation::delaunay::*; + /// Batch construction options, builders, and construction errors. + /// + /// This focused prelude is for callers configuring Delaunay construction + /// without importing the broader triangulation editing and repair + /// surface. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, + /// }; + /// + /// # fn main() -> Result<(), DelaunayTriangulationConstructionError> { + /// let vertices = vec![ + /// vertex!([0.0, 0.0]), + /// vertex!([1.0, 0.0]), + /// vertex!([0.0, 1.0]), + /// ]; + /// let triangulation = DelaunayTriangulationBuilder::new(&vertices) + /// .build::<()>()?; + /// + /// assert_eq!(triangulation.number_of_vertices(), 3); + /// # Ok(()) + /// # } + /// ``` + pub mod construction { + pub use crate::core::triangulation::{ + TopologyGuarantee, Triangulation, TriangulationConstructionError, + }; + pub use crate::core::vertex::{ + Vertex, VertexBuilder, VertexBuilderError, VertexValidationError, + }; + pub use crate::topology::traits::{ + GlobalTopology, TopologyKind, ToroidalConstructionMode, + }; + pub use crate::triangulation::builder::{ + DelaunayTriangulationBuilder, ExplicitConstructionError, + }; + pub use crate::triangulation::delaunay::{ + ConstructionOptions, ConstructionSkipSample, ConstructionSlowInsertionSample, + ConstructionStatistics, DedupPolicy, DelaunayConstructionFailure, + DelaunayConstructionRepairPhase, DelaunayRepairPolicy, DelaunayTriangulation, + DelaunayTriangulationConstructionError, + DelaunayTriangulationConstructionErrorWithStatistics, InitialSimplexStrategy, + InsertionOrderStrategy, RetryPolicy, + }; + // Convenience macro (commonly used in docs/examples). + pub use crate::vertex; + } + /// Bistellar (Pachner) flips for explicit triangulation editing. pub mod flips { pub use crate::core::algorithms::flips::*; @@ -1027,7 +1085,8 @@ pub mod prelude { pub use crate::core::util::{DelaunayValidationError, find_delaunay_violations}; pub use crate::triangulation::delaunay::{ DelaunayCheckPolicy, DelaunayRepairHeuristicConfig, DelaunayRepairHeuristicSeeds, - DelaunayRepairOutcome, DelaunayRepairPolicy, DelaunayTriangulation, + DelaunayRepairOperation, DelaunayRepairOutcome, DelaunayRepairPolicy, + DelaunayTriangulation, DelaunayTriangulationValidationError, }; // Convenience macro (commonly used in docs/examples). @@ -1050,6 +1109,37 @@ pub mod prelude { pub use crate::vertex; } + /// Construction telemetry diagnostics. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; + /// + /// let telemetry = ConstructionTelemetry::default(); + /// assert!(!telemetry.has_data()); + /// ``` + pub mod diagnostics { + pub use crate::triangulation::diagnostics::ConstructionTelemetry; + } + + /// Validation scheduling helpers for construction diagnostics. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::validation::ValidationCadence; + /// + /// let cadence = ValidationCadence::from_optional_every(Some(32)); + /// assert!(!cadence.should_validate(31)); + /// assert!(cadence.should_validate(32)); + /// ``` + pub mod validation { + pub use crate::core::triangulation::{TriangulationValidationError, ValidationPolicy}; + pub use crate::triangulation::delaunay::DelaunayTriangulationValidationError; + pub use crate::triangulation::validation::*; + } + pub use crate::core::algorithms::incremental_insertion::{ CavityFillingError, CavityRepairStage, HullExtensionReason, InsertionError, NeighborWiringError, diff --git a/src/triangulation.rs b/src/triangulation.rs new file mode 100644 index 00000000..220dc1ee --- /dev/null +++ b/src/triangulation.rs @@ -0,0 +1,56 @@ +//! Triangulation-facing APIs. +//! +//! This module is the public facade for triangulation workflows. It deliberately +//! stays thin: +//! +//! - [`crate::core::triangulation`] owns the generic `Triangulation` container +//! and low-level mutation invariants. +//! - [`crate::triangulation`] owns higher-level construction, Delaunay repair, +//! diagnostics, validation scheduling, editing, and builder workflows. +//! - Submodules under this namespace keep those concerns separate while this +//! facade preserves the stable public import surface. +//! +//! # Examples +//! +//! ```rust +//! use delaunay::prelude::triangulation::construction::{ +//! DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, vertex, +//! }; +//! +//! # fn main() -> Result<(), DelaunayTriangulationConstructionError> { +//! let vertices = vec![ +//! vertex!([0.0, 0.0]), +//! vertex!([1.0, 0.0]), +//! vertex!([0.0, 1.0]), +//! ]; +//! let triangulation = DelaunayTriangulationBuilder::new(&vertices) +//! .build::<()>()?; +//! +//! assert_eq!(triangulation.number_of_vertices(), 3); +//! # Ok(()) +//! # } +//! ``` + +#![forbid(unsafe_code)] + +/// Fluent builder for Delaunay triangulations. +/// +/// See [`DelaunayTriangulation`](crate::triangulation::delaunay::DelaunayTriangulation) +/// for the constructed triangulation type. +pub mod builder; +/// Delaunay triangulation layer with incremental insertion. +pub mod delaunay; +/// End-to-end "repair then delaunayize" workflow. +pub mod delaunayize; +/// Construction and performance diagnostics. +pub mod diagnostics; +/// Triangulation editing operations (bistellar flips). +pub mod flips; +pub(crate) mod locality; +/// Validation scheduling helpers for triangulation diagnostics. +pub mod validation; + +// Re-export commonly used triangulation types for discoverability. +pub use crate::core::triangulation::Triangulation; +pub use crate::triangulation::builder::DelaunayTriangulationBuilder; +pub use crate::triangulation::delaunay::DelaunayTriangulation; diff --git a/src/triangulation/builder.rs b/src/triangulation/builder.rs index 1db0627e..6e97b393 100644 --- a/src/triangulation/builder.rs +++ b/src/triangulation/builder.rs @@ -32,10 +32,9 @@ //! ## Standard Euclidean construction //! //! ```rust -//! use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; -//! use delaunay::vertex; +//! use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; //! -//! # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { +//! # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { //! let vertices = vec![ //! vertex!([0.0, 0.0]), //! vertex!([1.0, 0.0]), @@ -53,10 +52,9 @@ //! ## Toroidal construction (Phase 1: canonicalization only) //! //! ```rust -//! use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; -//! use delaunay::vertex; +//! use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; //! -//! # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { +//! # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { //! // Vertices that fall outside [0, 1)² are wrapped before triangulation. //! let vertices = vec![ //! vertex!([0.2, 0.3]), @@ -81,10 +79,9 @@ //! //! ```rust,no_run //! use delaunay::prelude::geometry::RobustKernel; -//! use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; -//! use delaunay::vertex; +//! use delaunay::prelude::triangulation::construction::{DelaunayTriangulationBuilder, vertex}; //! -//! # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { +//! # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { //! let vertices = vec![ //! vertex!([0.1, 0.2]), //! vertex!([0.4, 0.7]), @@ -304,11 +301,10 @@ fn search_closed_2d_selection( /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ +/// use delaunay::prelude::triangulation::construction::{ /// DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, -/// ExplicitConstructionError, +/// ExplicitConstructionError, vertex, /// }; -/// use delaunay::vertex; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let cells = vec![vec![0, 1]]; // Wrong arity for 2D (needs 3 vertices) @@ -402,12 +398,11 @@ pub enum ExplicitConstructionError { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ -/// ConstructionOptions, DelaunayTriangulationBuilder, TopologyGuarantee, +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DelaunayTriangulationBuilder, TopologyGuarantee, vertex, /// }; -/// use delaunay::vertex; /// -/// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { +/// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), /// vertex!([1.0, 0.0, 0.0]), @@ -480,9 +475,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, Vertex, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), DelaunayTriangulationConstructionError> { /// // No vertex data (U = () inferred) /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let _dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>()?; @@ -529,13 +526,12 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ + /// use delaunay::prelude::triangulation::construction::{ /// DelaunayTriangulationBuilder, DelaunayTriangulationConstructionError, - /// ExplicitConstructionError, + /// ExplicitConstructionError, vertex, /// }; - /// use delaunay::vertex; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0]), /// vertex!([1.0, 0.0]), @@ -598,14 +594,16 @@ where /// /// ```rust /// use delaunay::prelude::geometry::{Coordinate, Point}; - /// use delaunay::prelude::triangulation::{DelaunayTriangulationBuilder, Vertex, VertexBuilder}; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, Vertex, VertexBuilder, + /// }; /// /// # #[derive(Debug, thiserror::Error)] /// # enum ExampleError { /// # #[error(transparent)] - /// # Vertex(#[from] delaunay::prelude::triangulation::VertexBuilderError), + /// # Vertex(#[from] delaunay::prelude::triangulation::construction::VertexBuilderError), /// # #[error(transparent)] - /// # Construction(#[from] delaunay::prelude::triangulation::DelaunayTriangulationConstructionError), + /// # Construction(#[from] delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError), /// # } /// # fn main() -> Result<(), ExampleError> { /// let vertices: Vec> = vec![ @@ -649,14 +647,16 @@ where /// /// ```rust /// use delaunay::prelude::geometry::{Coordinate, Point}; - /// use delaunay::prelude::triangulation::{DelaunayTriangulationBuilder, Vertex, VertexBuilder}; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, Vertex, VertexBuilder, + /// }; /// /// # #[derive(Debug, thiserror::Error)] /// # enum ExampleError { /// # #[error(transparent)] - /// # Vertex(#[from] delaunay::prelude::triangulation::VertexBuilderError), + /// # Vertex(#[from] delaunay::prelude::triangulation::construction::VertexBuilderError), /// # #[error(transparent)] - /// # Construction(#[from] delaunay::prelude::triangulation::DelaunayTriangulationConstructionError), + /// # Construction(#[from] delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError), /// # } /// # fn main() -> Result<(), ExampleError> { /// // f32 vertices — new() is f64-only, so from_vertices is required here. @@ -701,10 +701,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.2, 0.3]), /// vertex!([0.8, 0.1]), @@ -751,10 +752,11 @@ where /// /// ```rust,no_run /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.1, 0.2]), /// vertex!([0.4, 0.7]), @@ -789,10 +791,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{DelaunayTriangulationBuilder, TopologyGuarantee}; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, TopologyGuarantee, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0]), /// vertex!([1.0, 0.0]), @@ -832,10 +835,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ - /// DelaunayTriangulationBuilder, GlobalTopology, ToroidalConstructionMode, + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, GlobalTopology, ToroidalConstructionMode, vertex, /// }; - /// use delaunay::vertex; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -866,12 +868,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ - /// ConstructionOptions, DelaunayTriangulationBuilder, InsertionOrderStrategy, + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DelaunayTriangulationBuilder, InsertionOrderStrategy, vertex, /// }; - /// use delaunay::vertex; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0]), /// vertex!([1.0, 0.0]), @@ -1054,10 +1055,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), /// vertex!([1.0, 0.0, 0.0]), @@ -1107,10 +1109,11 @@ where /// /// ```rust /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::DelaunayTriangulationBuilder; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// - /// # fn main() -> Result<(), delaunay::prelude::triangulation::DelaunayTriangulationConstructionError> { + /// # fn main() -> Result<(), delaunay::prelude::triangulation::construction::DelaunayTriangulationConstructionError> { /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), /// vertex!([1.0, 0.0, 0.0]), diff --git a/src/triangulation/delaunay.rs b/src/triangulation/delaunay.rs index f3f53576..421e99f6 100644 --- a/src/triangulation/delaunay.rs +++ b/src/triangulation/delaunay.rs @@ -7,20 +7,22 @@ use crate::core::adjacency::{AdjacencyIndex, AdjacencyIndexBuildError}; use crate::core::algorithms::flips::{ - DelaunayRepairError, DelaunayRepairRun, DelaunayRepairStats, FlipError, + DelaunayRepairError, DelaunayRepairRun, DelaunayRepairStats, FlipError, LocalRepairPhaseTiming, apply_bistellar_flip_k1_inverse, repair_delaunay_local_single_pass, - repair_delaunay_with_flips_k2_k3, repair_delaunay_with_flips_k2_k3_run, - verify_delaunay_for_triangulation, + repair_delaunay_local_single_pass_timed, repair_delaunay_with_flips_k2_k3, + repair_delaunay_with_flips_k2_k3_run, verify_delaunay_for_triangulation, }; use crate::core::algorithms::incremental_insertion::{InsertionError, TdsConstructionFailure}; use crate::core::algorithms::locate::LocateError; use crate::core::cell::{Cell, CellValidationError}; use crate::core::collections::spatial_hash_grid::HashGridIndex; -use crate::core::collections::{CellKeyBuffer, FastHashMap, FastHashSet, FastHasher, SmallBuffer}; +use crate::core::collections::{ + CellKeyBuffer, FastHashMap, FastHashSet, FastHasher, MAX_PRACTICAL_DIMENSION_SIZE, SmallBuffer, +}; use crate::core::edge::EdgeKey; use crate::core::facet::{AllFacetsIter, BoundaryFacetsIter}; use crate::core::operations::{ - DelaunayInsertionState, InsertionOutcome, InsertionStatistics, RepairDecision, + DelaunayInsertionState, InsertionOutcome, InsertionResult, InsertionStatistics, RepairDecision, TopologicalOperation, }; use crate::core::tds::{ @@ -38,11 +40,16 @@ use crate::core::util::{ }; use crate::core::vertex::Vertex; use crate::geometry::kernel::{AdaptiveKernel, ExactPredicates, Kernel, RobustKernel}; -use crate::geometry::traits::coordinate::CoordinateScalar; -use crate::geometry::util::safe_usize_to_scalar; +use crate::geometry::point::Point; +use crate::geometry::traits::coordinate::{Coordinate, CoordinateScalar}; +use crate::geometry::util::{safe_coords_to_f64, safe_usize_to_scalar, simplex_volume}; use crate::topology::manifold::{ManifoldError, validate_ridge_links_for_cells}; use crate::topology::traits::topological_space::{GlobalTopology, TopologyKind}; use crate::triangulation::builder::DelaunayTriangulationBuilder; +use crate::triangulation::diagnostics::{BatchLocalRepairTrigger, ConstructionTelemetry}; +use crate::triangulation::locality::{ + accumulate_live_cell_seeds, clear_cell_seed_set, retain_live_cell_seeds, +}; use core::{cmp::Ordering, fmt}; use num_traits::{NumCast, ToPrimitive, Zero}; use rand::SeedableRng; @@ -52,12 +59,13 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::env; use std::hash::{Hash, Hasher}; use std::num::NonZeroUsize; -use std::time::Instant; +use std::time::{Duration, Instant}; use thiserror::Error; use uuid::Uuid; const DELAUNAY_SHUFFLE_ATTEMPTS: usize = 6; const DELAUNAY_SHUFFLE_SEED_SALT: u64 = 0x9E37_79B9_7F4A_7C15; +const INITIAL_SIMPLEX_MAX_VOLUME_CANDIDATE_CAP: usize = 18; // Heuristic rebuild attempts must be consistent across build profiles to avoid // release-only construction failures (see #306). @@ -69,27 +77,21 @@ const HEURISTIC_REBUILD_ATTEMPTS: usize = 6; // `FLOOR`. Two regimes so that D≥4's higher queue demand does not force a // global budget increase. // -// The D≥4 constants are sized from the measured `max_queue` distribution on -// the 500-point 4D seeded repro (seed `0xD225B8A07E274AE6`, ball radius 100) +// The D≥4 constants are sized from the measured `max_queue` distribution on the +// 500-point 4D seeded repro (seed `0xD225B8A07E274AE6`, ball radius 100) // captured in `docs/archive/issue_204_investigation.md`: // // max_queue samples min=91 p50=207 p90=281 p95=312 p99=409 max=416 // // `FACTOR = 12` with `FLOOR = 96` yields a typical 300-flip budget (5-cell seed -// set), covering p50–p90 and brushing p95. The p95–p99 tail is intentionally -// left to the escalation pass (see `LOCAL_REPAIR_ESCALATION_*`) rather than -// paid for on every insertion. +// set), covering p50–p90 and brushing p95. The p95–p99 tail is deferred to the +// final completion repair rather than paid for during every cadenced repair. pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4: usize = 12; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_GE_4: usize = 96; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_LT_4: usize = 4; pub(crate) const LOCAL_REPAIR_FLIP_BUDGET_FLOOR_D_LT_4: usize = 16; - -// Escalation tunables for D≥4. When the base local repair hits its budget, -// the soft-fail path reruns the repair once with `BASE_BUDGET * ESCALATION_FACTOR` -// and the full TDS as seed set before giving up. The escalation is rate-limited -// so every insertion does not pay for a near-global flip pass. -pub(crate) const LOCAL_REPAIR_ESCALATION_BUDGET_FACTOR_D_GE_4: usize = 4; -pub(crate) const LOCAL_REPAIR_ESCALATION_MIN_GAP: usize = 8; +const LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_GE_4: usize = 24; +const LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_LT_4: usize = 16; const RIDGE_LINK_REPAIR_VALIDATION_MESSAGE: &str = "Topology invalid after Delaunay repair"; fn ridge_link_repair_validation_error(err: ManifoldError) -> InsertionError { @@ -102,63 +104,6 @@ fn ridge_link_repair_validation_error(err: ManifoldError) -> InsertionError { } } -/// Outcome of a per-insertion D≥4 local-repair escalation attempt. -/// -/// Three orthogonal cases so the caller and any telemetry downstream can match -/// on the outcome without string parsing: -/// -/// - [`Skipped`](Self::Skipped) — the escalation did not run. The caller -/// should fall through to the soft-fail path using the original -/// [`DelaunayRepairError`] that triggered escalation. -/// - [`Succeeded`](Self::Succeeded) — the escalation converged. The caller -/// has already canonicalized the triangulation and should continue to the -/// next insertion. -/// - [`FailedAlso`](Self::FailedAlso) — the escalation ran but also hit its -/// budget or postcondition. The typed `DelaunayRepairError` is preserved so -/// downstream diagnostics can correlate it with the original error; the -/// caller should fall through to the soft-fail path. -/// -/// [`DelaunayRepairError`]: crate::core::algorithms::flips::DelaunayRepairError -#[derive(Clone, Debug)] -enum LocalRepairEscalationOutcome { - /// The escalation was not attempted. - Skipped { - /// Why the escalation was skipped. - reason: EscalationSkipReason, - }, - /// The escalation ran and successfully converged. - Succeeded { - /// Repair diagnostics from the successful escalation attempt. - stats: DelaunayRepairStats, - }, - /// The escalation ran but also failed to converge or satisfy its - /// postcondition. - FailedAlso { - /// Typed repair error produced by the escalation attempt. Preserved - /// by value so callers can match on the variant instead of parsing - /// the display form. - escalation_error: DelaunayRepairError, - }, -} - -/// Why a [`LocalRepairEscalationOutcome::Skipped`] escalation attempt did not -/// run. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum EscalationSkipReason { - /// The previous escalation ran within the `min_gap` insertion window, so - /// this attempt was rate-limited. - RateLimited { - /// Insertion index of the previous escalation. - last_escalation_idx: usize, - /// Configured minimum gap between escalations. - min_gap: usize, - }, - /// The triangulation had no cells to seed repair with. This is an edge - /// case for early insertions where the initial simplex has not been - /// committed; escalation there has nothing to escalate against. - EmptyTds, -} - /// Returns true when a repair error represents input geometry or predicate /// instability that shuffled construction may be able to resolve. const fn is_geometric_repair_error(repair_err: &DelaunayRepairError) -> bool { @@ -208,6 +153,60 @@ const fn local_repair_flip_budget(seed_cells_len: usize) -> usiz if raw > floor { raw } else { floor } } +/// Pending local repair frontier size that triggers an early batch repair. +/// +/// The threshold keeps sparse repair cadences from letting a large seed +/// frontier accumulate. 3D uses a lower threshold because the 3000-point sweep +/// in #341 showed that repair cost rises sharply once the pending frontier +/// grows beyond the small-batch regime. +const fn local_repair_seed_backlog_threshold() -> usize { + let factor = if D >= 4 { + LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_GE_4 + } else { + LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_LT_4 + }; + (D + 1).saturating_mul(factor) +} + +/// Default local-repair cadence for batch construction. +/// +/// Direct incremental insertion keeps [`DelaunayRepairPolicy::default`] at +/// [`DelaunayRepairPolicy::EveryInsertion`]. Batch construction uses the same +/// default because the #341 1000/3000-point proxy sweeps showed every-insertion +/// repair preserved all vertices and was slightly faster than the N=2 cadence. +const fn default_batch_repair_policy() -> DelaunayRepairPolicy { + DelaunayRepairPolicy::EveryInsertion +} + +/// Decides whether batch construction should run local Delaunay repair now. +fn batch_local_repair_trigger( + policy: DelaunayRepairPolicy, + insertion_count: usize, + topology: TopologyGuarantee, + pending_seed_cells_len: usize, +) -> Option { + if policy == DelaunayRepairPolicy::Never + || pending_seed_cells_len == 0 + || !TopologicalOperation::FacetFlip.is_admissible_under(topology) + { + return None; + } + + if matches!( + policy.decide(insertion_count, topology, TopologicalOperation::FacetFlip,), + RepairDecision::Proceed + ) { + return Some(BatchLocalRepairTrigger::Cadence); + } + + (pending_seed_cells_len >= local_repair_seed_backlog_threshold::()) + .then_some(BatchLocalRepairTrigger::SeedBacklog) +} + +fn batch_repair_trace_enabled() -> bool { + env::var_os("DELAUNAY_BATCH_REPAIR_TRACE").is_some() +} + thread_local! { static HEURISTIC_REBUILD_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -222,6 +221,7 @@ mod test_hooks { thread_local! { static FORCE_HEURISTIC_REBUILD: Cell = const { Cell::new(false) }; static FORCE_REPAIR_NONCONVERGENT: Cell = const { Cell::new(false) }; + static BATCH_LOCAL_REPAIR_CALLS: Cell = const { Cell::new(0) }; } pub(super) fn force_heuristic_rebuild_enabled() -> bool { @@ -256,6 +256,18 @@ mod test_hooks { FORCE_REPAIR_NONCONVERGENT.with(|flag| flag.set(prior)); } + pub(super) fn reset_batch_local_repair_calls() { + BATCH_LOCAL_REPAIR_CALLS.with(|calls| calls.set(0)); + } + + pub(super) fn batch_local_repair_calls() -> usize { + BATCH_LOCAL_REPAIR_CALLS.with(Cell::get) + } + + pub(super) fn record_batch_local_repair_call() { + BATCH_LOCAL_REPAIR_CALLS.with(|calls| calls.set(calls.get().saturating_add(1))); + } + pub(super) fn synthetic_nonconvergent_error() -> DelaunayRepairError { DelaunayRepairError::NonConvergent { max_flips: 0, @@ -303,8 +315,9 @@ impl Drop for HeuristicRebuildRecursionGuard { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::DelaunayTriangulationConstructionError; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// DelaunayTriangulation, DelaunayTriangulationConstructionError, vertex, +/// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -343,6 +356,34 @@ impl From for DelaunayTriangulationConstructionE } } +/// Construction phase that invoked flip-based Delaunay repair. +/// +/// Batch construction can run local repair at the configured cadence or earlier +/// when the pending seed frontier grows too large. Both cases are reported as +/// [`Self::BatchLocal`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DelaunayConstructionRepairPhase { + /// Local repair during the bulk insertion loop. + BatchLocal { + /// Zero-based input index whose insertion triggered the repair. + index: usize, + }, + /// Seeded or fallback repair during construction finalization. + Completion, +} + +impl fmt::Display for DelaunayConstructionRepairPhase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BatchLocal { index } => { + write!(f, "batch local repair at input index {index}") + } + Self::Completion => f.write_str("completion repair"), + } + } +} + /// Pattern-matchable summary of a lower-layer construction failure. /// /// This is the payload for @@ -363,11 +404,10 @@ impl From for DelaunayTriangulationConstructionE /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ +/// use delaunay::prelude::triangulation::construction::{ /// DelaunayConstructionFailure, DelaunayTriangulation, -/// DelaunayTriangulationConstructionError, +/// DelaunayTriangulationConstructionError, vertex, /// }; -/// use delaunay::vertex; /// /// let vertices = vec![vertex!([0.0, 0.0, 0.0])]; /// let err = DelaunayTriangulation::new(&vertices).unwrap_err(); @@ -421,6 +461,16 @@ pub enum DelaunayConstructionFailure { message: String, }, + /// Flip-based Delaunay repair failed during construction. + #[error("Delaunay repair failed during {phase}: {source}")] + DelaunayRepair { + /// Construction phase that invoked repair. + phase: DelaunayConstructionRepairPhase, + /// Underlying typed repair failure. + #[source] + source: Box, + }, + /// Duplicate coordinates were detected. #[error("duplicate coordinates detected: {coordinates}")] DuplicateCoordinates { @@ -552,7 +602,7 @@ impl From for DelaunayConstructionFailure { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::DelaunayRepairOperation; +/// use delaunay::prelude::triangulation::repair::DelaunayRepairOperation; /// /// assert_eq!(DelaunayRepairOperation::VertexRemoval.to_string(), "vertex removal"); /// ``` @@ -589,8 +639,8 @@ impl fmt::Display for DelaunayRepairOperation { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::DelaunayTriangulationValidationError; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; +/// use delaunay::prelude::triangulation::validation::DelaunayTriangulationValidationError; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -675,8 +725,9 @@ pub enum DelaunayTriangulationValidationError { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ConstructionOptions, InsertionOrderStrategy}; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, vertex, +/// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -731,8 +782,9 @@ pub enum InsertionOrderStrategy { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ConstructionOptions, DedupPolicy}; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DedupPolicy, DelaunayTriangulation, vertex, +/// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -769,17 +821,49 @@ pub enum DedupPolicy { /// Strategy controlling how the initial D+1 simplex vertices are selected during batch construction. /// -/// The default (`First`) preserves current behavior by taking the first D+1 vertices after -/// preprocessing and insertion-ordering. The balanced strategy is opt-in and chooses a more -/// spread-out simplex using a deterministic farthest-point heuristic. +/// The default ([`MaxVolume`](Self::MaxVolume)) searches a bounded pool of real extreme vertices +/// for the largest nondegenerate simplex before construction. The +/// [`Balanced`](Self::Balanced) strategy chooses a spread-out simplex using a deterministic +/// farthest-point heuristic. The [`First`](Self::First) strategy preserves legacy behavior by +/// taking the first D+1 vertices after preprocessing and insertion-ordering. +/// +/// These strategies only change construction order. They never introduce synthetic vertices, +/// relax topology checks, or bypass final Delaunay validation. If a strategy that reorders +/// vertices cannot select a usable initial simplex, preprocessing falls back to the existing vertex +/// order and the normal construction error path decides whether the input is valid. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, InitialSimplexStrategy, +/// }; +/// +/// let options = ConstructionOptions::default(); +/// +/// assert_eq!( +/// options.initial_simplex_strategy(), +/// InitialSimplexStrategy::MaxVolume, +/// ); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] pub enum InitialSimplexStrategy { - /// Use the first D+1 vertices after preprocessing (legacy behavior). - #[default] + /// Use the first D+1 vertices after preprocessing. + /// + /// This preserves the legacy construction order and is useful when callers need exact + /// compatibility with an explicitly supplied insertion sequence. First, /// Choose a better-conditioned simplex using a deterministic farthest-point heuristic. Balanced, + /// Choose the largest-volume simplex from a bounded real-vertex candidate pool. + /// + /// This is the default because a larger real starting simplex can reduce early convex-hull + /// insertions and their associated local repair work, especially for large 3D point clouds. + /// Candidate scoring is a deterministic preprocessing heuristic; correctness still comes from + /// the ordinary construction, repair, and validation pipeline. + #[default] + MaxVolume, } /// Policy controlling deterministic "retry with alternative insertion orders" during batch @@ -793,8 +877,9 @@ pub enum InitialSimplexStrategy { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ConstructionOptions, RetryPolicy}; -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DelaunayTriangulation, RetryPolicy, vertex, +/// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -858,16 +943,24 @@ impl Default for RetryPolicy { /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::{ -/// ConstructionOptions, DedupPolicy, InsertionOrderStrategy, RetryPolicy, +/// use delaunay::prelude::triangulation::construction::{ +/// ConstructionOptions, DedupPolicy, DelaunayRepairPolicy, InsertionOrderStrategy, RetryPolicy, /// }; +/// use std::num::NonZeroUsize; /// /// let options = ConstructionOptions::default() /// .with_insertion_order(InsertionOrderStrategy::Hilbert) /// .with_dedup_policy(DedupPolicy::Off) +/// .with_batch_repair_policy(DelaunayRepairPolicy::EveryN( +/// NonZeroUsize::new(4).unwrap(), +/// )) /// .with_retry_policy(RetryPolicy::Disabled); /// /// assert_eq!(options.insertion_order(), InsertionOrderStrategy::Hilbert); +/// assert_eq!( +/// options.batch_repair_policy(), +/// DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()), +/// ); /// ``` #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] @@ -876,10 +969,12 @@ pub struct ConstructionOptions { dedup_policy: DedupPolicy, initial_simplex: InitialSimplexStrategy, retry_policy: RetryPolicy, - /// When `true` (default), D<4 per-insertion repair falls back to a global - /// `repair_delaunay_with_flips_k2_k3` pass when the bounded local pass - /// cycles. Set to `false` for constructions where global repair could - /// disrupt the triangulation topology (e.g. periodic image-point builds). + batch_repair_policy: DelaunayRepairPolicy, + /// When `true` (default), final bulk repair can fall back to a global + /// `repair_delaunay_with_flips_k2_k3` pass before acceptance when the + /// seeded completion pass cycles. Set to `false` for constructions where + /// global repair could disrupt the triangulation topology (e.g. periodic + /// image-point builds). pub(crate) use_global_repair_fallback: bool, } @@ -890,6 +985,7 @@ impl Default for ConstructionOptions { dedup_policy: DedupPolicy::default(), initial_simplex: InitialSimplexStrategy::default(), retry_policy: RetryPolicy::default(), + batch_repair_policy: default_batch_repair_policy(), use_global_repair_fallback: true, } } @@ -919,6 +1015,29 @@ impl ConstructionOptions { self.retry_policy } + /// Returns the automatic local Delaunay repair policy used during batch construction. + /// + /// [`DelaunayRepairPolicy::EveryN`] controls the scheduled cadence. Batch + /// construction may also run an earlier local repair when the accumulated + /// seed frontier grows large; [`DelaunayRepairPolicy::Never`] disables both. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DelaunayRepairPolicy, + /// }; + /// + /// assert_eq!( + /// ConstructionOptions::default().batch_repair_policy(), + /// DelaunayRepairPolicy::EveryInsertion, + /// ); + /// ``` + #[must_use] + pub const fn batch_repair_policy(&self) -> DelaunayRepairPolicy { + self.batch_repair_policy + } + /// Sets the input ordering strategy used for batch construction. #[must_use] pub const fn with_insertion_order(mut self, insertion_order: InsertionOrderStrategy) -> Self { @@ -933,6 +1052,28 @@ impl ConstructionOptions { self } /// Sets the initial simplex selection strategy. + /// + /// Use this as a construction-ordering performance knob. The strategy selects real input + /// vertices for the starting simplex and does not change repair policy, topology guarantees, + /// or final validation. Call this with [`InitialSimplexStrategy::Balanced`] or + /// [`InitialSimplexStrategy::First`] to opt out of the default + /// [`InitialSimplexStrategy::MaxVolume`] heuristic. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, InitialSimplexStrategy, + /// }; + /// + /// let options = ConstructionOptions::default() + /// .with_initial_simplex_strategy(InitialSimplexStrategy::Balanced); + /// + /// assert_eq!( + /// options.initial_simplex_strategy(), + /// InitialSimplexStrategy::Balanced, + /// ); + /// ``` #[must_use] pub const fn with_initial_simplex_strategy( mut self, @@ -949,6 +1090,38 @@ impl ConstructionOptions { self } + /// Sets the automatic local Delaunay repair policy used during batch construction. + /// + /// [`DelaunayRepairPolicy::EveryN`] controls the scheduled cadence. Batch + /// construction may also run an earlier local repair when the accumulated + /// seed frontier grows large; [`DelaunayRepairPolicy::Never`] disables both. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DelaunayRepairPolicy, + /// }; + /// use std::num::NonZeroUsize; + /// + /// let repair_every = NonZeroUsize::new(2).expect("literal 2 is nonzero"); + /// let options = ConstructionOptions::default() + /// .with_batch_repair_policy(DelaunayRepairPolicy::EveryN(repair_every)); + /// + /// assert_eq!( + /// options.batch_repair_policy(), + /// DelaunayRepairPolicy::EveryN(repair_every), + /// ); + /// ``` + #[must_use] + pub const fn with_batch_repair_policy( + mut self, + batch_repair_policy: DelaunayRepairPolicy, + ) -> Self { + self.batch_repair_policy = batch_repair_policy; + self + } + /// Disables the D<4 global repair fallback. #[must_use] pub(crate) const fn without_global_repair_fallback(mut self) -> Self { @@ -957,10 +1130,6 @@ impl ConstructionOptions { } } -// ============================================================================= -// BATCH CONSTRUCTION STATISTICS -// ============================================================================= - /// Aggregate statistics collected during batch construction. /// /// This summarizes the per-vertex [`InsertionStatistics`] generated by the incremental insertion @@ -990,6 +1159,15 @@ pub struct ConstructionStatistics { /// Maximum number of cells removed during repair for any single insertion. pub cells_removed_max: usize, + /// Aggregate batch-construction telemetry. + pub telemetry: ConstructionTelemetry, + + /// Slowest transactional insertions observed during batch construction. + /// + /// This is intended for diagnosing scaling pathologies and is capped + /// (currently the top 8 by insertion wall time). + pub slow_insertions: Vec, + /// A small set of representative skipped vertices recorded during batch construction. /// /// This is intended for debugging/reproduction and is capped (currently the first 8 skips). @@ -1020,6 +1198,40 @@ pub struct ConstructionSkipSample { pub error: String, } +/// A slow transactional insertion sample captured during batch construction. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ConstructionSlowInsertionSample { + /// Index in the construction insertion order (after preprocessing and ordering). + pub index: usize, + /// UUID of the inserted or skipped vertex. + pub uuid: Uuid, + /// Number of insertion attempts for this vertex. + pub attempts: usize, + /// Final insertion result for this vertex. + pub result: InsertionResult, + /// Wall-clock nanoseconds spent in the transactional insertion call. + pub elapsed_nanos: u64, + /// Cell count immediately after the insertion attempt. + pub cells_after: usize, + /// Point-location calls performed by this insertion. + pub locate_calls: usize, + /// Facet-walk steps performed by this insertion. + pub locate_walk_steps_total: usize, + /// Local conflict-region calls performed by this insertion. + pub conflict_region_calls: usize, + /// Local conflict-region cells observed by this insertion. + pub conflict_region_cells_total: usize, + /// Cavity insertion calls performed by this insertion. + pub cavity_insertion_calls: usize, + /// Global exterior conflict scans performed by this insertion. + pub global_conflict_scans: usize, + /// Hull extension calls performed by this insertion. + pub hull_extension_calls: usize, + /// Post-insertion topology validations performed by this insertion. + pub topology_validation_calls: usize, +} + /// Construction error that also carries aggregate statistics collected up to the failure point. /// /// Returned by statistics constructors such as @@ -1066,6 +1278,7 @@ impl ConstructionStatistics { } const MAX_SKIP_SAMPLES: usize = 8; + const MAX_SLOW_INSERTION_SAMPLES: usize = 8; /// Record a single insertion attempt (inserted or skipped). pub fn record_insertion(&mut self, stats: &InsertionStatistics) { @@ -1087,6 +1300,51 @@ impl ConstructionStatistics { } } + /// Record a slow insertion sample, preserving the top samples by elapsed time. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionStatistics, DelaunayTriangulation, vertex, + /// }; + /// + /// let vertices = vec![ + /// vertex!([0.0, 0.0]), + /// vertex!([1.0, 0.0]), + /// vertex!([0.0, 1.0]), + /// vertex!([0.25, 0.25]), + /// ]; + /// let (_, stats) = + /// DelaunayTriangulation::<_, (), (), 2>::new_with_construction_statistics(&vertices) + /// .unwrap(); + /// let sample = stats + /// .slow_insertions + /// .first() + /// .cloned() + /// .expect("one non-simplex vertex produces a slow-insertion sample"); + /// + /// let mut summary = ConstructionStatistics::default(); + /// summary.record_slow_insertion_sample(sample.clone()); + /// + /// assert_eq!(summary.slow_insertions.len(), 1); + /// assert_eq!(summary.slow_insertions[0].index, sample.index); + /// ``` + pub fn record_slow_insertion_sample(&mut self, sample: ConstructionSlowInsertionSample) { + let insert_at = self + .slow_insertions + .iter() + .position(|existing| sample.elapsed_nanos > existing.elapsed_nanos) + .unwrap_or(self.slow_insertions.len()); + if insert_at >= Self::MAX_SLOW_INSERTION_SAMPLES { + return; + } + + self.slow_insertions.insert(insert_at, sample); + self.slow_insertions + .truncate(Self::MAX_SLOW_INSERTION_SAMPLES); + } + /// Merges attempt-level statistics from another construction pass. fn merge_from(&mut self, other: &Self) { self.inserted = self.inserted.saturating_add(other.inserted); @@ -1114,6 +1372,7 @@ impl ConstructionStatistics { .cells_removed_total .saturating_add(other.cells_removed_total); self.cells_removed_max = self.cells_removed_max.max(other.cells_removed_max); + self.telemetry.merge_from(&other.telemetry); for sample in &other.skip_samples { if self.skip_samples.len() >= Self::MAX_SKIP_SAMPLES { @@ -1121,6 +1380,10 @@ impl ConstructionStatistics { } self.skip_samples.push(sample.clone()); } + + for sample in &other.slow_insertions { + self.record_slow_insertion_sample(sample.clone()); + } } /// Total number of skipped vertices. @@ -1141,11 +1404,7 @@ struct PreprocessVertices { grid_cell_size: Option, } -impl PreprocessVertices -where - T: CoordinateScalar, - U: DataType, -{ +impl PreprocessVertices { /// Borrows the preprocessed vertex order when one exists, avoiding a clone /// for policies that leave the input unchanged. fn primary_slice<'a>(&'a self, input: &'a [Vertex]) -> &'a [Vertex] { @@ -1160,7 +1419,10 @@ where /// Carries the dedup grid size forward so incremental insertion can reuse a /// compatible spatial index. - const fn grid_cell_size(&self) -> Option { + const fn grid_cell_size(&self) -> Option + where + T: Copy, + { self.grid_cell_size } } @@ -1172,7 +1434,6 @@ type PreprocessVerticesResult = fn vertex_coordinate_hash(vertex: &Vertex) -> u64 where T: CoordinateScalar, - U: DataType, { let mut hasher = FastHasher::default(); vertex.hash(&mut hasher); @@ -1186,7 +1447,6 @@ fn order_vertices_lexicographic( ) -> Vec> where T: CoordinateScalar, - U: DataType, { let mut keyed: Vec<(Vertex, u64, usize)> = vertices .into_iter() @@ -1218,7 +1478,6 @@ fn order_vertices_by_strategy( ) -> Vec> where T: CoordinateScalar, - U: DataType, { match insertion_order { InsertionOrderStrategy::Input => vertices, @@ -1240,7 +1499,6 @@ fn hash_grid_usable_for_vertices( ) -> bool where T: CoordinateScalar, - U: DataType, { if !grid.is_usable() { return false; @@ -1257,7 +1515,6 @@ fn dedup_vertices_exact_sorted( ) -> Vec> where T: CoordinateScalar, - U: DataType, { let ordered = order_vertices_lexicographic(vertices); let mut unique: Vec> = Vec::with_capacity(ordered.len()); @@ -1284,7 +1541,6 @@ fn dedup_vertices_exact_hash_grid( ) -> Vec> where T: CoordinateScalar, - U: DataType, { if !hash_grid_usable_for_vertices(grid, &vertices) { return dedup_vertices_exact_sorted(vertices); @@ -1293,13 +1549,13 @@ where let mut unique: Vec> = Vec::with_capacity(vertices.len()); for v in vertices { - let coords = v.point().coords(); + let coords = *v.point().coords(); let mut duplicate = false; let mut candidate_count = 0usize; - let used_index = grid.for_each_candidate_vertex_key(coords, |idx| { + let used_index = grid.for_each_candidate_vertex_key(&coords, |idx| { candidate_count = candidate_count.saturating_add(1); let existing_coords = unique[idx].point().coords(); - if coords_equal_exact(coords, existing_coords) { + if coords_equal_exact(&coords, existing_coords) { duplicate = true; return false; } @@ -1311,7 +1567,7 @@ where if !duplicate { let idx = unique.len(); unique.push(v); - grid.insert_vertex(idx, coords); + grid.insert_vertex(idx, &coords); } } @@ -1379,7 +1635,6 @@ fn dedup_vertices_epsilon_n2( ) -> Vec> where T: CoordinateScalar, - U: DataType, { let mut unique: Vec> = Vec::with_capacity(vertices.len()); for v in vertices { @@ -1406,7 +1661,6 @@ fn dedup_vertices_epsilon_quantized( ) -> Vec> where T: CoordinateScalar, - U: DataType, { if D > BATCH_DEDUP_MAX_DIMENSION { return dedup_vertices_epsilon_n2(vertices, epsilon); @@ -1477,7 +1731,6 @@ fn dedup_vertices_epsilon_hash_grid( ) -> Vec> where T: CoordinateScalar, - U: DataType, { if !hash_grid_usable_for_vertices(grid, &vertices) { return dedup_vertices_epsilon_quantized(vertices, epsilon); @@ -1487,10 +1740,10 @@ where let epsilon_sq = epsilon * epsilon; for v in vertices { - let coords = v.point().coords(); + let coords = *v.point().coords(); let mut duplicate = false; let mut candidate_count = 0usize; - let used_index = grid.for_each_candidate_vertex_key(coords, |idx| { + let used_index = grid.for_each_candidate_vertex_key(&coords, |idx| { candidate_count = candidate_count.saturating_add(1); let existing_coords = unique[idx].point().coords(); let mut dist_sq = T::zero(); @@ -1510,13 +1763,207 @@ where if !duplicate { let idx = unique.len(); unique.push(v); - grid.insert_vertex(idx, coords); + grid.insert_vertex(idx, &coords); } } unique } +/// Converts candidate simplex vertices to f64 coordinates for deterministic +/// preprocessing heuristics without hiding non-finite inputs. +fn vertices_coords_f64(vertices: &[Vertex]) -> Option> +where + T: CoordinateScalar, +{ + let mut coords_f64: Vec<[f64; D]> = Vec::with_capacity(vertices.len()); + for v in vertices { + let coords = safe_coords_to_f64(v.point().coords()).ok()?; + if coords.iter().any(|coord| !coord.is_finite()) { + return None; + } + coords_f64.push(coords); + } + Some(coords_f64) +} + +/// Computes squared Euclidean distance for initial-simplex selection +/// heuristics that only need deterministic ordering. +fn squared_distance(a: &[f64; D], b: &[f64; D]) -> f64 { + a.iter() + .zip(b.iter()) + .map(|(lhs, rhs)| { + let diff = lhs - rhs; + diff * diff + }) + .sum::() +} + +/// Appends an index once so candidate pools remain small and deterministic. +fn push_unique_index(indices: &mut Vec, idx: usize) { + if !indices.contains(&idx) { + indices.push(idx); + } +} + +/// Computes the bounded candidate-pool size for max-volume simplex search. +const fn initial_simplex_candidate_cap(point_count: usize) -> usize { + let minimum = D.saturating_add(1); + let bounded_cap = if INITIAL_SIMPLEX_MAX_VOLUME_CANDIDATE_CAP > minimum { + INITIAL_SIMPLEX_MAX_VOLUME_CANDIDATE_CAP + } else { + minimum + }; + let requested = D.saturating_add(1).saturating_mul(2).saturating_add(4); + let target = if requested < bounded_cap { + requested + } else { + bounded_cap + }; + if point_count < target { + point_count + } else { + target + } +} + +/// Finds the deterministic lexicographic anchor for a candidate pool. +fn lexicographic_min_index(coords_f64: &[[f64; D]]) -> Option { + if coords_f64.is_empty() { + return None; + } + let mut lexicographic_min = 0usize; + for idx in 1..coords_f64.len() { + if coords_f64[idx].partial_cmp(&coords_f64[lexicographic_min]) == Some(Ordering::Less) { + lexicographic_min = idx; + } + } + Some(lexicographic_min) +} + +/// Adds per-axis coordinate extrema to the candidate pool. +fn append_axis_extrema(coords_f64: &[[f64; D]], candidates: &mut Vec) { + for axis in 0..D { + let mut min_idx = 0usize; + let mut max_idx = 0usize; + for idx in 1..coords_f64.len() { + let coord = coords_f64[idx][axis]; + let min_coord = coords_f64[min_idx][axis]; + let max_coord = coords_f64[max_idx][axis]; + + match coord.partial_cmp(&min_coord) { + Some(Ordering::Less) => min_idx = idx, + Some(Ordering::Equal) + if coords_f64[idx].partial_cmp(&coords_f64[min_idx]) + == Some(Ordering::Less) => + { + min_idx = idx; + } + _ => {} + } + match coord.partial_cmp(&max_coord) { + Some(Ordering::Greater) => max_idx = idx, + Some(Ordering::Equal) + if coords_f64[idx].partial_cmp(&coords_f64[max_idx]) + == Some(Ordering::Less) => + { + max_idx = idx; + } + _ => {} + } + } + push_unique_index(candidates, min_idx); + push_unique_index(candidates, max_idx); + } +} + +/// Extends the candidate pool with farthest-point samples until it reaches the +/// configured cap or exhausts usable points. +fn extend_candidate_pool_by_farthest_points( + coords_f64: &[[f64; D]], + candidates: &mut Vec, + candidate_cap: usize, +) { + let mut selected_mask = vec![false; coords_f64.len()]; + for &idx in candidates.iter() { + selected_mask[idx] = true; + } + + let mut min_dist_sq = vec![f64::INFINITY; coords_f64.len()]; + for idx in 0..coords_f64.len() { + if selected_mask[idx] { + min_dist_sq[idx] = 0.0; + continue; + } + for &candidate_idx in candidates.iter() { + let dist = squared_distance(&coords_f64[idx], &coords_f64[candidate_idx]); + if dist < min_dist_sq[idx] { + min_dist_sq[idx] = dist; + } + } + } + + while candidates.len() < candidate_cap { + let mut best_idx: Option = None; + let mut best_dist = -1.0_f64; + + for idx in 0..coords_f64.len() { + if selected_mask[idx] { + continue; + } + let dist = min_dist_sq[idx]; + if !dist.is_finite() { + continue; + } + let replace = best_idx.is_none_or(|best_idx_val| match dist.partial_cmp(&best_dist) { + Some(Ordering::Greater) => true, + Some(Ordering::Equal) => { + coords_f64[idx].partial_cmp(&coords_f64[best_idx_val]) == Some(Ordering::Less) + } + _ => false, + }); + if replace { + best_idx = Some(idx); + best_dist = dist; + } + } + + let Some(best_idx) = best_idx else { + break; + }; + push_unique_index(candidates, best_idx); + selected_mask[best_idx] = true; + + for idx in 0..coords_f64.len() { + if selected_mask[idx] { + continue; + } + let dist = squared_distance(&coords_f64[idx], &coords_f64[best_idx]); + if dist < min_dist_sq[idx] { + min_dist_sq[idx] = dist; + } + } + } +} + +/// Chooses a bounded pool of real extreme vertices for max-volume simplex +/// search. +fn initial_simplex_candidate_pool_indices(coords_f64: &[[f64; D]]) -> Vec { + let candidate_cap = initial_simplex_candidate_cap::(coords_f64.len()); + if candidate_cap == 0 { + return Vec::new(); + } + + let mut candidates = Vec::with_capacity(candidate_cap); + if let Some(lexicographic_min) = lexicographic_min_index(coords_f64) { + push_unique_index(&mut candidates, lexicographic_min); + } + append_axis_extrema(coords_f64, &mut candidates); + extend_candidate_pool_by_farthest_points(coords_f64, &mut candidates, candidate_cap); + + candidates +} + /// Chooses a well-spread initial simplex to reduce early degeneracy in /// incremental construction. fn select_balanced_simplex_indices( @@ -1524,33 +1971,12 @@ fn select_balanced_simplex_indices( ) -> Option> where T: CoordinateScalar, - U: DataType, { if vertices.len() < D + 1 { return None; } - let mut coords_f64: Vec<[f64; D]> = Vec::with_capacity(vertices.len()); - for v in vertices { - let mut coords = [0.0_f64; D]; - for (axis, coord) in v.point().coords().iter().enumerate() { - let c = coord.to_f64()?; - if !c.is_finite() { - return None; - } - coords[axis] = c; - } - coords_f64.push(coords); - } - let dist_sq = |a: &[f64; D], b: &[f64; D]| { - a.iter() - .zip(b.iter()) - .map(|(lhs, rhs)| { - let diff = lhs - rhs; - diff * diff - }) - .sum::() - }; + let coords_f64 = vertices_coords_f64(vertices)?; let mut seed_idx = 0usize; for i in 1..coords_f64.len() { @@ -1566,7 +1992,7 @@ where let mut min_dist_sq = vec![f64::INFINITY; coords_f64.len()]; for i in 0..coords_f64.len() { - min_dist_sq[i] = dist_sq(&coords_f64[i], &coords_f64[seed_idx]); + min_dist_sq[i] = squared_distance(&coords_f64[i], &coords_f64[seed_idx]); } min_dist_sq[seed_idx] = 0.0; @@ -1605,7 +2031,7 @@ where if selected_mask[i] { continue; } - let dist_sq = dist_sq(&coords_f64[i], &coords_f64[best_idx]); + let dist_sq = squared_distance(&coords_f64[i], &coords_f64[best_idx]); if dist_sq < min_dist_sq[i] { min_dist_sq[i] = dist_sq; } @@ -1619,6 +2045,87 @@ where } } +/// Advances a lexicographic combination in place so max-volume search can +/// enumerate bounded candidate pools without recursion. +fn advance_combination(indices: &mut [usize], upper: usize) -> bool { + let len = indices.len(); + if len > upper { + return false; + } + for pos in (0..len).rev() { + if indices[pos] < pos + upper - len { + indices[pos] += 1; + for next in pos + 1..len { + indices[next] = indices[next - 1] + 1; + } + return true; + } + } + false +} + +/// Scores a candidate simplex by f64 volume and rejects degenerate choices. +fn simplex_volume_for_indices( + coords_f64: &[[f64; D]], + simplex_indices: &[usize], +) -> Option { + if simplex_indices.len() != D + 1 { + return None; + } + + let mut points: SmallBuffer, MAX_PRACTICAL_DIMENSION_SIZE> = + SmallBuffer::with_capacity(simplex_indices.len()); + for &idx in simplex_indices { + points.push(Point::new(coords_f64[idx])); + } + simplex_volume(&points) + .ok() + .filter(|volume| volume.is_finite() && *volume > 0.0) +} + +/// Chooses the largest-volume nondegenerate real simplex from a bounded +/// extreme-vertex candidate pool. +fn select_max_volume_simplex_indices( + vertices: &[Vertex], +) -> Option> +where + T: CoordinateScalar, +{ + if vertices.len() < D + 1 { + return None; + } + + let coords_f64 = vertices_coords_f64(vertices)?; + let candidates = initial_simplex_candidate_pool_indices(&coords_f64); + if candidates.len() < D + 1 { + return None; + } + + let simplex_len = D + 1; + let mut combination: Vec = (0..simplex_len).collect(); + let mut best_volume = 0.0_f64; + let mut best_indices: Option> = None; + + loop { + let simplex_indices: SmallBuffer = combination + .iter() + .map(|&candidate_idx| candidates[candidate_idx]) + .collect(); + if let Some(volume) = simplex_volume_for_indices(&coords_f64, &simplex_indices) + && volume > best_volume + { + best_volume = volume; + best_indices = Some(simplex_indices.iter().copied().collect()); + } + + if !advance_combination(&mut combination, candidates.len()) { + break; + } + } + + best_indices +} + /// Places the selected simplex first while preserving every remaining input /// vertex exactly once. fn reorder_vertices_for_simplex( @@ -1626,8 +2133,8 @@ fn reorder_vertices_for_simplex( simplex_indices: &[usize], ) -> Option>> where - T: CoordinateScalar, - U: DataType, + T: Copy, + U: Copy, { if simplex_indices.len() != D + 1 { return None; @@ -1700,6 +2207,12 @@ fn construction_retry_trace_enabled() -> bool { || env::var_os("DELAUNAY_INSERT_TRACE").is_some() } +/// Converts a measured duration to nanoseconds while saturating pathological +/// values that exceed the public telemetry counter width. +fn duration_nanos_saturating(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) +} + #[derive(Clone, Copy, Debug)] /// Snapshot of one batch-construction progress sample. struct BatchProgressSample { @@ -1841,7 +2354,6 @@ fn log_construction_retry_result( fn vertex_coords_f64(vertex: &Vertex) -> Option> where T: CoordinateScalar, - U: DataType, { vertex .point() @@ -1862,7 +2374,6 @@ fn order_vertices_hilbert( ) -> Vec> where T: CoordinateScalar, - U: DataType, { if vertices.is_empty() || D == 0 { return vertices; @@ -2011,7 +2522,7 @@ where /// # Examples /// /// ```rust -/// use delaunay::prelude::triangulation::*; +/// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2054,7 +2565,9 @@ impl DelaunayTriangulation, (), (), D> { /// or toroidal (periodic) triangulations, use [`DelaunayTriangulationBuilder`]: /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2072,7 +2585,9 @@ impl DelaunayTriangulation, (), (), D> { /// /// For toroidal (periodic) triangulations: /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.1, 0.2]), @@ -2093,8 +2608,7 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2127,11 +2641,10 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ + /// use delaunay::prelude::triangulation::construction::{ /// DelaunayConstructionFailure, DelaunayTriangulation, - /// DelaunayTriangulationConstructionError, + /// DelaunayTriangulationConstructionError, vertex, /// }; - /// use delaunay::vertex; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2211,10 +2724,9 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ - /// ConstructionOptions, DedupPolicy, InsertionOrderStrategy, + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DedupPolicy, DelaunayTriangulation, InsertionOrderStrategy, vertex, /// }; - /// use delaunay::prelude::triangulation::*; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2256,8 +2768,9 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2292,7 +2805,7 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// // Start with empty triangulation /// let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -2320,8 +2833,9 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, + /// }; /// /// let dt: DelaunayTriangulation<_, (), (), 3> = /// DelaunayTriangulation::empty_with_topology_guarantee(TopologyGuarantee::Pseudomanifold); @@ -2349,7 +2863,9 @@ impl DelaunayTriangulation, (), (), D> { /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, DelaunayTriangulationBuilder, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -2367,7 +2883,9 @@ impl DelaunayTriangulation, (), (), D> { /// ## Toroidal construction /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, DelaunayTriangulationBuilder, vertex, + /// }; /// /// // Vertices outside [0, 1)² are canonicalized before building. /// let vertices = vec![ @@ -2419,8 +2937,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; /// use delaunay::prelude::geometry::RobustKernel; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// // Start with empty triangulation using robust kernel /// let mut dt: DelaunayTriangulation, (), (), 4> = @@ -2456,8 +2974,9 @@ where /// /// ```rust /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, vertex, + /// }; /// /// let dt: DelaunayTriangulation, (), (), 3> = /// DelaunayTriangulation::with_empty_kernel_and_topology_guarantee( @@ -2509,8 +3028,7 @@ where /// /// ```rust /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -2553,8 +3071,9 @@ where /// /// ```rust /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2599,12 +3118,11 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::{ - /// ConstructionOptions, DedupPolicy, InsertionOrderStrategy, - /// }; /// use delaunay::prelude::geometry::RobustKernel; - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// ConstructionOptions, DedupPolicy, DelaunayTriangulation, InsertionOrderStrategy, + /// TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -2639,6 +3157,7 @@ where dedup_policy, initial_simplex, retry_policy, + batch_repair_policy, use_global_repair_fallback, } = options; @@ -2667,6 +3186,7 @@ where attempts, base_seed, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ); } @@ -2685,6 +3205,7 @@ where attempts, base_seed, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ); } @@ -2696,6 +3217,7 @@ where vertices, topology_guarantee, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) }; @@ -2728,6 +3250,10 @@ where clippy::result_large_err, reason = "Public API intentionally returns by-value construction statistics for compatibility" )] + #[expect( + clippy::too_many_lines, + reason = "Statistics constructor handles preprocessing, retry, and fallback aggregation" + )] pub fn with_options_and_statistics( kernel: &K, vertices: &[Vertex], @@ -2740,21 +3266,32 @@ where dedup_policy, initial_simplex, retry_policy, + batch_repair_policy, use_global_repair_fallback, } = options; - let preprocessed = Self::preprocess_vertices_for_construction( + let preprocessing_started = Instant::now(); + let preprocessed = match Self::preprocess_vertices_for_construction( vertices, dedup_policy, insertion_order, initial_simplex, - ) - .map_err( - |error| DelaunayTriangulationConstructionErrorWithStatistics { - error, - statistics: ConstructionStatistics::default(), - }, - )?; + ) { + Ok(preprocessed) => preprocessed, + Err(error) => { + let mut statistics = ConstructionStatistics::default(); + statistics + .telemetry + .record_construction_preprocessing_timing(duration_nanos_saturating( + preprocessing_started.elapsed(), + )); + return Err(DelaunayTriangulationConstructionErrorWithStatistics { + error, + statistics, + }); + } + }; + let preprocessing_nanos = duration_nanos_saturating(preprocessing_started.elapsed()); let grid_cell_size = preprocessed.grid_cell_size(); let primary_vertices: &[Vertex] = preprocessed.primary_slice(vertices); let fallback_vertices = preprocessed.fallback_slice(); @@ -2774,6 +3311,7 @@ where attempts, base_seed, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ); } @@ -2792,6 +3330,7 @@ where attempts, base_seed, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ); } @@ -2803,14 +3342,24 @@ where vertices, topology_guarantee, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) }; match build_with_vertices(primary_vertices) { - Ok(result) => Ok(result), - Err(primary_err) => { + Ok((dt, mut stats)) => { + stats + .telemetry + .record_construction_preprocessing_timing(preprocessing_nanos); + Ok((dt, stats)) + } + Err(mut primary_err) => { let Some(fallback) = fallback_vertices else { + primary_err + .statistics + .telemetry + .record_construction_preprocessing_timing(preprocessing_nanos); return Err(primary_err); }; @@ -2818,11 +3367,17 @@ where Ok((dt, stats)) => { let mut aggregate = primary_err.statistics; aggregate.merge_from(&stats); + aggregate + .telemetry + .record_construction_preprocessing_timing(preprocessing_nanos); Ok((dt, aggregate)) } Err(fallback_err) => { let mut aggregate = primary_err.statistics; aggregate.merge_from(&fallback_err.statistics); + aggregate + .telemetry + .record_construction_preprocessing_timing(preprocessing_nanos); Err(DelaunayTriangulationConstructionErrorWithStatistics { error: fallback_err.error, statistics: aggregate, @@ -2923,6 +3478,18 @@ where (Some(base), None) } } + InitialSimplexStrategy::MaxVolume => { + let base = owned_vertices.unwrap_or_else(|| vertices.to_vec()); + if let Some(indices) = select_max_volume_simplex_indices(&base) { + if let Some(reordered) = reorder_vertices_for_simplex(&base, &indices) { + (Some(reordered), Some(base)) + } else { + (Some(base), None) + } + } else { + (Some(base), None) + } + } }; let final_slice = primary.as_deref().unwrap_or(vertices); @@ -2951,6 +3518,7 @@ where reason: TdsConstructionFailure::DuplicateUuid { .. } | TdsConstructionFailure::Validation { .. }, } | DelaunayConstructionFailure::InternalInconsistency { .. } + | DelaunayConstructionFailure::DelaunayRepair { .. } | DelaunayConstructionFailure::InsertionTopologyValidation { .. } | DelaunayConstructionFailure::FinalTopologyValidation { .. }, ) @@ -2963,6 +3531,10 @@ where clippy::too_many_lines, reason = "construction retry flow keeps seed selection, validation, and diagnostics together" )] + #[expect( + clippy::too_many_arguments, + reason = "private construction retry helper threads orthogonal batch knobs explicitly" + )] fn build_with_shuffled_retries( kernel: &K, vertices: &[Vertex], @@ -2970,6 +3542,7 @@ where attempts: NonZeroUsize, base_seed: Option, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result { let base_seed = base_seed.unwrap_or_else(|| Self::construction_shuffle_seed(vertices)); @@ -2996,9 +3569,10 @@ where 0_u64, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) { - Ok(candidate) => match is_delaunay_property_only(&candidate.tri.tds) { + Ok(candidate) => match candidate.is_delaunay_via_flips() { Ok(()) => { log_construction_retry_result(0, None, 0_u64, "succeeded", None, None); return Ok(candidate); @@ -3066,9 +3640,10 @@ where perturbation_seed, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) { - Ok(candidate) => match is_delaunay_property_only(&candidate.tri.tds) { + Ok(candidate) => match candidate.is_delaunay_via_flips() { Ok(()) => { log_construction_retry_result( attempt, @@ -3142,6 +3717,10 @@ where clippy::result_large_err, reason = "Internal helper propagates public by-value construction-statistics error type" )] + #[expect( + clippy::too_many_arguments, + reason = "statistics retry helper mirrors the non-statistics construction path" + )] fn build_with_shuffled_retries_with_construction_statistics( kernel: &K, vertices: &[Vertex], @@ -3149,6 +3728,7 @@ where attempts: NonZeroUsize, base_seed: Option, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result<(Self, ConstructionStatistics), DelaunayTriangulationConstructionErrorWithStatistics> { @@ -3180,27 +3760,37 @@ where 0_u64, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) { - Ok((candidate, stats)) => match is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => { - aggregate_stats.merge_from(&stats); - log_construction_retry_result( - 0, - None, - 0_u64, - "succeeded", - None, - Some(&stats), + Ok((candidate, mut stats)) => { + let delaunay_started = Instant::now(); + let delaunay_result = candidate.is_delaunay_via_flips(); + stats + .telemetry + .record_construction_final_delaunay_validation_timing( + duration_nanos_saturating(delaunay_started.elapsed()), ); - return Ok((candidate, aggregate_stats)); - } - Err(err) => { - aggregate_stats.merge_from(&stats); - last_stats.replace(stats); - format!("Delaunay property violated after construction: {err}") + match delaunay_result { + Ok(()) => { + aggregate_stats.merge_from(&stats); + log_construction_retry_result( + 0, + None, + 0_u64, + "succeeded", + None, + Some(&stats), + ); + return Ok((candidate, aggregate_stats)); + } + Err(err) => { + aggregate_stats.merge_from(&stats); + last_stats.replace(stats); + format!("Delaunay property violated after construction: {err}") + } } - }, + } Err(err) => { let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics } = err; @@ -3276,28 +3866,38 @@ where perturbation_seed, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, ) { - Ok((candidate, stats)) => match is_delaunay_property_only(&candidate.tri.tds) { - Ok(()) => { - aggregate_stats.merge_from(&stats); - log_construction_retry_result( - attempt, - Some(attempt_seed), - perturbation_seed, - "succeeded", - None, - Some(&stats), + Ok((candidate, mut stats)) => { + let delaunay_started = Instant::now(); + let delaunay_result = candidate.is_delaunay_via_flips(); + stats + .telemetry + .record_construction_final_delaunay_validation_timing( + duration_nanos_saturating(delaunay_started.elapsed()), ); - return Ok((candidate, aggregate_stats)); - } - Err(err) => { - aggregate_stats.merge_from(&stats); - last_stats.replace(stats); - last_error = - format!("Delaunay property violated after construction: {err}"); + match delaunay_result { + Ok(()) => { + aggregate_stats.merge_from(&stats); + log_construction_retry_result( + attempt, + Some(attempt_seed), + perturbation_seed, + "succeeded", + None, + Some(&stats), + ); + return Ok((candidate, aggregate_stats)); + } + Err(err) => { + aggregate_stats.merge_from(&stats); + last_stats.replace(stats); + last_error = + format!("Delaunay property violated after construction: {err}"); + } } - }, + } Err(err) => { let DelaunayTriangulationConstructionErrorWithStatistics { error, statistics } = err; @@ -3387,6 +3987,7 @@ where vertices: &[Vertex], topology_guarantee: TopologyGuarantee, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result { let dt = Self::build_with_kernel_inner_seeded( @@ -3396,6 +3997,7 @@ where 0, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, )?; @@ -3427,16 +4029,18 @@ where vertices: &[Vertex], topology_guarantee: TopologyGuarantee, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result<(Self, ConstructionStatistics), DelaunayTriangulationConstructionErrorWithStatistics> { - let (dt, stats) = Self::build_with_kernel_inner_seeded_with_construction_statistics( + let (dt, mut stats) = Self::build_with_kernel_inner_seeded_with_construction_statistics( kernel, vertices, topology_guarantee, 0, true, grid_cell_size, + batch_repair_policy, use_global_repair_fallback, )?; @@ -3445,8 +4049,14 @@ where tracing::debug!("post-construction: starting Delaunay validation (build stats)"); let delaunay_started = Instant::now(); let delaunay_result = dt.is_valid(); + let delaunay_elapsed = delaunay_started.elapsed(); + stats + .telemetry + .record_construction_final_delaunay_validation_timing(duration_nanos_saturating( + delaunay_elapsed, + )); tracing::debug!( - elapsed = ?delaunay_started.elapsed(), + elapsed = ?delaunay_elapsed, success = delaunay_result.is_ok(), "post-construction: Delaunay validation (build stats) completed" ); @@ -3469,6 +4079,10 @@ where clippy::result_large_err, reason = "Internal helper propagates public by-value construction-statistics error type" )] + #[expect( + clippy::too_many_arguments, + reason = "seeded construction helper carries retry, repair, and validation knobs" + )] fn build_with_kernel_inner_seeded_with_construction_statistics( kernel: K, vertices: &[Vertex], @@ -3476,6 +4090,7 @@ where perturbation_seed: u64, run_final_repair: bool, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result<(Self, ConstructionStatistics), DelaunayTriangulationConstructionErrorWithStatistics> { @@ -3540,12 +4155,11 @@ where // Disable maybe_repair_after_insertion during bulk construction: its full pipeline // (multi-pass repair + topology validation + heuristic rebuild) is too expensive - // per insertion. Instead, insert_remaining_vertices_seeded calls - // repair_delaunay_local_single_pass directly after each insertion (no topology - // check, no heuristic rebuild, soft-fail on non-convergence for D≥4). Soft-failed - // insertions (D≥4 only) record their adjacent cells in soft_fail_seeds, which is - // used as the seed for the final seeded repair in finalize_bulk_construction. If - // no soft-fails occurred the seed is empty and finalize skips the repair entirely. + // per insertion. Instead, insert_remaining_vertices_seeded accumulates the local + // frontier touched by successful insertions and calls repair_delaunay_local_single_pass + // at the requested cadence (no topology check, no heuristic rebuild, soft-fail on + // non-convergence for D≥4). Soft-failed repair frontiers are retained for the final + // seeded repair in finalize_bulk_construction. let original_repair_policy = dt.insertion_state.delaunay_repair_policy; dt.insertion_state.delaunay_repair_policy = DelaunayRepairPolicy::Never; dt.insertion_state.use_global_repair_fallback = use_global_repair_fallback; @@ -3560,25 +4174,45 @@ where } let mut soft_fail_seeds: Vec = Vec::new(); - if let Err(error) = dt.insert_remaining_vertices_seeded( + let mut pending_repair_seeds: Vec = Vec::new(); + let insert_loop_started = Instant::now(); + let insert_result = dt.insert_remaining_vertices_seeded( vertices, perturbation_seed, grid_cell_size, + batch_repair_policy, Some(&mut stats), + &mut pending_repair_seeds, &mut soft_fail_seeds, - ) { + ); + stats + .telemetry + .record_construction_insert_loop_timing(duration_nanos_saturating( + insert_loop_started.elapsed(), + )); + if let Err(error) = insert_result { return Err(DelaunayTriangulationConstructionErrorWithStatistics { error, statistics: stats, }); } - if let Err(error) = dt.finalize_bulk_construction( + let finalize_started = Instant::now(); + let finalize_result = dt.finalize_bulk_construction( original_validation_policy, original_repair_policy, run_final_repair, + batch_repair_policy, + &pending_repair_seeds, &soft_fail_seeds, - ) { + Some(&mut stats.telemetry), + ); + stats + .telemetry + .record_construction_finalize_timing(duration_nanos_saturating( + finalize_started.elapsed(), + )); + if let Err(error) = finalize_result { return Err(DelaunayTriangulationConstructionErrorWithStatistics { error, statistics: stats, @@ -3590,6 +4224,10 @@ where /// Implements the non-statistics seeded construction core for callers that /// only need the triangulation. + #[expect( + clippy::too_many_arguments, + reason = "seeded construction helper carries retry, repair, and validation knobs" + )] fn build_with_kernel_inner_seeded( kernel: K, vertices: &[Vertex], @@ -3597,6 +4235,7 @@ where perturbation_seed: u64, run_final_repair: bool, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, use_global_repair_fallback: bool, ) -> Result { if vertices.len() < D + 1 { @@ -3651,72 +4290,29 @@ where dt.insertion_state.delaunay_repair_policy = DelaunayRepairPolicy::Never; dt.insertion_state.use_global_repair_fallback = use_global_repair_fallback; let mut soft_fail_seeds: Vec = Vec::new(); + let mut pending_repair_seeds: Vec = Vec::new(); dt.insert_remaining_vertices_seeded( vertices, perturbation_seed, grid_cell_size, + batch_repair_policy, None, + &mut pending_repair_seeds, &mut soft_fail_seeds, )?; dt.finalize_bulk_construction( original_validation_policy, original_repair_policy, run_final_repair, + batch_repair_policy, + &pending_repair_seeds, &soft_fail_seeds, + None, )?; Ok(dt) } - /// Handle D<4 local repair non-convergence by falling back to global repair or - /// hard-failing to trigger shuffle retry. - /// - /// Returns `Ok(())` if global repair succeeded (caller should `continue` the - /// insertion loop). Returns `Err(...)` if the caller should propagate the - /// construction error. - fn try_d_lt4_global_repair_fallback( - tds: &mut Tds, - kernel: &K, - topology: TopologyGuarantee, - use_global_repair_fallback: bool, - index: usize, - repair_err: &DelaunayRepairError, - ) -> Result<(), DelaunayTriangulationConstructionError> { - if use_global_repair_fallback { - tracing::debug!( - error = %repair_err, - idx = index, - "bulk D<4: local repair cycling; falling back to global repair" - ); - let global_result = repair_delaunay_with_flips_k2_k3(tds, kernel, None, topology, None); - if let Err(global_err) = global_result { - tracing::debug!( - error = %global_err, - idx = index, - "bulk D<4: global repair also failed; aborting this vertex ordering" - ); - return Err(TriangulationConstructionError::GeometricDegeneracy { - message: format!( - "per-insertion Delaunay repair failed at index {index}: local error: {repair_err}; global fallback: {global_err}" - ), - } - .into()); - } - return Ok(()); - } - // Global repair disabled (e.g. periodic build): hard-fail to trigger - // shuffle retry with a different vertex ordering. - tracing::debug!( - error = %repair_err, - idx = index, - "bulk D<4: local repair cycling (global fallback disabled); aborting" - ); - Err(TriangulationConstructionError::GeometricDegeneracy { - message: format!("per-insertion Delaunay repair failed at index {index}: {repair_err}"), - } - .into()) - } - /// Restores positive geometric orientation after bulk repair calls the /// low-level TDS flip routine directly. fn canonicalize_after_bulk_repair( @@ -3735,124 +4331,151 @@ 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, + /// 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, - base_budget: usize, - last_escalation_idx: &mut Option, - original_err: &DelaunayRepairError, - ) -> Result { - // Rate-limit: only escalate if we have not escalated within the last - // LOCAL_REPAIR_ESCALATION_MIN_GAP insertions. This keeps healthy runs - // from paying the near-global flip pass on every insertion while still - // catching pathological clusters of consecutive soft-fails. - if let Some(last_idx) = *last_escalation_idx - && index.saturating_sub(last_idx) < LOCAL_REPAIR_ESCALATION_MIN_GAP - { - return Ok(LocalRepairEscalationOutcome::Skipped { - reason: EscalationSkipReason::RateLimited { - last_escalation_idx: last_idx, - min_gap: LOCAL_REPAIR_ESCALATION_MIN_GAP, + 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 { + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::DelaunayRepair { + phase: DelaunayConstructionRepairPhase::BatchLocal { index }, + source: Box::new(repair_err), }, - }); + ) } + } - // Escalation seed set: use every current cell key. This gives the - // repair the broadest possible view of the local backlog without - // switching to a different repair entry point. - let full_seeds: Vec = self.tri.tds.cell_keys().collect(); - if full_seeds.is_empty() { - return Ok(LocalRepairEscalationOutcome::Skipped { - reason: EscalationSkipReason::EmptyTds, - }); + /// Repairs the currently accumulated batch-local seed frontier. + fn repair_pending_local_seed_cells( + &mut self, + index: usize, + trigger: BatchLocalRepairTrigger, + pending_seed_cells: &mut Vec, + pending_seen: &mut FastHashSet, + soft_fail_seeds: &mut Vec, + mut construction_telemetry: Option<&mut ConstructionTelemetry>, + ) -> Result<(), DelaunayTriangulationConstructionError> { + retain_live_cell_seeds(&self.tri.tds, pending_seed_cells, pending_seen); + if pending_seed_cells.is_empty() { + return Ok(()); } - 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" - ); + #[cfg(test)] + test_hooks::record_batch_local_repair_call(); - let escalation_result = { + let seed_cells_len = pending_seed_cells.len(); + let max_flips = local_repair_flip_budget::(seed_cells_len); + let trace_repair = batch_repair_trace_enabled(); + let mut phase_timing = LocalRepairPhaseTiming::default(); + if trace_repair { + tracing::debug!( + idx = index, + seed_cells = seed_cells_len, + max_flips, + trigger = ?trigger, + "bulk batch repair: starting local repair" + ); + } + let collect_telemetry = construction_telemetry.is_some(); + let repair_started = (collect_telemetry || trace_repair).then(Instant::now); + + let repair_result = { self.invalidate_repair_caches(); let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass(tds, kernel, &full_seeds, escalated_budget) + let timing = if collect_telemetry { + Some(&mut phase_timing) + } else { + None + }; + repair_delaunay_local_single_pass_timed( + tds, + kernel, + pending_seed_cells, + max_flips, + timing, + ) }; + #[cfg(test)] + let repair_result = if test_hooks::force_repair_nonconvergent_enabled() { + Err(test_hooks::synthetic_nonconvergent_error()) + } else { + repair_result + }; + let repair_elapsed = repair_started.map(|started| started.elapsed()); + if let Some(telemetry) = construction_telemetry.as_mut() { + let repair_elapsed = repair_elapsed.unwrap_or_default(); + telemetry.record_local_repair_timing(duration_nanos_saturating(repair_elapsed)); + telemetry.record_local_repair_phase_timing(&phase_timing); + telemetry.record_local_repair_frontier(seed_cells_len, trigger); + } - *last_escalation_idx = Some(index); - - match escalation_result { + match repair_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 }) + if let Some(telemetry) = construction_telemetry.as_mut() { + telemetry.record_local_repair_work( + stats.facets_checked, + stats.flips_performed, + stats.max_queue_len, + ); + } + if trace_repair { + tracing::debug!( + idx = index, + seed_cells = seed_cells_len, + flips = stats.flips_performed, + checked = stats.facets_checked, + max_queue = stats.max_queue_len, + elapsed = ?repair_elapsed.unwrap_or_default(), + "bulk batch repair: local repair succeeded" + ); + } + if stats.flips_performed > 0 { + self.canonicalize_after_bulk_repair()?; + } + clear_cell_seed_set(pending_seed_cells, pending_seen); } - Err(escalation_err) => { - if !Self::can_soft_fail(&escalation_err) { - return Err(Self::map_hard_repair_error(index, &escalation_err)); + Err(repair_err) => { + if trace_repair { + tracing::debug!( + idx = index, + seed_cells = seed_cells_len, + error = %repair_err, + elapsed = ?repair_elapsed.unwrap_or_default(), + "bulk batch repair: local repair failed" + ); + } + if !Self::can_soft_fail(&repair_err) { + return Err(Self::map_hard_repair_error(index, repair_err)); } tracing::debug!( idx = index, - error = %escalation_err, - "bulk D≥4: escalation also non-convergent; falling through to soft-fail" + error = %repair_err, + seed_cells = seed_cells_len, + "bulk batch repair: local repair soft-failed; deferring seeds to final repair" ); - Ok(LocalRepairEscalationOutcome::FailedAlso { - escalation_error: escalation_err, - }) + self.canonicalize_after_bulk_repair()?; + soft_fail_seeds.extend(pending_seed_cells.iter().copied()); + clear_cell_seed_set(pending_seed_cells, pending_seen); } } - } - - /// 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() - } + Ok(()) } /// Inserts the non-simplex vertices under a fixed perturbation seed so bulk @@ -3861,12 +4484,18 @@ where clippy::too_many_lines, reason = "seeded insertion loop keeps cache repair and retry diagnostics in one flow" )] + #[expect( + clippy::too_many_arguments, + reason = "seeded insertion loop needs batch repair and construction-statistics state" + )] fn insert_remaining_vertices_seeded( &mut self, vertices: &[Vertex], perturbation_seed: u64, grid_cell_size: Option, + batch_repair_policy: DelaunayRepairPolicy, construction_stats: Option<&mut ConstructionStatistics>, + pending_repair_seeds: &mut Vec, soft_fail_seeds: &mut Vec, ) -> Result<(), DelaunayTriangulationConstructionError> { let mut grid_index = grid_cell_size.map(HashGridIndex::new); @@ -3902,10 +4531,8 @@ where // progress line reads `processed=N/total inserted=I skipped=S` coherently. let mut inserted_vertices = 0usize; let mut skipped_vertices = 0usize; - // Last insertion index at which the D≥4 local-repair escalation ran, - // used for `LOCAL_REPAIR_ESCALATION_MIN_GAP` rate limiting across both - // stats-enabled and stats-disabled arms. - let mut last_escalation_idx: Option = None; + let mut pending_repair_seen: FastHashSet = + pending_repair_seeds.iter().copied().collect(); match construction_stats { None => { @@ -3950,16 +4577,22 @@ where let elapsed = started.map(|started| started.elapsed()); let insert_result = insert_result.map(|detail| { let repair_seed_cells = detail.repair_seed_cells; - (detail.outcome, detail.stats, repair_seed_cells) + ( + detail.outcome, + detail.stats, + repair_seed_cells, + detail.delaunay_repair_required, + ) }); match insert_result { Ok(( InsertionOutcome::Inserted { - vertex_key: v_key, + vertex_key: _, hint, }, _stats, repair_seed_cells, + delaunay_repair_required, )) => { inserted_vertices = inserted_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { @@ -3971,154 +4604,37 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - // Per-insertion local Delaunay repair: seeded from the star of - // the inserted vertex with a seed-proportional flip budget. - // - // For D<4: the flip graph is proven convergent (Lawson 1977 for - // D=2, Rajan 1991/Joe 1991 for D=3). On cycling (FP noise near - // co-spherical configurations), roll back the insertion and retry - // with perturbation to break the co-sphericity. - // - // For D≥4: Bowyer-Watson with the fast kernel can produce - // non-Delaunay facets when the conflict region is detected - // imprecisely (co-spherical configurations). A bounded - // per-insertion repair pass fixes these violations. If repair - // does not converge (e.g. co-spherical cycling suppressed by - // both_positive_artifact), the soft-fail path lets construction - // continue; the final is_valid() check validates the result. + // Batch local Delaunay repair: accumulate the local frontier + // touched by each successful insertion, then repair the whole + // frontier when the policy fires or the frontier grows too large. + // This keeps EveryN semantics local to the recent insertion window + // rather than repairing only the final insertion in the batch. let topology = self.tri.topology_guarantee(); - if D >= 2 + if delaunay_repair_required + && batch_repair_policy != DelaunayRepairPolicy::Never && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - let seed_cells = - self.collect_local_repair_seed_cells(v_key, &repair_seed_cells); - if !seed_cells.is_empty() { - let max_flips = local_repair_flip_budget::(seed_cells.len()); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass( - tds, - kernel, - &seed_cells, - max_flips, - ) - }; - #[cfg(test)] - let repair_result = - if test_hooks::force_repair_nonconvergent_enabled() { - Err(test_hooks::synthetic_nonconvergent_error()) - } else { - repair_result - }; - match repair_result { - Ok(stats) => { - if stats.flips_performed > 0 { - self.canonicalize_after_bulk_repair()?; - } - } - Err(repair_err) => { - if D < 4 { - self.invalidate_repair_caches(); - Self::try_d_lt4_global_repair_fallback( - &mut self.tri.tds, - &self.tri.kernel, - topology, - self.insertion_state.use_global_repair_fallback, - index, - &repair_err, - )?; - self.canonicalize_after_bulk_repair()?; - log_bulk_progress_if_due( - BatchProgressSample { - processed: offset + 1, - inserted: inserted_vertices, - skipped: skipped_vertices, - cell_count: self.tri.tds.number_of_cells(), - perturbation_seed, - }, - &mut batch_progress, - ); - continue; - } - // D≥4: try one escalation with a 4× budget and the full - // TDS as seed set before accepting the soft-fail. The - // escalation is rate-limited so healthy runs do not pay - // for it on every insertion. - if !Self::can_soft_fail(&repair_err) { - return Err(Self::map_hard_repair_error( - index, - &repair_err, - )); - } - let outcome = self.try_local_repair_escalation_d_ge_4( - index, - max_flips, - &mut last_escalation_idx, - &repair_err, - )?; - match outcome { - LocalRepairEscalationOutcome::Succeeded { - stats, - } => { - tracing::debug!( - idx = index, - flips = stats.flips_performed, - max_queue = stats.max_queue_len, - "bulk D≥4: escalation closed the \ - non-convergence; continuing" - ); - log_bulk_progress_if_due( - BatchProgressSample { - processed: offset + 1, - inserted: inserted_vertices, - skipped: skipped_vertices, - cell_count: self - .tri - .tds - .number_of_cells(), - perturbation_seed, - }, - &mut batch_progress, - ); - continue; - } - LocalRepairEscalationOutcome::Skipped { - reason, - } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "skipped", - skip_reason = ?reason, - "bulk D≥4: per-insertion repair \ - non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds - .extend(seed_cells.iter().copied()); - } - LocalRepairEscalationOutcome::FailedAlso { - escalation_error, - } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "failed_also", - escalation_error = %escalation_error, - "bulk D≥4: per-insertion repair \ - non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds - .extend(seed_cells.iter().copied()); - } - } - } - } + accumulate_live_cell_seeds( + &self.tri.tds, + &repair_seed_cells, + pending_repair_seeds, + &mut pending_repair_seen, + ); + if let Some(trigger) = batch_local_repair_trigger::( + batch_repair_policy, + inserted_vertices, + topology, + pending_repair_seeds.len(), + ) { + self.repair_pending_local_seed_cells( + index, + trigger, + pending_repair_seeds, + &mut pending_repair_seen, + soft_fail_seeds, + None, + )?; } } log_bulk_progress_if_due( @@ -4132,7 +4648,12 @@ where &mut batch_progress, ); } - Ok((InsertionOutcome::Skipped { error }, stats, _repair_seed_cells)) => { + Ok(( + InsertionOutcome::Skipped { error }, + stats, + _repair_seed_cells, + _delaunay_repair_required, + )) => { skipped_vertices = skipped_vertices.saturating_add(1); if trace_insertion && let Some(elapsed) = elapsed { tracing::debug!( @@ -4195,7 +4716,7 @@ where tracing::debug!(index, %uuid, coords = ?coords, "[bulk] start"); } - let started = trace_insertion.then(Instant::now); + let started = Instant::now(); let mut insert = || { // Keep the stats and non-stats branches aligned so bulk-index-based // tracing behaves the same regardless of whether the caller records @@ -4220,22 +4741,31 @@ where } else { insert() }; - let elapsed = started.map(|started| started.elapsed()); + let elapsed = started.elapsed(); + let elapsed_nanos = duration_nanos_saturating(elapsed); let insert_result = insert_result.map(|detail| { let repair_seed_cells = detail.repair_seed_cells; - (detail.outcome, detail.stats, repair_seed_cells) + ( + detail.outcome, + detail.stats, + repair_seed_cells, + detail.delaunay_repair_required, + detail.telemetry, + ) }); match insert_result { Ok(( InsertionOutcome::Inserted { - vertex_key: v_key, + vertex_key: _, hint, }, stats, repair_seed_cells, + delaunay_repair_required, + telemetry, )) => { inserted_vertices = inserted_vertices.saturating_add(1); - if trace_insertion && let Some(elapsed) = elapsed { + if trace_insertion { tracing::debug!( index, %uuid, @@ -4245,6 +4775,29 @@ where ); } construction_stats.record_insertion(&stats); + construction_stats.telemetry.record_insertion(&telemetry); + construction_stats + .telemetry + .record_insertion_timing(elapsed_nanos); + construction_stats.record_slow_insertion_sample( + ConstructionSlowInsertionSample { + index, + uuid, + attempts: stats.attempts, + result: stats.result, + elapsed_nanos, + cells_after: self.tri.tds.number_of_cells(), + locate_calls: telemetry.locate_calls, + locate_walk_steps_total: telemetry.locate_walk_steps_total, + conflict_region_calls: telemetry.conflict_region_calls, + conflict_region_cells_total: telemetry + .conflict_region_cells_total, + cavity_insertion_calls: telemetry.cavity_insertion_calls, + global_conflict_scans: telemetry.global_conflict_scans, + hull_extension_calls: telemetry.hull_extension_calls, + topology_validation_calls: telemetry.topology_validation_calls, + }, + ); // Cache hint for faster subsequent insertions. self.insertion_state.last_inserted_cell = hint; @@ -4252,141 +4805,41 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - // Per-insertion local repair: see the non-stats branch + // Batch local repair: see the non-stats branch // comment for full details. let topology = self.tri.topology_guarantee(); - if D >= 2 + if delaunay_repair_required + && batch_repair_policy != DelaunayRepairPolicy::Never && TopologicalOperation::FacetFlip.is_admissible_under(topology) && self.tri.tds.number_of_cells() > 0 { - let seed_cells = - self.collect_local_repair_seed_cells(v_key, &repair_seed_cells); - if !seed_cells.is_empty() { - let max_flips = local_repair_flip_budget::(seed_cells.len()); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass( - tds, - kernel, - &seed_cells, - max_flips, - ) - }; - #[cfg(test)] - let repair_result = - if test_hooks::force_repair_nonconvergent_enabled() { - Err(test_hooks::synthetic_nonconvergent_error()) - } else { - repair_result - }; - match repair_result { - Ok(stats) => { - if stats.flips_performed > 0 { - self.canonicalize_after_bulk_repair()?; - } - } - Err(repair_err) => { - if D < 4 { - self.invalidate_repair_caches(); - Self::try_d_lt4_global_repair_fallback( - &mut self.tri.tds, - &self.tri.kernel, - topology, - self.insertion_state.use_global_repair_fallback, - index, - &repair_err, - )?; - self.canonicalize_after_bulk_repair()?; - log_bulk_progress_if_due( - BatchProgressSample { - processed: offset + 1, - inserted: inserted_vertices, - skipped: skipped_vertices, - cell_count: self.tri.tds.number_of_cells(), - perturbation_seed, - }, - &mut batch_progress, - ); - continue; - } - // D≥4: try one escalation with a 4× budget and the full - // TDS as seed set before accepting the soft-fail. The - // escalation is rate-limited so healthy runs do not pay - // for it on every insertion. - if !Self::can_soft_fail(&repair_err) { - return Err(Self::map_hard_repair_error( - index, - &repair_err, - )); - } - let outcome = self.try_local_repair_escalation_d_ge_4( - index, - max_flips, - &mut last_escalation_idx, - &repair_err, - )?; - match outcome { - LocalRepairEscalationOutcome::Succeeded { - stats, - } => { - tracing::debug!( - idx = index, - flips = stats.flips_performed, - max_queue = stats.max_queue_len, - "bulk D≥4: escalation closed the \ - non-convergence; continuing" - ); - log_bulk_progress_if_due( - BatchProgressSample { - processed: offset + 1, - inserted: inserted_vertices, - skipped: skipped_vertices, - cell_count: self - .tri - .tds - .number_of_cells(), - perturbation_seed, - }, - &mut batch_progress, - ); - continue; - } - LocalRepairEscalationOutcome::Skipped { - reason, - } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "skipped", - skip_reason = ?reason, - "bulk D≥4: per-insertion repair \ - non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds - .extend(seed_cells.iter().copied()); - } - LocalRepairEscalationOutcome::FailedAlso { - escalation_error, - } => { - tracing::debug!( - idx = index, - error = %repair_err, - escalation_outcome = "failed_also", - escalation_error = %escalation_error, - "bulk D≥4: per-insertion repair \ - non-convergent; continuing \ - (both_positive_artifact handled)" - ); - self.canonicalize_after_bulk_repair()?; - soft_fail_seeds - .extend(seed_cells.iter().copied()); - } - } - } - } + let seed_started = Instant::now(); + let seed_cells_added = accumulate_live_cell_seeds( + &self.tri.tds, + &repair_seed_cells, + pending_repair_seeds, + &mut pending_repair_seen, + ); + construction_stats + .telemetry + .record_repair_seed_accumulation( + duration_nanos_saturating(seed_started.elapsed()), + seed_cells_added, + ); + if let Some(trigger) = batch_local_repair_trigger::( + batch_repair_policy, + inserted_vertices, + topology, + pending_repair_seeds.len(), + ) { + self.repair_pending_local_seed_cells( + index, + trigger, + pending_repair_seeds, + &mut pending_repair_seen, + soft_fail_seeds, + Some(&mut construction_stats.telemetry), + )?; } } log_bulk_progress_if_due( @@ -4400,9 +4853,15 @@ where &mut batch_progress, ); } - Ok((InsertionOutcome::Skipped { error }, stats, _repair_seed_cells)) => { + Ok(( + InsertionOutcome::Skipped { error }, + stats, + _repair_seed_cells, + _delaunay_repair_required, + telemetry, + )) => { skipped_vertices = skipped_vertices.saturating_add(1); - if trace_insertion && let Some(elapsed) = elapsed { + if trace_insertion { tracing::debug!( index, %uuid, @@ -4413,6 +4872,29 @@ where ); } construction_stats.record_insertion(&stats); + construction_stats.telemetry.record_insertion(&telemetry); + construction_stats + .telemetry + .record_insertion_timing(elapsed_nanos); + construction_stats.record_slow_insertion_sample( + ConstructionSlowInsertionSample { + index, + uuid, + attempts: stats.attempts, + result: stats.result, + elapsed_nanos, + cells_after: self.tri.tds.number_of_cells(), + locate_calls: telemetry.locate_calls, + locate_walk_steps_total: telemetry.locate_walk_steps_total, + conflict_region_calls: telemetry.conflict_region_calls, + conflict_region_cells_total: telemetry + .conflict_region_cells_total, + cavity_insertion_calls: telemetry.cavity_insertion_calls, + global_conflict_scans: telemetry.global_conflict_scans, + hull_extension_calls: telemetry.hull_extension_calls, + topology_validation_calls: telemetry.topology_validation_calls, + }, + ); // Keep the first few skip samples so we have concrete reproduction anchors. let (coords, coords_available) = vertex_coords_f64(vertex) @@ -4450,7 +4932,7 @@ where ); } Err(e) => { - if trace_insertion && let Some(elapsed) = elapsed { + if trace_insertion { tracing::debug!( index, %uuid, @@ -4475,95 +4957,75 @@ where /// Restores runtime policies and performs the final repair/orientation /// checks that were deferred during batch insertion. + #[expect( + clippy::too_many_arguments, + reason = "bulk finalization restores policies, repair state, and optional statistics telemetry" + )] fn finalize_bulk_construction( &mut self, original_validation_policy: ValidationPolicy, original_repair_policy: DelaunayRepairPolicy, run_final_repair: bool, + batch_repair_policy: DelaunayRepairPolicy, + pending_repair_seeds: &[CellKey], soft_fail_seeds: &[CellKey], + mut construction_telemetry: Option<&mut ConstructionTelemetry>, ) -> Result<(), DelaunayTriangulationConstructionError> { // Restore policies after batch construction. self.tri.validation_policy = original_validation_policy; self.insertion_state.delaunay_repair_policy = original_repair_policy; - let topology = self.tri.topology_guarantee(); - if run_final_repair && self.should_run_delaunay_repair_for(topology, 0) { - // For D≥4: always run a global repair seeded from ALL cells. - // BW with the fast kernel can produce non-Delaunay facets anywhere, - // not only in the star of soft-failed insertions. A small fixed - // budget ensures we fail fast on cycling rather than spending minutes. - // Non-convergence is a soft-fail; correctness is validated by - // is_delaunay_property_only() in build_with_shuffled_retries. - // - // For D<4: repair is proven convergent; per-insertion repair now - // falls back to global repair_delaunay_with_flips_k2_k3 on - // local non-convergence, so soft_fail_seeds is typically empty - // for D<4. The seeded path below is kept for completeness. - if D >= 4 { - let cell_count = self.tri.tds.number_of_cells(); - if cell_count > 0 { - let all_cells: Vec = self.tri.tds.cell_keys().collect(); - tracing::debug!( - cell_count, - "post-construction: starting global D≥4 finalize repair" - ); - let repair_started = Instant::now(); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass(tds, kernel, &all_cells, 512).map(|_| ()) - }; - tracing::debug!( - elapsed = ?repair_started.elapsed(), - success = repair_result.is_ok(), - "post-construction: D≥4 finalize repair completed (soft-fail)" - ); - // Always soft-fail: is_delaunay_property_only() validates correctness. - } - } else if !soft_fail_seeds.is_empty() { - // D<4 seeded repair (unused in practice; kept for completeness). - tracing::debug!( - seed_count = soft_fail_seeds.len(), - "post-construction: starting seeded D<4 finalize repair" - ); - let repair_started = Instant::now(); - let max_flips = (soft_fail_seeds.len() * (D + 1) * 16).max(512); - let repair_result = { - self.invalidate_repair_caches(); - let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); - repair_delaunay_local_single_pass(tds, kernel, soft_fail_seeds, max_flips) - .map(|_| ()) - }; - let repair_outcome: Result<(), DelaunayTriangulationConstructionError> = - match repair_result { - Ok(()) => Ok(()), - Err(e) => Err(TriangulationConstructionError::GeometricDegeneracy { - message: format!("Delaunay repair failed after construction: {e}"), - } - .into()), - }; - tracing::debug!( - elapsed = ?repair_started.elapsed(), - success = repair_outcome.is_ok(), - "post-construction: D<4 finalize repair completed" - ); - repair_outcome?; + let has_cells = self.tri.tds.number_of_cells() > 0; + let mut completion_seed_cells = Vec::new(); + let mut completion_seen = FastHashSet::default(); + for &cell_key in pending_repair_seeds.iter().chain(soft_fail_seeds.iter()) { + if self.tri.tds.contains_cell(cell_key) && completion_seen.insert(cell_key) { + completion_seed_cells.push(cell_key); + } + } + if run_final_repair + && has_cells + && batch_repair_policy != DelaunayRepairPolicy::Never + && !completion_seed_cells.is_empty() + { + let repair_started = Instant::now(); + let repair_result = self.run_seeded_completion_repair(&completion_seed_cells); + if let Some(telemetry) = construction_telemetry.as_mut() { + telemetry.record_construction_completion_repair_timing(duration_nanos_saturating( + repair_started.elapsed(), + )); } + repair_result?; } // Flip-based repair calls normalize_coherent_orientation() which makes all cells // combinatorially coherent but can leave the global sign negative. Re-canonicalize // geometric orientation to positive before validation (#258). - self.tri + let orientation_started = Instant::now(); + let orientation_result = self + .tri .normalize_and_promote_positive_orientation() - .map_err(Self::map_orientation_canonicalization_error)?; + .map_err(Self::map_orientation_canonicalization_error); + if let Some(telemetry) = construction_telemetry.as_mut() { + telemetry.record_construction_orientation_timing(duration_nanos_saturating( + orientation_started.elapsed(), + )); + } + orientation_result?; + let topology = self.tri.topology_guarantee(); if topology.requires_vertex_links_at_completion() { tracing::debug!("post-construction: starting topology validation (finalize)"); let validation_started = Instant::now(); let validation_result = self.tri.validate(); + let validation_elapsed = validation_started.elapsed(); + if let Some(telemetry) = construction_telemetry.as_mut() { + telemetry.record_construction_topology_validation_timing( + duration_nanos_saturating(validation_elapsed), + ); + } tracing::debug!( - elapsed = ?validation_started.elapsed(), + elapsed = ?validation_elapsed, success = validation_result.is_ok(), "post-construction: topology validation (finalize) completed" ); @@ -4579,6 +5041,85 @@ where Ok(()) } + fn run_seeded_completion_repair( + &mut self, + completion_seed_cells: &[CellKey], + ) -> Result<(), DelaunayTriangulationConstructionError> { + let seed_count = completion_seed_cells.len(); + let max_flips = local_repair_flip_budget::(seed_count); + tracing::debug!( + seed_count, + max_flips, + "post-construction: starting seeded completion Delaunay repair" + ); + let repair_started = Instant::now(); + let repair_result = { + self.invalidate_repair_caches(); + let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); + repair_delaunay_local_single_pass(tds, kernel, completion_seed_cells, max_flips) + }; + let repair_outcome = match repair_result { + Ok(_) => Ok(()), + Err(error) => self.try_final_global_repair_after_seeded_failure(&error), + }; + tracing::debug!( + elapsed = ?repair_started.elapsed(), + success = repair_outcome.is_ok(), + "post-construction: seeded completion Delaunay repair finished" + ); + repair_outcome + } + + fn try_final_global_repair_after_seeded_failure( + &mut self, + seeded_error: &DelaunayRepairError, + ) -> Result<(), DelaunayTriangulationConstructionError> { + if !self.insertion_state.use_global_repair_fallback || !Self::can_soft_fail(seeded_error) { + let message = format!("Delaunay repair failed after construction: {seeded_error}"); + return Err(Self::map_completion_repair_error( + message, + seeded_error.clone(), + )); + } + + tracing::debug!( + error = %seeded_error, + "post-construction: seeded completion repair soft-failed; trying final global repair" + ); + self.invalidate_repair_caches(); + let topology = self.tri.topology_guarantee(); + let global_result = { + let (tds, kernel) = (&mut self.tri.tds, &self.tri.kernel); + repair_delaunay_with_flips_k2_k3(tds, kernel, None, topology, None) + }; + match global_result { + Ok(_) => Ok(()), + Err(global_error) => { + let message = format!( + "Delaunay repair failed after construction: seeded local error: \ + {seeded_error}; global fallback: {global_error}" + ); + Err(Self::map_completion_repair_error(message, global_error)) + } + } + } + + fn map_completion_repair_error( + message: String, + repair_error: DelaunayRepairError, + ) -> DelaunayTriangulationConstructionError { + if is_geometric_repair_error(&repair_error) { + TriangulationConstructionError::GeometricDegeneracy { message }.into() + } else { + DelaunayTriangulationConstructionError::Triangulation( + DelaunayConstructionFailure::DelaunayRepair { + phase: DelaunayConstructionRepairPhase::Completion, + source: Box::new(repair_error), + }, + ) + } + } + /// Map an [`InsertionError`] from post-construction orientation canonicalization /// into a [`TriangulationConstructionError`]. /// @@ -4740,8 +5281,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -4766,8 +5306,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -4794,8 +5333,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -4892,7 +5430,9 @@ where /// # Examples /// /// ``` - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, Vertex, vertex, + /// }; /// /// let vertices: [Vertex; 3] = [ /// vertex!([0.0, 0.0], 10i32), @@ -4931,7 +5471,9 @@ where /// # Examples /// /// ``` - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, vertex, + /// }; /// /// let vertices = [ /// vertex!([0.0, 0.0]), @@ -4964,8 +5506,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -5024,8 +5565,7 @@ where /// /// ```rust /// use delaunay::prelude::query::ConvexHull; - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices: Vec<_> = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -5058,7 +5598,7 @@ where /// /// ```rust /// #![allow(deprecated)] - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -5090,7 +5630,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -5127,7 +5667,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; + /// use delaunay::prelude::triangulation::validation::ValidationPolicy; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -5138,10 +5679,10 @@ where /// let mut dt: DelaunayTriangulation<_, (), (), 2> = /// DelaunayTriangulation::new(&vertices).unwrap(); /// - /// dt.set_validation_policy(delaunay::core::triangulation::ValidationPolicy::Always); + /// dt.set_validation_policy(ValidationPolicy::Always); /// assert_eq!( /// dt.validation_policy(), - /// delaunay::core::triangulation::ValidationPolicy::Always + /// ValidationPolicy::Always /// ); /// ``` #[inline] @@ -5189,7 +5730,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; + /// use delaunay::prelude::triangulation::repair::DelaunayRepairStats; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -5301,6 +5843,24 @@ where ) } + /// Applies repair-policy and topology gates to non-insertion mutating operations. + /// + /// These operations do not have a meaningful insertion cadence, so every enabled + /// repair policy permits the post-mutation repair attempt. + fn should_run_delaunay_repair_after_mutation(&self, topology: TopologyGuarantee) -> bool { + if D < 2 { + return false; + } + if self.tri.tds.number_of_cells() == 0 { + return false; + } + if self.insertion_state.delaunay_repair_policy == DelaunayRepairPolicy::Never { + return false; + } + + TopologicalOperation::FacetFlip.is_admissible_under(topology) + } + /// Enables test-only repair fallback paths without exposing a public knob. #[cfg_attr( not(test), @@ -5356,7 +5916,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// use delaunay::prelude::triangulation::repair::DelaunayRepairHeuristicConfig; /// /// let vertices = vec![ @@ -5538,6 +6098,7 @@ where })? }; let repair_seed_cells = insert_detail.repair_seed_cells; + let delaunay_repair_required = insert_detail.delaunay_repair_required; match insert_detail.outcome { InsertionOutcome::Inserted { vertex_key, hint } => { @@ -5547,18 +6108,20 @@ where .delaunay_repair_insertion_count .saturating_add(1); - candidate - .maybe_repair_after_insertion_capped( - vertex_key, - hint, - &repair_seed_cells, - max_flips_override, - ) - .map_err(|e| DelaunayRepairError::HeuristicRebuildFailed { - message: format!( - "heuristic rebuild repair failed at idx={idx} uuid={uuid} coords={coords:?}: {e}" - ), - })?; + if delaunay_repair_required { + candidate + .maybe_repair_after_insertion_capped( + vertex_key, + hint, + &repair_seed_cells, + max_flips_override, + ) + .map_err(|e| DelaunayRepairError::HeuristicRebuildFailed { + message: format!( + "heuristic rebuild repair failed at idx={idx} uuid={uuid} coords={coords:?}: {e}" + ), + })?; + } candidate .maybe_check_after_insertion() @@ -5673,7 +6236,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>().unwrap(); @@ -5690,7 +6255,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, GlobalTopology, vertex, + /// }; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>().unwrap(); @@ -5707,7 +6274,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, TopologyKind, vertex, + /// }; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>().unwrap(); @@ -5724,7 +6293,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulationBuilder, GlobalTopology, vertex, + /// }; /// /// let vertices = vec![vertex!([0.0, 0.0]), vertex!([1.0, 0.0]), vertex!([0.0, 1.0])]; /// let mut dt = DelaunayTriangulationBuilder::new(&vertices).build::<()>().unwrap(); @@ -5741,8 +6312,9 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::TopologyGuarantee; - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, + /// }; /// /// let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); /// dt.set_topology_guarantee(TopologyGuarantee::Pseudomanifold); @@ -5766,8 +6338,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -5796,8 +6367,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0]), @@ -6165,8 +6735,7 @@ where /// Incremental insertion from empty triangulation: /// /// ```rust - /// use delaunay::prelude::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// // Start with empty triangulation /// let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); @@ -6194,8 +6763,7 @@ where /// Using batch construction (traditional approach): /// /// ```rust - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// // Create initial triangulation with 5 vertices (4-simplex) /// let vertices = vec![ @@ -6264,6 +6832,7 @@ where )? }; let repair_seed_cells = insert_detail.repair_seed_cells; + let delaunay_repair_required = insert_detail.delaunay_repair_required; match insert_detail.outcome { InsertionOutcome::Inserted { @@ -6275,7 +6844,9 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - self.maybe_repair_after_insertion(v_key, hint, &repair_seed_cells)?; + if delaunay_repair_required { + self.maybe_repair_after_insertion(v_key, hint, &repair_seed_cells)?; + } self.maybe_check_after_insertion()?; Ok(v_key) } @@ -6310,7 +6881,8 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; + /// use delaunay::prelude::triangulation::insertion::InsertionOutcome; /// /// let mut dt: DelaunayTriangulation<_, (), (), 3> = DelaunayTriangulation::empty(); /// @@ -6364,6 +6936,7 @@ where }; let stats = insert_detail.stats; let repair_seed_cells = insert_detail.repair_seed_cells; + let delaunay_repair_required = insert_detail.delaunay_repair_required; let outcome = match insert_detail.outcome { InsertionOutcome::Inserted { vertex_key, hint } => { @@ -6372,7 +6945,9 @@ where .insertion_state .delaunay_repair_insertion_count .saturating_add(1); - self.maybe_repair_after_insertion(vertex_key, hint, &repair_seed_cells)?; + if delaunay_repair_required { + self.maybe_repair_after_insertion(vertex_key, hint, &repair_seed_cells)?; + } self.maybe_check_after_insertion()?; InsertionOutcome::Inserted { vertex_key, hint } } @@ -6643,7 +7218,7 @@ where /// # Examples /// /// ```rust - /// use delaunay::prelude::triangulation::*; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = [ /// vertex!([0.0, 0.0]), @@ -6700,7 +7275,7 @@ where }; let topology = self.tri.topology_guarantee(); - if self.should_run_delaunay_repair_for(topology, 0) { + if self.should_run_delaunay_repair_after_mutation(topology) { let seed_ref = seed_cells.as_deref(); let repair_result = { self.invalidate_repair_caches(); @@ -7002,8 +7577,7 @@ where /// ```rust /// use delaunay::prelude::geometry::FastKernel; /// use delaunay::prelude::tds::Tds; - /// use delaunay::prelude::triangulation::DelaunayTriangulation; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// /// let vertices = vec![ /// vertex!([0.0, 0.0, 0.0, 0.0]), @@ -7049,8 +7623,9 @@ where /// /// ```rust /// use delaunay::prelude::geometry::FastKernel; - /// use delaunay::prelude::triangulation::{DelaunayTriangulation, TopologyGuarantee}; - /// use delaunay::vertex; + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, TopologyGuarantee, vertex, + /// }; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -7100,10 +7675,9 @@ where /// /// ```rust /// use delaunay::prelude::geometry::FastKernel; - /// use delaunay::prelude::triangulation::{ - /// DelaunayTriangulation, GlobalTopology, TopologyGuarantee, + /// use delaunay::prelude::triangulation::construction::{ + /// DelaunayTriangulation, GlobalTopology, TopologyGuarantee, vertex, /// }; - /// use delaunay::vertex; /// /// let vertices = vec![ /// vertex!([0.0, 0.0]), @@ -7228,7 +7802,7 @@ where /// ```rust /// # use delaunay::prelude::geometry::*; /// # use delaunay::prelude::tds::Tds; -/// # use delaunay::prelude::triangulation::*; +/// # use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, vertex}; /// # fn example() { /// // Create and serialize a triangulation /// let vertices = vec![ @@ -7268,6 +7842,12 @@ where /// It is separate from any *validation-only* policy to allow checking the Delaunay /// property without mutating topology when needed. /// +/// During batch construction, [`DelaunayRepairPolicy::EveryN`] is a scheduled +/// cadence rather than a hard lower bound on repair frequency: construction may +/// run an additional local repair earlier when the accumulated seed frontier +/// grows large. [`DelaunayRepairPolicy::Never`] disables those automatic batch +/// repairs. +/// /// # Examples /// /// ```rust @@ -7275,6 +7855,7 @@ where /// use std::num::NonZeroUsize; /// /// let policy = DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()); +/// assert!(!policy.should_repair(0)); /// assert!(!policy.should_repair(3)); /// assert!(policy.should_repair(4)); /// ``` @@ -7302,8 +7883,8 @@ impl DelaunayRepairPolicy { pub const fn should_repair(self, insertion_count: usize) -> bool { match self { Self::Never => false, - Self::EveryInsertion => true, - Self::EveryN(n) => insertion_count.is_multiple_of(n.get()), + Self::EveryInsertion => insertion_count != 0, + Self::EveryN(n) => insertion_count != 0 && insertion_count.is_multiple_of(n.get()), } } } @@ -7484,8 +8065,7 @@ mod tests { use crate::core::vertex::VertexBuilder; use crate::geometry::kernel::{AdaptiveKernel, FastKernel, RobustKernel}; use crate::geometry::point::Point; - use crate::geometry::traits::coordinate::Coordinate; - use crate::geometry::traits::coordinate::CoordinateConversionError; + use crate::geometry::traits::coordinate::{Coordinate, CoordinateConversionError}; use crate::topology::characteristics::euler::TopologyClassification; use crate::topology::traits::topological_space::ToroidalConstructionMode; use crate::triangulation::flips::BistellarFlips; @@ -7578,6 +8158,104 @@ mod tests { LOCAL_REPAIR_FLIP_BUDGET_FACTOR_D_GE_4 ); + #[test] + fn test_local_repair_seed_backlog_threshold_uses_dimension_regimes() { + assert_eq!( + local_repair_seed_backlog_threshold::<3>(), + 4 * LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_LT_4 + ); + assert_eq!( + local_repair_seed_backlog_threshold::<4>(), + 5 * LOCAL_REPAIR_SEED_BACKLOG_FACTOR_D_GE_4 + ); + } + + #[test] + fn test_batch_local_repair_trigger_prefers_cadence_over_backlog() { + let policy = DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()); + let threshold = local_repair_seed_backlog_threshold::<3>(); + + assert_eq!( + batch_local_repair_trigger::<3>(policy, 4, TopologyGuarantee::PLManifold, threshold), + Some(BatchLocalRepairTrigger::Cadence) + ); + } + + #[test] + fn test_batch_local_repair_trigger_runs_every_insertion_below_backlog() { + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::EveryInsertion, + 1, + TopologyGuarantee::PLManifold, + 1, + ), + Some(BatchLocalRepairTrigger::Cadence) + ); + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::EveryInsertion, + 0, + TopologyGuarantee::PLManifold, + 1, + ), + None + ); + } + + #[test] + fn test_batch_local_repair_trigger_repairs_early_on_seed_backlog() { + let policy = DelaunayRepairPolicy::EveryN(NonZeroUsize::new(128).unwrap()); + let threshold = local_repair_seed_backlog_threshold::<3>(); + + assert_eq!( + batch_local_repair_trigger::<3>(policy, 7, TopologyGuarantee::PLManifold, threshold), + Some(BatchLocalRepairTrigger::SeedBacklog) + ); + assert_eq!( + batch_local_repair_trigger::<3>( + policy, + 7, + TopologyGuarantee::PLManifold, + threshold - 1 + ), + None + ); + } + + #[test] + fn test_batch_local_repair_trigger_respects_policy_and_topology() { + let threshold = local_repair_seed_backlog_threshold::<3>(); + + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::Never, + 7, + TopologyGuarantee::PLManifold, + threshold + ), + None + ); + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(128).unwrap()), + 7, + TopologyGuarantee::PLManifold, + 0 + ), + None + ); + assert_eq!( + batch_local_repair_trigger::<3>( + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(128).unwrap()), + 7, + TopologyGuarantee::Pseudomanifold, + threshold + ), + Some(BatchLocalRepairTrigger::SeedBacklog) + ); + } + #[test] fn test_log_bulk_progress_if_due_updates_progress_state_only_when_due() { let sample = BatchProgressSample { @@ -7664,83 +8342,11 @@ mod tests { 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 { .. } - )); + + assert_eq!(seeds.len(), adjacent.len() + 1); + assert_eq!(&seeds[..adjacent.len()], adjacent.as_slice()); + assert_eq!(seeds[adjacent.len()], extra_cell); + assert!(!seeds.contains(&stale_cell)); } struct ForceHeuristicRebuildGuard { @@ -7777,16 +8383,38 @@ mod tests { } } + #[test] + fn test_construction_options_default_uses_batch_repair_cadence() { + init_tracing(); + assert_eq!( + ConstructionOptions::default().initial_simplex_strategy(), + InitialSimplexStrategy::MaxVolume + ); + assert_eq!( + ConstructionOptions::default().batch_repair_policy(), + DelaunayRepairPolicy::EveryInsertion + ); + assert_eq!( + DelaunayRepairPolicy::default(), + DelaunayRepairPolicy::EveryInsertion + ); + } + #[test] fn test_construction_options_builder_roundtrip() { init_tracing(); let opts = ConstructionOptions::default() .with_insertion_order(InsertionOrderStrategy::Input) .with_dedup_policy(DedupPolicy::Exact) + .with_batch_repair_policy(DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap())) .with_retry_policy(RetryPolicy::Disabled); assert_eq!(opts.insertion_order(), InsertionOrderStrategy::Input); assert_eq!(opts.dedup_policy(), DedupPolicy::Exact); + assert_eq!( + opts.batch_repair_policy(), + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()) + ); assert_eq!(opts.retry_policy(), RetryPolicy::Disabled); } @@ -8257,6 +8885,44 @@ mod tests { ); } + #[test] + fn test_construction_statistics_records_slowest_insertion_samples() { + init_tracing(); + + let mut summary = ConstructionStatistics::default(); + for index in 0..10 { + let sample_index_u32 = u32::try_from(index).unwrap(); + summary.record_slow_insertion_sample(ConstructionSlowInsertionSample { + index, + uuid: Uuid::from_u128( + >::from(sample_index_u32) + 1, + ), + attempts: 1, + result: InsertionResult::Inserted, + elapsed_nanos: >::from(sample_index_u32) * 1_000, + cells_after: index, + locate_calls: 1, + locate_walk_steps_total: index, + conflict_region_calls: 1, + conflict_region_cells_total: index, + cavity_insertion_calls: 1, + global_conflict_scans: 0, + hull_extension_calls: 0, + topology_validation_calls: 1, + }); + } + + assert_eq!(summary.slow_insertions.len(), 8); + assert_eq!(summary.slow_insertions.first().map(|s| s.index), Some(9)); + assert_eq!(summary.slow_insertions.last().map(|s| s.index), Some(2)); + assert!( + summary + .slow_insertions + .windows(2) + .all(|pair| pair[0].elapsed_nanos >= pair[1].elapsed_nanos) + ); + } + #[test] fn test_select_balanced_simplex_indices_insufficient_vertices() { init_tracing(); @@ -8284,6 +8950,107 @@ mod tests { assert!(result.is_none()); } + macro_rules! max_volume_axis_simplex_test { + ($test_name:ident, $dimension:literal, [$($coords:expr),+ $(,)?], [$($expected_idx:expr),+ $(,)?]) => { + #[test] + fn $test_name() { + init_tracing(); + let vertices: Vec> = vec![$(vertex!($coords)),+]; + + let result = select_max_volume_simplex_indices(&vertices) + .expect("max-volume simplex selection failed"); + let expected_indices = [$($expected_idx),+]; + + assert_eq!(result.len(), expected_indices.len()); + for expected_idx in expected_indices { + assert!( + result.contains(&expected_idx), + "expected selected simplex {result:?} to contain vertex index {expected_idx}" + ); + } + } + }; + } + + max_volume_axis_simplex_test!( + test_select_max_volume_simplex_indices_prefers_largest_triangle_2d, + 2, + [ + [0.0, 0.0], + [1.0, 0.0], + [0.0, 1.0], + [10.0, 0.0], + [0.0, 10.0], + [1.0, 1.0], + ], + [0, 3, 4] + ); + + max_volume_axis_simplex_test!( + test_select_max_volume_simplex_indices_prefers_largest_tetrahedron, + 3, + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 10.0], + ], + [0, 4, 5, 6] + ); + + max_volume_axis_simplex_test!( + test_select_max_volume_simplex_indices_prefers_largest_simplex_4d, + 4, + [ + [0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + [10.0, 0.0, 0.0, 0.0], + [0.0, 10.0, 0.0, 0.0], + [0.0, 0.0, 10.0, 0.0], + [0.0, 0.0, 0.0, 10.0], + ], + [0, 5, 6, 7, 8] + ); + + max_volume_axis_simplex_test!( + test_select_max_volume_simplex_indices_prefers_largest_simplex_5d, + 5, + [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 1.0], + [10.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 10.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 10.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 10.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 10.0], + ], + [0, 6, 7, 8, 9, 10] + ); + + #[test] + fn test_select_max_volume_simplex_indices_rejects_degenerate_pool() { + init_tracing(); + let vertices: Vec> = vec![ + vertex!([0.0, 0.0, 0.0]), + vertex!([1.0, 0.0, 0.0]), + vertex!([2.0, 0.0, 0.0]), + vertex!([3.0, 0.0, 0.0]), + ]; + + let result = select_max_volume_simplex_indices(&vertices); + assert!(result.is_none()); + } + #[test] fn test_reorder_vertices_for_simplex_valid_and_invalid() { init_tracing(); @@ -8343,6 +9110,53 @@ mod tests { assert!(preprocess.grid_cell_size().is_some()); } + #[test] + fn test_preprocess_vertices_for_construction_max_volume_sets_largest_simplex_first() { + init_tracing(); + let vertices: Vec> = vec![ + vertex!([0.0, 0.0, 0.0]), + vertex!([1.0, 0.0, 0.0]), + vertex!([0.0, 1.0, 0.0]), + vertex!([0.0, 0.0, 1.0]), + vertex!([10.0, 0.0, 0.0]), + vertex!([0.0, 10.0, 0.0]), + vertex!([0.0, 0.0, 10.0]), + ]; + + let preprocess = DelaunayTriangulation::< + AdaptiveKernel, + (), + (), + 3, + >::preprocess_vertices_for_construction( + &vertices, + DedupPolicy::Off, + InsertionOrderStrategy::Input, + InitialSimplexStrategy::MaxVolume, + ) + .expect("preprocess failed"); + + let primary = preprocess.primary_slice(&vertices); + assert!(primary.len() >= 4); + let first_simplex = &primary[..4]; + let first_simplex_contains = |expected_coords: [f64; 3]| { + first_simplex.iter().any(|vertex| { + vertex + .point() + .coords() + .iter() + .zip(expected_coords) + .all(|(actual, expected)| (*actual - expected).abs() <= f64::EPSILON) + }) + }; + + assert!(preprocess.fallback_slice().is_some()); + assert!(first_simplex_contains([0.0, 0.0, 0.0])); + assert!(first_simplex_contains([10.0, 0.0, 0.0])); + assert!(first_simplex_contains([0.0, 10.0, 0.0])); + assert!(first_simplex_contains([0.0, 0.0, 10.0])); + } + #[test] fn test_preprocess_vertices_rejects_invalid_epsilon_tolerance() { init_tracing(); @@ -8774,10 +9588,36 @@ mod tests { dt.set_delaunay_repair_policy(DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap())); let topology = dt.topology_guarantee(); + assert!(!dt.should_run_delaunay_repair_for(topology, 0)); assert!(!dt.should_run_delaunay_repair_for(topology, 1)); assert!(dt.should_run_delaunay_repair_for(topology, 2)); } + #[test] + fn test_delaunay_repair_policy_zero_insertions_never_repairs() { + assert!(!DelaunayRepairPolicy::EveryInsertion.should_repair(0)); + assert!(!DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap()).should_repair(0)); + } + + #[test] + fn test_non_insertion_mutation_repair_gate_ignores_insertion_cadence() { + init_tracing(); + let vertices: Vec> = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + ]; + let mut dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let topology = dt.topology_guarantee(); + + dt.set_delaunay_repair_policy(DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap())); + assert!(dt.should_run_delaunay_repair_after_mutation(topology)); + + dt.set_delaunay_repair_policy(DelaunayRepairPolicy::Never); + assert!(!dt.should_run_delaunay_repair_after_mutation(topology)); + } + #[test] fn test_vertex_key_valid_after_explicit_heuristic_rebuild() { init_tracing(); @@ -10524,6 +11364,46 @@ mod tests { assert!(dt.validate().is_ok()); } + /// Exercises the `EveryN` cadence through the full bulk path: vertices + /// accumulate `pending_repair_seeds`, trigger cadenced local repair, and + /// then complete through `finalize_bulk_construction`. + #[test] + fn test_batch_4d_every_n_repair_cadence_runs_with_pending_seeds() { + init_tracing(); + let vertices = vec![ + vertex!([0.0, 0.0, 0.0, 0.0]), + vertex!([1.0, 0.0, 0.0, 0.0]), + vertex!([0.0, 1.0, 0.0, 0.0]), + vertex!([0.0, 0.0, 1.0, 0.0]), + vertex!([0.0, 0.0, 0.0, 1.0]), + vertex!([0.2, 0.2, 0.2, 0.2]), + vertex!([0.35, 0.25, 0.15, 0.3]), + ]; + + test_hooks::reset_batch_local_repair_calls(); + let _guard = ForceRepairNonconvergentGuard::enable(); + let kernel = RobustKernel::::new(); + let options = ConstructionOptions::default() + .with_batch_repair_policy(DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap())); + let (dt, stats) = + DelaunayTriangulation::, (), (), 4>::with_options_and_statistics( + &kernel, + &vertices, + TopologyGuarantee::DEFAULT, + options, + ) + .expect("EveryN batch repair should soft-fail forced local non-convergence and finish"); + + assert_eq!(dt.number_of_vertices(), vertices.len()); + assert_eq!(stats.inserted, vertices.len()); + assert_eq!( + test_hooks::batch_local_repair_calls(), + 1, + "EveryN(2) should run one cadenced repair before finalize_bulk_construction" + ); + assert!(dt.validate().is_ok()); + } + #[test] fn test_repair_soft_fail_classification() { let nonconvergent = test_hooks::synthetic_nonconvergent_error(); @@ -10558,20 +11438,22 @@ mod tests { }; assert!(!TestDelaunay::<4>::can_soft_fail(&canonicalization_error)); - let mapped_hard = TestDelaunay::<4>::map_hard_repair_error(23, &flip_error); + let mapped_hard = TestDelaunay::<4>::map_hard_repair_error(23, flip_error); assert!( matches!( mapped_hard, DelaunayTriangulationConstructionError::Triangulation( - DelaunayConstructionFailure::InternalInconsistency { ref message } - ) if message.contains("per-insertion Delaunay repair failed at index 23") - && message.contains("Bistellar flip not supported for D=1") + DelaunayConstructionFailure::DelaunayRepair { + phase: DelaunayConstructionRepairPhase::BatchLocal { index: 23 }, + ref source, + } + ) if matches!(**source, DelaunayRepairError::Flip(FlipError::UnsupportedDimension { dimension: 1 })) ), "deterministic hard D>=4 repair failures should stop shuffled retries: {mapped_hard:?}" ); let geometric_error = DelaunayRepairError::Flip(FlipError::DegenerateCell); - let mapped_geometric = TestDelaunay::<4>::map_hard_repair_error(24, &geometric_error); + let mapped_geometric = TestDelaunay::<4>::map_hard_repair_error(24, geometric_error); assert!( matches!( mapped_geometric, @@ -10583,14 +11465,16 @@ mod tests { "geometric hard D>=4 repair failures should remain retryable degeneracies: {mapped_geometric:?}" ); - let mapped_verification = TestDelaunay::<4>::map_hard_repair_error(25, &verification_error); + let mapped_verification = TestDelaunay::<4>::map_hard_repair_error(25, verification_error); assert!( matches!( mapped_verification, DelaunayTriangulationConstructionError::Triangulation( - DelaunayConstructionFailure::InternalInconsistency { ref message } - ) if message.contains("per-insertion Delaunay repair failed at index 25") - && message.contains("removed cell frame") + DelaunayConstructionFailure::DelaunayRepair { + phase: DelaunayConstructionRepairPhase::BatchLocal { index: 25 }, + ref source, + } + ) if matches!(**source, DelaunayRepairError::VerificationFailed { .. }) ), "verification context failures should stop shuffled retries: {mapped_verification:?}" ); @@ -10609,8 +11493,7 @@ mod tests { }, }), }; - let mapped_predicate = - TestDelaunay::<4>::map_hard_repair_error(26, &predicate_verification); + let mapped_predicate = TestDelaunay::<4>::map_hard_repair_error(26, predicate_verification); assert!( matches!( mapped_predicate, @@ -10623,151 +11506,6 @@ mod tests { ); } - // ========================================================================= - // Tests for try_d_lt4_global_repair_fallback - // ========================================================================= - - /// When `use_global_repair_fallback` is false the helper should return an error - /// immediately without attempting global repair. - #[test] - fn test_try_d_lt4_global_repair_fallback_disabled_returns_error() { - init_tracing(); - - let vertices = vec![ - vertex!([0.0, 0.0, 0.0]), - vertex!([1.0, 0.0, 0.0]), - vertex!([0.0, 1.0, 0.0]), - vertex!([0.0, 0.0, 1.0]), - ]; - let mut dt: DelaunayTriangulation, (), (), 3> = - DelaunayTriangulation::new(&vertices).unwrap(); - - let repair_err = DelaunayRepairError::NonConvergent { - max_flips: 16, - diagnostics: Box::new(DelaunayRepairDiagnostics { - facets_checked: 0, - flips_performed: 0, - max_queue_len: 0, - ambiguous_predicates: 0, - ambiguous_predicate_samples: Vec::new(), - predicate_failures: 0, - cycle_detections: 0, - cycle_signature_samples: Vec::new(), - attempt: 1, - queue_order: RepairQueueOrder::Fifo, - }), - }; - - let result = - DelaunayTriangulation::, (), (), 3>::try_d_lt4_global_repair_fallback( - &mut dt.tri.tds, - &dt.tri.kernel, - TopologyGuarantee::PLManifold, - false, // disabled - 5, - &repair_err, - ); - - assert!(result.is_err()); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("per-insertion Delaunay repair failed at index 5"), - "error should mention the index: {err_msg}" - ); - } - - /// When `use_global_repair_fallback` is true and the TDS is already valid, - /// global repair succeeds and the helper returns `Ok(())`. - #[test] - fn test_try_d_lt4_global_repair_fallback_enabled_succeeds_on_valid_tds() { - init_tracing(); - - let vertices = vec![ - vertex!([0.0, 0.0, 0.0]), - vertex!([1.0, 0.0, 0.0]), - vertex!([0.0, 1.0, 0.0]), - vertex!([0.0, 0.0, 1.0]), - vertex!([0.3, 0.3, 0.3]), - ]; - let mut dt: DelaunayTriangulation, (), (), 3> = - DelaunayTriangulation::new(&vertices).unwrap(); - - let repair_err = DelaunayRepairError::NonConvergent { - max_flips: 16, - diagnostics: Box::new(DelaunayRepairDiagnostics { - facets_checked: 0, - flips_performed: 0, - max_queue_len: 0, - ambiguous_predicates: 0, - ambiguous_predicate_samples: Vec::new(), - predicate_failures: 0, - cycle_detections: 0, - cycle_signature_samples: Vec::new(), - attempt: 1, - queue_order: RepairQueueOrder::Fifo, - }), - }; - - // TDS is valid, so global repair should succeed (nothing to fix). - let result = - DelaunayTriangulation::, (), (), 3>::try_d_lt4_global_repair_fallback( - &mut dt.tri.tds, - &dt.tri.kernel, - TopologyGuarantee::PLManifold, - true, // enabled - 5, - &repair_err, - ); - - assert!( - result.is_ok(), - "global repair on valid TDS should succeed: {:?}", - result.err() - ); - } - - /// Verify the error message includes both local and global error details when - /// global repair also fails. - #[test] - fn test_try_d_lt4_global_repair_fallback_error_includes_both_messages() { - init_tracing(); - - // Build a 1D triangulation — repair_delaunay_with_flips_k2_k3 returns - // UnsupportedDimension for D<2, guaranteeing the global repair fails. - let vertices = vec![vertex!([0.0]), vertex!([1.0])]; - let mut dt: DelaunayTriangulation, (), (), 1> = - DelaunayTriangulation::new(&vertices).unwrap(); - - let repair_err = DelaunayRepairError::PostconditionFailed { - message: "synthetic local error".to_string(), - }; - - let result = - DelaunayTriangulation::, (), (), 1>::try_d_lt4_global_repair_fallback( - &mut dt.tri.tds, - &dt.tri.kernel, - TopologyGuarantee::PLManifold, - true, // enabled — but global repair will fail (D=1) - 7, - &repair_err, - ); - - assert!(result.is_err()); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("local error:"), - "error should contain local error detail: {err_msg}" - ); - assert!( - err_msg.contains("global fallback:"), - "error should contain global fallback detail: {err_msg}" - ); - assert!( - err_msg.contains("index 7"), - "error should contain the index: {err_msg}" - ); - } - #[test] fn test_map_orientation_canonicalization_error_topology_validation_is_internal() { let error = InsertionError::TopologyValidation(TdsError::InconsistentDataStructure { diff --git a/src/triangulation/diagnostics.rs b/src/triangulation/diagnostics.rs new file mode 100644 index 00000000..9b6c664d --- /dev/null +++ b/src/triangulation/diagnostics.rs @@ -0,0 +1,957 @@ +//! Construction and performance diagnostics for triangulation workflows. +//! +//! # Examples +//! +//! ```rust +//! use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; +//! +//! let telemetry = ConstructionTelemetry::default(); +//! assert!(!telemetry.has_data()); +//! ``` + +#![forbid(unsafe_code)] + +use crate::core::algorithms::flips::LocalRepairPhaseTiming; +use crate::core::operations::InsertionTelemetry; + +/// Reason a batch local repair pass was scheduled. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BatchLocalRepairTrigger { + /// The configured repair cadence fired. + Cadence, + /// The pending repair seed frontier exceeded the backlog threshold. + SeedBacklog, +} + +/// Aggregate release-visible telemetry collected during batch construction. +/// +/// These counters summarize batch construction at a coarse level so large-scale +/// debug runs can separate construction phases, per-insertion primitive costs, +/// batch-local repair work, and global exterior conflict scans without enabling +/// per-insertion tracing. +#[derive(Debug, Default, Clone)] +#[non_exhaustive] +pub struct ConstructionTelemetry { + /// Number of transactional insertion calls with wall-clock timing. + pub insertion_wall_time_calls: usize, + /// Wall-clock nanoseconds spent in transactional insertion calls. + pub insertion_wall_time_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one transactional insertion call. + pub insertion_wall_time_nanos_max: u64, + + /// Wall-clock nanoseconds spent preprocessing vertices before topology construction. + pub construction_preprocessing_nanos: u64, + /// Wall-clock nanoseconds spent in the bulk insertion loop, including batch local repair. + pub construction_insert_loop_nanos: u64, + /// Wall-clock nanoseconds spent finalizing bulk construction after the insertion loop. + pub construction_finalize_nanos: u64, + /// Wall-clock nanoseconds spent in the seeded completion repair during finalization. + pub construction_completion_repair_nanos: u64, + /// Wall-clock nanoseconds spent canonicalizing orientation during finalization. + pub construction_orientation_nanos: u64, + /// Wall-clock nanoseconds spent in final topology validation during finalization. + pub construction_topology_validation_nanos: u64, + /// Wall-clock nanoseconds spent in the final global Delaunay validation pass. + pub construction_final_delaunay_validation_nanos: u64, + + /// Number of point-location calls performed during construction. + pub locate_calls: usize, + /// Total facet-walk steps across all point-location calls. + pub locate_walk_steps_total: usize, + /// Maximum facet-walk steps taken by a single point-location call. + pub locate_walk_steps_max: usize, + /// Number of point-location calls that used a caller-provided hint. + pub locate_hint_uses: usize, + /// Number of point-location calls that fell back to a brute-force scan. + pub locate_scan_fallbacks: usize, + /// Number of point-location calls that ended inside a cell. + pub located_inside: usize, + /// Number of point-location calls that ended outside the convex hull. + pub located_outside: usize, + /// Number of point-location calls that ended on a lower-dimensional feature. + pub located_on_boundary: usize, + + /// Number of local conflict-region computations observed during construction. + pub conflict_region_calls: usize, + /// Total number of cells in local conflict regions. + pub conflict_region_cells_total: usize, + /// Maximum number of cells in a single local conflict region. + pub conflict_region_cells_max: usize, + /// Wall-clock nanoseconds spent computing local conflict regions. + pub conflict_region_nanos: u64, + /// Maximum wall-clock nanoseconds spent computing one local conflict region. + pub conflict_region_nanos_max: u64, + + /// Number of cavity insertion attempts observed during construction. + pub cavity_insertion_calls: usize, + /// Wall-clock nanoseconds spent filling cavities and wiring neighbors. + pub cavity_insertion_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one cavity insertion attempt. + pub cavity_insertion_nanos_max: u64, + + /// Number of hull extension attempts observed during construction. + pub hull_extension_calls: usize, + /// Wall-clock nanoseconds spent extending the convex hull. + pub hull_extension_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one hull extension attempt. + pub hull_extension_nanos_max: u64, + + /// Number of post-insertion topology validations observed during construction. + pub topology_validation_calls: usize, + /// Wall-clock nanoseconds spent in post-insertion topology validation. + pub topology_validation_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one post-insertion validation. + pub topology_validation_nanos_max: u64, + + /// Number of batch local Delaunay repair calls during construction. + pub local_repair_calls: usize, + /// Wall-clock nanoseconds spent in batch local Delaunay repair. + pub local_repair_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one batch local repair call. + pub local_repair_nanos_max: u64, + /// Wall-clock nanoseconds spent cloning local-repair rollback snapshots. + pub local_repair_snapshot_nanos: u64, + /// Maximum wall-clock nanoseconds spent cloning one local-repair rollback snapshot. + pub local_repair_snapshot_nanos_max: u64, + /// Wall-clock nanoseconds spent applying local-repair flip attempts. + pub local_repair_attempt_nanos: u64, + /// Maximum wall-clock nanoseconds spent applying flip attempts in one local repair. + pub local_repair_attempt_nanos_max: u64, + /// Wall-clock nanoseconds spent seeding local-repair attempt queues. + pub local_repair_attempt_seed_nanos: u64, + /// Maximum wall-clock nanoseconds spent seeding local-repair queues in one repair. + pub local_repair_attempt_seed_nanos_max: u64, + /// Wall-clock nanoseconds spent processing k=2 facet queue items. + pub local_repair_attempt_facet_nanos: u64, + /// Maximum wall-clock nanoseconds spent processing k=2 facets in one repair. + pub local_repair_attempt_facet_nanos_max: u64, + /// Wall-clock nanoseconds spent processing k=3 ridge queue items. + pub local_repair_attempt_ridge_nanos: u64, + /// Maximum wall-clock nanoseconds spent processing k=3 ridges in one repair. + pub local_repair_attempt_ridge_nanos_max: u64, + /// Wall-clock nanoseconds spent processing inverse k=2 edge queue items. + pub local_repair_attempt_edge_nanos: u64, + /// Maximum wall-clock nanoseconds spent processing inverse k=2 edges in one repair. + pub local_repair_attempt_edge_nanos_max: u64, + /// Wall-clock nanoseconds spent processing inverse k=3 triangle queue items. + pub local_repair_attempt_triangle_nanos: u64, + /// Maximum wall-clock nanoseconds spent processing inverse k=3 triangles in one repair. + pub local_repair_attempt_triangle_nanos_max: u64, + /// Wall-clock nanoseconds spent checking local-repair postconditions. + pub local_repair_postcondition_nanos: u64, + /// Maximum wall-clock nanoseconds spent checking postconditions in one local repair. + pub local_repair_postcondition_nanos_max: u64, + /// Wall-clock nanoseconds spent restoring local-repair rollback snapshots. + pub local_repair_restore_nanos: u64, + /// Maximum wall-clock nanoseconds spent restoring one local-repair rollback snapshot. + pub local_repair_restore_nanos_max: u64, + /// Total pending seed cells repaired by batch local repair calls. + pub local_repair_seed_cells_total: usize, + /// Maximum pending seed-cell frontier repaired by one batch local repair call. + pub local_repair_seed_cells_max: usize, + /// Number of batch local repair calls fired by the configured cadence. + pub local_repair_cadence_triggers: usize, + /// Number of batch local repair calls fired by the seed-backlog threshold. + pub local_repair_backlog_triggers: usize, + /// Total queued repair items checked by successful batch local repair calls. + pub local_repair_items_checked_total: usize, + /// Total flips performed by successful batch local repair calls. + pub local_repair_flips_total: usize, + /// Maximum flips performed by one successful batch local repair call. + pub local_repair_flips_max: usize, + /// Maximum queue length reported by one successful batch local repair call. + pub local_repair_queue_len_max: usize, + /// Number of successful batch local repair calls that performed no flips. + pub local_repair_no_flip_calls: usize, + + /// Number of bulk local-repair seed accumulation calls. + pub repair_seed_accumulation_calls: usize, + /// Wall-clock nanoseconds spent accumulating bulk local-repair seeds. + pub repair_seed_accumulation_nanos: u64, + /// Maximum wall-clock nanoseconds spent in one bulk seed accumulation call. + pub repair_seed_accumulation_nanos_max: u64, + /// Total live seed cells added to pending bulk local-repair frontiers. + pub repair_seed_cells_added_total: usize, + /// Maximum live seed cells added by one bulk seed accumulation call. + pub repair_seed_cells_added_max: usize, + + /// Number of global exterior-point conflict scans. + pub global_conflict_scans: usize, + /// Total cells scanned by global exterior-point conflict scans. + pub global_conflict_cells_scanned: usize, + /// Total cells found by global exterior-point conflict scans. + pub global_conflict_cells_found_total: usize, + /// Maximum cells found by a single global exterior-point conflict scan. + pub global_conflict_cells_found_max: usize, + /// Wall-clock nanoseconds spent in global exterior-point conflict scans. + pub global_conflict_scan_nanos: u64, +} + +impl ConstructionTelemetry { + /// Returns true when any construction telemetry was recorded. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; + /// + /// let mut telemetry = ConstructionTelemetry::default(); + /// assert!(!telemetry.has_data()); + /// + /// telemetry.construction_insert_loop_nanos = 1; + /// assert!(telemetry.has_data()); + /// ``` + #[must_use] + pub const fn has_data(&self) -> bool { + self.insertion_wall_time_calls > 0 + || self.insertion_wall_time_nanos > 0 + || self.construction_preprocessing_nanos > 0 + || self.construction_insert_loop_nanos > 0 + || self.construction_finalize_nanos > 0 + || self.construction_completion_repair_nanos > 0 + || self.construction_orientation_nanos > 0 + || self.construction_topology_validation_nanos > 0 + || self.construction_final_delaunay_validation_nanos > 0 + || self.locate_calls > 0 + || self.conflict_region_calls > 0 + || self.cavity_insertion_calls > 0 + || self.hull_extension_calls > 0 + || self.topology_validation_calls > 0 + || self.local_repair_calls > 0 + || self.local_repair_snapshot_nanos > 0 + || self.local_repair_attempt_nanos > 0 + || self.local_repair_attempt_seed_nanos > 0 + || self.local_repair_attempt_facet_nanos > 0 + || self.local_repair_attempt_ridge_nanos > 0 + || self.local_repair_attempt_edge_nanos > 0 + || self.local_repair_attempt_triangle_nanos > 0 + || self.local_repair_postcondition_nanos > 0 + || self.local_repair_restore_nanos > 0 + || self.local_repair_seed_cells_total > 0 + || self.local_repair_items_checked_total > 0 + || self.local_repair_flips_total > 0 + || self.local_repair_no_flip_calls > 0 + || self.repair_seed_accumulation_calls > 0 + || self.global_conflict_scans > 0 + } + + /// Records the wall-clock duration of one transactional insertion call. + pub(crate) fn record_insertion_timing(&mut self, elapsed_nanos: u64) { + self.insertion_wall_time_calls = self.insertion_wall_time_calls.saturating_add(1); + self.insertion_wall_time_nanos = + self.insertion_wall_time_nanos.saturating_add(elapsed_nanos); + self.insertion_wall_time_nanos_max = self.insertion_wall_time_nanos_max.max(elapsed_nanos); + } + + /// Records the wall-clock duration of construction preprocessing. + pub(crate) const fn record_construction_preprocessing_timing(&mut self, elapsed_nanos: u64) { + self.construction_preprocessing_nanos = self + .construction_preprocessing_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of the bulk insertion loop. + pub(crate) const fn record_construction_insert_loop_timing(&mut self, elapsed_nanos: u64) { + self.construction_insert_loop_nanos = self + .construction_insert_loop_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of bulk-construction finalization. + pub(crate) const fn record_construction_finalize_timing(&mut self, elapsed_nanos: u64) { + self.construction_finalize_nanos = self + .construction_finalize_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of seeded completion repair. + pub(crate) const fn record_construction_completion_repair_timing( + &mut self, + elapsed_nanos: u64, + ) { + self.construction_completion_repair_nanos = self + .construction_completion_repair_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of orientation canonicalization. + pub(crate) const fn record_construction_orientation_timing(&mut self, elapsed_nanos: u64) { + self.construction_orientation_nanos = self + .construction_orientation_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of final topology validation. + pub(crate) const fn record_construction_topology_validation_timing( + &mut self, + elapsed_nanos: u64, + ) { + self.construction_topology_validation_nanos = self + .construction_topology_validation_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of final global Delaunay validation. + pub(crate) const fn record_construction_final_delaunay_validation_timing( + &mut self, + elapsed_nanos: u64, + ) { + self.construction_final_delaunay_validation_nanos = self + .construction_final_delaunay_validation_nanos + .saturating_add(elapsed_nanos); + } + + /// Records the wall-clock duration of one batch local repair call. + pub(crate) fn record_local_repair_timing(&mut self, elapsed_nanos: u64) { + self.local_repair_calls = self.local_repair_calls.saturating_add(1); + self.local_repair_nanos = self.local_repair_nanos.saturating_add(elapsed_nanos); + self.local_repair_nanos_max = self.local_repair_nanos_max.max(elapsed_nanos); + } + + /// Records phase timing for one batch local repair call. + pub(crate) fn record_local_repair_phase_timing(&mut self, timing: &LocalRepairPhaseTiming) { + self.local_repair_snapshot_nanos = self + .local_repair_snapshot_nanos + .saturating_add(timing.snapshot_nanos); + self.local_repair_snapshot_nanos_max = self + .local_repair_snapshot_nanos_max + .max(timing.snapshot_nanos); + self.local_repair_attempt_nanos = self + .local_repair_attempt_nanos + .saturating_add(timing.attempt_nanos); + self.local_repair_attempt_nanos_max = self + .local_repair_attempt_nanos_max + .max(timing.attempt_nanos); + self.local_repair_attempt_seed_nanos = self + .local_repair_attempt_seed_nanos + .saturating_add(timing.attempt_seed_nanos); + self.local_repair_attempt_seed_nanos_max = self + .local_repair_attempt_seed_nanos_max + .max(timing.attempt_seed_nanos); + self.local_repair_attempt_facet_nanos = self + .local_repair_attempt_facet_nanos + .saturating_add(timing.attempt_facet_nanos); + self.local_repair_attempt_facet_nanos_max = self + .local_repair_attempt_facet_nanos_max + .max(timing.attempt_facet_nanos); + self.local_repair_attempt_ridge_nanos = self + .local_repair_attempt_ridge_nanos + .saturating_add(timing.attempt_ridge_nanos); + self.local_repair_attempt_ridge_nanos_max = self + .local_repair_attempt_ridge_nanos_max + .max(timing.attempt_ridge_nanos); + self.local_repair_attempt_edge_nanos = self + .local_repair_attempt_edge_nanos + .saturating_add(timing.attempt_edge_nanos); + self.local_repair_attempt_edge_nanos_max = self + .local_repair_attempt_edge_nanos_max + .max(timing.attempt_edge_nanos); + self.local_repair_attempt_triangle_nanos = self + .local_repair_attempt_triangle_nanos + .saturating_add(timing.attempt_triangle_nanos); + self.local_repair_attempt_triangle_nanos_max = self + .local_repair_attempt_triangle_nanos_max + .max(timing.attempt_triangle_nanos); + self.local_repair_postcondition_nanos = self + .local_repair_postcondition_nanos + .saturating_add(timing.postcondition_nanos); + self.local_repair_postcondition_nanos_max = self + .local_repair_postcondition_nanos_max + .max(timing.postcondition_nanos); + self.local_repair_restore_nanos = self + .local_repair_restore_nanos + .saturating_add(timing.restore_nanos); + self.local_repair_restore_nanos_max = self + .local_repair_restore_nanos_max + .max(timing.restore_nanos); + } + + /// Records the repaired local frontier size and why the repair fired. + pub(crate) fn record_local_repair_frontier( + &mut self, + seed_cells: usize, + trigger: BatchLocalRepairTrigger, + ) { + self.local_repair_seed_cells_total = self + .local_repair_seed_cells_total + .saturating_add(seed_cells); + self.local_repair_seed_cells_max = self.local_repair_seed_cells_max.max(seed_cells); + match trigger { + BatchLocalRepairTrigger::Cadence => { + self.local_repair_cadence_triggers = + self.local_repair_cadence_triggers.saturating_add(1); + } + BatchLocalRepairTrigger::SeedBacklog => { + self.local_repair_backlog_triggers = + self.local_repair_backlog_triggers.saturating_add(1); + } + } + } + + /// Records aggregate work reported by one successful local repair pass. + pub(crate) fn record_local_repair_work( + &mut self, + items_checked: usize, + flips_performed: usize, + max_queue_len: usize, + ) { + self.local_repair_items_checked_total = self + .local_repair_items_checked_total + .saturating_add(items_checked); + self.local_repair_flips_total = self + .local_repair_flips_total + .saturating_add(flips_performed); + self.local_repair_flips_max = self.local_repair_flips_max.max(flips_performed); + self.local_repair_queue_len_max = self.local_repair_queue_len_max.max(max_queue_len); + if flips_performed == 0 { + self.local_repair_no_flip_calls = self.local_repair_no_flip_calls.saturating_add(1); + } + } + + /// Records one bulk local-repair seed accumulation step. + pub(crate) fn record_repair_seed_accumulation( + &mut self, + elapsed_nanos: u64, + cells_added: usize, + ) { + self.repair_seed_accumulation_calls = self.repair_seed_accumulation_calls.saturating_add(1); + self.repair_seed_accumulation_nanos = self + .repair_seed_accumulation_nanos + .saturating_add(elapsed_nanos); + self.repair_seed_accumulation_nanos_max = + self.repair_seed_accumulation_nanos_max.max(elapsed_nanos); + self.repair_seed_cells_added_total = self + .repair_seed_cells_added_total + .saturating_add(cells_added); + self.repair_seed_cells_added_max = self.repair_seed_cells_added_max.max(cells_added); + } + + /// Adds one insertion's telemetry into this construction summary. + pub(crate) fn record_insertion(&mut self, telemetry: &InsertionTelemetry) { + self.locate_calls = self.locate_calls.saturating_add(telemetry.locate_calls); + self.locate_walk_steps_total = self + .locate_walk_steps_total + .saturating_add(telemetry.locate_walk_steps_total); + self.locate_walk_steps_max = self + .locate_walk_steps_max + .max(telemetry.locate_walk_steps_max); + self.locate_hint_uses = self + .locate_hint_uses + .saturating_add(telemetry.locate_hint_uses); + self.locate_scan_fallbacks = self + .locate_scan_fallbacks + .saturating_add(telemetry.locate_scan_fallbacks); + self.located_inside = self.located_inside.saturating_add(telemetry.located_inside); + self.located_outside = self + .located_outside + .saturating_add(telemetry.located_outside); + self.located_on_boundary = self + .located_on_boundary + .saturating_add(telemetry.located_on_boundary); + + self.conflict_region_calls = self + .conflict_region_calls + .saturating_add(telemetry.conflict_region_calls); + self.conflict_region_cells_total = self + .conflict_region_cells_total + .saturating_add(telemetry.conflict_region_cells_total); + self.conflict_region_cells_max = self + .conflict_region_cells_max + .max(telemetry.conflict_region_cells_max); + self.conflict_region_nanos = self + .conflict_region_nanos + .saturating_add(telemetry.conflict_region_nanos); + self.conflict_region_nanos_max = self + .conflict_region_nanos_max + .max(telemetry.conflict_region_nanos_max); + + self.cavity_insertion_calls = self + .cavity_insertion_calls + .saturating_add(telemetry.cavity_insertion_calls); + self.cavity_insertion_nanos = self + .cavity_insertion_nanos + .saturating_add(telemetry.cavity_insertion_nanos); + self.cavity_insertion_nanos_max = self + .cavity_insertion_nanos_max + .max(telemetry.cavity_insertion_nanos_max); + + self.hull_extension_calls = self + .hull_extension_calls + .saturating_add(telemetry.hull_extension_calls); + self.hull_extension_nanos = self + .hull_extension_nanos + .saturating_add(telemetry.hull_extension_nanos); + self.hull_extension_nanos_max = self + .hull_extension_nanos_max + .max(telemetry.hull_extension_nanos_max); + + self.topology_validation_calls = self + .topology_validation_calls + .saturating_add(telemetry.topology_validation_calls); + self.topology_validation_nanos = self + .topology_validation_nanos + .saturating_add(telemetry.topology_validation_nanos); + self.topology_validation_nanos_max = self + .topology_validation_nanos_max + .max(telemetry.topology_validation_nanos_max); + + self.global_conflict_scans = self + .global_conflict_scans + .saturating_add(telemetry.global_conflict_scans); + self.global_conflict_cells_scanned = self + .global_conflict_cells_scanned + .saturating_add(telemetry.global_conflict_cells_scanned); + self.global_conflict_cells_found_total = self + .global_conflict_cells_found_total + .saturating_add(telemetry.global_conflict_cells_found_total); + self.global_conflict_cells_found_max = self + .global_conflict_cells_found_max + .max(telemetry.global_conflict_cells_found_max); + self.global_conflict_scan_nanos = self + .global_conflict_scan_nanos + .saturating_add(telemetry.global_conflict_scan_nanos); + } + + /// Merges another construction telemetry summary into this one. + pub(crate) fn merge_from(&mut self, other: &Self) { + self.insertion_wall_time_nanos = self + .insertion_wall_time_nanos + .saturating_add(other.insertion_wall_time_nanos); + self.insertion_wall_time_calls = self + .insertion_wall_time_calls + .saturating_add(other.insertion_wall_time_calls); + self.insertion_wall_time_nanos_max = self + .insertion_wall_time_nanos_max + .max(other.insertion_wall_time_nanos_max); + + self.merge_construction_phase_timings_from(other); + + self.locate_calls = self.locate_calls.saturating_add(other.locate_calls); + self.locate_walk_steps_total = self + .locate_walk_steps_total + .saturating_add(other.locate_walk_steps_total); + self.locate_walk_steps_max = self.locate_walk_steps_max.max(other.locate_walk_steps_max); + self.locate_hint_uses = self.locate_hint_uses.saturating_add(other.locate_hint_uses); + self.locate_scan_fallbacks = self + .locate_scan_fallbacks + .saturating_add(other.locate_scan_fallbacks); + self.located_inside = self.located_inside.saturating_add(other.located_inside); + self.located_outside = self.located_outside.saturating_add(other.located_outside); + self.located_on_boundary = self + .located_on_boundary + .saturating_add(other.located_on_boundary); + + self.conflict_region_calls = self + .conflict_region_calls + .saturating_add(other.conflict_region_calls); + self.conflict_region_cells_total = self + .conflict_region_cells_total + .saturating_add(other.conflict_region_cells_total); + self.conflict_region_cells_max = self + .conflict_region_cells_max + .max(other.conflict_region_cells_max); + self.conflict_region_nanos = self + .conflict_region_nanos + .saturating_add(other.conflict_region_nanos); + self.conflict_region_nanos_max = self + .conflict_region_nanos_max + .max(other.conflict_region_nanos_max); + + self.cavity_insertion_calls = self + .cavity_insertion_calls + .saturating_add(other.cavity_insertion_calls); + self.cavity_insertion_nanos = self + .cavity_insertion_nanos + .saturating_add(other.cavity_insertion_nanos); + self.cavity_insertion_nanos_max = self + .cavity_insertion_nanos_max + .max(other.cavity_insertion_nanos_max); + + self.hull_extension_calls = self + .hull_extension_calls + .saturating_add(other.hull_extension_calls); + self.hull_extension_nanos = self + .hull_extension_nanos + .saturating_add(other.hull_extension_nanos); + self.hull_extension_nanos_max = self + .hull_extension_nanos_max + .max(other.hull_extension_nanos_max); + + self.topology_validation_calls = self + .topology_validation_calls + .saturating_add(other.topology_validation_calls); + self.topology_validation_nanos = self + .topology_validation_nanos + .saturating_add(other.topology_validation_nanos); + self.topology_validation_nanos_max = self + .topology_validation_nanos_max + .max(other.topology_validation_nanos_max); + + self.merge_local_repair_from(other); + + self.merge_repair_seed_accumulation_from(other); + + self.global_conflict_scans = self + .global_conflict_scans + .saturating_add(other.global_conflict_scans); + self.global_conflict_cells_scanned = self + .global_conflict_cells_scanned + .saturating_add(other.global_conflict_cells_scanned); + self.global_conflict_cells_found_total = self + .global_conflict_cells_found_total + .saturating_add(other.global_conflict_cells_found_total); + self.global_conflict_cells_found_max = self + .global_conflict_cells_found_max + .max(other.global_conflict_cells_found_max); + self.global_conflict_scan_nanos = self + .global_conflict_scan_nanos + .saturating_add(other.global_conflict_scan_nanos); + } + + /// Keeps construction-phase merge accounting isolated so aggregate merges stay readable. + const fn merge_construction_phase_timings_from(&mut self, other: &Self) { + self.construction_preprocessing_nanos = self + .construction_preprocessing_nanos + .saturating_add(other.construction_preprocessing_nanos); + self.construction_insert_loop_nanos = self + .construction_insert_loop_nanos + .saturating_add(other.construction_insert_loop_nanos); + self.construction_finalize_nanos = self + .construction_finalize_nanos + .saturating_add(other.construction_finalize_nanos); + self.construction_completion_repair_nanos = self + .construction_completion_repair_nanos + .saturating_add(other.construction_completion_repair_nanos); + self.construction_orientation_nanos = self + .construction_orientation_nanos + .saturating_add(other.construction_orientation_nanos); + self.construction_topology_validation_nanos = self + .construction_topology_validation_nanos + .saturating_add(other.construction_topology_validation_nanos); + self.construction_final_delaunay_validation_nanos = self + .construction_final_delaunay_validation_nanos + .saturating_add(other.construction_final_delaunay_validation_nanos); + } + + /// Keeps local-repair merge accounting isolated so the aggregate merge stays readable. + fn merge_local_repair_from(&mut self, other: &Self) { + self.local_repair_calls = self + .local_repair_calls + .saturating_add(other.local_repair_calls); + self.local_repair_nanos = self + .local_repair_nanos + .saturating_add(other.local_repair_nanos); + self.local_repair_nanos_max = self + .local_repair_nanos_max + .max(other.local_repair_nanos_max); + self.local_repair_snapshot_nanos = self + .local_repair_snapshot_nanos + .saturating_add(other.local_repair_snapshot_nanos); + self.local_repair_snapshot_nanos_max = self + .local_repair_snapshot_nanos_max + .max(other.local_repair_snapshot_nanos_max); + self.local_repair_attempt_nanos = self + .local_repair_attempt_nanos + .saturating_add(other.local_repair_attempt_nanos); + self.local_repair_attempt_nanos_max = self + .local_repair_attempt_nanos_max + .max(other.local_repair_attempt_nanos_max); + self.local_repair_attempt_seed_nanos = self + .local_repair_attempt_seed_nanos + .saturating_add(other.local_repair_attempt_seed_nanos); + self.local_repair_attempt_seed_nanos_max = self + .local_repair_attempt_seed_nanos_max + .max(other.local_repair_attempt_seed_nanos_max); + self.local_repair_attempt_facet_nanos = self + .local_repair_attempt_facet_nanos + .saturating_add(other.local_repair_attempt_facet_nanos); + self.local_repair_attempt_facet_nanos_max = self + .local_repair_attempt_facet_nanos_max + .max(other.local_repair_attempt_facet_nanos_max); + self.local_repair_attempt_ridge_nanos = self + .local_repair_attempt_ridge_nanos + .saturating_add(other.local_repair_attempt_ridge_nanos); + self.local_repair_attempt_ridge_nanos_max = self + .local_repair_attempt_ridge_nanos_max + .max(other.local_repair_attempt_ridge_nanos_max); + self.local_repair_attempt_edge_nanos = self + .local_repair_attempt_edge_nanos + .saturating_add(other.local_repair_attempt_edge_nanos); + self.local_repair_attempt_edge_nanos_max = self + .local_repair_attempt_edge_nanos_max + .max(other.local_repair_attempt_edge_nanos_max); + self.local_repair_attempt_triangle_nanos = self + .local_repair_attempt_triangle_nanos + .saturating_add(other.local_repair_attempt_triangle_nanos); + self.local_repair_attempt_triangle_nanos_max = self + .local_repair_attempt_triangle_nanos_max + .max(other.local_repair_attempt_triangle_nanos_max); + self.local_repair_postcondition_nanos = self + .local_repair_postcondition_nanos + .saturating_add(other.local_repair_postcondition_nanos); + self.local_repair_postcondition_nanos_max = self + .local_repair_postcondition_nanos_max + .max(other.local_repair_postcondition_nanos_max); + self.local_repair_restore_nanos = self + .local_repair_restore_nanos + .saturating_add(other.local_repair_restore_nanos); + self.local_repair_restore_nanos_max = self + .local_repair_restore_nanos_max + .max(other.local_repair_restore_nanos_max); + self.local_repair_seed_cells_total = self + .local_repair_seed_cells_total + .saturating_add(other.local_repair_seed_cells_total); + self.local_repair_seed_cells_max = self + .local_repair_seed_cells_max + .max(other.local_repair_seed_cells_max); + self.local_repair_cadence_triggers = self + .local_repair_cadence_triggers + .saturating_add(other.local_repair_cadence_triggers); + self.local_repair_backlog_triggers = self + .local_repair_backlog_triggers + .saturating_add(other.local_repair_backlog_triggers); + self.local_repair_items_checked_total = self + .local_repair_items_checked_total + .saturating_add(other.local_repair_items_checked_total); + self.local_repair_flips_total = self + .local_repair_flips_total + .saturating_add(other.local_repair_flips_total); + self.local_repair_flips_max = self + .local_repair_flips_max + .max(other.local_repair_flips_max); + self.local_repair_queue_len_max = self + .local_repair_queue_len_max + .max(other.local_repair_queue_len_max); + self.local_repair_no_flip_calls = self + .local_repair_no_flip_calls + .saturating_add(other.local_repair_no_flip_calls); + } + + /// Keeps seed-accumulation merge accounting isolated from the aggregate merge body. + fn merge_repair_seed_accumulation_from(&mut self, other: &Self) { + self.repair_seed_accumulation_calls = self + .repair_seed_accumulation_calls + .saturating_add(other.repair_seed_accumulation_calls); + self.repair_seed_accumulation_nanos = self + .repair_seed_accumulation_nanos + .saturating_add(other.repair_seed_accumulation_nanos); + self.repair_seed_accumulation_nanos_max = self + .repair_seed_accumulation_nanos_max + .max(other.repair_seed_accumulation_nanos_max); + self.repair_seed_cells_added_total = self + .repair_seed_cells_added_total + .saturating_add(other.repair_seed_cells_added_total); + self.repair_seed_cells_added_max = self + .repair_seed_cells_added_max + .max(other.repair_seed_cells_added_max); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[expect( + clippy::too_many_lines, + reason = "single-field telemetry regression covers every aggregate counter" + )] + fn test_construction_telemetry_records_all_counters() { + let mut summary = ConstructionTelemetry::default(); + let telemetry = InsertionTelemetry { + locate_calls: 2, + locate_walk_steps_total: 9, + locate_walk_steps_max: 7, + locate_hint_uses: 1, + locate_scan_fallbacks: 1, + located_inside: 1, + located_outside: 1, + conflict_region_calls: 1, + conflict_region_cells_total: 4, + conflict_region_cells_max: 4, + conflict_region_nanos: 125_000, + conflict_region_nanos_max: 125_000, + cavity_insertion_calls: 1, + cavity_insertion_nanos: 375_000, + cavity_insertion_nanos_max: 375_000, + hull_extension_calls: 1, + hull_extension_nanos: 500_000, + hull_extension_nanos_max: 500_000, + topology_validation_calls: 1, + topology_validation_nanos: 625_000, + topology_validation_nanos_max: 625_000, + global_conflict_scans: 1, + global_conflict_cells_scanned: 12, + global_conflict_cells_found_total: 3, + global_conflict_cells_found_max: 3, + global_conflict_scan_nanos: 250_000, + ..InsertionTelemetry::default() + }; + + summary.record_insertion(&telemetry); + summary.record_insertion_timing(1_000_000); + summary.record_local_repair_timing(2_000_000); + summary.record_local_repair_phase_timing(&LocalRepairPhaseTiming { + snapshot_nanos: 100_000, + attempt_nanos: 1_250_000, + attempt_seed_nanos: 10_000, + attempt_facet_nanos: 750_000, + attempt_ridge_nanos: 450_000, + attempt_edge_nanos: 25_000, + attempt_triangle_nanos: 15_000, + postcondition_nanos: 500_000, + restore_nanos: 25_000, + }); + summary.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); + summary.record_local_repair_work(123, 5, 17); + summary.record_repair_seed_accumulation(500_000, 7); + summary.record_construction_preprocessing_timing(10_000); + summary.record_construction_insert_loop_timing(20_000); + summary.record_construction_finalize_timing(30_000); + summary.record_construction_completion_repair_timing(40_000); + summary.record_construction_orientation_timing(50_000); + summary.record_construction_topology_validation_timing(60_000); + summary.record_construction_final_delaunay_validation_timing(70_000); + + assert!(summary.has_data()); + assert_eq!(summary.insertion_wall_time_calls, 1); + assert_eq!(summary.insertion_wall_time_nanos, 1_000_000); + assert_eq!(summary.insertion_wall_time_nanos_max, 1_000_000); + assert_eq!(summary.construction_preprocessing_nanos, 10_000); + assert_eq!(summary.construction_insert_loop_nanos, 20_000); + assert_eq!(summary.construction_finalize_nanos, 30_000); + assert_eq!(summary.construction_completion_repair_nanos, 40_000); + assert_eq!(summary.construction_orientation_nanos, 50_000); + assert_eq!(summary.construction_topology_validation_nanos, 60_000); + assert_eq!(summary.construction_final_delaunay_validation_nanos, 70_000); + assert_eq!(summary.locate_calls, 2); + assert_eq!(summary.locate_walk_steps_total, 9); + assert_eq!(summary.locate_walk_steps_max, 7); + assert_eq!(summary.locate_hint_uses, 1); + assert_eq!(summary.locate_scan_fallbacks, 1); + assert_eq!(summary.located_inside, 1); + assert_eq!(summary.located_outside, 1); + assert_eq!(summary.conflict_region_calls, 1); + assert_eq!(summary.conflict_region_cells_total, 4); + assert_eq!(summary.conflict_region_nanos, 125_000); + assert_eq!(summary.conflict_region_nanos_max, 125_000); + assert_eq!(summary.cavity_insertion_calls, 1); + assert_eq!(summary.cavity_insertion_nanos, 375_000); + assert_eq!(summary.hull_extension_calls, 1); + assert_eq!(summary.hull_extension_nanos, 500_000); + assert_eq!(summary.topology_validation_calls, 1); + assert_eq!(summary.topology_validation_nanos, 625_000); + assert_eq!(summary.local_repair_calls, 1); + assert_eq!(summary.local_repair_nanos, 2_000_000); + assert_eq!(summary.local_repair_snapshot_nanos, 100_000); + assert_eq!(summary.local_repair_snapshot_nanos_max, 100_000); + assert_eq!(summary.local_repair_attempt_nanos, 1_250_000); + assert_eq!(summary.local_repair_attempt_nanos_max, 1_250_000); + assert_eq!(summary.local_repair_attempt_seed_nanos, 10_000); + assert_eq!(summary.local_repair_attempt_seed_nanos_max, 10_000); + assert_eq!(summary.local_repair_attempt_facet_nanos, 750_000); + assert_eq!(summary.local_repair_attempt_facet_nanos_max, 750_000); + assert_eq!(summary.local_repair_attempt_ridge_nanos, 450_000); + assert_eq!(summary.local_repair_attempt_ridge_nanos_max, 450_000); + assert_eq!(summary.local_repair_attempt_edge_nanos, 25_000); + assert_eq!(summary.local_repair_attempt_edge_nanos_max, 25_000); + assert_eq!(summary.local_repair_attempt_triangle_nanos, 15_000); + assert_eq!(summary.local_repair_attempt_triangle_nanos_max, 15_000); + assert_eq!(summary.local_repair_postcondition_nanos, 500_000); + assert_eq!(summary.local_repair_postcondition_nanos_max, 500_000); + assert_eq!(summary.local_repair_restore_nanos, 25_000); + assert_eq!(summary.local_repair_restore_nanos_max, 25_000); + assert_eq!(summary.local_repair_seed_cells_total, 11); + assert_eq!(summary.local_repair_seed_cells_max, 11); + assert_eq!(summary.local_repair_cadence_triggers, 0); + assert_eq!(summary.local_repair_backlog_triggers, 1); + assert_eq!(summary.local_repair_items_checked_total, 123); + assert_eq!(summary.local_repair_flips_total, 5); + assert_eq!(summary.local_repair_flips_max, 5); + assert_eq!(summary.local_repair_queue_len_max, 17); + assert_eq!(summary.local_repair_no_flip_calls, 0); + assert_eq!(summary.repair_seed_accumulation_calls, 1); + assert_eq!(summary.repair_seed_accumulation_nanos, 500_000); + assert_eq!(summary.repair_seed_cells_added_total, 7); + assert_eq!(summary.repair_seed_cells_added_max, 7); + assert_eq!(summary.global_conflict_scans, 1); + assert_eq!(summary.global_conflict_cells_scanned, 12); + assert_eq!(summary.global_conflict_cells_found_total, 3); + assert_eq!(summary.global_conflict_scan_nanos, 250_000); + } + + #[test] + fn test_construction_telemetry_merge_preserves_local_repair_frontiers() { + let mut left = ConstructionTelemetry::default(); + left.record_local_repair_timing(10); + left.record_local_repair_phase_timing(&LocalRepairPhaseTiming { + snapshot_nanos: 1, + attempt_nanos: 2, + attempt_seed_nanos: 3, + attempt_facet_nanos: 4, + attempt_ridge_nanos: 5, + attempt_edge_nanos: 6, + attempt_triangle_nanos: 7, + postcondition_nanos: 3, + restore_nanos: 4, + }); + left.record_local_repair_frontier(5, BatchLocalRepairTrigger::Cadence); + left.record_local_repair_work(10, 0, 5); + left.record_construction_insert_loop_timing(100); + left.record_construction_final_delaunay_validation_timing(200); + + let mut right = ConstructionTelemetry::default(); + right.record_local_repair_timing(30); + right.record_local_repair_phase_timing(&LocalRepairPhaseTiming { + snapshot_nanos: 10, + attempt_nanos: 20, + attempt_seed_nanos: 30, + attempt_facet_nanos: 40, + attempt_ridge_nanos: 50, + attempt_edge_nanos: 60, + attempt_triangle_nanos: 70, + postcondition_nanos: 30, + restore_nanos: 40, + }); + right.record_local_repair_frontier(11, BatchLocalRepairTrigger::SeedBacklog); + right.record_local_repair_work(30, 4, 12); + right.record_construction_insert_loop_timing(300); + right.record_construction_final_delaunay_validation_timing(400); + + left.merge_from(&right); + + assert!(left.has_data()); + assert_eq!(left.construction_insert_loop_nanos, 400); + assert_eq!(left.construction_final_delaunay_validation_nanos, 600); + assert_eq!(left.local_repair_calls, 2); + assert_eq!(left.local_repair_nanos, 40); + assert_eq!(left.local_repair_nanos_max, 30); + assert_eq!(left.local_repair_snapshot_nanos, 11); + assert_eq!(left.local_repair_snapshot_nanos_max, 10); + assert_eq!(left.local_repair_attempt_nanos, 22); + assert_eq!(left.local_repair_attempt_nanos_max, 20); + assert_eq!(left.local_repair_attempt_seed_nanos, 33); + assert_eq!(left.local_repair_attempt_seed_nanos_max, 30); + assert_eq!(left.local_repair_attempt_facet_nanos, 44); + assert_eq!(left.local_repair_attempt_facet_nanos_max, 40); + assert_eq!(left.local_repair_attempt_ridge_nanos, 55); + assert_eq!(left.local_repair_attempt_ridge_nanos_max, 50); + assert_eq!(left.local_repair_attempt_edge_nanos, 66); + assert_eq!(left.local_repair_attempt_edge_nanos_max, 60); + assert_eq!(left.local_repair_attempt_triangle_nanos, 77); + assert_eq!(left.local_repair_attempt_triangle_nanos_max, 70); + assert_eq!(left.local_repair_postcondition_nanos, 33); + assert_eq!(left.local_repair_postcondition_nanos_max, 30); + assert_eq!(left.local_repair_restore_nanos, 44); + assert_eq!(left.local_repair_restore_nanos_max, 40); + assert_eq!(left.local_repair_seed_cells_total, 16); + assert_eq!(left.local_repair_seed_cells_max, 11); + assert_eq!(left.local_repair_cadence_triggers, 1); + assert_eq!(left.local_repair_backlog_triggers, 1); + assert_eq!(left.local_repair_items_checked_total, 40); + assert_eq!(left.local_repair_flips_total, 4); + assert_eq!(left.local_repair_flips_max, 4); + assert_eq!(left.local_repair_queue_len_max, 12); + assert_eq!(left.local_repair_no_flip_calls, 1); + } +} diff --git a/src/triangulation/locality.rs b/src/triangulation/locality.rs new file mode 100644 index 00000000..42209beb --- /dev/null +++ b/src/triangulation/locality.rs @@ -0,0 +1,367 @@ +//! Locality helpers for triangulation construction and repair. +//! +//! These utilities sit at the boundary between spatial locality and topological +//! locality: callers may use Hilbert ordering or point-location hints to find a +//! nearby insertion site, then pass the concrete cell keys touched by the TDS +//! mutation here to build bounded repair frontiers. + +#![forbid(unsafe_code)] + +use crate::core::algorithms::locate::{ConflictError, find_conflict_region}; +use crate::core::collections::{CellKeyBuffer, FastHashSet, fast_hash_set_with_capacity}; +use crate::core::tds::{CellKey, Tds}; +use crate::core::traits::data_type::DataType; +use crate::geometry::kernel::Kernel; +use crate::geometry::point::Point; + +/// Local conflict-seed collection result for exterior insertion repair. +#[must_use] +pub struct LocalConflictSeedCells { + /// Live cells that should seed local Delaunay repair. + pub seed_cells: CellKeyBuffer, + /// Number of cells returned by the local conflict-region search before any fallback seed. + pub conflict_cells_found: usize, +} + +/// Adds live, deduplicated candidate cells to a pending local repair frontier. +/// +/// Returns the number of cells newly appended to `pending_seed_cells`. +pub(super) fn accumulate_live_cell_seeds( + tds: &Tds, + candidate_seed_cells: &[CellKey], + pending_seed_cells: &mut Vec, + pending_seen: &mut FastHashSet, +) -> usize +where + U: DataType, + V: DataType, +{ + let mut added = 0usize; + for &cell_key in candidate_seed_cells { + if tds.contains_cell(cell_key) && pending_seen.insert(cell_key) { + pending_seed_cells.push(cell_key); + added = added.saturating_add(1); + } + } + added +} + +/// Adds live, deduplicated candidate cells to a compact repair seed buffer. +/// +/// Returns the number of cells newly appended to `seed_cells`. +pub fn append_live_unique_cell_seeds( + tds: &Tds, + candidate_seed_cells: &[CellKey], + seed_cells: &mut CellKeyBuffer, +) -> usize +where + U: DataType, + V: DataType, +{ + let mut seen: FastHashSet = + fast_hash_set_with_capacity(seed_cells.len().saturating_add(candidate_seed_cells.len())); + seen.extend(seed_cells.iter().copied()); + + let mut added = 0usize; + for &cell_key in candidate_seed_cells { + if tds.contains_cell(cell_key) && seen.insert(cell_key) { + seed_cells.push(cell_key); + added = added.saturating_add(1); + } + } + added +} + +/// Retains only live, deduplicated cells in a pending local repair frontier. +pub(super) fn retain_live_cell_seeds( + tds: &Tds, + seed_cells: &mut Vec, + seen: &mut FastHashSet, +) where + U: DataType, + V: DataType, +{ + seen.clear(); + seed_cells.retain(|cell_key| tds.contains_cell(*cell_key) && seen.insert(*cell_key)); +} + +/// Clears a local repair frontier and its deduplication set together. +pub(super) fn clear_cell_seed_set(seed_cells: &mut Vec, seen: &mut FastHashSet) { + seed_cells.clear(); + seen.clear(); +} + +/// Retains conflict cells and records removed cells as local repair seeds. +pub fn retain_cells_and_record_removed( + conflict_cells: &mut CellKeyBuffer, + repair_seed_cells: &mut CellKeyBuffer, + mut keep_cell: impl FnMut(CellKey) -> bool, +) { + conflict_cells.retain(|cell_key| { + let keep = keep_cell(*cell_key); + if !keep { + repair_seed_cells.push(*cell_key); + } + keep + }); +} + +/// Replaces conflict cells and records cells missing from the replacement. +pub fn replace_cells_and_record_removed( + conflict_cells: &mut CellKeyBuffer, + repair_seed_cells: &mut CellKeyBuffer, + replacement: CellKeyBuffer, +) { + let replacement_set: FastHashSet = replacement.iter().copied().collect(); + for &cell_key in conflict_cells.iter() { + if !replacement_set.contains(&cell_key) { + repair_seed_cells.push(cell_key); + } + } + *conflict_cells = replacement; +} + +/// Collects local repair seeds for an exterior insertion from the terminal walk cell. +/// +/// The terminal cell is adjacent to the hull facet crossed by point location, so a +/// BFS conflict search from it gives a bounded local frontier without scanning the +/// entire triangulation. If no circumsphere conflict is found, the terminal cell +/// itself is still a useful local seed. +pub fn collect_local_exterior_conflict_seed_cells( + tds: &Tds, + kernel: &K, + point: &Point, + terminal_cell: CellKey, +) -> Result +where + K: Kernel, + U: DataType, + V: DataType, +{ + let mut seed_cells = CellKeyBuffer::new(); + if !tds.contains_cell(terminal_cell) { + return Ok(LocalConflictSeedCells { + seed_cells, + conflict_cells_found: 0, + }); + } + + let computed = find_conflict_region(tds, kernel, point, terminal_cell)?; + let conflict_cells_found = computed.len(); + if computed.is_empty() { + seed_cells.push(terminal_cell); + } else { + seed_cells = computed; + } + + Ok(LocalConflictSeedCells { + seed_cells, + conflict_cells_found, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::triangulation::Triangulation; + use crate::geometry::kernel::FastKernel; + use crate::geometry::point::Point; + use crate::geometry::traits::coordinate::Coordinate; + use crate::triangulation::delaunay::DelaunayTriangulation; + use crate::vertex; + use slotmap::KeyData; + + fn simplex_triangulation_3d() -> Triangulation, (), (), 3> { + let vertices = [ + vertex!([0.0, 0.0, 0.0]), + vertex!([1.0, 0.0, 0.0]), + vertex!([0.0, 1.0, 0.0]), + vertex!([0.0, 0.0, 1.0]), + ]; + let tds = + Triangulation::, (), (), 3>::build_initial_simplex(&vertices).unwrap(); + Triangulation::, (), (), 3>::new_with_tds(FastKernel::new(), tds) + } + + #[test] + fn accumulate_live_cell_seeds_dedupes_and_ignores_stale() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + vertex!([1.0, 1.0]), + vertex!([0.5, 0.5]), + ]; + let dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let all_cells: Vec = dt.cells().map(|(cell_key, _)| cell_key).collect(); + assert!( + all_cells.len() >= 2, + "fixture should produce multiple cells for seed accumulation" + ); + + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let mut pending_seed_cells = vec![all_cells[0]]; + let mut pending_seen: FastHashSet = pending_seed_cells.iter().copied().collect(); + let added = accumulate_live_cell_seeds( + dt.tds(), + &[all_cells[0], all_cells[1], all_cells[1], stale_cell], + &mut pending_seed_cells, + &mut pending_seen, + ); + + assert_eq!(added, 1); + assert_eq!(pending_seed_cells, vec![all_cells[0], all_cells[1]]); + assert!(!pending_seed_cells.contains(&stale_cell)); + + let added_again = accumulate_live_cell_seeds( + dt.tds(), + &[all_cells[1]], + &mut pending_seed_cells, + &mut pending_seen, + ); + assert_eq!(added_again, 0); + assert_eq!(pending_seed_cells, vec![all_cells[0], all_cells[1]]); + } + + #[test] + fn append_live_unique_cell_seeds_dedupes_and_ignores_stale() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + vertex!([1.0, 1.0]), + vertex!([0.5, 0.5]), + ]; + let dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let all_cells: Vec = dt.cells().map(|(cell_key, _)| cell_key).collect(); + assert!( + all_cells.len() >= 2, + "fixture should produce multiple cells for compact seed accumulation" + ); + + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let mut seed_cells = CellKeyBuffer::new(); + seed_cells.push(all_cells[0]); + let added = append_live_unique_cell_seeds( + dt.tds(), + &[all_cells[0], all_cells[1], stale_cell, all_cells[1]], + &mut seed_cells, + ); + + assert_eq!(added, 1); + assert_eq!( + seed_cells.iter().copied().collect::>(), + vec![all_cells[0], all_cells[1],] + ); + } + + #[test] + fn retain_live_cell_seeds_filters_stale_and_dedupes() { + let vertices = vec![ + vertex!([0.0, 0.0]), + vertex!([1.0, 0.0]), + vertex!([0.0, 1.0]), + vertex!([1.0, 1.0]), + vertex!([0.5, 0.5]), + ]; + let dt: DelaunayTriangulation<_, (), (), 2> = + DelaunayTriangulation::new(&vertices).unwrap(); + let all_cells: Vec = dt.cells().map(|(cell_key, _)| cell_key).collect(); + assert!( + all_cells.len() >= 2, + "fixture should produce multiple cells for seed retention" + ); + + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let mut seed_cells = vec![all_cells[0], stale_cell, all_cells[1], all_cells[0]]; + let mut seen = FastHashSet::default(); + retain_live_cell_seeds(dt.tds(), &mut seed_cells, &mut seen); + + assert_eq!(seed_cells, vec![all_cells[0], all_cells[1]]); + assert_eq!(seen.len(), 2); + } + + #[test] + fn clear_cell_seed_set_clears_both_collections() { + let stale_cell = CellKey::from(KeyData::from_ffi(999_999)); + let mut seed_cells = vec![stale_cell]; + let mut seen = FastHashSet::default(); + seen.insert(stale_cell); + + clear_cell_seed_set(&mut seed_cells, &mut seen); + + assert!(seed_cells.is_empty()); + assert!(seen.is_empty()); + } + + #[test] + fn retain_and_replace_cells_record_removed_repair_seeds() { + let a = CellKey::from(KeyData::from_ffi(31)); + let b = CellKey::from(KeyData::from_ffi(32)); + let c = CellKey::from(KeyData::from_ffi(33)); + let d = CellKey::from(KeyData::from_ffi(34)); + + let mut conflict_cells: CellKeyBuffer = [a, b, c].into_iter().collect(); + let mut repair_seed_cells = CellKeyBuffer::new(); + retain_cells_and_record_removed(&mut conflict_cells, &mut repair_seed_cells, |ck| ck != b); + assert_eq!( + conflict_cells.iter().copied().collect::>(), + vec![a, c] + ); + assert_eq!( + repair_seed_cells.iter().copied().collect::>(), + vec![b] + ); + + let replacement: CellKeyBuffer = [c, d].into_iter().collect(); + replace_cells_and_record_removed(&mut conflict_cells, &mut repair_seed_cells, replacement); + assert_eq!( + conflict_cells.iter().copied().collect::>(), + vec![c, d] + ); + assert_eq!( + repair_seed_cells.iter().copied().collect::>(), + vec![b, a] + ); + } + + #[test] + fn collect_local_exterior_conflict_seed_cells_uses_terminal_seed_when_empty() { + let tri = simplex_triangulation_3d(); + let terminal_cell = tri.tds.cell_keys().next().unwrap(); + let result = collect_local_exterior_conflict_seed_cells( + &tri.tds, + &FastKernel::new(), + &Point::new([2.0, 2.0, 2.0]), + terminal_cell, + ) + .unwrap(); + + assert_eq!(result.conflict_cells_found, 0); + assert_eq!( + result.seed_cells.iter().copied().collect::>(), + vec![terminal_cell] + ); + } + + #[test] + fn collect_local_exterior_conflict_seed_cells_returns_local_conflicts() { + let tri = simplex_triangulation_3d(); + let terminal_cell = tri.tds.cell_keys().next().unwrap(); + let result = collect_local_exterior_conflict_seed_cells( + &tri.tds, + &FastKernel::new(), + &Point::new([0.5, 0.5, 0.5]), + terminal_cell, + ) + .unwrap(); + + assert_eq!(result.conflict_cells_found, 1); + assert_eq!( + result.seed_cells.iter().copied().collect::>(), + vec![terminal_cell] + ); + } +} diff --git a/src/triangulation/validation.rs b/src/triangulation/validation.rs new file mode 100644 index 00000000..4dd5cd3e --- /dev/null +++ b/src/triangulation/validation.rs @@ -0,0 +1,129 @@ +//! Validation scheduling helpers for triangulation construction diagnostics. +//! +//! This module contains validation-control concepts that are orthogonal to the +//! Delaunay data structure itself. Keeping them here leaves +//! [`crate::triangulation::delaunay`] focused on construction, repair, and query logic. + +#![forbid(unsafe_code)] + +use std::num::NonZeroUsize; + +/// Cadence for explicit validation checkpoints during construction diagnostics. +/// +/// This is separate from [`ValidationPolicy`](crate::core::triangulation::ValidationPolicy), +/// which controls automatic insertion-time validation inside +/// [`Triangulation`](crate::core::triangulation::Triangulation). Diagnostic +/// harnesses can use this cadence for explicit periodic +/// [`DelaunayTriangulation::is_valid`](crate::triangulation::delaunay::DelaunayTriangulation::is_valid) +/// checks without overloading repair policy or exposing raw `Option` +/// scheduling in logs. +/// +/// # Examples +/// +/// ```rust +/// use delaunay::prelude::triangulation::validation::ValidationCadence; +/// +/// let cadence = ValidationCadence::from_optional_every(Some(128)); +/// assert!(!cadence.should_validate(0)); +/// assert!(!cadence.should_validate(127)); +/// assert!(cadence.should_validate(128)); +/// ``` +#[must_use = "validation cadence values only affect diagnostics when they are used"] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidationCadence { + /// Disable explicit periodic validation checkpoints. + Never, + /// Run explicit validation every N successful insertion attempts. + EveryN(NonZeroUsize), +} + +impl ValidationCadence { + /// Converts an optional integer cadence into a typed validation cadence. + /// + /// `None` and `Some(0)` disable periodic validation. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::validation::ValidationCadence; + /// + /// assert!(matches!( + /// ValidationCadence::from_optional_every(Some(32)), + /// ValidationCadence::EveryN(every) if every.get() == 32, + /// )); + /// assert_eq!( + /// ValidationCadence::from_optional_every(None), + /// ValidationCadence::Never, + /// ); + /// ``` + pub const fn from_optional_every(validate_every: Option) -> Self { + match validate_every { + None | Some(0) => Self::Never, + Some(every) => { + if let Some(every) = NonZeroUsize::new(every) { + Self::EveryN(every) + } else { + Self::Never + } + } + } + } + + /// Returns true when validation should run for a one-based insertion count. + /// + /// A count of `0` never triggers validation because no insertion has + /// completed yet. + /// + /// # Examples + /// + /// ```rust + /// use delaunay::prelude::triangulation::validation::ValidationCadence; + /// + /// let cadence = ValidationCadence::from_optional_every(Some(4)); + /// assert!(!cadence.should_validate(0)); + /// assert!(!cadence.should_validate(3)); + /// assert!(cadence.should_validate(4)); + /// ``` + #[must_use] + pub const fn should_validate(self, insertion_count: usize) -> bool { + match self { + Self::Never => false, + Self::EveryN(every) => { + insertion_count != 0 && insertion_count.is_multiple_of(every.get()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_cadence_maps_optional_every() { + assert_eq!( + ValidationCadence::from_optional_every(None), + ValidationCadence::Never + ); + assert_eq!( + ValidationCadence::from_optional_every(Some(0)), + ValidationCadence::Never + ); + assert_eq!( + ValidationCadence::from_optional_every(Some(128)), + ValidationCadence::EveryN(NonZeroUsize::new(128).unwrap()) + ); + } + + #[test] + fn validation_cadence_should_validate_on_multiples() { + let cadence = ValidationCadence::EveryN(NonZeroUsize::new(64).unwrap()); + + assert!(!cadence.should_validate(0)); + assert!(!cadence.should_validate(63)); + assert!(cadence.should_validate(64)); + assert!(!cadence.should_validate(65)); + assert!(cadence.should_validate(128)); + assert!(!ValidationCadence::Never.should_validate(64)); + } +} diff --git a/tests/allocation_api.rs b/tests/allocation_api.rs index 8d394da5..0705af8c 100644 --- a/tests/allocation_api.rs +++ b/tests/allocation_api.rs @@ -7,7 +7,9 @@ #[cfg(feature = "count-allocations")] use allocation_counter::measure; use delaunay::prelude::geometry::*; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, DelaunayTriangulationConstructionError, TopologyGuarantee, vertex, +}; use delaunay::geometry::kernel::AdaptiveKernel; diff --git a/tests/check_perturbation_stats.rs b/tests/check_perturbation_stats.rs index 5e0370bd..07566b7a 100644 --- a/tests/check_perturbation_stats.rs +++ b/tests/check_perturbation_stats.rs @@ -1,10 +1,16 @@ +#![forbid(unsafe_code)] + //! Regression: in `TopologyGuarantee::PLManifold` mode, incremental insertion must //! never commit a triangulation with invalid vertex links, independent of //! `ValidationPolicy`. use delaunay::core::vertex::VertexBuilder; use delaunay::geometry::util::generate_random_points_seeded; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayRepairPolicy, DelaunayTriangulation, TopologyGuarantee, +}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; +use delaunay::prelude::triangulation::validation::ValidationPolicy; #[test] fn pl_manifold_insertion_is_non_negotiable_under_validation_policy_never() { diff --git a/tests/circumsphere_debug_tools.rs b/tests/circumsphere_debug_tools.rs index ecc97965..b05c7019 100644 --- a/tests/circumsphere_debug_tools.rs +++ b/tests/circumsphere_debug_tools.rs @@ -16,7 +16,7 @@ use delaunay::geometry::matrix::{Matrix, determinant}; use delaunay::geometry::util::hypot; use delaunay::prelude::geometry::*; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{Vertex, vertex}; use serde::{Deserialize, Serialize}; // Macro for standard test output formatting diff --git a/tests/conflict_region_verification.rs b/tests/conflict_region_verification.rs index f827a2f6..16c3530b 100644 --- a/tests/conflict_region_verification.rs +++ b/tests/conflict_region_verification.rs @@ -15,7 +15,7 @@ use delaunay::prelude::algorithms::{LocateResult, find_conflict_region, locate}; use delaunay::prelude::diagnostics::verify_conflict_region_completeness; use delaunay::prelude::generators::generate_random_points_in_ball_seeded; use delaunay::prelude::geometry::AdaptiveKernel; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{DelaunayTriangulation, Vertex, vertex}; /// Verify that `verify_conflict_region_completeness` runs without panicking on /// the known-failing 3D case (35 vertices, seed 0xE30C78582376677C, ball diff --git a/tests/dedup_batch_construction.rs b/tests/dedup_batch_construction.rs index 4e509525..ecd88714 100644 --- a/tests/dedup_batch_construction.rs +++ b/tests/dedup_batch_construction.rs @@ -9,7 +9,11 @@ //! //! Dimension coverage: 2D–5D via `gen_dedup_batch_tests!`. -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DedupPolicy, DelaunayConstructionFailure, DelaunayTriangulation, + DelaunayTriangulationConstructionError, InsertionOrderStrategy, TopologyGuarantee, Vertex, + vertex, +}; // ============================================================================= // HELPERS diff --git a/tests/delaunay_edge_cases.rs b/tests/delaunay_edge_cases.rs index 294b03fc..5bf5613d 100644 --- a/tests/delaunay_edge_cases.rs +++ b/tests/delaunay_edge_cases.rs @@ -12,7 +12,10 @@ use delaunay::prelude::diagnostics::debug_print_first_delaunay_violation; use delaunay::prelude::generators::generate_random_points_in_ball_seeded; use delaunay::prelude::geometry::RobustKernel; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayConstructionFailure, DelaunayTriangulation, DelaunayTriangulationConstructionError, + TopologyGuarantee, Vertex, vertex, +}; use rand::SeedableRng; use rand::seq::SliceRandom; fn init_tracing() { diff --git a/tests/delaunay_incremental_insertion.rs b/tests/delaunay_incremental_insertion.rs index 3a7a4122..bb6b0608 100644 --- a/tests/delaunay_incremental_insertion.rs +++ b/tests/delaunay_incremental_insertion.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Integration tests for `DelaunayTriangulation` incremental insertion. //! //! These tests focus on the incremental insertion workflow and features @@ -12,7 +14,9 @@ use delaunay::prelude::algorithms::{LocateResult, find_conflict_region, locate}; use delaunay::prelude::collections::MAX_PRACTICAL_DIMENSION_SIZE; use delaunay::prelude::geometry::{AdaptiveKernel, Coordinate, Point}; use delaunay::prelude::tds::{Cell, CellKey, SmallBuffer, VertexKey, facet_key_from_vertices}; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DedupPolicy, DelaunayTriangulation, TopologyGuarantee, vertex, +}; /// Build the canonical facet key used to compare neighbor mirror facets in tests. fn facet_key_for_cell(cell: &Cell, facet_idx: usize) -> u64 { diff --git a/tests/delaunay_public_api_coverage.rs b/tests/delaunay_public_api_coverage.rs index 54fb1f43..ef91ffb0 100644 --- a/tests/delaunay_public_api_coverage.rs +++ b/tests/delaunay_public_api_coverage.rs @@ -7,11 +7,11 @@ )] use delaunay::prelude::geometry::AdaptiveKernel; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ ConstructionOptions, DedupPolicy, DelaunayTriangulation, - DelaunayTriangulationConstructionError, InsertionError, InsertionOrderStrategy, RetryPolicy, - TopologyGuarantee, + DelaunayTriangulationConstructionError, InsertionOrderStrategy, RetryPolicy, TopologyGuarantee, }; +use delaunay::prelude::triangulation::insertion::InsertionError; use delaunay::vertex; #[cfg(feature = "diagnostics")] use rand::{RngExt, SeedableRng, rngs::StdRng}; diff --git a/tests/delaunay_repair_fallback.rs b/tests/delaunay_repair_fallback.rs index 05205bbb..6700bcfc 100644 --- a/tests/delaunay_repair_fallback.rs +++ b/tests/delaunay_repair_fallback.rs @@ -4,7 +4,9 @@ //! Delaunay violations, the deterministic rebuild heuristic is triggered and //! successfully produces a valid Delaunay triangulation. -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayRepairPolicy, DelaunayTriangulation, TopologyGuarantee, vertex, +}; #[cfg(feature = "diagnostics")] fn init_tracing() { diff --git a/tests/euler_characteristic.rs b/tests/euler_characteristic.rs index 344ea8e9..e926c758 100644 --- a/tests/euler_characteristic.rs +++ b/tests/euler_characteristic.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + //! Deterministic integration tests for Euler characteristic computation. //! //! This module tests the topology module's Euler characteristic calculation @@ -14,7 +16,10 @@ use delaunay::prelude::query::BoundaryAnalysis; use delaunay::prelude::tds::Tds; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, DelaunayTriangulationConstructionError, ExplicitConstructionError, + TopologyGuarantee, vertex, +}; use delaunay::topology::characteristics::{euler, validation}; // ============================================================================= diff --git a/tests/insert_with_statistics.rs b/tests/insert_with_statistics.rs index b2d42c28..f95add9d 100644 --- a/tests/insert_with_statistics.rs +++ b/tests/insert_with_statistics.rs @@ -14,7 +14,10 @@ //! - Bootstrap phase (< D+1 vertices) //! - Post-bootstrap phase (≥ D+1 vertices) -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, vertex, +}; +use delaunay::prelude::triangulation::insertion::{InsertionError, InsertionOutcome}; // ============================================================================= // DELAUNAY TRIANGULATION TESTS diff --git a/tests/large_scale_debug.rs b/tests/large_scale_debug.rs index fd45e878..f430a26a 100644 --- a/tests/large_scale_debug.rs +++ b/tests/large_scale_debug.rs @@ -31,7 +31,12 @@ //! # - "new" (default): build via DelaunayTriangulation::new() which applies Hilbert ordering //! # - "incremental": manual insert loop (debug/profiling) //! DELAUNAY_LARGE_DEBUG_CONSTRUCTION_MODE=new \ -//! # Debug mode: "cadenced" (default, repair/validate on a cadence) or "strict" (per-insertion) +//! # Initial simplex strategy for batch construction: "max-volume" (default), "balanced", or "first" +//! DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX=max-volume \ +//! # Debug mode: +//! # - "cadenced" (default): PLManifold, ridge-link validation during insertion, +//! # vertex-link validation at completion +//! # - "strict": PLManifoldStrict, vertex-link validation after every insertion //! DELAUNAY_LARGE_DEBUG_DEBUG_MODE=cadenced \ //! # Deterministically shuffle insertion order (incremental mode only) //! DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED=123 \ @@ -39,12 +44,16 @@ //! DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY=1000 \ //! # (Optional) validate topology every N insertions once cells exist (incremental mode only; can be expensive) //! DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY=2000 \ -//! # Allow skipped vertices (otherwise the test fails if any are skipped) +//! # Maximum skipped-vertex percentage before the run fails (default: 5.0) +//! DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT=5.0 \ +//! # Allow any number of skipped vertices (bypasses DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT) //! DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS=1 \ //! # Skip the final flip-based repair pass (faster, but may leave Delaunay violations) //! DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR=1 \ -//! # Run bounded incremental flip repair every N successful insertions (incremental mode only; 0 disables; default: 128) -//! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=128 \ +//! # Run bounded flip repair every N successful insertions (0 disables; default: 1) +//! DELAUNAY_LARGE_DEBUG_REPAIR_EVERY=1 \ +//! # Optional: trace cadenced local-repair seed counts, flips, queues, and elapsed time +//! DELAUNAY_BATCH_REPAIR_TRACE=1 \ //! # Hard wall-clock cap in seconds before the harness aborts (0 = no cap; default: 600) //! DELAUNAY_LARGE_DEBUG_MAX_RUNTIME_SECS=600 \ //! # Optional: emit periodic batch-construction summaries for new()/Hilbert runs @@ -64,17 +73,24 @@ #![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, safe_usize_to_scalar, }; use delaunay::prelude::tds::{InvariantKind, TriangulationValidationReport}; -use delaunay::prelude::triangulation::*; -use delaunay::triangulation::delaunay::{ - ConstructionOptions, ConstructionStatistics, DelaunayRepairHeuristicConfig, - DelaunayTriangulationConstructionErrorWithStatistics, +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, ConstructionStatistics, DelaunayRepairPolicy, DelaunayTriangulation, + DelaunayTriangulationConstructionErrorWithStatistics, InitialSimplexStrategy, + TopologyGuarantee, Vertex, vertex, }; +use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; +use delaunay::prelude::triangulation::insertion::{ + InsertionOutcome, InsertionResult, InsertionStatistics, +}; +use delaunay::prelude::triangulation::repair::{ + DelaunayCheckPolicy, DelaunayRepairHeuristicConfig, +}; +use delaunay::prelude::triangulation::validation::ValidationCadence; use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; use std::env; use std::fmt; @@ -131,6 +147,24 @@ struct SkipSample { error: String, } +#[derive(Debug, Clone)] +struct SlowInsertionSample { + index: usize, + uuid: uuid::Uuid, + attempts: usize, + result: InsertionResult, + elapsed_nanos: u64, + cells_after: usize, + locate_calls: usize, + locate_walk_steps_total: usize, + conflict_region_calls: usize, + conflict_region_cells_total: usize, + cavity_insertion_calls: usize, + global_conflict_scans: usize, + hull_extension_calls: usize, + topology_validation_calls: usize, +} + #[derive(Debug, Default, Clone)] struct InsertionSummary { inserted: usize, @@ -146,6 +180,9 @@ struct InsertionSummary { cells_removed_total: usize, cells_removed_max: usize, + telemetry: ConstructionTelemetry, + + slow_insertions: Vec, skip_samples: Vec>, } @@ -199,9 +236,29 @@ impl InsertionSummary { impl From for InsertionSummary { fn from(stats: ConstructionStatistics) -> Self { + let slow_insertions = stats + .slow_insertions + .into_iter() + .map(|sample| SlowInsertionSample { + index: sample.index, + uuid: sample.uuid, + attempts: sample.attempts, + result: sample.result, + elapsed_nanos: sample.elapsed_nanos, + cells_after: sample.cells_after, + locate_calls: sample.locate_calls, + locate_walk_steps_total: sample.locate_walk_steps_total, + conflict_region_calls: sample.conflict_region_calls, + conflict_region_cells_total: sample.conflict_region_cells_total, + cavity_insertion_calls: sample.cavity_insertion_calls, + global_conflict_scans: sample.global_conflict_scans, + hull_extension_calls: sample.hull_extension_calls, + topology_validation_calls: sample.topology_validation_calls, + }) + .collect(); let skip_samples: Vec> = stats .skip_samples - .iter() + .into_iter() .map(|s| { let coords = if s.coords_available { s.coords.as_slice().try_into().map_or_else( @@ -225,7 +282,7 @@ impl From for InsertionSummary { uuid: s.uuid, coords, attempts: s.attempts, - error: s.error.clone(), + error: s.error, } }) .collect(); @@ -240,6 +297,8 @@ impl From for InsertionSummary { used_perturbation: stats.used_perturbation, cells_removed_total: stats.cells_removed_total, cells_removed_max: stats.cells_removed_max, + telemetry: stats.telemetry, + slow_insertions, skip_samples, } } @@ -288,6 +347,7 @@ fn init_tracing() { "DELAUNAY_REPAIR_DEBUG_POSTCONDITION_FACET", "DELAUNAY_REPAIR_DEBUG_RIDGE_MIN_MULTIPLICITY", "DELAUNAY_BULK_PROGRESS_EVERY", + "DELAUNAY_BATCH_REPAIR_TRACE", "DELAUNAY_DEBUG_SHUFFLE", ]; let default_filter = if debug_env_vars @@ -330,6 +390,54 @@ impl ConstructionMode { } } +fn initial_simplex_strategy_name(strategy: InitialSimplexStrategy) -> &'static str { + match strategy { + InitialSimplexStrategy::First => "first", + InitialSimplexStrategy::Balanced => "balanced", + InitialSimplexStrategy::MaxVolume => "max-volume", + _ => { + tracing::debug!(?strategy, "unknown initial simplex strategy"); + "unknown" + } + } +} + +fn initial_simplex_strategy_from_name(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return Some(InitialSimplexStrategy::MaxVolume); + } + + if raw.eq_ignore_ascii_case("first") { + return Some(InitialSimplexStrategy::First); + } + + if raw.eq_ignore_ascii_case("balanced") { + return Some(InitialSimplexStrategy::Balanced); + } + + if raw.eq_ignore_ascii_case("max-volume") + || raw.eq_ignore_ascii_case("max_volume") + || raw.eq_ignore_ascii_case("maxvolume") + { + return Some(InitialSimplexStrategy::MaxVolume); + } + + None +} + +fn initial_simplex_strategy_from_env() -> InitialSimplexStrategy { + let Ok(raw) = env::var("DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX") else { + return InitialSimplexStrategy::MaxVolume; + }; + + initial_simplex_strategy_from_name(&raw).unwrap_or_else(|| { + panic!( + "invalid DELAUNAY_LARGE_DEBUG_INITIAL_SIMPLEX={raw:?} (expected 'max-volume', 'balanced', or 'first')" + ) + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DebugMode { /// Faster default: repair/check in cadence, with suspicion-driven automatic validation. @@ -347,6 +455,23 @@ impl DebugMode { } } +/// Selects the topology guarantee that matches the requested debug intensity. +const fn topology_for_debug_mode(debug_mode: DebugMode) -> TopologyGuarantee { + match debug_mode { + DebugMode::Cadenced => TopologyGuarantee::PLManifold, + DebugMode::Strict => TopologyGuarantee::PLManifoldStrict, + } +} + +/// Converts the repair cadence knob into the policy shared by batch and incremental modes. +const fn repair_policy_from_repair_every(repair_every: usize) -> DelaunayRepairPolicy { + match NonZeroUsize::new(repair_every) { + None => DelaunayRepairPolicy::Never, + Some(n) if n.get() == 1 => DelaunayRepairPolicy::EveryInsertion, + Some(n) => DelaunayRepairPolicy::EveryN(n), + } +} + impl PointDistribution { const fn name(self) -> &'static str { match self { @@ -356,6 +481,29 @@ impl PointDistribution { } } +/// Reads the skipped-vertex percentage budget for large-scale debug runs. +fn max_skip_pct_from_env() -> f64 { + let max_skip_pct = env_f64("DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT").unwrap_or(5.0); + assert!( + max_skip_pct.is_finite() && max_skip_pct >= 0.0, + "invalid DELAUNAY_LARGE_DEBUG_MAX_SKIP_PCT={max_skip_pct:?} (expected finite non-negative percentage)" + ); + max_skip_pct +} + +/// Computes the skipped-vertex percentage for budget checks and reporting. +fn skip_percentage(skipped: usize, total: usize) -> f64 { + if total == 0 { + return 0.0; + } + + let skipped = safe_usize_to_scalar::(skipped) + .expect("skipped-vertex count should fit in f64 for debug reporting"); + let total = safe_usize_to_scalar::(total) + .expect("point count should fit in f64 for debug reporting"); + (skipped / total) * 100.0 +} + fn env_f64(name: &str) -> Option { let Ok(raw) = env::var(name) else { return None; @@ -444,6 +592,8 @@ enum DebugOutcome { SkippedVertices { skipped: usize, total: usize, + skip_pct: f64, + max_skip_pct: f64, }, RepairNonConvergence { error: String, @@ -461,8 +611,16 @@ impl fmt::Display for DebugOutcome { Self::ConstructionFailure { error } => { write!(f, "ConstructionFailure | {error}") } - Self::SkippedVertices { skipped, total } => { - write!(f, "SkippedVertices | {skipped}/{total} skipped") + Self::SkippedVertices { + skipped, + total, + skip_pct, + max_skip_pct, + } => { + write!( + f, + "SkippedVertices | {skipped}/{total} skipped ({skip_pct:.2}%, max {max_skip_pct:.2}%)" + ) } Self::RepairNonConvergence { error } => { write!(f, "RepairNonConvergence | {error}") @@ -514,6 +672,314 @@ fn print_validation_report(report: &TriangulationValidationReport) { } } +fn usize_to_u128(value: usize) -> u128 { + u128::try_from(value).expect("usize should always fit in u128") +} + +fn format_ratio_2(numerator: usize, denominator: usize) -> String { + format_ratio_2_u128(usize_to_u128(numerator), usize_to_u128(denominator)) +} + +fn format_ratio_2_u128(numerator: u128, denominator: u128) -> String { + if denominator == 0 { + return "0.00".to_string(); + } + + let scaled = numerator.saturating_mul(100) / denominator; + format!("{}.{:02}", scaled / 100, scaled % 100) +} + +fn format_nanos_as_ms(nanos: u64) -> String { + let micros = u128::from(nanos) / 1_000; + format!("{}.{:03}", micros / 1_000, micros % 1_000) +} + +fn format_avg_nanos_as_ms(total_nanos: u64, count: usize) -> String { + if count == 0 { + return "0.000".to_string(); + } + + let count = u64::try_from(count).expect("sample count should fit in u64 for debug reporting"); + format_nanos_as_ms(total_nanos / count) +} + +fn print_timing_summary(label: &str, calls: usize, total_nanos: u64, max_nanos: u64) { + if calls == 0 { + return; + } + + println!( + " {label}: calls={calls} total_ms={} avg_ms={} max_ms={}", + format_nanos_as_ms(total_nanos), + format_avg_nanos_as_ms(total_nanos, calls), + format_nanos_as_ms(max_nanos) + ); +} + +fn print_repair_seed_accumulation_telemetry(telemetry: &ConstructionTelemetry) { + if telemetry.repair_seed_accumulation_calls == 0 { + return; + } + + println!( + " repair_seed_accumulation: calls={} cells_added_total={} avg_cells_added={} max_cells_added={} total_ms={} avg_ms={} max_ms={}", + telemetry.repair_seed_accumulation_calls, + telemetry.repair_seed_cells_added_total, + format_ratio_2( + telemetry.repair_seed_cells_added_total, + telemetry.repair_seed_accumulation_calls, + ), + telemetry.repair_seed_cells_added_max, + format_nanos_as_ms(telemetry.repair_seed_accumulation_nanos), + format_avg_nanos_as_ms( + telemetry.repair_seed_accumulation_nanos, + telemetry.repair_seed_accumulation_calls, + ), + format_nanos_as_ms(telemetry.repair_seed_accumulation_nanos_max) + ); +} + +fn print_local_repair_frontier_telemetry(telemetry: &ConstructionTelemetry) { + if telemetry.local_repair_calls == 0 { + return; + } + + println!( + " local_repair_frontiers: seed_cells_total={} avg_seed_cells={} max_seed_cells={} cadence_triggers={} backlog_triggers={}", + telemetry.local_repair_seed_cells_total, + format_ratio_2( + telemetry.local_repair_seed_cells_total, + telemetry.local_repair_calls, + ), + telemetry.local_repair_seed_cells_max, + telemetry.local_repair_cadence_triggers, + telemetry.local_repair_backlog_triggers + ); +} + +fn print_local_repair_work_telemetry(telemetry: &ConstructionTelemetry) { + if telemetry.local_repair_calls == 0 { + return; + } + + println!( + " local_repair_work: checked_total={} avg_checked={} flips_total={} avg_flips={} max_flips={} max_queue={} no_flip_calls={}", + telemetry.local_repair_items_checked_total, + format_ratio_2( + telemetry.local_repair_items_checked_total, + telemetry.local_repair_calls, + ), + telemetry.local_repair_flips_total, + format_ratio_2( + telemetry.local_repair_flips_total, + telemetry.local_repair_calls + ), + telemetry.local_repair_flips_max, + telemetry.local_repair_queue_len_max, + telemetry.local_repair_no_flip_calls + ); +} + +fn print_local_repair_phase_telemetry(telemetry: &ConstructionTelemetry) { + let phase_total = telemetry + .local_repair_snapshot_nanos + .saturating_add(telemetry.local_repair_attempt_nanos) + .saturating_add(telemetry.local_repair_postcondition_nanos) + .saturating_add(telemetry.local_repair_restore_nanos); + if phase_total == 0 { + return; + } + + print_timing_summary( + "local_repair_snapshot", + telemetry.local_repair_calls, + telemetry.local_repair_snapshot_nanos, + telemetry.local_repair_snapshot_nanos_max, + ); + print_timing_summary( + "local_repair_attempts", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_nanos, + telemetry.local_repair_attempt_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_seed", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_seed_nanos, + telemetry.local_repair_attempt_seed_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_facets", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_facet_nanos, + telemetry.local_repair_attempt_facet_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_ridges", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_ridge_nanos, + telemetry.local_repair_attempt_ridge_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_edges", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_edge_nanos, + telemetry.local_repair_attempt_edge_nanos_max, + ); + print_timing_summary( + "local_repair_attempt_triangles", + telemetry.local_repair_calls, + telemetry.local_repair_attempt_triangle_nanos, + telemetry.local_repair_attempt_triangle_nanos_max, + ); + print_timing_summary( + "local_repair_postconditions", + telemetry.local_repair_calls, + telemetry.local_repair_postcondition_nanos, + telemetry.local_repair_postcondition_nanos_max, + ); + print_timing_summary( + "local_repair_restores", + telemetry.local_repair_calls, + telemetry.local_repair_restore_nanos, + telemetry.local_repair_restore_nanos_max, + ); +} + +fn print_construction_phase_telemetry(telemetry: &ConstructionTelemetry) { + let outer_total_nanos = telemetry + .construction_preprocessing_nanos + .saturating_add(telemetry.construction_insert_loop_nanos) + .saturating_add(telemetry.construction_finalize_nanos) + .saturating_add(telemetry.construction_final_delaunay_validation_nanos); + if outer_total_nanos == 0 { + return; + } + + println!( + " construction_phases: preprocessing_ms={} insert_loop_ms={} finalize_ms={} final_delaunay_validation_ms={} outer_total_ms={}", + format_nanos_as_ms(telemetry.construction_preprocessing_nanos), + format_nanos_as_ms(telemetry.construction_insert_loop_nanos), + format_nanos_as_ms(telemetry.construction_finalize_nanos), + format_nanos_as_ms(telemetry.construction_final_delaunay_validation_nanos), + format_nanos_as_ms(outer_total_nanos) + ); + + let finalize_breakdown_nanos = telemetry + .construction_completion_repair_nanos + .saturating_add(telemetry.construction_orientation_nanos) + .saturating_add(telemetry.construction_topology_validation_nanos); + if finalize_breakdown_nanos == 0 { + return; + } + + println!( + " finalize_breakdown: completion_repair_ms={} orientation_ms={} topology_validation_ms={}", + format_nanos_as_ms(telemetry.construction_completion_repair_nanos), + format_nanos_as_ms(telemetry.construction_orientation_nanos), + format_nanos_as_ms(telemetry.construction_topology_validation_nanos) + ); +} + +fn print_construction_telemetry(telemetry: &ConstructionTelemetry) { + if !telemetry.has_data() { + return; + } + + println!(); + println!(" insertion telemetry:"); + print_construction_phase_telemetry(telemetry); + print_timing_summary( + "insertion_wall", + telemetry.insertion_wall_time_calls, + telemetry.insertion_wall_time_nanos, + telemetry.insertion_wall_time_nanos_max, + ); + println!( + " locate: calls={} walk_steps_total={} avg_walk={} max_walk={} hint_uses={} scan_fallbacks={}", + telemetry.locate_calls, + telemetry.locate_walk_steps_total, + format_ratio_2(telemetry.locate_walk_steps_total, telemetry.locate_calls), + telemetry.locate_walk_steps_max, + telemetry.locate_hint_uses, + telemetry.locate_scan_fallbacks + ); + println!( + " locate_results: inside={} outside={} boundary={}", + telemetry.located_inside, telemetry.located_outside, telemetry.located_on_boundary + ); + + if telemetry.conflict_region_calls > 0 { + println!( + " conflict_regions: calls={} cells_total={} avg_cells={} max_cells={} total_ms={} avg_ms={} max_ms={}", + telemetry.conflict_region_calls, + telemetry.conflict_region_cells_total, + format_ratio_2( + telemetry.conflict_region_cells_total, + telemetry.conflict_region_calls, + ), + telemetry.conflict_region_cells_max, + format_nanos_as_ms(telemetry.conflict_region_nanos), + format_avg_nanos_as_ms( + telemetry.conflict_region_nanos, + telemetry.conflict_region_calls, + ), + format_nanos_as_ms(telemetry.conflict_region_nanos_max) + ); + } + + print_timing_summary( + "cavity_insertions", + telemetry.cavity_insertion_calls, + telemetry.cavity_insertion_nanos, + telemetry.cavity_insertion_nanos_max, + ); + print_timing_summary( + "hull_extensions", + telemetry.hull_extension_calls, + telemetry.hull_extension_nanos, + telemetry.hull_extension_nanos_max, + ); + print_timing_summary( + "topology_validations", + telemetry.topology_validation_calls, + telemetry.topology_validation_nanos, + telemetry.topology_validation_nanos_max, + ); + print_timing_summary( + "local_repairs", + telemetry.local_repair_calls, + telemetry.local_repair_nanos, + telemetry.local_repair_nanos_max, + ); + print_local_repair_phase_telemetry(telemetry); + print_local_repair_frontier_telemetry(telemetry); + print_local_repair_work_telemetry(telemetry); + print_repair_seed_accumulation_telemetry(telemetry); + + if telemetry.global_conflict_scans > 0 { + let scans = u64::try_from(telemetry.global_conflict_scans) + .expect("scan count should fit in u64 for debug reporting"); + println!( + " global_conflict_scans: scans={} cells_scanned_total={} avg_cells_scanned={} cells_found_total={} avg_cells_found={} max_cells_found={} total_ms={} avg_ms={}", + telemetry.global_conflict_scans, + telemetry.global_conflict_cells_scanned, + format_ratio_2( + telemetry.global_conflict_cells_scanned, + telemetry.global_conflict_scans, + ), + telemetry.global_conflict_cells_found_total, + format_ratio_2( + telemetry.global_conflict_cells_found_total, + telemetry.global_conflict_scans, + ), + telemetry.global_conflict_cells_found_max, + format_nanos_as_ms(telemetry.global_conflict_scan_nanos), + format_nanos_as_ms(telemetry.global_conflict_scan_nanos / scans) + ); + } +} + fn print_insertion_summary(summary: &InsertionSummary, elapsed: Duration) { println!("Insertion summary:"); println!(" inserted: {}", summary.inserted); @@ -539,6 +1005,35 @@ fn print_insertion_summary(summary: &InsertionSummary, elapse } } + print_construction_telemetry(&summary.telemetry); + + if !summary.slow_insertions.is_empty() { + println!(); + println!( + " slow_insertions (top {} by transactional insertion wall time):", + summary.slow_insertions.len() + ); + for s in &summary.slow_insertions { + println!( + " idx={} uuid={} attempts={} result={:?} elapsed_ms={} cells_after={} locate_calls={} walk_steps={} conflict_calls={} conflict_cells={} cavity_calls={} global_scans={} hull_calls={} validation_calls={}", + s.index, + s.uuid, + s.attempts, + s.result, + format_nanos_as_ms(s.elapsed_nanos), + s.cells_after, + s.locate_calls, + s.locate_walk_steps_total, + s.conflict_region_calls, + s.conflict_region_cells_total, + s.cavity_insertion_calls, + s.global_conflict_scans, + s.hull_extension_calls, + s.topology_validation_calls + ); + } + } + if !summary.skip_samples.is_empty() { println!(); println!(" skip_samples (first {}):", summary.skip_samples.len()); @@ -587,7 +1082,9 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz let box_half_width = env_f64("DELAUNAY_LARGE_DEBUG_BOX_HALF_WIDTH").unwrap_or(100.0); let mode = construction_mode_from_env(); + let initial_simplex_strategy = initial_simplex_strategy_from_env(); let debug_mode = debug_mode_from_env(); + let topology_guarantee = topology_for_debug_mode(debug_mode); let shuffle_seed = env_u64("DELAUNAY_LARGE_DEBUG_SHUFFLE_SEED"); let progress_every = env_usize("DELAUNAY_LARGE_DEBUG_PROGRESS_EVERY") @@ -595,13 +1092,15 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz .max(1); let allow_skips = env_flag("DELAUNAY_LARGE_DEBUG_ALLOW_SKIPS"); + let max_skip_pct = max_skip_pct_from_env(); let skip_final_repair = env_flag("DELAUNAY_LARGE_DEBUG_SKIP_FINAL_REPAIR"); // Delaunay repair scheduling // - 0 disables incremental repair // - 1 runs repair after every insertion // - N>1 runs repair after every N successful insertions - let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(128); + let repair_every = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_EVERY").unwrap_or(1); + let repair_policy = repair_policy_from_repair_every(repair_every); let repair_max_flips = env_usize("DELAUNAY_LARGE_DEBUG_REPAIR_MAX_FLIPS"); let validate_every = env_usize("DELAUNAY_LARGE_DEBUG_VALIDATE_EVERY").or_else(|| { if matches!(debug_mode, DebugMode::Cadenced) { @@ -610,6 +1109,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz None } }); + let validation_cadence = ValidationCadence::from_optional_every(validate_every); println!("============================================="); println!("Large-scale triangulation debug: {dimension_name}"); @@ -628,13 +1128,20 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz PointDistribution::Box => println!(" box_half_width:{box_half_width}"), } println!(" construction_mode: {}", mode.name()); + println!( + " initial_simplex: {}", + initial_simplex_strategy_name(initial_simplex_strategy) + ); println!(" debug_mode: {}", debug_mode.name()); + println!(" topology_guarantee: {topology_guarantee:?}"); println!(" shuffle_seed: {shuffle_seed:?}"); println!(" progress_every:{progress_every}"); - println!(" validate_every:{validate_every:?}"); + println!(" validation_cadence: {validation_cadence:?}"); println!(" allow_skips: {allow_skips}"); + println!(" max_skip_pct: {max_skip_pct}"); println!(" skip_final_repair: {skip_final_repair}"); println!(" repair_every: {repair_every}"); + println!(" repair_policy: {repair_policy:?}"); println!(" repair_max_flips: {repair_max_flips:?}"); if max_runtime_secs > 0 { println!(" max_runtime_secs: {max_runtime_secs}"); @@ -676,17 +1183,17 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz ConstructionMode::New => { // `DelaunayTriangulation::new()` applies Hilbert ordering during batch construction. // Use the statistics-returning variant so we can report aggregate insertion telemetry. - // - // Use PLManifoldStrict during batch construction to ensure vertex-link invariants are - // maintained on each insertion. let kernel = RobustKernel::::new(); println!("Starting batch construction (new)..."); let t_batch = Instant::now(); + let options = ConstructionOptions::default() + .with_initial_simplex_strategy(initial_simplex_strategy) + .with_batch_repair_policy(repair_policy); match DelaunayTriangulation::with_options_and_statistics( &kernel, &vertices, - TopologyGuarantee::PLManifoldStrict, - ConstructionOptions::default(), + topology_guarantee, + options, ) { Ok((dt, stats)) => { let summary: InsertionSummary = stats.into(); @@ -719,15 +1226,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz println!("Shuffled insertion order with seed {shuffle_seed}"); } - let (topology_guarantee, validation_policy) = match debug_mode { - DebugMode::Cadenced => { - (TopologyGuarantee::PLManifold, ValidationPolicy::OnSuspicion) - } - DebugMode::Strict => ( - TopologyGuarantee::PLManifoldStrict, - ValidationPolicy::Always, - ), - }; + let validation_policy = topology_guarantee.default_validation_policy(); let mut dt: DelaunayTriangulation<_, (), (), D> = DelaunayTriangulation::with_empty_kernel_and_topology_guarantee( @@ -739,11 +1238,6 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz // - Enable bounded incremental repair (local flip queue) every N successful insertions. // - Keep global Delaunay checks off during insertion; the harness can optionally run a final // global repair pass at the end. - let repair_policy = match NonZeroUsize::new(repair_every) { - None => DelaunayRepairPolicy::Never, - Some(n) if n.get() == 1 => DelaunayRepairPolicy::EveryInsertion, - Some(n) => DelaunayRepairPolicy::EveryN(n), - }; dt.set_delaunay_repair_policy(repair_policy); dt.set_delaunay_check_policy(DelaunayCheckPolicy::EndOnly); @@ -767,9 +1261,10 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz let coords = *vertex.point().coords(); let uuid = vertex.uuid(); - match dt.insert_with_statistics(vertex) { + let inserted_this_loop = match dt.insert_with_statistics(vertex) { Ok((InsertionOutcome::Inserted { .. }, stats)) => { summary.record_inserted(stats); + true } Ok((InsertionOutcome::Skipped { error }, stats)) => { let sample = SkipSample { @@ -780,6 +1275,7 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz error: error.to_string(), }; summary.record_skipped(sample, stats); + false } Err(err) => { println!( @@ -798,17 +1294,16 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz print_abort_summary::(&outcome, seed, n_points, "incremental insertion"); return outcome; } - } + }; if !had_cells && dt.number_of_cells() > 0 { had_cells = true; println!("Initial simplex created at insertion {}", idx + 1); } - if let Some(every) = validate_every - && every > 0 + if inserted_this_loop && had_cells - && (idx + 1) % every == 0 + && validation_cadence.should_validate(summary.inserted) && let Err(e) = dt.as_triangulation().is_valid() { println!("Topology validation failed at idx={idx}: {e}"); @@ -861,10 +1356,13 @@ fn debug_large_case(dimension_name: &str, default_n_points: usiz dt.dim() ); - if !allow_skips && skipped_total > 0 { + let skipped_pct = skip_percentage(skipped_total, n_points); + if !allow_skips && skipped_pct > max_skip_pct { let outcome = DebugOutcome::SkippedVertices { skipped: skipped_total, total: n_points, + skip_pct: skipped_pct, + max_skip_pct, }; print_abort_summary::(&outcome, seed, n_points, "skip check"); return outcome; @@ -988,6 +1486,79 @@ fn test_write_timeout_abort_message_propagates_error() { } } +#[test] +fn test_topology_for_debug_mode_uses_ridge_links_by_default() { + assert_eq!( + topology_for_debug_mode(DebugMode::Cadenced), + TopologyGuarantee::PLManifold + ); + assert_eq!( + topology_for_debug_mode(DebugMode::Strict), + TopologyGuarantee::PLManifoldStrict + ); +} + +#[test] +fn test_initial_simplex_strategy_from_name_maps_supported_values() { + assert_eq!( + initial_simplex_strategy_from_name(""), + Some(InitialSimplexStrategy::MaxVolume) + ); + assert_eq!( + initial_simplex_strategy_from_name("first"), + Some(InitialSimplexStrategy::First) + ); + assert_eq!( + initial_simplex_strategy_from_name("BALANCED"), + Some(InitialSimplexStrategy::Balanced) + ); + assert_eq!( + initial_simplex_strategy_from_name("max-volume"), + Some(InitialSimplexStrategy::MaxVolume) + ); + assert_eq!( + initial_simplex_strategy_from_name("MAX_VOLUME"), + Some(InitialSimplexStrategy::MaxVolume) + ); + assert_eq!(initial_simplex_strategy_from_name("unknown"), None); + assert_eq!( + initial_simplex_strategy_name(InitialSimplexStrategy::Balanced), + "balanced" + ); + assert_eq!( + initial_simplex_strategy_name(InitialSimplexStrategy::MaxVolume), + "max-volume" + ); +} + +#[test] +fn test_skip_percentage_reports_ratio() { + assert!((skip_percentage(0, 100) - 0.0).abs() < f64::EPSILON); + assert!((skip_percentage(0, 0) - 0.0).abs() < f64::EPSILON); + assert!((skip_percentage(4, 400) - 1.0).abs() < f64::EPSILON); + assert!((skip_percentage(12, 100) - 12.0).abs() < f64::EPSILON); +} + +#[test] +fn test_repair_policy_from_repair_every_maps_cadence() { + assert_eq!( + repair_policy_from_repair_every(0), + DelaunayRepairPolicy::Never + ); + assert_eq!( + repair_policy_from_repair_every(1), + DelaunayRepairPolicy::EveryInsertion + ); + assert_eq!( + repair_policy_from_repair_every(2), + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(2).unwrap()) + ); + assert_eq!( + repair_policy_from_repair_every(4), + DelaunayRepairPolicy::EveryN(NonZeroUsize::new(4).unwrap()) + ); +} + /// Regression test for issue #228: 3D 1000-point flip-repair non-convergence. /// /// Before the fix, `AdaptiveKernel`'s exact+SoS predicates were overridden by diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 4b0774d0..78520b53 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -30,25 +30,27 @@ use delaunay::prelude::ordering::{ use delaunay::prelude::query::ConvexHull; #[cfg(feature = "diagnostics")] use delaunay::prelude::tds::Tds; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, ConstructionSkipSample, ConstructionSlowInsertionSample, + DelaunayConstructionFailure, DelaunayRepairPolicy, DelaunayTriangulation, + DelaunayTriangulationConstructionError, InsertionOrderStrategy, Vertex, +}; use delaunay::prelude::triangulation::delaunayize::{ DelaunayizeConfig, DelaunayizeError, DelaunayizeOutcome, delaunayize_by_flips, }; +use delaunay::prelude::triangulation::diagnostics::ConstructionTelemetry; use delaunay::prelude::triangulation::flips::{BistellarFlips, TopologyGuarantee}; use delaunay::prelude::triangulation::insertion::{ InsertionError, NeighborRebuildError, Tds as InsertionTds, TdsMutationError, repair_neighbor_pointers_local, }; use delaunay::prelude::triangulation::repair::{ - DelaunayCheckPolicy, DelaunayRepairDiagnostics, DelaunayRepairError, DelaunayRepairOutcome, - DelaunayRepairPolicy, DelaunayRepairStats, FlipEdgeAdjacencyError, FlipError, - FlipTriangleAdjacencyError, FlipVertexAdjacencyError, RepairQueueOrder, - verify_delaunay_for_triangulation, -}; -use delaunay::prelude::triangulation::{ - ConstructionOptions, DelaunayConstructionFailure, DelaunayRepairOperation, - DelaunayTriangulation, DelaunayTriangulationConstructionError, - DelaunayTriangulationValidationError, InsertionOrderStrategy, Vertex, + DelaunayCheckPolicy, DelaunayRepairDiagnostics, DelaunayRepairError, DelaunayRepairOperation, + DelaunayRepairOutcome, DelaunayRepairStats, DelaunayTriangulationValidationError, + FlipEdgeAdjacencyError, FlipError, FlipTriangleAdjacencyError, FlipVertexAdjacencyError, + RepairQueueOrder, verify_delaunay_for_triangulation, }; +use delaunay::prelude::triangulation::validation::ValidationCadence; use delaunay::vertex; #[derive(Debug, thiserror::Error)] @@ -84,6 +86,10 @@ fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { ]; let options = ConstructionOptions::default().with_insertion_order(InsertionOrderStrategy::Input); + assert!(matches!( + options.batch_repair_policy(), + DelaunayRepairPolicy::EveryInsertion + )); let dt = DelaunayTriangulation::new_with_options(&vertices, options)?; assert_eq!(dt.topology_guarantee(), TopologyGuarantee::PLManifold); @@ -104,8 +110,16 @@ fn preludes_cover_bench_apis() -> Result<(), PreludeExportTestError> { DelaunayConstructionFailure::GeometricDegeneracy { .. } )); assert!(matches!(LocateResult::Outside, LocateResult::Outside)); + assert!(matches!( + ValidationCadence::from_optional_every(Some(128)), + ValidationCadence::EveryN(every) if every.get() == 128 + )); assert_send_sync_unpin::(); assert_send_sync_unpin::(); + assert_send_sync_unpin::(); + assert_send_sync_unpin::(); + let telemetry = ConstructionTelemetry::default(); + assert!(!telemetry.has_data()); Ok(()) } diff --git a/tests/proptest_delaunay_triangulation.rs b/tests/proptest_delaunay_triangulation.rs index 59d4161f..37b394f3 100644 --- a/tests/proptest_delaunay_triangulation.rs +++ b/tests/proptest_delaunay_triangulation.rs @@ -33,7 +33,12 @@ use delaunay::geometry::kernel::{AdaptiveKernel, RobustKernel}; use delaunay::prelude::geometry::*; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DedupPolicy, DelaunayRepairPolicy, DelaunayTriangulation, + TopologyGuarantee, Vertex, vertex, +}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; +use delaunay::prelude::triangulation::validation::ValidationPolicy; use proptest::prelude::*; use rand::{SeedableRng, seq::SliceRandom}; diff --git a/tests/proptest_euler_characteristic.rs b/tests/proptest_euler_characteristic.rs index fc231acc..6081362c 100644 --- a/tests/proptest_euler_characteristic.rs +++ b/tests/proptest_euler_characteristic.rs @@ -17,7 +17,9 @@ //! For deterministic tests with known configurations, see `euler_characteristic.rs`. use delaunay::geometry::util::generate_random_triangulation_with_topology_guarantee; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, vertex, +}; use delaunay::topology::characteristics::{euler, validation}; use proptest::prelude::*; diff --git a/tests/proptest_flips.rs b/tests/proptest_flips.rs index cad05978..14c35f67 100644 --- a/tests/proptest_flips.rs +++ b/tests/proptest_flips.rs @@ -12,10 +12,10 @@ use ::uuid::Uuid; use delaunay::prelude::geometry::{ AdaptiveKernel, Coordinate, FastKernel, Kernel, Point, RobustKernel, }; -use delaunay::prelude::triangulation::flips::BistellarFlips; -use delaunay::prelude::triangulation::{ +use delaunay::prelude::triangulation::construction::{ DelaunayTriangulation, TopologyGuarantee, Triangulation, Vertex, }; +use delaunay::prelude::triangulation::flips::BistellarFlips; use proptest::prelude::*; use std::collections::{BTreeSet, HashMap}; diff --git a/tests/proptest_orientation.rs b/tests/proptest_orientation.rs index 14921a10..b37de51c 100644 --- a/tests/proptest_orientation.rs +++ b/tests/proptest_orientation.rs @@ -12,8 +12,10 @@ use delaunay::core::tds::{Tds, TdsError}; use delaunay::prelude::geometry::*; -use delaunay::prelude::triangulation::*; -use delaunay::triangulation::delaunay::DelaunayTriangulation; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, +}; +use delaunay::prelude::triangulation::insertion::InsertionOutcome; use proptest::prelude::*; /// Strategy for generating finite `f64` coordinates in a reasonable range. diff --git a/tests/proptest_triangulation.rs b/tests/proptest_triangulation.rs index 707002fe..f739671a 100644 --- a/tests/proptest_triangulation.rs +++ b/tests/proptest_triangulation.rs @@ -37,7 +37,9 @@ use ::uuid::Uuid; use delaunay::prelude::geometry::*; use delaunay::prelude::tds::CellKey; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + DelaunayTriangulation, TopologyGuarantee, Vertex, vertex, +}; use proptest::prelude::*; use std::collections::HashMap; @@ -70,8 +72,7 @@ fn finite_coordinate() -> impl Strategy { /// Returns `Ok(())` if metrics match within tolerance, `Err(TestCaseError)` otherwise. /// /// # Returns -/// * `Ok(true)` - At least one cell was successfully matched and compared -/// * `Ok(false)` - No cells could be matched (topology changed too much) +/// * `Ok(())` - At least one cell was successfully matched and compared /// * `Err(TestCaseError)` - A metric comparison failed /// /// # Purpose @@ -82,7 +83,11 @@ fn finite_coordinate() -> impl Strategy { /// 2. Map their vertex UUIDs to transformed triangulation /// 3. Find matching cell in transformed triangulation /// 4. Compare quality metrics between matched cells -/// 5. Assert every original cell has a transformed counterpart. +/// +/// Degenerate Delaunay inputs can have more than one valid cell set, so an +/// independently constructed transformed triangulation may choose a different +/// valid tessellation. Unmatched cells are skipped; cases with no comparable +/// cells are rejected rather than treated as metric failures. fn compare_transformed_cells( dt_orig: &DelaunayTriangulation, (), (), D>, dt_transformed: &DelaunayTriangulation, (), (), D>, @@ -96,9 +101,12 @@ where { let tds_orig = dt_orig.tds(); let tds_transformed = dt_transformed.tds(); + let mut cells_considered = 0usize; + let mut matched_cells = 0usize; // Iterate through all cells in original triangulation for orig_key in tds_orig.cell_keys() { + cells_considered += 1; prop_assert!( tds_orig.cell(orig_key).is_some(), "original cell key from iterator should exist: {orig_key:?}" @@ -116,7 +124,6 @@ where "all original cell UUIDs should map to transformed UUIDs" ); - let mut found_match = false; for trans_key in tds_transformed.cell_keys() { prop_assert!( tds_transformed.cell(trans_key).is_some(), @@ -132,17 +139,16 @@ where { // Found matching cell - compare quality metrics compare_fn(orig_key, trans_key)?; - found_match = true; + matched_cells += 1; break; // Found the match, no need to check other cells } } } - prop_assert!( - found_match, - "no transformed cell matched original cell {orig_key:?}" - ); } + prop_assume!(cells_considered > 1); + prop_assume!(matched_cells >= 2); + Ok(()) } diff --git a/tests/regressions.rs b/tests/regressions.rs index 858bdf21..92f006cc 100644 --- a/tests/regressions.rs +++ b/tests/regressions.rs @@ -9,7 +9,10 @@ use delaunay::prelude::diagnostics::debug_print_first_delaunay_violation; use delaunay::prelude::generators::generate_random_points_in_ball_seeded; use delaunay::prelude::geometry::{Point, RobustKernel}; use delaunay::prelude::ordering::{hilbert_indices_prequantized, hilbert_quantize}; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, RetryPolicy, + TopologyGuarantee, Vertex, vertex, +}; /// Replays a full Hilbert ordering while keeping only the prefix that first /// exposed issue #307, so the regression stays fast and deterministic. diff --git a/tests/serialization_vertex_preservation.rs b/tests/serialization_vertex_preservation.rs index 5ee4357b..0d6d6d4b 100644 --- a/tests/serialization_vertex_preservation.rs +++ b/tests/serialization_vertex_preservation.rs @@ -13,7 +13,9 @@ use delaunay::assert_jaccard_gte; use delaunay::core::util::extract_vertex_coordinate_set; use delaunay::prelude::geometry::*; use delaunay::prelude::tds::Tds; -use delaunay::prelude::triangulation::*; +use delaunay::prelude::triangulation::construction::{ + ConstructionOptions, DelaunayTriangulation, InsertionOrderStrategy, TopologyGuarantee, Vertex, +}; use std::collections::HashSet; /// Test vertex preservation with duplicate coordinates