1use crate::core::{Graph, IgraphError, IgraphResult};
38
39const TOLERANCE: f64 = 128.0 * f64::EPSILON;
45
46#[derive(Clone, Debug)]
53pub struct BetaWeightedGabriel {
54 pub graph: Graph,
56 pub weights: Vec<f64>,
58}
59
60#[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 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 if raw < beta_floor { 0.0 } else { raw }
173 };
174 if beta < smallest && beta < max_beta {
175 smallest = beta;
176 }
177 }
178
179 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
196fn 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 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 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 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 #[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 #[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 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 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 #[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}