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}