Skip to content

Core data-structure overhaul: interned simplices, facet adjacency, incremental hull#5

Merged
basnijholt merged 2 commits into
mainfrom
refactor/core-data-structures
Jun 10, 2026
Merged

Core data-structure overhaul: interned simplices, facet adjacency, incremental hull#5
basnijholt merged 2 commits into
mainfrom
refactor/core-data-structures

Conversation

@basnijholt

Copy link
Copy Markdown
Member

Summary

Reworks the core triangulation storage for performance and maintainability. The Python API is unchanged and drop-in compatible with adaptive.learner.triangulation; all 64 cross-validation tests pass unmodified.

Commit 1 mechanically splits the PyO3 surface (argument parsing, error conversion, proxies, PyTriangulation) out of triangulation.rs into a new src/py.rs, leaving the core pure Rust.

Commit 2 replaces the FxHashSet<Vec<usize>>-everywhere layout — which cloned and re-hashed whole simplices in every hot path — with interned storage:

  • a slab assigns each simplex a u32 SimplexId; the sorted vertex list is stored once and referenced by id everywhere else
  • vertex_to_simplices becomes vertex → FxHashSet<SimplexId> (integer sets instead of sets of cloned Vecs)
  • a new facet index maps every (dim−1)-face to its incident simplex ids, maintained by a single link_simplex/unlink_simplex pair, together with the derived boundary-facet set (the convex hull) and an overfull-facet count (corruption detector)

This addresses three known hot spots:

  • locate_point walk steps and containing() facet queries are now one hash lookup (previously a set intersection over cloned Vecs)
  • the bowyer_watson cascade finds neighbours via dim+1 facet lookups instead of unioning and scanning every simplex around all cavity vertices
  • extend_hull reads the maintained boundary set instead of recounting every face of every simplex per outside-hull insertion — this was the O(n²) bottleneck for hull-heavy workloads — and hull() is now O(hull)

reference_invariant() additionally cross-checks the facet index, boundary set, and overfull count against a recount, so the stress suites verify the incremental bookkeeping.

Benchmarks

A/B against unmodified main, same machine, best of 3, incremental insertion (add_point loop):

workload main this PR speedup
interior 2D, 5000 pts 0.296 s 0.143 s 2.1×
interior 3D, 2000 pts 0.540 s 0.163 s 3.3×
interior 4D, 500 pts 1.959 s 0.241 s 8.1×
hull-heavy 2D, 3000 pts 3.271 s 1.058 s 3.1×
hull-heavy 3D, 1500 pts 3.395 s 0.698 s 4.9×

examples/adaptive_learnernd.py end-to-end: 3.5× vs pure-Python LearnerND (unchanged from main).

Verification

  • pytest: 64 passed (cross-validation vs Python reference on randomized 2/3/4D inputs)
  • cargo test --lib --tests: 18 passed (incl. new tests for facet-index consistency, incremental hull, and containing facet queries)
  • Stress sweeps (50 seeds × {mixed-scale, off-origin small-scale, near-duplicates, random} × {2D, 3D}): identical pass/fail profile to main (failures only on the inputs documented in src/tolerances.rs where the Python reference fails more often; robustness work is a separate follow-up PR)
  • pre-commit clean

Pure mechanical move, no logic changes: the pure-Rust Triangulation core
stays in src/triangulation.rs; argument parsing, result conversion,
TriangulationError::into_pyerr, the proxy/iterator views, and the
PyTriangulation class move to the new src/py.rs. Prepares for reworking
the core data structures without PyO3 noise in the diff.
Replace the FxHashSet<Vec<usize>>-everywhere layout (which cloned and
re-hashed whole simplices in every hot path) with interned storage:

- a slab assigns each simplex a small integer SimplexId; the sorted
  vertex list is stored once and referenced by id everywhere else
- vertex_to_simplices becomes vertex -> FxHashSet<SimplexId> (integer
  sets instead of sets of cloned Vecs)
- a new facet index maps every (dim-1)-face to the ids of its incident
  simplices, maintained by the single link/unlink pair of mutation
  primitives, together with the derived boundary-facet set (hull) and
  an overfull-facet count (corruption detector)

This turns the hot paths from set intersections over cloned vectors
into single hash lookups:

- locate_point walk steps and containing() facet queries are one
  facet-map lookup
- the bowyer_watson cascade finds neighbours via dim+1 facet lookups
  instead of unioning and scanning every simplex around all vertices
- extend_hull reads the maintained boundary set instead of recounting
  every face of every simplex per outside-hull insertion (was the
  O(n^2) bottleneck for hull-heavy workloads), and hull() is O(hull)
- reference_invariant() now also cross-checks the facet index,
  boundary set, and overfull count against a recount

Behavior is unchanged: the full pytest cross-validation suite against
the Python reference passes, and stress sweeps (mixed-scale,
off-origin, near-duplicate, random) show the same pass/fail profile
as main. A/B benchmark (best of 3, same machine, incremental
insertion): 2D 5000 pts 0.296s -> 0.143s, 3D 2000 pts 0.540s ->
0.163s, 4D 500 pts 1.959s -> 0.241s, hull-heavy 2D 3000 pts 3.271s ->
1.058s, hull-heavy 3D 1500 pts 3.395s -> 0.698s.
@basnijholt basnijholt merged commit ce43abf 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