Skip to content

Commit 3d236b1

Browse files
authored
Internal cleanup + numerical robustness fixes (#4)
* refactor: tolerance policy, dedupe numerics, rustdoc Internal cleanup; no behavior or API change (cross-validation against the Python reference and an A/B benchmark against main confirm identical results and equal-or-better performance). - Add src/tolerances.rs: every fuzzy comparison now goes through a named, documented constant (BARYCENTRIC_EPS, CIRCUMCIRCLE_RTOL, DEGENERATE_VOLUME_EPS, VOLUME_CONSERVATION_ATOL/RTOL, ORIENTATION_LOG_DET_CUTOFF) stating whether it is scale-invariant and why. Replaces the multi-purpose DEFAULT_EPS and scattered literals. - Dedupe the barycentric solve (was written three times) into geometry::barycentric_coordinates, generic over AsRef<[f64]> so the triangulation passes vertex references without cloning. - Dedupe the reduced-simplex threshold logic shared by the core and the signed-index Python wrapper into reduced_simplex_positions. - Dedupe face-multiplicity counting (bowyer_watson / extend_hull / hull) into Triangulation::face_multiplicities. - Reuse triangulation::numpy_linalg_error in lib.rs instead of a copy. - Recover the last_simplex location cache from RwLock poisoning instead of panicking across the FFI boundary; collapse redundant into_pyerr match arms; drop an unnecessary coords clone in the constructor. - Rustdoc for the crate, both modules, and all public items (cargo doc builds warning-free). * fix: scale-aware degeneracy filter and orphan-free insertion in 2D+ Two deliberate behavior divergences from the Python reference, in regimes where the reference corrupts its own state. Cross-validation on well-conditioned inputs is unaffected (all parity tests still pass). - simplex_is_numerically_degenerate: drop a Bowyer-Watson candidate only when its volume is negligible BOTH absolutely (the reference's criterion, which keeps volume conservation safe) AND as scale-invariant flatness. The absolute test alone silently emptied finely refined or small-coordinate meshes (inserting into a [0, 1e-4]^2 mesh deleted every simplex and orphaned all vertices); the flatness test alone (briefly used here for 1D) deletes large-but-flat simplices the cavity needs. The conjunction also subsumes the previous 1D special case. - bowyer_watson now collects the cavity without mutating, and rejects the insertion as 'Point already in triangulation.' when it would strand a cavity vertex (a point within the circumcircle slack of every simplex around an existing vertex - previously this silently orphaned the vertex). add_point rolls back the pushed vertex/hull extension on these pre-mutation rejections. - New regression tests for both, plus a parametrized 2D/3D off-origin small-scale insertion sweep (the class of test whose absence hid the PR #3 bugs). Known limitation, now documented in tolerances.rs: point sets mixing widely separated coordinate scales in one triangulation create ~1e7 aspect-ratio slivers whose circumsphere tests are unreliable; the volume conservation assertion catches this loudly. The Python reference fails the same stress more often (41/50 runs vs 33/50 here); a real fix needs exact geometric predicates.
1 parent cf1b37d commit 3d236b1

5 files changed

Lines changed: 535 additions & 169 deletions

File tree

‎src/geometry.rs‎

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
1+
//! Pure geometric primitives: norms, determinants, barycentric coordinates,
2+
//! circumspheres, orientations, and simplex volumes.
3+
//!
4+
//! Nothing in this module touches Python or triangulation state, so every
5+
//! function here is testable in isolation. Error messages mirror the Python
6+
//! reference implementation in `adaptive.learner.triangulation`.
7+
18
use nalgebra::{DMatrix, DVector};
29
use thiserror::Error;
310

11+
use crate::tolerances::ORIENTATION_LOG_DET_CUTOFF;
12+
13+
/// Errors produced by the geometric primitives.
414
#[derive(Debug, Error)]
515
pub enum GeometryError {
16+
/// Input has the wrong shape (point/vertex counts or coordinate lengths).
617
#[error("{0}")]
718
InvalidDimensions(String),
19+
/// The vertices span a lower-dimensional space than a simplex requires.
820
#[error("Provided vertices do not form a simplex")]
921
DegenerateSimplex,
22+
/// A linear solve hit a (numerically) singular matrix.
1023
#[error("Singular matrix")]
1124
SingularMatrix,
1225
}
1326

27+
/// Euclidean norm, with unrolled 2D and 3D fast paths.
1428
#[inline]
1529
pub fn fast_norm(v: &[f64]) -> f64 {
1630
match v.len() {
@@ -113,6 +127,9 @@ fn solve_square(matrix: &[Vec<f64>], rhs: &[f64]) -> Result<Vec<f64>, GeometryEr
113127
.ok_or(GeometryError::SingularMatrix)
114128
}
115129

130+
/// Rank of the matrix whose rows are `vectors`, counting singular values
131+
/// above `tol`. A negative `tol` selects numpy's default tolerance
132+
/// (`eps * max(rows, cols) * largest_singular_value`).
116133
pub fn matrix_rank(vectors: &[Vec<f64>], tol: f64) -> Result<usize, GeometryError> {
117134
if vectors.is_empty() {
118135
return Ok(0);
@@ -139,10 +156,38 @@ pub fn matrix_rank(vectors: &[Vec<f64>], tol: f64) -> Result<usize, GeometryErro
139156
.count())
140157
}
141158

159+
/// [`matrix_rank`] with numpy's default tolerance.
142160
pub fn numpy_matrix_rank(vectors: &[Vec<f64>]) -> Result<usize, GeometryError> {
143161
matrix_rank(vectors, -1.0)
144162
}
145163

164+
/// Barycentric coordinates of `point` with respect to vertices `1..=dim` of
165+
/// `simplex`; the coordinate of vertex 0 is `1 - sum(result)`.
166+
///
167+
/// Accepts any slice of coordinate slices so callers can pass either owned
168+
/// vertices or references into triangulation storage without cloning.
169+
pub fn barycentric_coordinates<P: AsRef<[f64]>>(
170+
simplex: &[P],
171+
point: &[f64],
172+
) -> Result<Vec<f64>, GeometryError> {
173+
let dim = point.len();
174+
let x0 = simplex[0].as_ref();
175+
let mut matrix = vec![vec![0.0; dim]; dim];
176+
let mut rhs = vec![0.0; dim];
177+
178+
for row in 0..dim {
179+
rhs[row] = point[row] - x0[row];
180+
for col in 0..dim {
181+
matrix[row][col] = simplex[col + 1].as_ref()[row] - x0[row];
182+
}
183+
}
184+
185+
solve_square(&matrix, &rhs)
186+
}
187+
188+
/// 2D point-in-triangle test via explicit barycentric formulas, with `eps`
189+
/// slack on the barycentric coordinates (see
190+
/// [`crate::tolerances::BARYCENTRIC_EPS`]).
146191
pub fn fast_2d_point_in_simplex(
147192
point: &[f64; 2],
148193
simplex: &[[f64; 2]; 3],
@@ -165,6 +210,8 @@ pub fn fast_2d_point_in_simplex(
165210
Ok(t >= -eps && s + t <= 1.0 + eps)
166211
}
167212

213+
/// N-dimensional point-in-simplex test with `eps` slack on the barycentric
214+
/// coordinates. Dispatches to [`fast_2d_point_in_simplex`] in 2D.
168215
pub fn point_in_simplex(
169216
point: &[f64],
170217
simplex: &[Vec<f64>],
@@ -191,22 +238,12 @@ pub fn point_in_simplex(
191238
return fast_2d_point_in_simplex(&point, &simplex, eps);
192239
}
193240

194-
let x0 = &simplex[0];
195-
let dim = point.len();
196-
let mut matrix = vec![vec![0.0; dim]; dim];
197-
let mut rhs = vec![0.0; dim];
198-
199-
for row in 0..dim {
200-
rhs[row] = point[row] - x0[row];
201-
for col in 0..dim {
202-
matrix[row][col] = simplex[col + 1][row] - x0[row];
203-
}
204-
}
205-
206-
let alpha = solve_square(&matrix, &rhs)?;
241+
let alpha = barycentric_coordinates(simplex, point)?;
207242
Ok(alpha.iter().all(|value| *value > -eps) && alpha.iter().sum::<f64>() < 1.0 + eps)
208243
}
209244

245+
/// Circumcircle (center, radius) of a triangle, computed in coordinates
246+
/// relative to the first vertex for numerical stability.
210247
pub fn fast_2d_circumcircle(points: &[[f64; 2]; 3]) -> ([f64; 2], f64) {
211248
let [p0, p1, p2] = *points;
212249
let x1 = p1[0] - p0[0];
@@ -228,6 +265,8 @@ pub fn fast_2d_circumcircle(points: &[[f64; 2]; 3]) -> ([f64; 2], f64) {
228265
([x + p0[0], y + p0[1]], radius)
229266
}
230267

268+
/// Circumsphere (center, radius) of a tetrahedron, computed in coordinates
269+
/// relative to the first vertex for numerical stability.
231270
pub fn fast_3d_circumsphere(points: &[[f64; 3]; 4]) -> ([f64; 3], f64) {
232271
let [p0, p1, p2, p3] = *points;
233272
let x1 = p1[0] - p0[0];
@@ -258,6 +297,11 @@ pub fn fast_3d_circumsphere(points: &[[f64; 3]; 4]) -> ([f64; 3], f64) {
258297
([cx + p0[0], cy + p0[1], cz + p0[2]], radius)
259298
}
260299

300+
/// Circumsphere (center, radius) of an N-dimensional simplex with `dim + 1`
301+
/// vertices. Dispatches to the unrolled 2D/3D paths; the generic path solves
302+
/// in coordinates relative to the first vertex (see PR #3 — the naive
303+
/// `|x_i|^2 - |x_0|^2` form cancels catastrophically for small simplices far
304+
/// from the origin). Returns NaNs for degenerate input, matching numpy.
261305
pub fn circumsphere(pts: &[Vec<f64>]) -> Result<(Vec<f64>, f64), GeometryError> {
262306
let dim = validate_points(pts)?;
263307
if pts.len() != dim + 1 {
@@ -371,6 +415,10 @@ fn slogdet(matrix: &[Vec<f64>]) -> Result<(f64, f64), GeometryError> {
371415
Ok((sign, log_abs_det))
372416
}
373417

418+
/// Sign (+1, -1, or 0) of the determinant of `face - origin`, i.e. which side
419+
/// of the hyperplane through `face` the point `origin` lies on. Returns 0
420+
/// when the log-determinant falls below
421+
/// [`ORIENTATION_LOG_DET_CUTOFF`].
374422
pub fn orientation(face: &[Vec<f64>], origin: &[f64]) -> Result<i32, GeometryError> {
375423
let dim = validate_points(face)?;
376424
if face.len() != dim || origin.len() != dim {
@@ -384,7 +432,7 @@ pub fn orientation(face: &[Vec<f64>], origin: &[f64]) -> Result<i32, GeometryErr
384432
.map(|point| point.iter().zip(origin).map(|(x, y)| x - y).collect())
385433
.collect();
386434
let (sign, log_det) = slogdet(&matrix)?;
387-
if sign == 0.0 || log_det < -50.0 {
435+
if sign == 0.0 || log_det < ORIENTATION_LOG_DET_CUTOFF {
388436
Ok(0)
389437
} else if sign.is_sign_positive() {
390438
Ok(1)
@@ -393,6 +441,8 @@ pub fn orientation(face: &[Vec<f64>], origin: &[f64]) -> Result<i32, GeometryErr
393441
}
394442
}
395443

444+
/// Volume of a `dim`-simplex given its `dim + 1` vertices
445+
/// (`|det| / dim!` of the edge matrix).
396446
pub fn volume(vertices: &[Vec<f64>]) -> Result<f64, GeometryError> {
397447
let dim = validate_points(vertices)?;
398448
if vertices.len() != dim + 1 {
@@ -413,6 +463,10 @@ pub fn volume(vertices: &[Vec<f64>]) -> Result<f64, GeometryError> {
413463
Ok(determinant(&matrix)?.abs() / factorial(dim))
414464
}
415465

466+
/// Volume of a `k`-simplex embedded in a higher-dimensional space (segment
467+
/// length, triangle area via Heron's formula, Cayley-Menger determinant in
468+
/// general). Unlike [`volume`], the number of vertices may be smaller than
469+
/// `dim + 1`.
416470
pub fn simplex_volume_in_embedding(vertices: &[Vec<f64>]) -> Result<f64, GeometryError> {
417471
validate_points(vertices)?;
418472
if vertices.len() < 2 {

‎src/lib.rs‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1+
//! Rust backend for `adaptive_triangulation`: a drop-in replacement for
2+
//! `adaptive.learner.triangulation` (see [`triangulation`] for the class and
3+
//! [`geometry`] for the free functions), with the numerical tolerance policy
4+
//! documented in [`tolerances`].
5+
16
pub mod geometry;
7+
pub mod tolerances;
28
pub mod triangulation;
39

410
use pyo3::exceptions::{PyValueError, PyZeroDivisionError};
@@ -7,21 +13,11 @@ use pyo3::types::{PyAny, PyModule, PyTuple};
713

814
use crate::geometry as geom;
915
use crate::triangulation::{
10-
parse_point, parse_points_sized, point_tuple, PyFacesIter, PySimplicesProxy, PyTriangulation,
11-
PyVertexToSimplicesIter, PyVertexToSimplicesProxy, PyVerticesIter, PyVerticesProxy,
16+
numpy_linalg_error, parse_point, parse_points_sized, point_tuple, PyFacesIter,
17+
PySimplicesProxy, PyTriangulation, PyVertexToSimplicesIter, PyVertexToSimplicesProxy,
18+
PyVerticesIter, PyVerticesProxy,
1219
};
1320

14-
fn numpy_linalg_error(py: Python<'_>, message: &str) -> PyErr {
15-
PyModule::import(py, "numpy.linalg")
16-
.and_then(|module| {
17-
let error_type = module.getattr("LinAlgError")?;
18-
let args = PyTuple::new(py, [message]).unwrap();
19-
let value = error_type.call1(args)?;
20-
Ok(PyErr::from_value(value))
21-
})
22-
.unwrap_or_else(|_| PyValueError::new_err(message.to_string()))
23-
}
24-
2521
fn point_in_simplex_error(py: Python<'_>, err: geom::GeometryError) -> PyErr {
2622
match err {
2723
geom::GeometryError::DegenerateSimplex => PyZeroDivisionError::new_err("division by zero"),
@@ -126,7 +122,7 @@ fn py_point_in_simplex(
126122
) -> PyResult<bool> {
127123
let point = parse_point(point)?;
128124
let simplex = parse_points_sized(simplex, "Please provide a 2-dimensional list of points")?;
129-
geom::point_in_simplex(&point, &simplex, eps.unwrap_or(1e-8))
125+
geom::point_in_simplex(&point, &simplex, eps.unwrap_or(tolerances::BARYCENTRIC_EPS))
130126
.map_err(|err| point_in_simplex_error(py, err))
131127
}
132128

@@ -152,7 +148,7 @@ fn py_fast_2d_point_in_simplex(
152148
[simplex[1][0], simplex[1][1]],
153149
[simplex[2][0], simplex[2][1]],
154150
],
155-
eps.unwrap_or(1e-8),
151+
eps.unwrap_or(tolerances::BARYCENTRIC_EPS),
156152
)
157153
.map_err(|err| point_in_simplex_error(py, err))
158154
}

‎src/tolerances.rs‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//! Numerical tolerance policy.
2+
//!
3+
//! Every fuzzy comparison in this crate goes through one of the constants
4+
//! below. The values intentionally match the Python reference implementation
5+
//! in `adaptive.learner.triangulation` so that both backends produce
6+
//! identical triangulations from identical input; do not change one without
7+
//! cross-validating against the reference (see `tests/test_triangulation.py`).
8+
//!
9+
//! When touching any of these, first check whether the comparison is
10+
//! *scale-invariant* (relative to the simplex being tested) or *absolute*
11+
//! (silently assumes coordinates of order 1). Mixing the two up is how small
12+
//! simplices far from the origin get misclassified — see the regression tests
13+
//! added for exactly that failure in PR #3.
14+
//!
15+
//! # Known limitation
16+
//!
17+
//! Point sets that mix widely separated coordinate scales in one
18+
//! triangulation (e.g. a unit-sized cloud plus a 1e-5-sized cluster 100
19+
//! away) force sliver simplices with aspect ratios around 1e7 into the mesh.
20+
//! Floating-point circumsphere tests on such slivers are unreliable, and the
21+
//! Bowyer-Watson cascade can then assemble an inconsistent cavity; this is
22+
//! caught loudly by the volume-conservation assertion rather than corrupting
23+
//! state silently. The Python reference fails the same way (more often, in
24+
//! fact). Fixing it would require exact geometric predicates, i.e.
25+
//! adaptive-precision orientation/insphere tests, which is out of scope for
26+
//! a tolerance constant. Homogeneous-scale inputs — at any magnitude and any
27+
//! offset from the origin — are unaffected; see
28+
//! `test_random_insertions_small_scale_far_from_origin`.
29+
30+
/// Relative slack on barycentric coordinates when deciding whether a point
31+
/// lies inside a simplex, on one of its faces, or coincides with a vertex.
32+
///
33+
/// Scale-invariant: barycentric coordinates are already normalized by the
34+
/// simplex size. Also exposed to Python as the default `eps` argument of
35+
/// `point_in_simplex` and `get_reduced_simplex`.
36+
pub const BARYCENTRIC_EPS: f64 = 1e-8;
37+
38+
/// Relative slack on the circumsphere radius in the Bowyer-Watson
39+
/// in-circumcircle test: a point is "inside" when
40+
/// `distance < radius * (1.0 + CIRCUMCIRCLE_RTOL)`.
41+
///
42+
/// Scale-invariant, but note it is *proportional to the neighbouring
43+
/// simplex's size*, not the inserted point's distance to it — a point can be
44+
/// within this slack of a large neighbour while being a meaningful fraction
45+
/// of a small simplex away from it. Two guards in
46+
/// `Triangulation::bowyer_watson` keep that asymmetry from corrupting the
47+
/// triangulation: 1D insertion skips the circumcircle cascade entirely, and
48+
/// in higher dimensions an insertion that would strand a cavity vertex is
49+
/// rejected as a duplicate before anything is mutated.
50+
pub const CIRCUMCIRCLE_RTOL: f64 = 1e-8;
51+
52+
/// Threshold below which a candidate simplex is discarded as numerically
53+
/// degenerate during Bowyer-Watson re-triangulation. A candidate is dropped
54+
/// only when BOTH its raw volume (absolute; guarantees the omission cannot
55+
/// leave a material hole) and its normalized volume (volume divided by
56+
/// characteristic length to the dim-th power, i.e. scale-invariant
57+
/// *flatness*) fall below this constant.
58+
///
59+
/// The flatness condition is a deliberate divergence from the Python
60+
/// reference, which uses the absolute test alone and therefore silently
61+
/// deletes well-shaped simplices once a mesh is refined below ~1e-8 volume
62+
/// (in 2D that is edge lengths of ~1e-4) — emptying whole regions of the
63+
/// triangulation. See `Triangulation::simplex_is_numerically_degenerate`.
64+
pub const DEGENERATE_VOLUME_EPS: f64 = 1e-8;
65+
66+
/// Absolute and relative tolerances of the volume-conservation check after
67+
/// Bowyer-Watson (the sum of deleted simplex volumes must equal the sum of
68+
/// added ones). Same constants as `numpy.isclose`, but applied symmetrically
69+
/// (scaled by `max(|a|, |b|)` rather than `|b|`) so the check cannot depend
70+
/// on argument order.
71+
pub const VOLUME_CONSERVATION_ATOL: f64 = 1e-8;
72+
/// See [`VOLUME_CONSERVATION_ATOL`].
73+
pub const VOLUME_CONSERVATION_RTOL: f64 = 1e-5;
74+
75+
/// `orientation` treats a face as coplanar with the origin point when the
76+
/// log-determinant of the difference matrix is below this cutoff
77+
/// (`|det| < exp(-50) ~= 2e-22`).
78+
///
79+
/// NOT scale-invariant: the determinant scales with the dim-th power of the
80+
/// face's edge lengths, so faces with edges below ~`exp(-50/dim)` are always
81+
/// reported as degenerate. Matches the Python reference.
82+
pub const ORIENTATION_LOG_DET_CUTOFF: f64 = -50.0;

0 commit comments

Comments
 (0)