Skip to content

Robustness: validate cavities before mutating; repair with exact predicates or reject atomically#6

Merged
basnijholt merged 3 commits into
mainfrom
robustness/cavity-repair
Jun 10, 2026
Merged

Robustness: validate cavities before mutating; repair with exact predicates or reject atomically#6
basnijholt merged 3 commits into
mainfrom
robustness/cavity-repair

Conversation

@basnijholt

Copy link
Copy Markdown
Member

Summary

Fixes the known mixed-coordinate-scale failure mode documented in src/tolerances.rs: sliver simplices (aspect ratio ~1e7) feed the floating-point circumsphere test cancellation noise, so Bowyer-Watson can assemble a cavity that is not re-triangulable. On main this corrupts state: the volume-conservation assert fires only after mutation, and the failure modes the assert cannot see (cavities whose total volume sits below its absolute tolerance) silently orphan vertices.

Three changes, in order of the commits:

  1. Exact geometric primitives (geometry.rs, new robust dependency): robust_orientation, robust_in_circumsphere, and precise_volume, backed by Shewchuk's adaptive-precision predicates in 2D/3D. These deliberately do not replace the reference-compatible tolerance tests; they serve the repair path only.

  2. Validate before mutate, repair, or reject (triangulation.rs): bowyer_watson computes the would-be (deleted, created) outcome read-only and validates it — volume conservation (with precise volumes) and point connectivity (catches the empty-cavity and swallowed-hull-simplices orphan modes the assert is blind to). On failure the cavity is repaired and re-validated: 2D/3D rebuild it with exact insphere predicates (the exact Delaunay cavity is connected, void-free, star-shaped); 4D+ prune to the seed component and shrink until star-shaped. If even the repaired cavity fails, the insertion is rejected with the triangulation untouched (same AssertionError, or ValueError when the point cannot be connected), and add_point rolls back its vertex — failed insertions are fully atomic, so callers can skip the point and continue.

  3. Tests pinning the contract (seeded mixed-scale/near-duplicate sweeps in 2D/3D, atomicity of rejections, no orphans, invariant holds, and a guard that well-conditioned inputs never reject).

Well-conditioned insertions never enter the repair path and behave bit-identically to the reference — all 64 cross-validation tests pass unchanged.

Stress results (50 seeds per pattern, end-state classification)

pattern main this PR
mixed-scale 2D 31 corrupt / 19 ok 0 corrupt / 44 ok / 6 with clean rejections
near-duplicates 2D 1 corrupt / 49 ok 0 corrupt / 45 ok / 5 with clean rejections
off-origin 2D 50 ok 50 ok
random 2D 50 ok 50 ok (0 rejections)
mixed-scale 3D 50 corrupt 0 corrupt / 3 ok / 47 with clean rejections
near-duplicates 3D 14 corrupt / 36 ok 0 corrupt / 34 ok / 16 with clean rejections
off-origin 3D 3 corrupt / 47 ok 0 corrupt / 47 ok / 3 with clean rejections
random 3D 50 ok 50 ok (0 rejections)

"corrupt" = invariant broken, a vertex lost its last simplex, a successful insertion left its vertex unconnected, or a rejection leaked the vertex. Construction-time orphans from scipy/QHull merging near-degenerate points are excluded (reference-identical behavior).

Performance

A/B benchmark vs main, same machine, best of 3: no change (2D 5k 0.139→0.137 s, 3D 2k 0.160→0.157 s, 4D 500 0.239→0.247 s, hull-heavy 2D 1.063→1.042 s). The hull-heavy 3D spiral benchmark previously had 22 insertions fail with AssertionError per run; all 22 now succeed via repair, and the run is still marginally faster (0.699→0.676 s). examples/adaptive_learnernd.py keeps its 3.5× speedup.

Verification

  • pytest: 106 passed (64 existing cross-validation + 42 new robustness)
  • cargo test --lib --tests: 18 passed (incl. predicate sign-convention pins)
  • pre-commit clean

Add three primitives to geometry, all backed by Shewchuk's
adaptive-precision predicates via the robust crate in 2D/3D:

- robust_orientation: like orientation() but exact, with no log-det
  cutoff; resolves the true sign of determinants that slogdet rounds
  to zero on slivers (falls back to orientation() in other dimensions)
- robust_in_circumsphere: strict exact in-circumsphere test
  (incircle/insphere normalized by the simplex orientation so the
  answer is orientation-independent); None outside 2D/3D
- precise_volume: simplex volume from the adaptive-precision
  determinant value, immune to the cancellation that corrupts plain
  f64 volumes of high-aspect slivers

These deliberately do NOT replace the reference-compatible tolerance
based tests; they exist for the cavity validation/repair path added in
the next commit, where geometric truth matters more than bug
compatibility. Sign conventions are pinned by unit tests against
orientation() on well-conditioned inputs plus a below-cutoff sliver
case.
Sliver simplices (mixed coordinate scales force aspect ratios ~1e7)
feed the floating-point circumsphere test cancellation noise, so the
cascade can assemble a cavity that is not re-triangulable: voids or
protrusions (volume not conserved), a falsely-empty cavity that leaves
the inserted point unconnected, or a cavity that swallows every
simplex of the point while its total volume sits below the
conservation check's absolute tolerance (making that check vacuous).
The Python reference asserts volume conservation only AFTER mutating -
a failing insertion corrupts its state - and silently orphans vertices
in the cases its check cannot see.

bowyer_watson now computes the would-be (deleted, created) outcome and
validates it read-only, BEFORE any mutation:

- volume conservation is checked with adaptive-precision determinant
  volumes (geometry::precise_volume), so the check compares true
  volumes instead of cancellation noise
- the point must end up connected (catches both the empty-cavity and
  the swallowed-hull-simplices failure modes)

When validation fails, the cavity is repaired and re-validated: in
2D/3D it is rebuilt from the seeds with exact insphere predicates
(geometry::robust_in_circumsphere) - the exact Delaunay cavity is
connected, void-free, and star-shaped around the point - and in higher
dimensions it is pruned to the seed component and shrunk until every
hole face is visible from the point. If even the repaired cavity fails
validation, the insertion is rejected with the triangulation untouched
(same AssertionError as before, or ValueError when the point cannot be
connected), and add_point rolls back its vertex on these rejections,
so a failed insertion is fully atomic and callers can skip the point
and continue.

Well-conditioned insertions never enter the repair path and behave
bit-identically to the reference (the 64 cross-validation tests pass
unchanged). Stress sweeps over 50 seeds x {mixed-scale, off-origin,
near-duplicate, random} x {2D,3D}: corrupted end states drop from
99/400 runs on main to 0/400, with every former corruption now either
repaired (e.g. all 22 previously-failing insertions in the hull-heavy
3D benchmark now succeed) or atomically rejected. A/B benchmark shows
no performance change on well-conditioned workloads. Policy documented
in src/tolerances.rs.
Seeded mixed-scale and near-duplicate sweeps (2D/3D, 10 seeds each)
assert the contract from src/tolerances.rs: insertions either succeed
and connect the new vertex, or raise leaving vertices and simplices
exactly as they were; no vertex ever loses its last simplex; the
internal indexes stay consistent. Plus a regression test for the
empty-cavity orphan (mixed-scale seed 2) and a guard that random
unit-cube sweeps never reject (the repair path must not fire on
well-conditioned input).
@basnijholt basnijholt merged commit 810ad01 into main Jun 10, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant