Skip to main content

rust_igraph/algorithms/spatial/
lune_beta_skeleton.rs

1//! Lune-based β-skeleton of a spatial point set (ALGO-GEO-006).
2//!
3//! Counterpart of `igraph_lune_beta_skeleton()` from
4//! `references/igraph/src/spatial/beta_skeleton.cpp:444`.
5//!
6//! For a parameter `β > 0`, two points `A` and `B` are connected when the
7//! *lune* of the edge `A-B` contains no other point. The lune is the
8//! intersection of two equal balls whose radius and centres are governed by
9//! `β`:
10//!
11//! - For `β ≥ 1` (any dimension) the balls have radius `β/2 · dist(A, B)`
12//!   and centres `A + (r−1)(A−B)` and `B + (r−1)(B−A)` with `r = β/2`,
13//!   sliding apart along the line `AB` as `β` grows. `β = 1` collapses both
14//!   centres onto the midpoint, recovering the [Gabriel
15//!   graph](crate::gabriel_graph).
16//! - For `β < 1` (2D only) the two centres sit symmetrically off the line
17//!   `AB`, perpendicular to it, with `r = 1/(2β)`; larger lunes than the
18//!   Gabriel ball, so a denser graph.
19//!
20//! A larger `β` enlarges the empty region and so yields a sparser graph.
21//! The boundary is **closed** (matching the reference's `is_closed = true`):
22//! a point sitting exactly on the lune boundary counts as inside and removes
23//! the edge.
24
25use crate::core::{Graph, IgraphError, IgraphResult};
26
27/// Closed-region tolerance, mirroring the C reference's
28/// `#define TOLERANCE (128 * DBL_EPSILON)`. The squared ball radius is
29/// inflated by `(1 + TOLERANCE)²` so a point on the exact boundary falls
30/// strictly inside and removes the edge (`is_closed = true`).
31const TOLERANCE: f64 = 128.0 * f64::EPSILON;
32
33/// Build the lune-based β-skeleton of a point set.
34///
35/// `points` holds one row per point with a shared, arbitrary dimensionality
36/// (inferred from the first row). `beta` is the skeleton parameter. The
37/// result is an undirected [`Graph`] on `points.len()` vertices.
38///
39/// This is an `O(n²·d)` candidate enumeration with an `O(n·d)` empty-lune
40/// test per pair. For `β ≥ 1` the β-skeleton is a subgraph of the Delaunay
41/// triangulation, so testing every pair yields the same edge set as the
42/// reference's Delaunay-pruned candidate superset; for `β < 1` the reference
43/// itself starts from the complete graph, which this matches directly.
44///
45/// # Errors
46///
47/// - [`IgraphError::InvalidArgument`] if `beta` is not a finite positive
48///   number, if the points are zero-dimensional (with at least one point),
49///   or if the rows have inconsistent dimensionality.
50/// - [`IgraphError::Unsupported`] if `beta < 1` and the points are not
51///   2-dimensional (the perpendicular-centre construction is only defined in
52///   2D, matching the reference's `IGRAPH_UNIMPLEMENTED`).
53///
54/// # Examples
55///
56/// ```
57/// use rust_igraph::lune_beta_skeleton;
58///
59/// // Four corners of a unit square. At β = 1 (the Gabriel graph) the four
60/// // sides survive and both diagonals drop: each diagonal's midpoint has the
61/// // two opposite corners sitting on the diametral circle.
62/// let pts = vec![
63///     vec![0.0, 0.0],
64///     vec![1.0, 0.0],
65///     vec![0.0, 1.0],
66///     vec![1.0, 1.0],
67/// ];
68/// let g = lune_beta_skeleton(&pts, 1.0).unwrap();
69/// assert_eq!(g.vcount(), 4);
70/// assert_eq!(g.ecount(), 4); // sides only
71/// ```
72// Single-character names (`a`, `b`, `c` for the three points, `d` for the
73// dimension axis) are the natural geometric notation here.
74#[allow(clippy::many_single_char_names)]
75pub fn lune_beta_skeleton(points: &[Vec<f64>], beta: f64) -> IgraphResult<Graph> {
76    if !beta.is_finite() || beta <= 0.0 {
77        return Err(IgraphError::InvalidArgument(format!(
78            "lune_beta_skeleton: beta must be a finite positive number, got {beta}"
79        )));
80    }
81
82    let n = points.len();
83    let dim = if n == 0 { 0 } else { points[0].len() };
84    if n > 0 {
85        if dim == 0 {
86            return Err(IgraphError::InvalidArgument(
87                "lune_beta_skeleton: points must not be zero-dimensional".into(),
88            ));
89        }
90        for (i, row) in points.iter().enumerate().skip(1) {
91            if row.len() != dim {
92                return Err(IgraphError::InvalidArgument(format!(
93                    "lune_beta_skeleton: point row {i} has dimension {} but expected {dim}",
94                    row.len()
95                )));
96            }
97        }
98    }
99
100    if beta < 1.0 && n > 0 && dim != 2 {
101        return Err(IgraphError::Unsupported(
102            "lune_beta_skeleton: beta < 1 is only supported in 2 dimensions",
103        ));
104    }
105
106    let n_u32 = u32::try_from(n)
107        .map_err(|_| IgraphError::InvalidArgument("lune_beta_skeleton: too many points".into()))?;
108    let mut graph = Graph::with_vertices(n_u32);
109
110    let r = if beta < 1.0 { 0.5 / beta } else { 0.5 * beta };
111    let tol_sq = (1.0 + TOLERANCE) * (1.0 + TOLERANCE);
112    // For β < 1 the perpendicular offset scales by sqrt(r² − 1/4); r > 1/2
113    // there, so the argument is non-negative.
114    let perp_scale = if beta < 1.0 {
115        (r * r - 0.25).max(0.0).sqrt()
116    } else {
117        0.0
118    };
119
120    let mut a_center = vec![0.0_f64; dim];
121    let mut b_center = vec![0.0_f64; dim];
122
123    for i in 0..n {
124        let a = &points[i];
125        for j in (i + 1)..n {
126            let b = &points[j];
127
128            let mut dist_sq = 0.0_f64;
129            for d in 0..dim {
130                let t = a[d] - b[d];
131                dist_sq += t * t;
132            }
133
134            if beta < 1.0 {
135                // 2D perpendicular construction (dim == 2 enforced above).
136                let mid0 = 0.5 * (a[0] + b[0]);
137                let mid1 = 0.5 * (a[1] + b[1]);
138                // perp = (a − b) · sqrt(r² − 1/4), rotated 90° CCW: (x,y) → (−y,x).
139                let perp0 = -(a[1] - b[1]) * perp_scale;
140                let perp1 = (a[0] - b[0]) * perp_scale;
141                a_center[0] = mid0 + perp0;
142                a_center[1] = mid1 + perp1;
143                b_center[0] = mid0 - perp0;
144                b_center[1] = mid1 - perp1;
145            } else {
146                for d in 0..dim {
147                    a_center[d] = a[d] + (r - 1.0) * (a[d] - b[d]);
148                    b_center[d] = b[d] + (r - 1.0) * (b[d] - a[d]);
149                }
150            }
151
152            // Inflated squared ball radius; a point inside BOTH balls lies in
153            // the lune and removes the edge.
154            let beta_radius = dist_sq * r * r * tol_sq;
155
156            let mut empty = true;
157            for (k, c) in points.iter().enumerate() {
158                if k == i || k == j {
159                    continue;
160                }
161                let mut da = 0.0_f64;
162                let mut db = 0.0_f64;
163                for d in 0..dim {
164                    let ta = c[d] - a_center[d];
165                    da += ta * ta;
166                    let tb = c[d] - b_center[d];
167                    db += tb * tb;
168                }
169                if da < beta_radius && db < beta_radius {
170                    empty = false;
171                    break;
172                }
173            }
174
175            if empty {
176                let u = u32::try_from(i)
177                    .map_err(|_| IgraphError::Internal("lune_beta_skeleton: vertex id overflow"))?;
178                let v = u32::try_from(j)
179                    .map_err(|_| IgraphError::Internal("lune_beta_skeleton: vertex id overflow"))?;
180                graph.add_edge(u, v)?;
181            }
182        }
183    }
184
185    Ok(graph)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    /// Collect the undirected edge set as a sorted `(min, max)` vec.
193    fn edge_set(g: &Graph) -> Vec<(u32, u32)> {
194        let mut edges: Vec<(u32, u32)> = (0..g.ecount())
195            .map(|e| {
196                let (u, v) = g
197                    .edge(u32::try_from(e).expect("edge id fits in u32"))
198                    .expect("edge");
199                (u.min(v), u.max(v))
200            })
201            .collect();
202        edges.sort_unstable();
203        edges
204    }
205
206    /// The 25-point 2-D set transcribed verbatim from igraph's
207    /// `beta_skeletons.c` (row-major, 25×2).
208    #[allow(clippy::unreadable_literal)]
209    fn points_25() -> Vec<Vec<f64>> {
210        let flat = [
211            0.474217, 0.0314797, 0.208089, 0.439308, 0.967367, 0.530466, 0.177005, 0.426713,
212            0.568462, 0.57507, 0.441834, 0.284514, 0.479224, 0.817988, 0.720209, 0.225744,
213            0.204941, 0.44297, 0.285318, 0.912984, 0.831097, 0.0176603, 0.827154, 0.472702,
214            0.173059, 0.561858, 0.156276, 0.88019, 0.65935, 0.538207, 0.570379, 0.518081, 0.900553,
215            0.656416, 0.726631, 0.863709, 0.380264, 0.287159, 0.31098, 0.230773, 0.243089,
216            0.164584, 0.967974, 0.524992, 0.726605, 0.0724703, 0.739752, 0.447069, 0.0443581,
217            0.444839,
218        ];
219        flat.chunks_exact(2).map(<[f64]>::to_vec).collect()
220    }
221
222    /// The first 10 of the 25 raw values reinterpreted as 10 3-D points
223    /// (`igraph_matrix_init_array(..., 10, 3, false)` column-major over the
224    /// same backing array → here the same flat prefix taken 3 at a time).
225    #[allow(clippy::unreadable_literal)]
226    fn points_10_3d() -> Vec<Vec<f64>> {
227        let flat = [
228            0.474217, 0.0314797, 0.208089, 0.439308, 0.967367, 0.530466, 0.177005, 0.426713,
229            0.568462, 0.57507, 0.441834, 0.284514, 0.479224, 0.817988, 0.720209, 0.225744,
230            0.204941, 0.44297, 0.285318, 0.912984, 0.831097, 0.0176603, 0.827154, 0.472702,
231            0.173059, 0.561858, 0.156276, 0.88019, 0.65935, 0.538207,
232        ];
233        flat.chunks_exact(3).map(<[f64]>::to_vec).collect()
234    }
235
236    /// A 10-point triangular lattice (verbatim from `trig_lattice_points`).
237    #[allow(clippy::unreadable_literal)]
238    fn triangular_lattice() -> Vec<Vec<f64>> {
239        vec![
240            vec![0.5, 2.598076211353316],
241            vec![0.0, 1.7320508075688772],
242            vec![1.0, 1.7320508075688772],
243            vec![-0.5, 0.8660254037844386],
244            vec![0.5, 0.8660254037844386],
245            vec![1.5, 0.8660254037844386],
246            vec![-1.0, 0.0],
247            vec![0.0, 0.0],
248            vec![1.0, 0.0],
249            vec![2.0, 0.0],
250        ]
251    }
252
253    #[test]
254    fn empty_point_set() {
255        let g = lune_beta_skeleton(&[], 1.0).expect("empty ok");
256        assert_eq!(g.vcount(), 0);
257        assert_eq!(g.ecount(), 0);
258    }
259
260    #[test]
261    fn single_point() {
262        let g = lune_beta_skeleton(&[vec![0.5, 0.5]], 2.0).expect("singleton ok");
263        assert_eq!(g.vcount(), 1);
264        assert_eq!(g.ecount(), 0);
265    }
266
267    #[test]
268    fn two_points_always_connected() {
269        let g = lune_beta_skeleton(&[vec![0.0, 0.0], vec![3.0, 4.0]], 2.0).expect("pair ok");
270        assert_eq!(edge_set(&g), vec![(0, 1)]);
271    }
272
273    #[test]
274    fn non_finite_beta_is_error() {
275        assert!(matches!(
276            lune_beta_skeleton(&[vec![0.0, 0.0]], f64::NAN).unwrap_err(),
277            IgraphError::InvalidArgument(_)
278        ));
279        assert!(matches!(
280            lune_beta_skeleton(&[vec![0.0, 0.0]], f64::INFINITY).unwrap_err(),
281            IgraphError::InvalidArgument(_)
282        ));
283    }
284
285    #[test]
286    fn non_positive_beta_is_error() {
287        assert!(matches!(
288            lune_beta_skeleton(&[vec![0.0, 0.0]], 0.0).unwrap_err(),
289            IgraphError::InvalidArgument(_)
290        ));
291        assert!(matches!(
292            lune_beta_skeleton(&[vec![0.0, 0.0]], -1.0).unwrap_err(),
293            IgraphError::InvalidArgument(_)
294        ));
295    }
296
297    #[test]
298    fn zero_dimensional_error() {
299        let err = lune_beta_skeleton(&[vec![], vec![]], 1.0).unwrap_err();
300        assert!(matches!(err, IgraphError::InvalidArgument(_)));
301    }
302
303    #[test]
304    fn inconsistent_dimensions_error() {
305        let err = lune_beta_skeleton(&[vec![0.0, 0.0], vec![1.0]], 1.0).unwrap_err();
306        assert!(matches!(err, IgraphError::InvalidArgument(_)));
307    }
308
309    #[test]
310    fn small_beta_non_2d_is_unsupported() {
311        let pts = vec![vec![0.0, 0.0, 0.0], vec![1.0, 1.0, 1.0]];
312        let err = lune_beta_skeleton(&pts, 0.5).unwrap_err();
313        assert!(matches!(err, IgraphError::Unsupported(_)));
314    }
315
316    #[test]
317    fn beta_one_equals_gabriel() {
318        // β = 1 is exactly the Gabriel graph; cross-check the dedicated impl.
319        let pts = points_25();
320        let lune = lune_beta_skeleton(&pts, 1.0).expect("lune ok");
321        let gabriel = crate::gabriel_graph(&pts).expect("gabriel ok");
322        assert_eq!(edge_set(&lune), edge_set(&gabriel));
323    }
324
325    // --- Authentic igraph C anchors (beta_skeletons.out) ---
326
327    #[allow(clippy::unreadable_literal)]
328    #[test]
329    fn c_anchor_beta2_25_points() {
330        let g = lune_beta_skeleton(&points_25(), 2.0).expect("ok");
331        let expected = vec![
332            (0, 5),
333            (0, 22),
334            (1, 8),
335            (1, 18),
336            (2, 16),
337            (2, 21),
338            (3, 8),
339            (3, 24),
340            (4, 6),
341            (4, 15),
342            (5, 7),
343            (5, 15),
344            (5, 18),
345            (6, 9),
346            (6, 17),
347            (7, 22),
348            (7, 23),
349            (8, 12),
350            (9, 13),
351            (10, 22),
352            (11, 21),
353            (11, 23),
354            (12, 13),
355            (14, 15),
356            (14, 23),
357            (16, 17),
358            (18, 19),
359            (19, 20),
360        ];
361        assert_eq!(edge_set(&g), expected);
362    }
363
364    #[allow(clippy::unreadable_literal)]
365    #[test]
366    fn c_anchor_gabriel_25_points() {
367        let g = lune_beta_skeleton(&points_25(), 1.0).expect("ok");
368        let expected = vec![
369            (0, 5),
370            (0, 19),
371            (0, 20),
372            (0, 22),
373            (1, 8),
374            (1, 15),
375            (1, 18),
376            (1, 19),
377            (2, 16),
378            (2, 21),
379            (3, 8),
380            (3, 24),
381            (4, 6),
382            (4, 12),
383            (4, 14),
384            (4, 15),
385            (4, 17),
386            (5, 7),
387            (5, 15),
388            (5, 18),
389            (6, 9),
390            (6, 12),
391            (6, 17),
392            (7, 22),
393            (7, 23),
394            (8, 12),
395            (9, 13),
396            (10, 22),
397            (11, 16),
398            (11, 21),
399            (11, 23),
400            (12, 13),
401            (12, 24),
402            (14, 15),
403            (14, 17),
404            (14, 23),
405            (16, 17),
406            (18, 19),
407            (19, 20),
408        ];
409        assert_eq!(edge_set(&g), expected);
410    }
411
412    #[allow(clippy::unreadable_literal)]
413    #[test]
414    fn c_anchor_beta2_10_points_3d() {
415        let g = lune_beta_skeleton(&points_10_3d(), 2.0).expect("ok");
416        let expected = vec![
417            (0, 3),
418            (0, 5),
419            (1, 4),
420            (1, 7),
421            (2, 4),
422            (2, 5),
423            (2, 8),
424            (3, 8),
425            (3, 9),
426            (4, 6),
427            (4, 9),
428            (7, 8),
429        ];
430        assert_eq!(edge_set(&g), expected);
431    }
432
433    #[test]
434    fn c_anchor_beta2_triangular_lattice_is_empty() {
435        // Closed-boundary witness: at β = 2 every lattice lune boundary hosts
436        // a third point, so all candidate edges drop and the graph is empty.
437        let g = lune_beta_skeleton(&triangular_lattice(), 2.0).expect("ok");
438        assert_eq!(g.vcount(), 10);
439        assert_eq!(g.ecount(), 0);
440    }
441}