Skip to main content

rust_igraph/algorithms/spatial/
relative_neighborhood_graph.rs

1//! Relative neighborhood graph of a spatial point set (ALGO-GEO-004).
2//!
3//! Counterpart of `igraph_relative_neighborhood_graph()` from
4//! `references/igraph/src/spatial/beta_skeleton.cpp:798`.
5//!
6//! In the relative neighborhood graph (RNG) two points `A` and `B` are
7//! connected if and only if no other point `C` is *strictly* closer to
8//! both of them than they are to each other:
9//! `dist(A, B) <= max(dist(C, A), dist(C, B))` for every other point `C`.
10//! Equivalently, the open lune (the intersection of the two open balls of
11//! radius `dist(A, B)` centred at `A` and at `B`) contains no other point.
12//!
13//! The RNG is the **open** lune-based β-skeleton with `β = 2`. Because the
14//! lune is open, a point sitting exactly on its boundary — at distance
15//! `dist(A, B)` from both endpoints, as happens for every edge of a
16//! triangular lattice — does *not* remove the edge. This is the sole
17//! difference from the closed β = 2 lune skeleton (which would delete those
18//! edges) and the reason a triangular lattice keeps its full edge set under
19//! the RNG. The RNG is a supergraph of the Euclidean minimum spanning tree
20//! and a subgraph of the Gabriel graph, hence connected; arbitrary
21//! dimensionality is supported.
22
23use crate::core::{Graph, IgraphError, IgraphResult};
24
25/// Open-lune tolerance, mirroring the C reference's
26/// `#define TOLERANCE (128 * DBL_EPSILON)`. For the open lune the squared
27/// radius is *deflated* by `(1 - TOLERANCE)²` so a point lying exactly on
28/// the lune boundary falls strictly outside and the edge is kept — matching
29/// `igraph`'s `is_closed = false` filter and leaving a triangular lattice
30/// (whose edges all have a co-equidistant third point) fully connected.
31const TOLERANCE: f64 = 128.0 * f64::EPSILON;
32
33/// Build the relative neighborhood graph of a point set.
34///
35/// `points` holds one row per point with a shared, arbitrary
36/// dimensionality (inferred from the first row). The result is an
37/// undirected [`Graph`] on `points.len()` vertices whose edges are exactly
38/// the relatively-neighborly pairs.
39///
40/// This is an `O(n²·d)` candidate enumeration with an `O(n·d)` empty-lune
41/// test per pair (`O(n³·d)` overall), which reproduces the reference filter
42/// exactly: the RNG is a subgraph of the Delaunay triangulation, so testing
43/// every pair yields the same edge set as the reference's Delaunay-pruned
44/// candidate superset.
45///
46/// # Errors
47///
48/// - [`IgraphError::InvalidArgument`] if the points are zero-dimensional
49///   (and there is at least one point).
50/// - [`IgraphError::InvalidArgument`] if the rows have inconsistent
51///   dimensionality.
52///
53/// # Examples
54///
55/// ```
56/// use rust_igraph::relative_neighborhood_graph;
57///
58/// // An equilateral triangle: every pair is mutually nearest, and the
59/// // third vertex sits exactly on the open lune's boundary, so all three
60/// // edges survive (the open lune distinguishes the RNG from the closed
61/// // β = 2 skeleton, which would delete them).
62/// let h = 3.0_f64.sqrt() / 2.0;
63/// let pts = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![0.5, h]];
64/// let g = relative_neighborhood_graph(&pts).unwrap();
65/// assert_eq!(g.vcount(), 3);
66/// assert_eq!(g.ecount(), 3); // complete triangle
67/// ```
68// Single-character names (`a`, `b`, `c` for the three points, `d` for the
69// dimension axis) are the natural geometric notation here; `to_a_sq` /
70// `to_b_sq` are the squared distances from a candidate to each endpoint.
71#[allow(clippy::many_single_char_names, clippy::similar_names)]
72pub fn relative_neighborhood_graph(points: &[Vec<f64>]) -> IgraphResult<Graph> {
73    let n = points.len();
74
75    let dim = if n == 0 { 0 } else { points[0].len() };
76    if n > 0 {
77        if dim == 0 {
78            return Err(IgraphError::InvalidArgument(
79                "relative_neighborhood_graph: points must not be zero-dimensional".into(),
80            ));
81        }
82        for (i, row) in points.iter().enumerate().skip(1) {
83            if row.len() != dim {
84                return Err(IgraphError::InvalidArgument(format!(
85                    "relative_neighborhood_graph: point row {i} has dimension {} but expected {dim}",
86                    row.len()
87                )));
88            }
89        }
90    }
91
92    let n_u32 = u32::try_from(n).map_err(|_| {
93        IgraphError::InvalidArgument("relative_neighborhood_graph: too many points".into())
94    })?;
95    let mut graph = Graph::with_vertices(n_u32);
96
97    // (1 - TOLERANCE)² deflation of the squared lune radius, matching the
98    // reference `beta_radius = sqr_dist * r² * tol²` with `r = 1` (β = 2)
99    // and the open-lune `tol = 1 - TOLERANCE`.
100    let tol = 1.0 - TOLERANCE;
101    let tol_sq = tol * tol;
102
103    for i in 0..n {
104        let a = &points[i];
105        for j in (i + 1)..n {
106            let b = &points[j];
107
108            let mut dist_sq = 0.0_f64;
109            for d in 0..dim {
110                let t = a[d] - b[d];
111                dist_sq += t * t;
112            }
113            // Squared lune radius (= dist² for β = 2), deflated by tol² so a
114            // boundary point falls strictly outside and keeps the edge.
115            let threshold = dist_sq * tol_sq;
116
117            let mut empty = true;
118            for (k, c) in points.iter().enumerate() {
119                if k == i || k == j {
120                    continue;
121                }
122                let mut to_a_sq = 0.0_f64;
123                let mut to_b_sq = 0.0_f64;
124                for d in 0..dim {
125                    let ta = c[d] - a[d];
126                    to_a_sq += ta * ta;
127                    let tb = c[d] - b[d];
128                    to_b_sq += tb * tb;
129                }
130                if to_a_sq < threshold && to_b_sq < threshold {
131                    empty = false;
132                    break;
133                }
134            }
135
136            if empty {
137                let u = u32::try_from(i).map_err(|_| {
138                    IgraphError::Internal("relative_neighborhood_graph: vertex id overflow")
139                })?;
140                let v = u32::try_from(j).map_err(|_| {
141                    IgraphError::Internal("relative_neighborhood_graph: vertex id overflow")
142                })?;
143                graph.add_edge(u, v)?;
144            }
145        }
146    }
147
148    Ok(graph)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    /// Collect the undirected edge set as a sorted `(min, max)` vec for
156    /// order-independent comparison.
157    fn edge_set(g: &Graph) -> Vec<(u32, u32)> {
158        let mut edges: Vec<(u32, u32)> = (0..g.ecount())
159            .map(|e| {
160                let (u, v) = g
161                    .edge(u32::try_from(e).expect("edge id fits in u32"))
162                    .expect("edge");
163                (u.min(v), u.max(v))
164            })
165            .collect();
166        edges.sort_unstable();
167        edges
168    }
169
170    #[test]
171    fn empty_point_set() {
172        let g = relative_neighborhood_graph(&[]).expect("empty ok");
173        assert_eq!(g.vcount(), 0);
174        assert_eq!(g.ecount(), 0);
175    }
176
177    #[test]
178    fn single_point() {
179        let g = relative_neighborhood_graph(&[vec![0.5, 0.5]]).expect("singleton ok");
180        assert_eq!(g.vcount(), 1);
181        assert_eq!(g.ecount(), 0);
182    }
183
184    #[test]
185    fn two_points_always_connected() {
186        let g = relative_neighborhood_graph(&[vec![0.0, 0.0], vec![3.0, 4.0]]).expect("pair ok");
187        assert_eq!(g.vcount(), 2);
188        assert_eq!(edge_set(&g), vec![(0, 1)]);
189    }
190
191    #[test]
192    fn equilateral_triangle_open_lune_keeps_all() {
193        // Each vertex sits exactly on the opposite edge's open-lune
194        // boundary, so the OPEN lune keeps all three edges (the closed
195        // β = 2 lune would delete them). This is the defining RNG case.
196        let h = 3.0_f64.sqrt() / 2.0;
197        let pts = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![0.5, h]];
198        let g = relative_neighborhood_graph(&pts).expect("triangle ok");
199        assert_eq!(edge_set(&g), vec![(0, 1), (0, 2), (1, 2)]);
200    }
201
202    #[test]
203    fn collinear_midpoint_breaks_long_edge() {
204        // Three collinear points: the middle point is strictly closer to
205        // both ends than they are to each other, so the long edge is
206        // removed; the two short edges remain (a path).
207        let pts = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![2.0, 0.0]];
208        let g = relative_neighborhood_graph(&pts).expect("collinear ok");
209        assert_eq!(edge_set(&g), vec![(0, 1), (1, 2)]);
210    }
211
212    #[test]
213    fn unit_square_keeps_sides_drops_diagonals() {
214        let pts = vec![
215            vec![0.0, 0.0], // 0
216            vec![1.0, 0.0], // 1
217            vec![0.0, 1.0], // 2
218            vec![1.0, 1.0], // 3
219        ];
220        let g = relative_neighborhood_graph(&pts).expect("square ok");
221        // Each diagonal's opposite corner is strictly closer to both
222        // endpoints (dist 1 < diagonal √2), so both diagonals go.
223        assert_eq!(edge_set(&g), vec![(0, 1), (0, 2), (1, 3), (2, 3)]);
224    }
225
226    #[test]
227    fn three_dimensional_points() {
228        let pts = vec![vec![0.0, 0.0, 0.0], vec![1.0, 1.0, 1.0]];
229        let g = relative_neighborhood_graph(&pts).expect("3d ok");
230        assert_eq!(edge_set(&g), vec![(0, 1)]);
231    }
232
233    #[test]
234    fn zero_dimensional_error() {
235        let err = relative_neighborhood_graph(&[vec![], vec![]]).unwrap_err();
236        assert!(matches!(err, IgraphError::InvalidArgument(_)));
237    }
238
239    #[test]
240    fn inconsistent_dimensions_error() {
241        let err = relative_neighborhood_graph(&[vec![0.0, 0.0], vec![1.0]]).unwrap_err();
242        assert!(matches!(err, IgraphError::InvalidArgument(_)));
243    }
244
245    // Coordinates are transcribed verbatim from igraph's own
246    // `beta_skeletons.c` (`trig_lattice_points`); underscores would obscure
247    // that they are an authentic anchor, and the extra digits beyond f64
248    // precision are kept to match the source byte-for-byte, so both
249    // readability lints are waived.
250    #[allow(clippy::unreadable_literal, clippy::excessive_precision)]
251    #[test]
252    fn triangular_lattice_c_anchor() {
253        // Authentic igraph C anchor (beta_skeletons.out, "Relative
254        // neighborhood graph, triangular lattice"): a 10-point triangular
255        // lattice whose RNG keeps all 18 lattice edges. Every edge has a
256        // third lattice point exactly equidistant (on the open-lune
257        // boundary), so only the OPEN lune retains them — the closed β = 2
258        // skeleton on the same points is empty.
259        let pts = vec![
260            vec![0.5, 2.5980762113533159402911695122588085504142078807156],
261            vec![0.0, 1.7320508075688772935274463415058723669428052538104],
262            vec![1.0, 1.7320508075688772935274463415058723669428052538104],
263            vec![-0.5, 0.86602540378443864676372317075293618347140262690519],
264            vec![0.5, 0.86602540378443864676372317075293618347140262690519],
265            vec![1.5, 0.86602540378443864676372317075293618347140262690519],
266            vec![-1.0, 0.0],
267            vec![0.0, 0.0],
268            vec![1.0, 0.0],
269            vec![2.0, 0.0],
270        ];
271        let g = relative_neighborhood_graph(&pts).expect("lattice ok");
272        let expected: Vec<(u32, u32)> = vec![
273            (0, 1),
274            (0, 2),
275            (1, 2),
276            (1, 3),
277            (1, 4),
278            (2, 4),
279            (2, 5),
280            (3, 4),
281            (3, 6),
282            (3, 7),
283            (4, 5),
284            (4, 7),
285            (4, 8),
286            (5, 8),
287            (5, 9),
288            (6, 7),
289            (7, 8),
290            (8, 9),
291        ];
292        assert_eq!(g.vcount(), 10);
293        assert_eq!(edge_set(&g), expected);
294    }
295}