1use crate::matrix::common_io::read_lines_of_words_delim;
16use crate::matrix::graph::AdjListGraph;
17use crate::matrix::membership::{detect_delimiter, GeneIndexResolver};
18use crate::matrix::parquet::{parquet_add_bytearray, parquet_add_string_column, ParquetWriter};
19use log::info;
20use parquet::basic::Type as ParquetType;
21use rayon::prelude::*;
22use rustc_hash::{FxHashMap, FxHashSet as HashSet};
23
24pub struct FeaturePairGraph {
25 pub feature_names: Vec<Box<str>>,
26 pub n_features: usize,
27 pub feature_edges: Vec<(usize, usize)>,
28}
29
30struct AdjCsr {
35 row_ptr: Vec<usize>,
37 col_idx: Vec<u32>,
39}
40
41impl AdjCsr {
42 #[inline]
43 fn row(&self, u: usize) -> &[u32] {
44 &self.col_idx[self.row_ptr[u]..self.row_ptr[u + 1]]
45 }
46}
47
48#[inline]
51fn intersect_count(a: &[u32], b: &[u32]) -> usize {
52 let (a, b) = if a.len() <= b.len() { (a, b) } else { (b, a) };
53 let mut i = 0usize;
54 let mut j = 0usize;
55 let mut count = 0usize;
56 while i < a.len() && j < b.len() {
57 match a[i].cmp(&b[j]) {
58 std::cmp::Ordering::Less => i += 1,
59 std::cmp::Ordering::Greater => j += 1,
60 std::cmp::Ordering::Equal => {
61 count += 1;
62 i += 1;
63 j += 1;
64 }
65 }
66 }
67 count
68}
69
70impl FeaturePairGraph {
75 pub fn from_edge_list(
83 file_path: &str,
84 feature_names: Vec<Box<str>>,
85 allow_prefix: bool,
86 delimiter: Option<char>,
87 ) -> anyhow::Result<Self> {
88 Self::from_edge_list_canon(file_path, feature_names, allow_prefix, delimiter, &|s| {
89 s.into()
90 })
91 }
92
93 pub fn from_edge_list_canon(
100 file_path: &str,
101 feature_names: Vec<Box<str>>,
102 allow_prefix: bool,
103 delimiter: Option<char>,
104 canon: &dyn Fn(&str) -> Box<str>,
105 ) -> anyhow::Result<Self> {
106 let n_features = feature_names.len();
107 let canon_names: Vec<Box<str>> = feature_names.iter().map(|n| canon(n)).collect();
110 let resolver = GeneIndexResolver::build(&canon_names, delimiter, allow_prefix);
111
112 let file_delim = detect_delimiter(file_path);
113 let read_out = read_lines_of_words_delim(file_path, file_delim, -1)?;
114
115 let mut edge_set: HashSet<(usize, usize)> = Default::default();
116 let mut n_matched = 0usize;
117 let mut n_skipped = 0usize;
118 for line in &read_out.lines {
119 if line.len() < 2 {
120 continue;
121 }
122 let idx1 = resolver.resolve(&canon(&line[0]));
123 let idx2 = resolver.resolve(&canon(&line[1]));
124 match (idx1, idx2) {
125 (Some(i), Some(j)) if i != j => {
126 let (lo, hi) = if i < j { (i, j) } else { (j, i) };
127 edge_set.insert((lo, hi));
128 n_matched += 1;
129 }
130 _ => {
131 n_skipped += 1;
132 }
133 }
134 }
135
136 let mut feature_edges: Vec<(usize, usize)> = edge_set.into_iter().collect();
137 feature_edges.par_sort_unstable();
138 info!(
139 "Feature-pair graph: {} edges loaded from {} ({} matched, {} skipped, {} unique)",
140 read_out.lines.len(),
141 file_path,
142 n_matched,
143 n_skipped,
144 feature_edges.len(),
145 );
146 Ok(Self {
147 feature_names,
148 n_features,
149 feature_edges,
150 })
151 }
152
153 pub fn filter_edges(&mut self, keep_indices: &[usize]) {
155 self.feature_edges = keep_indices
156 .iter()
157 .map(|&i| self.feature_edges[i])
158 .collect();
159 }
160
161 pub fn num_edges(&self) -> usize {
162 self.feature_edges.len()
163 }
164
165 pub fn num_features(&self) -> usize {
166 self.n_features
167 }
168
169 pub fn feature_degrees(&self) -> Vec<usize> {
171 let mut d = vec![0usize; self.n_features];
172 for &(u, v) in &self.feature_edges {
173 d[u] += 1;
174 d[v] += 1;
175 }
176 d
177 }
178
179 pub fn build_directed_adjacency(&self) -> Vec<Vec<(usize, usize)>> {
182 let mut adj: Vec<Vec<(usize, usize)>> = vec![Vec::new(); self.n_features];
183 for (edge_idx, &(g1, g2)) in self.feature_edges.iter().enumerate() {
184 adj[g1].push((g2, edge_idx));
185 }
186 adj
187 }
188
189 fn build_adj_csr(&self) -> AdjCsr {
194 let n = self.n_features;
195 let mut per_row: Vec<Vec<u32>> = (0..n).map(|_| Vec::new()).collect();
196 for &(u, v) in &self.feature_edges {
197 per_row[u].push(v as u32);
198 per_row[v].push(u as u32);
199 }
200 per_row.par_iter_mut().for_each(|row| row.sort_unstable());
201
202 let total: usize = per_row.iter().map(Vec::len).sum();
203 let mut row_ptr = Vec::with_capacity(n + 1);
204 row_ptr.push(0);
205 let mut col_idx = Vec::with_capacity(total);
206 for row in per_row.iter() {
207 col_idx.extend_from_slice(row);
208 row_ptr.push(col_idx.len());
209 }
210 AdjCsr { row_ptr, col_idx }
211 }
212
213 pub fn shared_neighbor_counts(&self, pairs: &[(usize, usize)]) -> Vec<usize> {
222 let csr = self.build_adj_csr();
223 pairs
224 .par_iter()
225 .map(|&(u, v)| intersect_count(csr.row(u), csr.row(v)))
226 .collect()
227 }
228
229 pub fn augment_with_snn(&mut self, min_shared: usize) {
240 if min_shared == 0 {
241 return;
242 }
243 let csr = self.build_adj_csr();
244 let existing: HashSet<(u32, u32)> = self
245 .feature_edges
246 .iter()
247 .map(|&(u, v)| (u as u32, v as u32))
248 .collect();
249
250 let new_edges: Vec<(usize, usize)> = (0..self.n_features)
251 .into_par_iter()
252 .filter(|&u| !csr.row(u).is_empty())
253 .flat_map_iter(|u| {
254 let mut seen: HashSet<u32> = HashSet::default();
255 let mut local: Vec<(usize, usize)> = Vec::new();
256 let ru = csr.row(u);
257 for &m in ru {
258 for &v in csr.row(m as usize) {
259 if (v as usize) <= u {
260 continue;
261 }
262 if !seen.insert(v) {
263 continue;
264 }
265 if existing.contains(&(u as u32, v)) {
266 continue;
267 }
268 let rv = csr.row(v as usize);
269 if intersect_count(ru, rv) >= min_shared {
270 local.push((u, v as usize));
271 }
272 }
273 }
274 local
275 })
276 .collect();
277
278 if !new_edges.is_empty() {
279 let added = new_edges.len();
280 self.feature_edges.extend(new_edges);
281 self.feature_edges.par_sort_unstable();
282 self.feature_edges.dedup();
283 info!(
284 "SNN augmentation (min_shared={}): +{} edges ({} total)",
285 min_shared,
286 added,
287 self.feature_edges.len(),
288 );
289 }
290 }
291
292 pub fn prune_by_shared_neighbors(&mut self, min_shared: usize) {
297 if min_shared == 0 || self.feature_edges.is_empty() {
298 return;
299 }
300 let initial = self.feature_edges.len();
301 let snapshot = self.feature_edges.clone();
302 let counts = self.shared_neighbor_counts(&snapshot);
303 self.feature_edges = snapshot
304 .into_par_iter()
305 .zip(counts.into_par_iter())
306 .filter_map(|(e, c)| (c >= min_shared).then_some(e))
307 .collect();
308 self.feature_edges.par_sort_unstable();
310 let kept = self.feature_edges.len();
311 if kept != initial {
312 info!(
313 "shared-neighbor QC (min_shared={}): {} edges → {} edges",
314 min_shared, initial, kept,
315 );
316 }
317 }
318
319 pub fn cap_per_node_degree(&mut self, max_degree: usize) {
327 if max_degree == 0 || self.feature_edges.is_empty() {
328 return;
329 }
330 let initial = self.feature_edges.len();
331 let snapshot = self.feature_edges.clone();
332 let sn_scores = self.shared_neighbor_counts(&snapshot);
333
334 let mut per_node: Vec<Vec<(u32, u32, u32)>> = vec![Vec::new(); self.n_features];
335 for (i, &(u, v)) in snapshot.iter().enumerate() {
336 per_node[u].push((v as u32, sn_scores[i] as u32, i as u32));
337 per_node[v].push((u as u32, sn_scores[i] as u32, i as u32));
338 }
339
340 let kept_per_node: Vec<Vec<u32>> = per_node
341 .par_iter_mut()
342 .map(|edges_of_u| {
343 if edges_of_u.len() <= max_degree {
344 return edges_of_u.iter().map(|&(_, _, idx)| idx).collect();
345 }
346 edges_of_u.sort_unstable_by_key(|&(nbr, score, _)| (std::cmp::Reverse(score), nbr));
347 edges_of_u
348 .iter()
349 .take(max_degree)
350 .map(|&(_, _, idx)| idx)
351 .collect()
352 })
353 .collect();
354
355 let mut keep: Vec<bool> = vec![false; snapshot.len()];
356 for kept_idxs in &kept_per_node {
357 for &idx in kept_idxs {
358 keep[idx as usize] = true;
359 }
360 }
361 self.feature_edges = snapshot
362 .into_iter()
363 .zip(keep.iter())
364 .filter_map(|(e, &k)| k.then_some(e))
365 .collect();
366 let kept = self.feature_edges.len();
367 if kept != initial {
368 info!(
369 "per-node degree cap (max={}, SN-score, union): {} edges → {} edges",
370 max_degree, initial, kept,
371 );
372 }
373 }
374
375 pub fn prune_by_min_degree(&mut self, min_degree: usize) {
381 if min_degree == 0 || self.feature_edges.is_empty() {
382 return;
383 }
384 let initial = self.feature_edges.len();
385 loop {
386 let degrees = self.feature_degrees();
387 let drop: Vec<bool> = degrees.iter().map(|&d| d > 0 && d < min_degree).collect();
388 if !drop.iter().any(|&x| x) {
389 break;
390 }
391 self.feature_edges.retain(|&(u, v)| !drop[u] && !drop[v]);
392 if self.feature_edges.is_empty() {
393 break;
394 }
395 }
396 let final_n = self.feature_edges.len();
397 if final_n != initial {
398 info!(
399 "k-core pruning (min_degree={}): {} edges → {} edges",
400 min_degree, initial, final_n,
401 );
402 }
403 }
404
405 pub fn shared_neighbor_edges(
425 &self,
426 min_shared: usize,
427 top_k: usize,
428 ) -> Vec<(usize, usize, usize, usize)> {
429 if min_shared == 0 {
430 return Vec::new();
431 }
432 let csr = self.build_adj_csr();
433 let existing: HashSet<(u32, u32)> = self
434 .feature_edges
435 .iter()
436 .map(|&(u, v)| (u as u32, v as u32))
437 .collect();
438 let per_node: Vec<Vec<(usize, usize, usize)>> = (0..self.n_features)
442 .into_par_iter()
443 .map(|u| {
444 let ru = csr.row(u);
445 if ru.is_empty() {
446 return Vec::new();
447 }
448 let mut seen: HashSet<u32> = HashSet::default();
449 let mut local: Vec<(usize, usize, usize)> = Vec::new();
450 for &m in ru {
451 for &v in csr.row(m as usize) {
452 let key = if (v as usize) < u {
453 (v, u as u32)
454 } else {
455 (u as u32, v)
456 };
457 if v as usize == u || !seen.insert(v) || existing.contains(&key) {
458 continue;
459 }
460 let rv = csr.row(v as usize);
461 let c = intersect_count(ru, rv);
462 if c >= min_shared {
463 local.push((v as usize, c, ru.len() + rv.len() - c));
464 }
465 }
466 }
467 if top_k > 0 && local.len() > top_k {
468 local.sort_unstable_by(|&(v, c, un), &(v2, c2, un2)| {
470 (c2 * un).cmp(&(c * un2)).then(c2.cmp(&c)).then(v.cmp(&v2))
471 });
472 local.truncate(top_k);
473 }
474 local
475 })
476 .collect();
477 let mut out: Vec<(usize, usize, usize, usize)> = per_node
478 .into_iter()
479 .enumerate()
480 .flat_map(|(u, vs)| {
481 vs.into_iter()
482 .map(move |(v, c, un)| (u.min(v), u.max(v), c, un))
483 })
484 .collect();
485 out.par_sort_unstable();
486 out.dedup();
487 out
488 }
489
490 pub fn personalized_pagerank_top_k(
500 &self,
501 alpha: f64,
502 eps: f64,
503 k: usize,
504 ) -> Vec<Vec<(usize, f32)>> {
505 let csr = self.build_adj_csr();
506 let n = self.n_features;
507 let alpha = alpha.clamp(1e-6, 1.0);
508 let eps = eps.max(1e-12);
509 (0..n)
510 .into_par_iter()
511 .map(|s| {
512 if csr.row(s).is_empty() || k == 0 {
513 return Vec::new();
514 }
515 let mut p: FxHashMap<u32, f64> = FxHashMap::default();
518 let mut r: FxHashMap<u32, f64> = FxHashMap::default();
519 r.insert(s as u32, 1.0);
520 let mut queue: Vec<u32> = vec![s as u32];
521 let mut queued: HashSet<u32> = HashSet::default();
522 queued.insert(s as u32);
523 while let Some(u) = queue.pop() {
524 queued.remove(&u);
525 let du = csr.row(u as usize).len() as f64;
526 let ru = r.get(&u).copied().unwrap_or(0.0);
527 if du == 0.0 || ru < eps * du {
528 continue;
529 }
530 *p.entry(u).or_default() += alpha * ru;
531 let push = (1.0 - alpha) * ru / du;
532 r.insert(u, 0.0);
533 for &v in csr.row(u as usize) {
534 let rv = r.entry(v).or_default();
535 *rv += push;
536 let dv = csr.row(v as usize).len() as f64;
537 if *rv >= eps * dv && queued.insert(v) {
538 queue.push(v);
539 }
540 }
541 }
542 let mut top: Vec<(usize, f32)> = p
543 .into_iter()
544 .filter(|&(v, _)| v as usize != s)
545 .map(|(v, m)| (v as usize, m as f32))
546 .collect();
547 top.sort_by(|a, b| {
548 b.1.partial_cmp(&a.1)
549 .unwrap_or(std::cmp::Ordering::Equal)
550 .then(a.0.cmp(&b.0))
551 });
552 top.truncate(k);
553 top
554 })
555 .collect()
556 }
557
558 pub fn to_adj_list(&self) -> AdjListGraph {
561 AdjListGraph::from_unweighted_edges(self.n_features, &self.feature_edges)
562 }
563
564 pub fn to_parquet(&self, file_path: &str, col_names: (&str, &str)) -> anyhow::Result<()> {
568 let n_edges = self.feature_edges.len();
569 let column_names: Vec<Box<str>> = vec![col_names.0.into(), col_names.1.into()];
570 let column_types = vec![ParquetType::BYTE_ARRAY, ParquetType::BYTE_ARRAY];
571
572 let shape = (n_edges, column_names.len());
573 let writer = ParquetWriter::new(
574 file_path,
575 shape,
576 (None, Some(&column_names)),
577 Some(&column_types),
578 None,
579 )?;
580 let row_names = writer.row_names_vec();
581 let mut writer = writer.get_writer()?;
582 let mut row_group_writer = writer.next_row_group()?;
583
584 parquet_add_bytearray(&mut row_group_writer, row_names)?;
585
586 let names1: Vec<Box<str>> = self
587 .feature_edges
588 .par_iter()
589 .map(|&(g1, _)| self.feature_names[g1].clone())
590 .collect();
591 parquet_add_string_column(&mut row_group_writer, &names1)?;
592
593 let names2: Vec<Box<str>> = self
594 .feature_edges
595 .par_iter()
596 .map(|&(_, g2)| self.feature_names[g2].clone())
597 .collect();
598 parquet_add_string_column(&mut row_group_writer, &names2)?;
599
600 row_group_writer.close()?;
601 writer.close()?;
602 Ok(())
603 }
604}
605
606pub fn test_graph_from_edges(edges: &[(usize, usize)], n_features: usize) -> FeaturePairGraph {
609 let names: Vec<Box<str>> = (0..n_features).map(|i| format!("g{}", i).into()).collect();
610 let mut canonical: Vec<(usize, usize)> = edges
611 .iter()
612 .map(|&(a, b)| if a < b { (a, b) } else { (b, a) })
613 .collect();
614 canonical.sort();
615 canonical.dedup();
616 FeaturePairGraph {
617 feature_names: names,
618 n_features,
619 feature_edges: canonical,
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 fn two_triangles() -> FeaturePairGraph {
630 FeaturePairGraph {
631 feature_names: (0..8).map(|i| format!("g{i}").into_boxed_str()).collect(),
632 n_features: 8,
633 feature_edges: vec![
634 (0, 1),
635 (0, 2),
636 (1, 2),
637 (2, 3),
638 (3, 4),
639 (3, 5),
640 (4, 5),
641 (0, 6),
642 ],
643 }
644 }
645
646 #[test]
647 fn shared_neighbor_edges_are_second_order_only_with_their_counts() {
648 let g = two_triangles();
649 let snn = g.shared_neighbor_edges(1, 0);
653 let pairs: Vec<(usize, usize, usize)> = snn.iter().map(|&(u, v, c, _)| (u, v, c)).collect();
654 assert_eq!(
655 pairs,
656 vec![
657 (0, 3, 1),
658 (1, 3, 1),
659 (1, 6, 1),
660 (2, 4, 1),
661 (2, 5, 1),
662 (2, 6, 1)
663 ]
664 );
665 assert!(snn
666 .iter()
667 .all(|&(u, v, _, _)| !g.feature_edges.contains(&(u, v))));
668 assert!(snn.contains(&(1, 6, 1, 2)));
670 assert!(snn.contains(&(0, 3, 1, 5)), "deg 3 + deg 3 − 1");
671 assert!(
672 g.shared_neighbor_edges(2, 0).is_empty(),
673 "no pair shares two neighbours"
674 );
675 assert!(g.shared_neighbor_edges(0, 10).is_empty());
676 let top1 = g.shared_neighbor_edges(1, 1);
682 assert_eq!(top1, snn);
683 let star = FeaturePairGraph {
687 feature_names: (0..6).map(|i| format!("g{i}").into_boxed_str()).collect(),
688 n_features: 6,
689 feature_edges: (1..6).map(|i| (0, i)).collect(),
690 };
691 assert_eq!(star.shared_neighbor_edges(1, 0).len(), 10);
692 let s1: Vec<(usize, usize)> = star
693 .shared_neighbor_edges(1, 1)
694 .iter()
695 .map(|&(u, v, _, _)| (u, v))
696 .collect();
697 assert_eq!(s1, vec![(1, 2), (1, 3), (1, 4), (1, 5)]);
698 let h = FeaturePairGraph {
700 feature_names: (0..4).map(|i| format!("g{i}").into_boxed_str()).collect(),
701 n_features: 4,
702 feature_edges: vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3)],
703 };
704 assert_eq!(h.shared_neighbor_edges(2, 0), vec![(2, 3, 2, 2)]);
705 }
706
707 #[test]
708 fn personalized_pagerank_ranks_the_own_triangle_above_the_far_one_and_skips_isolated_nodes() {
709 let g = two_triangles();
710 let ppr = g.personalized_pagerank_top_k(0.15, 1e-6, 3);
711 assert_eq!(ppr.len(), 8);
712 assert!(ppr[7].is_empty(), "isolated source has no targets");
713 let top0: Vec<usize> = ppr[0].iter().map(|&(v, _)| v).collect();
714 assert!(!top0.contains(&0), "self excluded");
715 assert_eq!(top0.len(), 3);
716 assert!(
717 top0.contains(&1) && top0.contains(&2),
718 "own triangle first: {top0:?}"
719 );
720 assert!(
721 !top0.contains(&4) && !top0.contains(&5),
722 "far triangle beyond the top 3: {top0:?}"
723 );
724 let s0: Vec<f32> = ppr[0].iter().map(|&(_, m)| m).collect();
726 assert!(s0.windows(2).all(|w| w[0] >= w[1]));
727 assert!(s0.iter().all(|&m| m > 0.0 && m <= 1.0));
728 let rank = |src: usize, v: usize| ppr[src].iter().position(|&(t, _)| t == v);
730 let full = g.personalized_pagerank_top_k(0.15, 1e-7, 7);
731 let rank_full = |src: usize, v: usize| full[src].iter().position(|&(t, _)| t == v).unwrap();
732 assert!(rank_full(4, 2) < rank_full(4, 0));
733 assert!(rank(4, 3).is_some() && rank(4, 5).is_some());
734 }
735 use std::io::Write;
736 use tempfile::NamedTempFile;
737
738 fn names_of(names: &[&str]) -> Vec<Box<str>> {
739 names.iter().map(|&s| s.into()).collect()
740 }
741
742 fn write_edge_file(lines: &[&str]) -> NamedTempFile {
743 let mut f = NamedTempFile::with_suffix(".tsv").unwrap();
744 for line in lines {
745 writeln!(f, "{}", line).unwrap();
746 }
747 f.flush().unwrap();
748 f
749 }
750
751 #[test]
752 fn from_edge_list_exact_match() {
753 let file = write_edge_file(&["TP53\tBRCA1", "BRCA1\tEGFR", "TP53\tEGFR"]);
754 let names = names_of(&["TP53", "BRCA1", "EGFR", "MYC"]);
755 let g = FeaturePairGraph::from_edge_list(file.path().to_str().unwrap(), names, false, None)
756 .unwrap();
757 assert_eq!(g.num_features(), 4);
758 assert_eq!(g.num_edges(), 3);
759 assert_eq!(g.feature_edges, vec![(0, 1), (0, 2), (1, 2)]);
760 }
761
762 #[test]
763 fn from_edge_list_dedup_and_self_loop() {
764 let file = write_edge_file(&["A\tB", "B\tA", "A\tB", "A\tA"]);
765 let names = names_of(&["A", "B", "C"]);
766 let g = FeaturePairGraph::from_edge_list(file.path().to_str().unwrap(), names, false, None)
767 .unwrap();
768 assert_eq!(g.feature_edges, vec![(0, 1)]);
769 }
770
771 #[test]
772 fn from_edge_list_unmatched_skipped() {
773 let file = write_edge_file(&["TP53\tBRCA1", "UNK\tBRCA1", "TP53\tUNK2"]);
774 let names = names_of(&["TP53", "BRCA1"]);
775 let g = FeaturePairGraph::from_edge_list(file.path().to_str().unwrap(), names, false, None)
776 .unwrap();
777 assert_eq!(g.feature_edges, vec![(0, 1)]);
778 }
779
780 #[test]
781 fn from_edge_list_prefix_and_delim_match() {
782 let file = write_edge_file(&["TP53\tBRCA1"]);
783 let g_prefix = FeaturePairGraph::from_edge_list(
784 file.path().to_str().unwrap(),
785 names_of(&["TP53.1", "BRCA1.2"]),
786 true,
787 None,
788 )
789 .unwrap();
790 assert_eq!(g_prefix.num_edges(), 1);
791
792 let g_delim = FeaturePairGraph::from_edge_list(
793 file.path().to_str().unwrap(),
794 names_of(&["TP53.v1", "BRCA1.v2"]),
795 false,
796 Some('.'),
797 )
798 .unwrap();
799 assert_eq!(g_delim.num_edges(), 1);
800 }
801
802 #[test]
803 fn from_edge_list_csv() {
804 let mut f = NamedTempFile::with_suffix(".csv").unwrap();
805 writeln!(f, "A,B").unwrap();
806 writeln!(f, "B,C").unwrap();
807 f.flush().unwrap();
808 let g = FeaturePairGraph::from_edge_list(
809 f.path().to_str().unwrap(),
810 names_of(&["A", "B", "C"]),
811 false,
812 None,
813 )
814 .unwrap();
815 assert_eq!(g.feature_edges, vec![(0, 1), (1, 2)]);
816 }
817
818 #[test]
819 fn feature_degrees_triangle() {
820 let g = test_graph_from_edges(&[(0, 1), (0, 2), (1, 2)], 3);
821 assert_eq!(g.feature_degrees(), vec![2, 2, 2]);
822 }
823
824 #[test]
825 fn shared_neighbors_triangle() {
826 let g = test_graph_from_edges(&[(0, 1), (0, 2), (1, 2)], 3);
828 let counts = g.shared_neighbor_counts(&[(0, 1), (0, 2), (1, 2)]);
829 assert_eq!(counts, vec![1, 1, 1]);
830 }
831
832 #[test]
833 fn shared_neighbors_path() {
834 let g = test_graph_from_edges(&[(0, 1), (1, 2), (2, 3)], 4);
836 let counts = g.shared_neighbor_counts(&[(0, 2), (0, 3), (1, 3)]);
837 assert_eq!(counts, vec![1, 0, 1]);
838 }
839
840 #[test]
841 fn snn_zero_is_noop() {
842 let mut g = test_graph_from_edges(&[(0, 1), (1, 2)], 3);
843 let before = g.feature_edges.clone();
844 g.augment_with_snn(0);
845 assert_eq!(g.feature_edges, before);
846 }
847
848 #[test]
849 fn snn_two_hop() {
850 let mut g = test_graph_from_edges(&[(0, 1), (1, 2)], 3);
851 g.augment_with_snn(1);
852 assert!(g.feature_edges.contains(&(0, 2)));
853 assert_eq!(g.feature_edges.len(), 3);
854 }
855
856 #[test]
857 fn snn_respects_min_shared() {
858 let mut g = test_graph_from_edges(&[(0, 1), (1, 3)], 4);
859 g.augment_with_snn(2);
860 assert_eq!(g.feature_edges.len(), 2);
861 }
862
863 #[test]
864 fn snn_no_duplicates() {
865 let mut g = test_graph_from_edges(&[(0, 1), (0, 2), (1, 2)], 3);
866 g.augment_with_snn(1);
867 let mut dedup = g.feature_edges.clone();
868 dedup.sort();
869 dedup.dedup();
870 assert_eq!(dedup, g.feature_edges);
871 }
872
873 #[test]
874 fn sn_prune_zero_is_noop() {
875 let mut g = test_graph_from_edges(&[(0, 1), (1, 2)], 3);
876 let before = g.feature_edges.clone();
877 g.prune_by_shared_neighbors(0);
878 assert_eq!(g.feature_edges, before);
879 }
880
881 #[test]
882 fn sn_prune_drops_isolated_edge() {
883 let mut g = test_graph_from_edges(&[(0, 1), (1, 2), (1, 3), (2, 3)], 4);
885 g.prune_by_shared_neighbors(1);
886 assert!(!g.feature_edges.contains(&(0, 1)));
887 assert!(g.feature_edges.contains(&(1, 2)));
888 assert!(g.feature_edges.contains(&(1, 3)));
889 assert!(g.feature_edges.contains(&(2, 3)));
890 assert_eq!(g.feature_edges.len(), 3);
891 }
892
893 #[test]
894 fn cap_zero_is_noop() {
895 let mut g = test_graph_from_edges(&[(0, 1), (0, 2), (1, 2)], 3);
896 let before = g.feature_edges.clone();
897 g.cap_per_node_degree(0);
898 assert_eq!(g.feature_edges, before);
899 }
900
901 #[test]
902 fn cap_drops_zero_cn_edge_via_union() {
903 let mut g = test_graph_from_edges(
909 &[
910 (0, 1),
911 (0, 2),
912 (0, 3),
913 (0, 4),
914 (1, 2),
915 (1, 3),
916 (4, 5),
917 (4, 6),
918 (5, 6),
919 ],
920 7,
921 );
922 g.cap_per_node_degree(2);
923 assert!(!g.feature_edges.contains(&(0, 4)));
924 assert!(g.feature_edges.contains(&(0, 1)));
926 }
927
928 #[test]
929 fn cap_union_symmetric() {
930 let mut g = test_graph_from_edges(&[(0, 1), (0, 2), (0, 4), (1, 2)], 5);
934 g.cap_per_node_degree(1);
935 assert!(g.feature_edges.contains(&(0, 4)));
938 }
939
940 #[test]
941 fn directed_adjacency() {
942 let g = test_graph_from_edges(&[(0, 1), (0, 2), (1, 2)], 3);
943 let adj = g.build_directed_adjacency();
944 assert_eq!(adj[0], vec![(1, 0), (2, 1)]);
945 assert_eq!(adj[1], vec![(2, 2)]);
946 assert!(adj[2].is_empty());
947 }
948}