Skip to main content

rust_igraph/algorithms/spatial/
beta_weighted_gabriel_graph.rs

1//! β-weighted Gabriel graph of a spatial point set (ALGO-GEO-008).
2//!
3//! Counterpart of `igraph_beta_weighted_gabriel_graph()` from
4//! `references/igraph/src/spatial/beta_skeleton.cpp:651`.
5//!
6//! This builds the [Gabriel graph](crate::gabriel_graph) and, for every edge,
7//! computes the **threshold β** at which that edge ceases to belong to the
8//! lune-based β-skeleton: the smallest β for which some other point first
9//! enters the edge's lune. Edges that survive for arbitrarily large β (no
10//! point ever enters) get weight `f64::INFINITY`. A `max_beta` cutoff caps the
11//! search — any edge whose threshold is at or above `max_beta` is reported as
12//! `INFINITY` instead of its exact (large) value, which bounds the work.
13//!
14//! ## Equivalence to the reference
15//!
16//! Upstream seeds the candidate edges from the Delaunay triangulation (a
17//! superset of the Gabriel graph) and prunes the per-edge point scan with a
18//! k-d tree. The k-d tree's `dist < max_radius` cutoff is a pure search bound,
19//! never a correctness filter: for the edge `AB` with `|AB|² = ab²`, a point
20//! `P` first blocks the edge at parameter `β₀ = pointBeta(P)`, and one can show
21//! (placing the midpoint at the origin, `A = (−h, 0)`, `B = (h, 0)`,
22//! `h² = ab²/4`) that
23//!
24//! ```text
25//!   dist²(P, midpoint) ≤ luneHalfHeight²(β₀)  ⟺  x·(x² + y² − h²) ≤ 0,
26//! ```
27//!
28//! which holds strictly for every point that yields a finite `β₀ ≥ 1` (those
29//! lie strictly outside the diametral ball, where `x² + y² > h²`). Points
30//! inside that ball produce `β₀ = 0`, marking a non-Gabriel edge. Hence
31//! scanning **all** `O(n²)` candidate pairs and **all** other points, with no
32//! k-d tree and no Delaunay seed, yields exactly the reference's surviving
33//! edges and identical weights — the same output-equivalence that the plain
34//! [`gabriel_graph`](crate::gabriel_graph) relies on. Works in any dimension
35//! (the formulas use only squared distances).
36
37use crate::core::{Graph, IgraphError, IgraphResult};
38
39/// β-threshold tolerance, mirroring the C reference's
40/// `#define TOLERANCE (128 * DBL_EPSILON)`. A computed β below `1 + TOLERANCE`
41/// is treated as `0` (the blocking point is inside the closed Gabriel ball,
42/// so the edge is not in the Gabriel graph), matching the reference's
43/// `beta < 1 + tol ? 0 : beta`.
44const TOLERANCE: f64 = 128.0 * f64::EPSILON;
45
46/// A Gabriel graph together with the per-edge β-threshold weights.
47///
48/// `weights[e]` is aligned with edge index `e` of `graph` (the edges are
49/// emitted in increasing `(min, max)` endpoint order). Each weight is the β
50/// value at which the edge leaves the lune-based β-skeleton, or
51/// [`f64::INFINITY`] for an edge that persists beyond `max_beta`.
52#[derive(Clone, Debug)]
53pub struct BetaWeightedGabriel {
54    /// The Gabriel graph on `points.len()` vertices.
55    pub graph: Graph,
56    /// Per-edge β-thresholds, aligned with `graph`'s edge indices.
57    pub weights: Vec<f64>,
58}
59
60/// Build the β-weighted Gabriel graph of a point set.
61///
62/// `points` holds one row per point with a shared, arbitrary dimensionality
63/// (inferred from the first row). `max_beta` is the search cutoff and must be
64/// at least `1.0` (it may be [`f64::INFINITY`] for no cutoff); the threshold β
65/// of any edge is itself `≥ 1`, so a smaller cutoff would be meaningless.
66///
67/// The result's `graph` is exactly the Gabriel graph; `weights[e]` is the
68/// β-threshold of edge `e`.
69///
70/// This is an `O(n²)` candidate enumeration with an `O(n·d)` per-pair point
71/// scan (`O(n³·d)` overall). See the module docs for why this reproduces the
72/// reference's Delaunay-seeded, k-d-tree-pruned result exactly.
73///
74/// # Errors
75///
76/// - [`IgraphError::InvalidArgument`] if `max_beta` is `NaN` or below `1.0`.
77/// - [`IgraphError::InvalidArgument`] if the points are zero-dimensional (and
78///   there is at least one point), or the rows have inconsistent
79///   dimensionality.
80/// - [`IgraphError::InvalidArgument`] if two points coincide, mirroring the
81///   reference's rejection of duplicate points.
82///
83/// # Examples
84///
85/// ```
86/// use rust_igraph::beta_weighted_gabriel_graph;
87///
88/// // The four corners of a unit square. The Gabriel graph keeps the four
89/// // sides; each side leaves the β-skeleton only as β → ∞ (no point ever
90/// // enters its lune), so every weight is infinite.
91/// let pts = vec![
92///     vec![0.0, 0.0],
93///     vec![1.0, 0.0],
94///     vec![0.0, 1.0],
95///     vec![1.0, 1.0],
96/// ];
97/// let res = beta_weighted_gabriel_graph(&pts, f64::INFINITY).unwrap();
98/// assert_eq!(res.graph.ecount(), 4); // sides only
99/// assert!(res.weights.iter().all(|w| w.is_infinite()));
100/// ```
101// Single-character names (`a`, `b`, `c` points, `d` axis) are natural here.
102#[allow(clippy::many_single_char_names)]
103#[allow(clippy::similar_names)]
104pub fn beta_weighted_gabriel_graph(
105    points: &[Vec<f64>],
106    max_beta: f64,
107) -> IgraphResult<BetaWeightedGabriel> {
108    if max_beta.is_nan() || max_beta < 1.0 {
109        return Err(IgraphError::InvalidArgument(format!(
110            "beta_weighted_gabriel_graph: max_beta must be >= 1 (or infinity), got {max_beta}"
111        )));
112    }
113
114    let n = points.len();
115    let dim = if n == 0 { 0 } else { points[0].len() };
116    if n > 0 {
117        if dim == 0 {
118            return Err(IgraphError::InvalidArgument(
119                "beta_weighted_gabriel_graph: points must not be zero-dimensional".into(),
120            ));
121        }
122        for (i, row) in points.iter().enumerate().skip(1) {
123            if row.len() != dim {
124                return Err(IgraphError::InvalidArgument(format!(
125                    "beta_weighted_gabriel_graph: point row {i} has dimension {} but expected {dim}",
126                    row.len()
127                )));
128            }
129        }
130    }
131
132    let n_u32 = u32::try_from(n).map_err(|_| {
133        IgraphError::InvalidArgument("beta_weighted_gabriel_graph: too many points".into())
134    })?;
135    let mut graph = Graph::with_vertices(n_u32);
136    let mut weights: Vec<f64> = Vec::new();
137
138    let beta_floor = 1.0 + TOLERANCE;
139
140    for i in 0..n {
141        let a = &points[i];
142        for j in (i + 1)..n {
143            let b = &points[j];
144
145            let ab2 = sq_dist(a, b);
146            if ab2 == 0.0 {
147                return Err(IgraphError::InvalidArgument(
148                    "beta_weighted_gabriel_graph: duplicate points are not allowed".into(),
149                ));
150            }
151
152            // BetaFinder: smallest β at which any other point first enters the
153            // lune of edge a-b, capped at max_beta. INFINITY means no point
154            // ever enters (below the cutoff).
155            let mut smallest = f64::INFINITY;
156            for (k, c) in points.iter().enumerate() {
157                if k == i || k == j {
158                    continue;
159                }
160                let mut ap2 = sq_dist(a, c);
161                let mut bp2 = sq_dist(b, c);
162                if ap2 > bp2 {
163                    std::mem::swap(&mut ap2, &mut bp2);
164                }
165                let denom = ab2 + ap2 - bp2;
166                let beta = if denom <= 0.0 {
167                    f64::INFINITY
168                } else {
169                    let raw = 2.0 * ap2 / denom;
170                    // A point inside the closed Gabriel ball gives β < 1; the
171                    // reference collapses it to 0, marking a non-Gabriel edge.
172                    if raw < beta_floor { 0.0 } else { raw }
173                };
174                if beta < smallest && beta < max_beta {
175                    smallest = beta;
176                }
177            }
178
179            // weight == 0 means the edge is not in the Gabriel graph: drop it.
180            if smallest != 0.0 {
181                let u = u32::try_from(i).map_err(|_| {
182                    IgraphError::Internal("beta_weighted_gabriel_graph: vertex id overflow")
183                })?;
184                let v = u32::try_from(j).map_err(|_| {
185                    IgraphError::Internal("beta_weighted_gabriel_graph: vertex id overflow")
186                })?;
187                graph.add_edge(u, v)?;
188                weights.push(smallest);
189            }
190        }
191    }
192
193    Ok(BetaWeightedGabriel { graph, weights })
194}
195
196/// Squared Euclidean distance between two equal-length coordinate rows.
197fn sq_dist(a: &[f64], b: &[f64]) -> f64 {
198    a.iter()
199        .zip(b.iter())
200        .map(|(x, y)| {
201            let t = x - y;
202            t * t
203        })
204        .sum()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    /// Collect the undirected edge set as a sorted `(min, max)` vec.
212    fn edge_set(g: &Graph) -> Vec<(u32, u32)> {
213        let mut edges: Vec<(u32, u32)> = (0..g.ecount())
214            .map(|e| {
215                let (u, v) = g
216                    .edge(u32::try_from(e).expect("edge id fits in u32"))
217                    .expect("edge");
218                (u.min(v), u.max(v))
219            })
220            .collect();
221        edges.sort_unstable();
222        edges
223    }
224
225    /// Compare a computed weight vector against an authentic golden vector
226    /// (order-independent): the finite weights must match as a sorted multiset
227    /// within relative tolerance, and the infinite-weight counts must agree.
228    fn assert_weights_match(actual: &[f64], golden: &[f64]) {
229        assert_eq!(
230            actual.len(),
231            golden.len(),
232            "edge/weight count mismatch: {} vs {}",
233            actual.len(),
234            golden.len()
235        );
236
237        let inf_actual = actual.iter().filter(|w| w.is_infinite()).count();
238        let inf_golden = golden.iter().filter(|w| w.is_infinite()).count();
239        assert_eq!(
240            inf_actual, inf_golden,
241            "infinite-weight count mismatch: {inf_actual} vs {inf_golden}"
242        );
243
244        let mut a: Vec<f64> = actual.iter().copied().filter(|w| w.is_finite()).collect();
245        let mut g: Vec<f64> = golden.iter().copied().filter(|w| w.is_finite()).collect();
246        a.sort_by(|x, y| x.partial_cmp(y).expect("finite"));
247        g.sort_by(|x, y| x.partial_cmp(y).expect("finite"));
248
249        for (x, y) in a.iter().zip(g.iter()) {
250            // Golden values are printed to ~6 significant figures; a 1e-4
251            // relative tolerance covers the rounding comfortably.
252            let tol = 1e-4 * y.abs().max(1.0);
253            assert!(
254                (x - y).abs() <= tol,
255                "weight mismatch: got {x}, expected {y} (tol {tol})"
256            );
257        }
258    }
259
260    /// The 25-point 2-D set from igraph's `beta_skeletons.c` (row-major 25×2).
261    #[allow(clippy::unreadable_literal)]
262    fn points_25() -> Vec<Vec<f64>> {
263        let flat = [
264            0.474217, 0.0314797, 0.208089, 0.439308, 0.967367, 0.530466, 0.177005, 0.426713,
265            0.568462, 0.57507, 0.441834, 0.284514, 0.479224, 0.817988, 0.720209, 0.225744,
266            0.204941, 0.44297, 0.285318, 0.912984, 0.831097, 0.0176603, 0.827154, 0.472702,
267            0.173059, 0.561858, 0.156276, 0.88019, 0.65935, 0.538207, 0.570379, 0.518081, 0.900553,
268            0.656416, 0.726631, 0.863709, 0.380264, 0.287159, 0.31098, 0.230773, 0.243089,
269            0.164584, 0.967974, 0.524992, 0.726605, 0.0724703, 0.739752, 0.447069, 0.0443581,
270            0.444839,
271        ];
272        flat.chunks_exact(2).map(<[f64]>::to_vec).collect()
273    }
274
275    /// The 10-point 3-D set from igraph's `beta_skeletons.c`: the same flat
276    /// coordinate array reshaped row-major as 10×3 (first 30 values).
277    #[allow(clippy::unreadable_literal)]
278    fn points_10_3d() -> Vec<Vec<f64>> {
279        let flat = [
280            0.474217, 0.0314797, 0.208089, 0.439308, 0.967367, 0.530466, 0.177005, 0.426713,
281            0.568462, 0.57507, 0.441834, 0.284514, 0.479224, 0.817988, 0.720209, 0.225744,
282            0.204941, 0.44297, 0.285318, 0.912984, 0.831097, 0.0176603, 0.827154, 0.472702,
283            0.173059, 0.561858, 0.156276, 0.88019, 0.65935, 0.538207,
284        ];
285        flat.chunks_exact(3).map(<[f64]>::to_vec).collect()
286    }
287
288    #[test]
289    fn empty_point_set() {
290        let res = beta_weighted_gabriel_graph(&[], f64::INFINITY).expect("empty ok");
291        assert_eq!(res.graph.vcount(), 0);
292        assert_eq!(res.graph.ecount(), 0);
293        assert!(res.weights.is_empty());
294    }
295
296    #[test]
297    fn single_point() {
298        let res = beta_weighted_gabriel_graph(&[vec![0.5, 0.5]], f64::INFINITY).expect("one ok");
299        assert_eq!(res.graph.vcount(), 1);
300        assert_eq!(res.graph.ecount(), 0);
301        assert!(res.weights.is_empty());
302    }
303
304    #[test]
305    fn two_points_persist_to_infinity() {
306        let res = beta_weighted_gabriel_graph(&[vec![0.0, 0.0], vec![3.0, 4.0]], f64::INFINITY)
307            .expect("pair ok");
308        assert_eq!(edge_set(&res.graph), vec![(0, 1)]);
309        assert_eq!(res.weights, vec![f64::INFINITY]);
310    }
311
312    #[test]
313    fn invalid_max_beta_is_error() {
314        for bad in [f64::NAN, 0.0, 0.5, -1.0] {
315            assert!(
316                matches!(
317                    beta_weighted_gabriel_graph(&[vec![0.0, 0.0]], bad).unwrap_err(),
318                    IgraphError::InvalidArgument(_)
319                ),
320                "max_beta = {bad} should be rejected"
321            );
322        }
323    }
324
325    #[test]
326    fn zero_dimensional_error() {
327        let err = beta_weighted_gabriel_graph(&[vec![], vec![]], f64::INFINITY).unwrap_err();
328        assert!(matches!(err, IgraphError::InvalidArgument(_)));
329    }
330
331    #[test]
332    fn inconsistent_dimensions_error() {
333        let err =
334            beta_weighted_gabriel_graph(&[vec![0.0, 0.0], vec![1.0]], f64::INFINITY).unwrap_err();
335        assert!(matches!(err, IgraphError::InvalidArgument(_)));
336    }
337
338    #[test]
339    fn duplicate_points_error() {
340        let err = beta_weighted_gabriel_graph(&[vec![0.0, 0.0], vec![0.0, 0.0]], f64::INFINITY)
341            .unwrap_err();
342        assert!(matches!(err, IgraphError::InvalidArgument(_)));
343    }
344
345    #[test]
346    fn edge_set_equals_gabriel() {
347        // The surviving edges are exactly the Gabriel graph, regardless of the
348        // cutoff (the cutoff only changes finite weights to infinity).
349        let pts = points_25();
350        let gabriel = crate::gabriel_graph(&pts).expect("gabriel");
351        for cutoff in [f64::INFINITY, 5.0, 1.0] {
352            let res = beta_weighted_gabriel_graph(&pts, cutoff).expect("weighted");
353            assert_eq!(
354                edge_set(&res.graph),
355                edge_set(&gabriel),
356                "edge set differs from Gabriel graph at cutoff {cutoff}"
357            );
358            assert_eq!(res.weights.len(), res.graph.ecount() as usize);
359        }
360    }
361
362    #[test]
363    fn cutoff_below_one_threshold_all_infinite() {
364        // With max_beta == 1, no edge's finite threshold (all >= 1 + tol) is
365        // below the cutoff, so every Gabriel edge persists to infinity.
366        let res = beta_weighted_gabriel_graph(&points_25(), 1.0).expect("ok");
367        assert!(res.weights.iter().all(|w| w.is_infinite()));
368    }
369
370    // --- Authentic igraph C anchors (beta_skeletons.out) ---
371
372    #[allow(clippy::unreadable_literal)]
373    #[test]
374    fn c_anchor_2d_25_points_cutoff_infinity() {
375        let res = beta_weighted_gabriel_graph(&points_25(), f64::INFINITY).expect("ok");
376        let golden = [
377            2.13771,
378            1.87896,
379            1.30643,
380            10.5357,
381            f64::INFINITY,
382            1.04766,
383            2.38206,
384            1.72356,
385            10.9882,
386            41555.3,
387            115.458,
388            6.14845,
389            4.87967,
390            1.32814,
391            1.42918,
392            8.52199,
393            1.20967,
394            2.72446,
395            3.66988,
396            f64::INFINITY,
397            11.4047,
398            1.33013,
399            3.50412,
400            36.5966,
401            10.6973,
402            4.82793,
403            48.4413,
404            628.646,
405            1.11477,
406            8.32087,
407            f64::INFINITY,
408            4.28868,
409            1.19886,
410            3.33,
411            1.31892,
412            11.3292,
413            3.3514,
414            15.7597,
415            29.7302,
416        ];
417        assert_weights_match(&res.weights, &golden);
418    }
419
420    #[allow(clippy::unreadable_literal)]
421    #[test]
422    fn c_anchor_2d_25_points_cutoff_5() {
423        let res = beta_weighted_gabriel_graph(&points_25(), 5.0).expect("ok");
424        let golden = [
425            2.13771,
426            1.87896,
427            1.30643,
428            f64::INFINITY,
429            f64::INFINITY,
430            1.04766,
431            2.38206,
432            1.72356,
433            f64::INFINITY,
434            f64::INFINITY,
435            f64::INFINITY,
436            f64::INFINITY,
437            4.87967,
438            1.32814,
439            1.42918,
440            f64::INFINITY,
441            1.20967,
442            2.72446,
443            3.66988,
444            f64::INFINITY,
445            f64::INFINITY,
446            1.33013,
447            3.50412,
448            f64::INFINITY,
449            f64::INFINITY,
450            4.82793,
451            f64::INFINITY,
452            f64::INFINITY,
453            1.11477,
454            f64::INFINITY,
455            f64::INFINITY,
456            4.28868,
457            1.19886,
458            3.33,
459            1.31892,
460            f64::INFINITY,
461            3.3514,
462            f64::INFINITY,
463            f64::INFINITY,
464        ];
465        assert_weights_match(&res.weights, &golden);
466    }
467
468    #[allow(clippy::unreadable_literal)]
469    #[test]
470    fn c_anchor_3d_10_points_cutoff_infinity() {
471        let res = beta_weighted_gabriel_graph(&points_10_3d(), f64::INFINITY).expect("ok");
472        let golden = [
473            2.29424, 2.87805, 1.51364, 15.0303, 1.01534, 2.12088, 1.05443, 1.30234, 2.07368,
474            8.72711, 1.0848, 1.99727, 2.0717, 1.25544, 1.77266, 2.21701, 4.16352, 58.0727, 4.91485,
475            1.47137, 1.89216, 2.00274,
476        ];
477        assert_weights_match(&res.weights, &golden);
478    }
479
480    #[allow(clippy::unreadable_literal)]
481    #[test]
482    fn c_anchor_3d_10_points_cutoff_5() {
483        let res = beta_weighted_gabriel_graph(&points_10_3d(), 5.0).expect("ok");
484        let golden = [
485            2.29424,
486            2.87805,
487            1.51364,
488            f64::INFINITY,
489            1.01534,
490            2.12088,
491            1.05443,
492            1.30234,
493            2.07368,
494            f64::INFINITY,
495            1.0848,
496            1.99727,
497            2.0717,
498            1.25544,
499            1.77266,
500            2.21701,
501            4.16352,
502            f64::INFINITY,
503            4.91485,
504            1.47137,
505            1.89216,
506            2.00274,
507        ];
508        assert_weights_match(&res.weights, &golden);
509    }
510}