Skip to main content

legume_numeric/matrix/
pair_graph.rs

1//! `FeaturePairGraph` — undirected graph over named features (genes,
2//! peaks, etc.) loaded from a two-column edge list (e.g. BioGRID, STRING,
3//! peak-coaccessibility).
4//!
5//! Name resolution piggy-backs on [`crate::matrix::membership::GeneIndexResolver`]
6//! (exact → delimiter → optional prefix). The result holds canonical
7//! undirected edges (`u < v`), de-duplicated and sorted, ready for
8//! downstream graph algorithms (Leiden, SGC propagation, link community).
9//!
10//! Every operation that needs row-neighbor access builds a directed CSR
11//! once and runs the inner kernel via rayon. The shared
12//! [`FeaturePairGraph::shared_neighbor_counts`] kernel drives SNN
13//! augmentation, shared-neighbor QC pruning, and hub-degree capping.
14
15use 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
30//////////////////////////////////////////////////////////////////////
31// Internal directed-CSR adjacency — built on demand, never stored. //
32//////////////////////////////////////////////////////////////////////
33
34struct AdjCsr {
35    /// `[n_features + 1]` offsets into `col_idx`.
36    row_ptr: Vec<usize>,
37    /// `[2 · E]` directed neighbors, each row sorted ascending.
38    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/// Sorted-merge intersection count for two ascending-sorted slices.
49/// Used by every shared-neighbor / SNN kernel.
50#[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
70//////////////////
71// Construction //
72//////////////////
73
74impl FeaturePairGraph {
75    /// Build a feature-pair graph from an external two-column edge list.
76    ///
77    /// Names are matched against `feature_names` via
78    /// `GeneIndexResolver` (exact → delimiter-stripped → optional prefix).
79    /// Self-loops, duplicates, and edges referencing unknown names are
80    /// dropped silently. The resulting `feature_edges` are canonical
81    /// (`u < v`), unique, and sorted.
82    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    /// Like [`Self::from_edge_list`] but canonicalizes both the feature
94    /// axis names and each edge endpoint through `canon` before matching.
95    /// Lets callers reuse a domain canonicalizer (e.g. `FeatureNameKind`
96    /// that normalizes gene symbols *and* `chrX:start-end` loci) so an
97    /// edge file with raw names resolves against a canonicalized axis.
98    /// The stored `feature_names` remain the originals.
99    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        // Resolver keyed on canonicalized names; index i still maps to
108        // feature_names[i] because canonicalization preserves order.
109        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    /// Keep only edges at the given indices.
154    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    /// Per-feature undirected degree from the canonical edge list.
170    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    /// Build directed adjacency: `adj[g] = [(neighbor, edge_idx)]` where
180    /// `neighbor > g` (so each undirected edge appears once).
181    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    /////////////////////////////////////////////
190    // CSR build (rayon-parallel per-row sort) //
191    /////////////////////////////////////////////
192
193    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    //////////////////////////////////
214    // Shared kernel: |N(u) ∩ N(v)| //
215    //////////////////////////////////
216
217    /// Common-neighbor count for each `(u, v)` in `pairs`, computed in
218    /// parallel via sorted-merge on the CSR rows. O(deg(u) + deg(v))
219    /// per pair. Self-loops (`u == v`) yield `deg(u)` (correct but
220    /// usually meaningless).
221    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    ///////////////////////////////////////////////////////////////////////
230    // Mutations: SNN augment, shared-neighbor QC prune, hub-cap, k-core //
231    ///////////////////////////////////////////////////////////////////////
232
233    /// Augment with shared-neighbor edges: any unordered pair `(u, v)`
234    /// with at least `min_shared` undirected neighbors in common gains a
235    /// synthetic edge (unless one is already present). `min_shared = 0`
236    /// is a no-op. Parallel over the outer node id; sorted-merge
237    /// intersection avoids the HashSet rebuild that the old serial
238    /// implementation paid per call.
239    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    /// QC prune: drop any edge `(u, v)` whose endpoints share fewer than
293    /// `min_shared` neighbors in the current graph. Standard PPI
294    /// denoising — an edge with no corroborating shared interactor is
295    /// likely a noisy hit. `min_shared = 0` is a no-op.
296    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        // par_iter zip may not preserve order; resort to canonical.
309        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    /// Per-node hard cap on degree, ranked by shared-neighbor count.
320    /// For each node `u` with `deg(u) > max_degree`, sort its neighbors
321    /// by `|N(u) ∩ N(v)|` descending (ties broken by neighbor id) and
322    /// keep the top `max_degree`. Symmetric union — an edge survives iff
323    /// *either* endpoint kept it. `max_degree = 0` is a no-op. Used to
324    /// cap PPI hubs whose degree would otherwise blow up the per-cell
325    /// sub-adjacency cache.
326    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    /// Iterative k-core: drop every feature whose current degree is
376    /// `< min_degree`, then recompute degrees and repeat until the
377    /// surviving subgraph is `(min_degree)`-degenerate. The feature axis
378    /// itself is kept the same — only edges incident to pruned features
379    /// are removed. `min_degree = 0` is a no-op.
380    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    ///////////////////////////////////////////////////////////////
406    // Derived relations: second-order neighbours, diffusion top-k //
407    ///////////////////////////////////////////////////////////////
408
409    /// Second-order edges: unordered pairs `(u, v)` NOT directly linked
410    /// that share at least `min_shared` neighbours, with the count. Where
411    /// [`Self::augment_with_snn`] folds such pairs into the graph
412    /// unweighted, this returns them on their own so a caller can treat
413    /// "co-interactors" as a relation distinct from "interactors",
414    /// weighted by how many partners they share. Each entry is
415    /// `(u, v, shared, union)` so a caller can weight by the raw count or
416    /// by the Jaccard overlap `shared / union`. `top_k > 0` keeps, for
417    /// every node, only its `top_k` co-interactors by Jaccard (ties by
418    /// count, then id) — the degree-normalised choice, since on a
419    /// scale-free graph a raw count is dominated by the hubs every pair
420    /// shares by chance — and a pair survives when either endpoint keeps
421    /// it, which bounds the result at `n · top_k` where "every pair
422    /// sharing one neighbour" would be quadratic. `min_shared = 0` yields
423    /// nothing. Canonical `u < v`, sorted.
424    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        // Per node, every second-order partner with the count, then the
439        // per-node top-k; the union of kept choices is folded to canonical
440        // pairs.
441        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                    // Jaccard descending: c/un > c'/un' ⇔ c·un' > c'·un.
469                    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    /// Personalized PageRank from every node, truncated to its `k`
491    /// strongest targets (self excluded), by the forward-push
492    /// approximation (Andersen, Chung & Lang 2006): random walk with
493    /// restart probability `alpha` on the unweighted graph, residual mass
494    /// per node pushed until every residual is below `eps · degree`. Local
495    /// and sparse, so the cost per source is `O(1/(eps · alpha))`
496    /// regardless of graph size; sources run in parallel. Scores are the
497    /// PPR mass in `(0, 1]`; the caller decides how to weight them.
498    /// Isolated nodes get an empty list.
499    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                // Sparse push with a work queue; `p` and `r` live in hash maps
516                // because a source touches a small neighbourhood.
517                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    /// Symmetric adjacency-list view implementing
559    /// `crate::matrix::graph::WeightedGraph` (for Leiden, SGC, etc.).
560    pub fn to_adj_list(&self) -> AdjListGraph {
561        AdjListGraph::from_unweighted_edges(self.n_features, &self.feature_edges)
562    }
563
564    /// Write the canonical edge list (two columns of feature names) to
565    /// parquet. `col_names` lets callers pick `("gene1","gene2")`,
566    /// `("peak1","peak2")`, etc.
567    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
606/// Synthetic test graph with feature names `g0..g{n-1}` from an unordered
607/// edge list. Public so downstream test modules can reuse it.
608pub 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    /// Two triangles {0,1,2} and {3,4,5} joined by the edge (2,3), plus a
628    /// pendant 6 on node 0 and an isolated node 7.
629    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        // Pairs sharing ≥ 1 neighbour that are not directly linked:
650        // (1,6) via 0; (2,6) via 0; (1,3) via 2; (0,3) via 2; (2,4) via 3;
651        // (2,5) via 3. Directly linked pairs (0,1) etc. never appear.
652        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        // union = deg(u) + deg(v) − shared: (1,6) has degrees 2 and 1 → 2.
669        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        // top-1 per node by Jaccard, and a pair survives when either side
677        // keeps it. Here every pair survives: 2 keeps 6 (union 3 beats the
678        // union-4 ties with 4 and 5), but 4 and 5 each have 2 as their only
679        // candidate, and 3 keeps 1 (union 4) over 0 (union 5) while 0's only
680        // candidate is 3.
681        let top1 = g.shared_neighbor_edges(1, 1);
682        assert_eq!(top1, snn);
683        // Truncation bites on a star: the five leaves pairwise share the hub
684        // (10 pairs, all Jaccard 1/2), and with k = 1 each leaf keeps the
685        // lowest-id other leaf, so only leaf 1's four pairs survive.
686        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        // A denser case: a 4-clique minus one edge — the missing pair shares 2.
699        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        // Scores fall off with the rank and stay in (0, 1].
725        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        // From node 4, node 2 (two hops via 3) outranks node 0 (three hops).
729        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        // Triangle: every pair shares the third node as a shared neighbor.
827        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        // Path 0-1-2-3: (0,2) shares {1}, (0,3) shares {}, (1,3) shares {2}.
835        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        // 0-1 has no shared neighbor; 1-2-3-1 triangle is fully connected.
884        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        // Hub 0 = {1,2,3,4} (deg 4). Node 4 = {0,5,6} (deg 3) with 5-6
904        // also connected, so node 4's high-CN neighbors are {5,6}.
905        // Triangles 0-1-2 and 0-1-3 give CN(0,1)=2, CN(0,2)=CN(0,3)=1,
906        // CN(0,4)=0. Cap=2: hub 0 picks {1,2}; node 4 picks {5,6}.
907        // Neither endpoint ranks (0,4) in its top-2; union drops it.
908        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        // Highest-CN edge from hub 0 survives.
925        assert!(g.feature_edges.contains(&(0, 1)));
926    }
927
928    #[test]
929    fn cap_union_symmetric() {
930        // Even if a hub's cap drops an edge, the *other* endpoint may
931        // still keep it — union semantics. Star with hub 0 capped to 1,
932        // but leaf 4 has only edge (0,4) so leaf 4 *must* keep it.
933        let mut g = test_graph_from_edges(&[(0, 1), (0, 2), (0, 4), (1, 2)], 5);
934        g.cap_per_node_degree(1);
935        // Hub 0 picks its highest-CN neighbor; leaf 4's only neighbor is 0,
936        // so (0,4) is in leaf 4's top-1 and survives via the union.
937        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}