Skip to main content

core_api/
algo.rs

1//! Graph algorithms: PageRank, weakly-connected components, degree centrality.
2//!
3//! ## Dependency rule note
4//!
5//! This module lives in `core-api` (not `core-query`) because it must read the
6//! *unified topology* — manual edges plus derived edges written by the rule
7//! engine via `GraphMut`. `core-query` has no dependency on `core-rules` and
8//! therefore cannot see derived provenance.  `GraphDb` fields are private; the
9//! algorithms are pure functions called from `GraphDb` methods that pass in a
10//! [`TopologyView`] over the in-memory overlay **and** the mmap V8 base. Reading
11//! the view (not the bare overlay `Topology`) is required: after a snapshot
12//! reopen the derived edges live in the base, and using the overlay alone would
13//! report zero degree/rank for every node.
14//!
15//! ## When to use views vs `degree_centrality`
16//!
17//! `degree_centrality` is a **one-shot compute**: call it, get a snapshot sorted
18//! by degree, done.  It does not persist anywhere and is not maintained as
19//! properties change.  Use it for offline analysis, ranking a batch, or feeding
20//! `write_scores` once.
21//!
22//! A **Degree materialized view** (`ViewDef { kind: AggFn::Degree, … }`) is
23//! *maintained incrementally*: every `insert_edge` / `delete_edge` /
24//! `insert_node` re-computes just the affected node's count and stores it as a
25//! live property.  Use it when you need the degree of individual nodes at query
26//! time with zero latency (e.g. `MATCH (n) WHERE n.out_degree > 5 RETURN n`).
27//!
28//! Rule of thumb: if you need the top-K by degree once → `degree_centrality`;
29//! if you need the degree of every node available in every Cypher query →
30//! create a Degree view.
31
32use core_query::Dir;
33use core_storage::v8::seam::TopologyView;
34use core_storage::{Direction, EdgePropsView, IdMap, Interner, Value};
35use serde::{Deserialize, Serialize};
36use std::collections::BTreeMap;
37use std::time::{Duration, Instant};
38
39// ---------------------------------------------------------------------------
40// Shared helpers
41// ---------------------------------------------------------------------------
42
43/// Collect the dense list of live node ids and their string keys.
44///
45/// Returns `(ids, keys)` where `ids[i]` is the internal u32 id for `keys[i]`.
46/// Tombstoned slots are skipped.  Stable order: ascending internal id.
47fn live_nodes(idmap: &IdMap, labels: &[u32]) -> (Vec<u32>, Vec<String>) {
48    let n = idmap.len() as u32;
49    let mut ids = Vec::new();
50    let mut keys = Vec::new();
51    for id in 0..n {
52        let Some(key) = idmap.key_of(id) else {
53            continue;
54        };
55        let Some(&sym) = labels.get(id as usize) else {
56            continue;
57        };
58        if sym == u32::MAX {
59            continue; // tombstoned
60        }
61        ids.push(id);
62        keys.push(key.to_string());
63    }
64    (ids, keys)
65}
66
67/// Resolve an optional edge-type name to its interned symbol.
68///
69/// Returns `None` if `edge_type` is `Some(name)` that is not interned (meaning
70/// no edges of that type exist).  Returns `Some(None)` when `edge_type` is
71/// `None` (all types).
72fn resolve_etype(syms: &Interner, edge_type: Option<&str>) -> Option<Option<u32>> {
73    match edge_type {
74        None => Some(None), // all etypes
75        Some(name) => {
76            let sym = syms.get(name)?; // not interned → no such edges
77            Some(Some(sym))
78        }
79    }
80}
81
82/// Iterate over etypes in the topology, optionally filtered to a single etype.
83fn etypes_filtered(topo: &TopologyView, filter: Option<u32>) -> Vec<u32> {
84    match filter {
85        Some(sym) => {
86            // Only include if the etype actually exists.
87            let all: Vec<u32> = topo.etypes().collect();
88            if all.contains(&sym) {
89                vec![sym]
90            } else {
91                vec![]
92            }
93        }
94        None => topo.etypes().collect(),
95    }
96}
97
98/// Resolve a list of edge-type names to their interned symbols, in the given
99/// order, de-duplicated. Unresolved names (not interned — no such edges
100/// exist) are silently skipped. An empty `names` means "all edge types".
101fn resolve_etypes_multi(syms: &Interner, topo: &TopologyView, names: &[String]) -> Vec<u32> {
102    if names.is_empty() {
103        return topo.etypes().collect();
104    }
105    let mut out = Vec::new();
106    for name in names {
107        if let Some(sym) = syms.get(name) {
108            if !out.contains(&sym) {
109                out.push(sym);
110            }
111        }
112    }
113    out
114}
115
116/// Like [`live_nodes`] but optionally restricted to nodes carrying `label`.
117/// `None` includes every live node; `Some(name)` not interned yields an empty
118/// result (no such nodes exist).
119fn live_nodes_for_label(
120    idmap: &IdMap,
121    syms: &Interner,
122    labels: &[u32],
123    label: Option<&str>,
124) -> (Vec<u32>, Vec<String>) {
125    let want = match label {
126        None => None,
127        Some(name) => match syms.get(name) {
128            Some(sym) => Some(sym),
129            None => return (Vec::new(), Vec::new()),
130        },
131    };
132    let n = idmap.len() as u32;
133    let mut ids = Vec::new();
134    let mut keys = Vec::new();
135    for id in 0..n {
136        let Some(key) = idmap.key_of(id) else {
137            continue;
138        };
139        let Some(&sym) = labels.get(id as usize) else {
140            continue;
141        };
142        if sym == u32::MAX {
143            continue; // tombstoned
144        }
145        if let Some(want_sym) = want {
146            if sym != want_sym {
147                continue;
148            }
149        }
150        ids.push(id);
151        keys.push(key.to_string());
152    }
153    (ids, keys)
154}
155
156/// Resolve the weight of edge `(etype, src, dst)`.
157///
158/// `weight_prop`: read this numeric edge property; missing or non-numeric
159/// values fall back to `1.0`. `None` treats every edge as weight `1.0`.
160/// `min_weight`: drop the edge (return `None`) when its resolved weight is
161/// below this threshold — applied regardless of whether `weight_prop` is set.
162fn edge_weight(
163    edge_props: &EdgePropsView,
164    etype: u32,
165    src: u32,
166    dst: u32,
167    weight_prop: Option<&str>,
168    min_weight: Option<f64>,
169) -> Option<f64> {
170    let w = match weight_prop {
171        None => 1.0,
172        Some(prop) => match edge_props.get(etype, src, dst, prop) {
173            Some(Value::Float(f)) => f,
174            Some(Value::Int(i)) => i as f64,
175            _ => 1.0,
176        },
177    };
178    match min_weight {
179        Some(min) if w < min => None,
180        _ => Some(w),
181    }
182}
183
184// ---------------------------------------------------------------------------
185// PageRank
186// ---------------------------------------------------------------------------
187
188/// Configuration for [`GraphDb::pagerank`].
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(default, deny_unknown_fields)]
191pub struct PageRankConfig {
192    /// Damping factor (probability of following an edge, not teleporting).
193    /// Default 0.85.
194    pub damping: f64,
195    /// Maximum number of power-iteration steps. Default 50.
196    pub max_iters: u32,
197    /// Convergence tolerance (L1 norm over all nodes). Default 1e-6.
198    pub tol: f64,
199    /// Restrict edges to this type. `None` uses all edge types (unified topology).
200    pub edge_type: Option<String>,
201    /// Edge direction to follow. `Dir::Out` follows out-edges (standard web
202    /// PageRank); `Dir::In` follows in-edges (authority scores); `Dir::Both`
203    /// treats all edges as undirected.
204    pub direction: AlgoDir,
205    /// Wall-clock budget (milliseconds) for the HTTP server endpoint.
206    /// `0` means no budget (run to convergence or `max_iters`).
207    pub budget_ms: u64,
208    /// Read this edge property as the edge weight; missing or non-numeric
209    /// values fall back to `1.0`. `None` treats every edge as weight `1.0`
210    /// (mass distributes uniformly across out-edges, as before).
211    pub weight_prop: Option<String>,
212    /// Drop edges whose resolved weight is below this threshold before the
213    /// algorithm runs. Applied whether or not `weight_prop` is set.
214    pub min_weight: Option<f64>,
215}
216
217impl Default for PageRankConfig {
218    fn default() -> Self {
219        Self {
220            damping: 0.85,
221            max_iters: 50,
222            tol: 1e-6,
223            edge_type: None,
224            direction: AlgoDir::Out,
225            budget_ms: 5_000,
226            weight_prop: None,
227            min_weight: None,
228        }
229    }
230}
231
232impl PageRankConfig {
233    /// Refuse a config [`GraphDb::pagerank`] cannot give a meaningful answer
234    /// for. `damping` is a probability: a NaN makes every score NaN, and a
235    /// value outside 0 to 1 makes the scores something other than a
236    /// distribution. `pagerank` itself returns a report, not a `Result`, so
237    /// a surface that takes a caller's config calls this first.
238    pub fn validate(&self) -> Result<(), String> {
239        if !(0.0..=1.0).contains(&self.damping) {
240            return Err(format!(
241                "damping must be a number from 0 to 1, got {}",
242                self.damping
243            ));
244        }
245        Ok(())
246    }
247}
248
249/// Result of [`GraphDb::pagerank`].
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct PageRankReport {
252    /// Node keys and their PageRank scores.  Sorted: score descending, key
253    /// ascending on ties (deterministic).
254    pub scores: Vec<(String, f64)>,
255    /// `true` if the algorithm converged before `max_iters` and before any time
256    /// budget fired.  `false` means scores are still valid but partial — more
257    /// iterations would refine them.
258    pub converged: bool,
259}
260
261/// Direction semantics for algo methods (mirrors `Dir` but serializable).
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "lowercase")]
264pub enum AlgoDir {
265    /// Follow outgoing edges only (standard directed PageRank / out-degree).
266    Out,
267    /// Follow incoming edges only (in-degree / authority score).
268    In,
269    /// Sum of out-degree and in-degree (a reciprocal pair counts 2 per endpoint).
270    /// Not the neighbour-set union `|N_out ∪ N_in|`.
271    Both,
272}
273
274impl From<Dir> for AlgoDir {
275    fn from(d: Dir) -> Self {
276        match d {
277            Dir::Out => AlgoDir::Out,
278            Dir::In => AlgoDir::In,
279            Dir::Both => AlgoDir::Both,
280        }
281    }
282}
283
284/// Run PageRank on the unified topology.
285///
286/// Returns a [`PageRankReport`] with scores sorted descending (ties: key asc).
287pub(crate) fn pagerank(
288    topo: &TopologyView,
289    idmap: &IdMap,
290    syms: &Interner,
291    labels: &[u32],
292    edge_props: &EdgePropsView,
293    config: &PageRankConfig,
294) -> PageRankReport {
295    let weighted = config.weight_prop.is_some() || config.min_weight.is_some();
296    let deadline = if config.budget_ms > 0 {
297        Some(Instant::now() + Duration::from_millis(config.budget_ms))
298    } else {
299        None
300    };
301
302    let (node_ids, node_keys) = live_nodes(idmap, labels);
303    let n = node_ids.len();
304
305    if n == 0 {
306        return PageRankReport {
307            scores: Vec::new(),
308            converged: true,
309        };
310    }
311
312    // Map internal id → compact index for fast array access.
313    let max_id = topo.etypes().count(); // just an upper bound check hint
314    let _ = max_id;
315    let mut id_to_idx: BTreeMap<u32, usize> = BTreeMap::new();
316    for (i, &id) in node_ids.iter().enumerate() {
317        id_to_idx.insert(id, i);
318    }
319
320    // Resolve etype filter.
321    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
322        None => {
323            // Edge type specified but not in graph → no edges, PR is uniform.
324            let score = 1.0 / n as f64;
325            let mut scores: Vec<(String, f64)> =
326                node_keys.iter().map(|k| (k.clone(), score)).collect();
327            scores.sort_by(|(ka, sa), (kb, sb)| {
328                sb.partial_cmp(sa)
329                    .unwrap_or(std::cmp::Ordering::Equal)
330                    .then(ka.cmp(kb))
331            });
332            return PageRankReport {
333                scores,
334                converged: true,
335            };
336        }
337        Some(f) => f,
338    };
339
340    let etypes = etypes_filtered(topo, etype_filter);
341
342    // Build adjacency list (compact index): for each compact node, which
343    // compact nodes does it "send" rank to (based on direction), and with
344    // what weight.  Unweighted mode dedups parallel edges to the same
345    // neighbor (matches pre-weight behavior exactly); weighted mode sums the
346    // resolved weight of every qualifying edge instance (accumulating across
347    // parallel edges / multiple edge types).
348    let mut send_to: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
349
350    for &et in &etypes {
351        for (i, &id) in node_ids.iter().enumerate() {
352            let dirs: &[Direction] = match config.direction {
353                AlgoDir::Out => &[Direction::Out],
354                AlgoDir::In => &[Direction::In],
355                AlgoDir::Both => &[Direction::Out, Direction::In],
356            };
357            for &dir in dirs {
358                for &nbr in topo.neighbors(et, dir, id).as_ref() {
359                    let Some(&j) = id_to_idx.get(&nbr) else {
360                        continue;
361                    };
362                    if weighted {
363                        let Some(w) = edge_weight(
364                            edge_props,
365                            et,
366                            id,
367                            nbr,
368                            config.weight_prop.as_deref(),
369                            config.min_weight,
370                        ) else {
371                            continue; // filtered by min_weight
372                        };
373                        if let Some(entry) = send_to[i].iter_mut().find(|(k, _)| *k == j) {
374                            entry.1 += w;
375                        } else {
376                            send_to[i].push((j, w));
377                        }
378                    } else if !send_to[i].iter().any(|(k, _)| *k == j) {
379                        send_to[i].push((j, 1.0));
380                    }
381                }
382            }
383        }
384    }
385
386    // Build receive_from[j] = list of (i, share) that send to j, where share
387    // is i's outgoing weight to j normalized by i's total outgoing weight.
388    // Also track dangling nodes (no outgoing weight).
389    let mut receive_from: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
390    let mut dangling: Vec<usize> = Vec::new();
391
392    for (i, send) in send_to.iter().enumerate() {
393        let out_weight: f64 = send.iter().map(|(_, w)| w).sum();
394        if send.is_empty() || out_weight <= 0.0 {
395            dangling.push(i);
396        } else {
397            for &(j, w) in send {
398                receive_from[j].push((i, w / out_weight));
399            }
400        }
401    }
402
403    // Power iteration.
404    let nf = n as f64;
405    let d = config.damping;
406    let teleport = (1.0 - d) / nf;
407    let mut pr: Vec<f64> = vec![1.0 / nf; n];
408    let mut converged = false;
409
410    for _iter in 0..config.max_iters {
411        // Check time budget between iterations.
412        if let Some(dl) = deadline {
413            if Instant::now() >= dl {
414                break;
415            }
416        }
417
418        // Sum PR leaked by dangling nodes → distribute uniformly.
419        let dangling_sum: f64 = dangling.iter().map(|&i| pr[i]).sum::<f64>() * d / nf;
420
421        let mut new_pr = vec![teleport + dangling_sum; n];
422        for j in 0..n {
423            let received: f64 = receive_from[j].iter().map(|&(i, w)| pr[i] * w).sum();
424            new_pr[j] += d * received;
425        }
426
427        // Check convergence: L1 norm.
428        let delta: f64 = pr
429            .iter()
430            .zip(new_pr.iter())
431            .map(|(a, b)| (a - b).abs())
432            .sum();
433        pr = new_pr;
434
435        if delta < config.tol {
436            converged = true;
437            break;
438        }
439    }
440
441    // Sort: score desc, key asc on ties.
442    let mut scores: Vec<(String, f64)> = node_keys.into_iter().zip(pr).collect();
443    scores.sort_by(|(ka, sa), (kb, sb)| {
444        sb.partial_cmp(sa)
445            .unwrap_or(std::cmp::Ordering::Equal)
446            .then(ka.cmp(kb))
447    });
448
449    PageRankReport { scores, converged }
450}
451
452// ---------------------------------------------------------------------------
453// Weakly-connected components (WCC)
454// ---------------------------------------------------------------------------
455
456/// Configuration for [`GraphDb::connected_components`].
457#[derive(Debug, Clone, Serialize, Deserialize)]
458#[serde(default, deny_unknown_fields)]
459pub struct WccConfig {
460    /// Restrict edges to this type. `None` uses all edge types.
461    pub edge_type: Option<String>,
462    /// Wall-clock budget (milliseconds) for the HTTP server endpoint.
463    pub budget_ms: u64,
464    /// Read this edge property as the edge weight; missing or non-numeric
465    /// values fall back to `1.0`. Only used together with `min_weight` — WCC
466    /// itself is unweighted, but a weighted edge can still be filtered out.
467    pub weight_prop: Option<String>,
468    /// Drop edges whose resolved weight is below this threshold before the
469    /// algorithm runs. Applied whether or not `weight_prop` is set.
470    pub min_weight: Option<f64>,
471}
472
473impl Default for WccConfig {
474    fn default() -> Self {
475        Self {
476            edge_type: None,
477            budget_ms: 5_000,
478            weight_prop: None,
479            min_weight: None,
480        }
481    }
482}
483
484/// Result of [`GraphDb::connected_components`].
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct WccReport {
487    /// Each live node and the key of the smallest member of its component
488    /// (deterministic component identifier).  Sorted by (component_id, key).
489    pub components: Vec<(String, String)>,
490    /// `true` if the time budget fired before all nodes were processed.
491    pub truncated: bool,
492}
493
494/// Union-Find with path compression and union by rank.
495struct UnionFind {
496    parent: Vec<usize>,
497    rank: Vec<u8>,
498}
499
500impl UnionFind {
501    fn new(n: usize) -> Self {
502        Self {
503            parent: (0..n).collect(),
504            rank: vec![0; n],
505        }
506    }
507
508    fn find(&mut self, mut x: usize) -> usize {
509        while self.parent[x] != x {
510            self.parent[x] = self.parent[self.parent[x]]; // path halving
511            x = self.parent[x];
512        }
513        x
514    }
515
516    fn union(&mut self, a: usize, b: usize) {
517        let ra = self.find(a);
518        let rb = self.find(b);
519        if ra == rb {
520            return;
521        }
522        match self.rank[ra].cmp(&self.rank[rb]) {
523            std::cmp::Ordering::Less => self.parent[ra] = rb,
524            std::cmp::Ordering::Greater => self.parent[rb] = ra,
525            std::cmp::Ordering::Equal => {
526                self.parent[rb] = ra;
527                self.rank[ra] += 1;
528            }
529        }
530    }
531}
532
533/// Run weakly-connected components on the unified topology (undirected).
534///
535/// Component ID is the smallest member key in each component (deterministic).
536pub(crate) fn wcc(
537    topo: &TopologyView,
538    idmap: &IdMap,
539    syms: &Interner,
540    labels: &[u32],
541    edge_props: &EdgePropsView,
542    config: &WccConfig,
543) -> WccReport {
544    let weighted = config.weight_prop.is_some() || config.min_weight.is_some();
545    let deadline = if config.budget_ms > 0 {
546        Some(Instant::now() + Duration::from_millis(config.budget_ms))
547    } else {
548        None
549    };
550
551    let (node_ids, node_keys) = live_nodes(idmap, labels);
552    let n = node_ids.len();
553
554    if n == 0 {
555        return WccReport {
556            components: Vec::new(),
557            truncated: false,
558        };
559    }
560
561    // Map internal id → compact index.
562    let mut id_to_idx: BTreeMap<u32, usize> = BTreeMap::new();
563    for (i, &id) in node_ids.iter().enumerate() {
564        id_to_idx.insert(id, i);
565    }
566
567    // Resolve etype filter.
568    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
569        None => {
570            // No edges of this type → every node is its own component.
571            let mut components: Vec<(String, String)> =
572                node_keys.iter().map(|k| (k.clone(), k.clone())).collect();
573            components.sort();
574            return WccReport {
575                components,
576                truncated: false,
577            };
578        }
579        Some(f) => f,
580    };
581
582    let etypes = etypes_filtered(topo, etype_filter);
583
584    let mut uf = UnionFind::new(n);
585    let mut truncated = false;
586
587    // Union all edges (both directions — WCC treats graph as undirected).
588    'outer: for &et in &etypes {
589        for (i, &id) in node_ids.iter().enumerate() {
590            if let Some(dl) = deadline {
591                if Instant::now() >= dl {
592                    truncated = true;
593                    break 'outer;
594                }
595            }
596            // Out-edges: union i with each out-neighbor.
597            for &nbr in topo.neighbors(et, Direction::Out, id).as_ref() {
598                if let Some(&j) = id_to_idx.get(&nbr) {
599                    if weighted
600                        && edge_weight(
601                            edge_props,
602                            et,
603                            id,
604                            nbr,
605                            config.weight_prop.as_deref(),
606                            config.min_weight,
607                        )
608                        .is_none()
609                    {
610                        continue; // filtered by min_weight
611                    }
612                    uf.union(i, j);
613                }
614            }
615            // In-edges handled by the mirror out-edge from the other side,
616            // but we also cover them here for safety (e.g., self-loops, or
617            // nodes with only in-edges for a filtered etype).
618            for &nbr in topo.neighbors(et, Direction::In, id).as_ref() {
619                if let Some(&j) = id_to_idx.get(&nbr) {
620                    if weighted
621                        && edge_weight(
622                            edge_props,
623                            et,
624                            nbr,
625                            id,
626                            config.weight_prop.as_deref(),
627                            config.min_weight,
628                        )
629                        .is_none()
630                    {
631                        continue; // filtered by min_weight
632                    }
633                    uf.union(i, j);
634                }
635            }
636        }
637    }
638
639    // Determine component representative: smallest key per root.
640    let mut root_min_key: BTreeMap<usize, &str> = BTreeMap::new();
641    for (i, key_str) in node_keys.iter().enumerate() {
642        let root = uf.find(i);
643        let key = key_str.as_str();
644        let entry = root_min_key.entry(root).or_insert(key);
645        if key < *entry {
646            *entry = key;
647        }
648    }
649
650    let mut components: Vec<(String, String)> = node_keys
651        .iter()
652        .enumerate()
653        .map(|(i, key_str)| {
654            let root = uf.find(i);
655            let comp_id = root_min_key[&root].to_string();
656            (key_str.clone(), comp_id)
657        })
658        .collect();
659    components.sort_by(|(ka, ca), (kb, cb)| ca.cmp(cb).then(ka.cmp(kb)));
660
661    WccReport {
662        components,
663        truncated,
664    }
665}
666
667// ---------------------------------------------------------------------------
668// Degree centrality
669// ---------------------------------------------------------------------------
670
671/// Configuration for [`GraphDb::degree_centrality`].
672#[derive(Debug, Clone, Serialize, Deserialize)]
673#[serde(default, deny_unknown_fields)]
674pub struct DegreeConfig {
675    /// Restrict edges to this type. `None` counts all edge types.
676    pub edge_type: Option<String>,
677    /// Which edges to count per node.
678    pub direction: AlgoDir,
679    /// Wall-clock budget (milliseconds) for the HTTP server endpoint.
680    pub budget_ms: u64,
681    /// Read this edge property as the edge weight; missing or non-numeric
682    /// values fall back to `1.0`. Degree stays an unweighted count of the
683    /// edges that survive `min_weight` filtering — this does not weight the
684    /// count itself.
685    pub weight_prop: Option<String>,
686    /// Drop edges whose resolved weight is below this threshold before the
687    /// algorithm runs. Applied whether or not `weight_prop` is set.
688    pub min_weight: Option<f64>,
689}
690
691impl Default for DegreeConfig {
692    fn default() -> Self {
693        Self {
694            edge_type: None,
695            direction: AlgoDir::Both,
696            budget_ms: 5_000,
697            weight_prop: None,
698            min_weight: None,
699        }
700    }
701}
702
703/// Result of [`GraphDb::degree_centrality`].
704#[derive(Debug, Clone, Serialize, Deserialize)]
705pub struct DegreeReport {
706    /// Node keys and their degree.  Sorted: degree descending, key ascending on ties.
707    pub scores: Vec<(String, u64)>,
708    /// `true` if the time budget fired before all nodes were processed.
709    pub truncated: bool,
710}
711
712/// Compute degree centrality for all live nodes.
713pub(crate) fn degree_centrality(
714    topo: &TopologyView,
715    idmap: &IdMap,
716    syms: &Interner,
717    labels: &[u32],
718    edge_props: &EdgePropsView,
719    config: &DegreeConfig,
720) -> DegreeReport {
721    let weighted = config.weight_prop.is_some() || config.min_weight.is_some();
722    let deadline = if config.budget_ms > 0 {
723        Some(Instant::now() + Duration::from_millis(config.budget_ms))
724    } else {
725        None
726    };
727
728    let (node_ids, node_keys) = live_nodes(idmap, labels);
729    let n = node_ids.len();
730
731    if n == 0 {
732        return DegreeReport {
733            scores: Vec::new(),
734            truncated: false,
735        };
736    }
737
738    // Resolve etype filter.
739    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
740        None => {
741            // No edges of this type → all degrees are 0.
742            let scores = node_keys.iter().map(|k| (k.clone(), 0u64)).collect();
743            return DegreeReport {
744                scores,
745                truncated: false,
746            };
747        }
748        Some(f) => f,
749    };
750
751    let etypes = etypes_filtered(topo, etype_filter);
752    let mut degrees: Vec<u64> = vec![0u64; n];
753    let mut truncated = false;
754
755    for (i, &id) in node_ids.iter().enumerate() {
756        if let Some(dl) = deadline {
757            if Instant::now() >= dl {
758                truncated = true;
759                break;
760            }
761        }
762        for &et in &etypes {
763            let dirs: &[Direction] = match config.direction {
764                AlgoDir::Out => &[Direction::Out],
765                AlgoDir::In => &[Direction::In],
766                AlgoDir::Both => &[Direction::Out, Direction::In],
767            };
768            for &dir in dirs {
769                if !weighted {
770                    degrees[i] += topo.neighbors(et, dir, id).len() as u64;
771                    continue;
772                }
773                for &nbr in topo.neighbors(et, dir, id).as_ref() {
774                    let (src, dst) = match dir {
775                        Direction::Out => (id, nbr),
776                        Direction::In => (nbr, id),
777                    };
778                    if edge_weight(
779                        edge_props,
780                        et,
781                        src,
782                        dst,
783                        config.weight_prop.as_deref(),
784                        config.min_weight,
785                    )
786                    .is_some()
787                    {
788                        degrees[i] += 1;
789                    }
790                }
791            }
792        }
793    }
794
795    let mut scores: Vec<(String, u64)> = node_keys.into_iter().zip(degrees).collect();
796    scores.sort_by(|(ka, da), (kb, db)| db.cmp(da).then(ka.cmp(kb)));
797
798    DegreeReport { scores, truncated }
799}
800
801// ---------------------------------------------------------------------------
802// Louvain community detection
803// ---------------------------------------------------------------------------
804
805/// Weighted undirected adjacency list: `adj[i]` is `(neighbor, weight)` pairs
806/// for compact node index `i`. Never contains a self-entry (`i == neighbor`)
807/// — internal/self weight is tracked separately (see `local_moving`,
808/// `aggregate`).
809type WeightedAdj = Vec<Vec<(usize, f64)>>;
810
811/// The next (coarser) level's graph, built by [`aggregate`].
812struct AggregatedLevel {
813    /// `renumbered[i]` is node `i`'s (at the level just aggregated) new,
814    /// compact `0..n` community id — callers compose this directly into
815    /// their own node→community mapping.
816    renumbered: Vec<usize>,
817    n: usize,
818    adj: WeightedAdj,
819    self_weight: Vec<f64>,
820}
821
822/// Configuration for [`GraphDb::communities`].
823#[derive(Debug, Clone, Serialize, Deserialize)]
824#[serde(default)]
825pub struct LouvainConfig {
826    /// Restrict to the union of these edge types. Empty means all edge types
827    /// (manual + rule-derived, via the unified topology).
828    pub edge_types: Vec<String>,
829    /// Read this edge property as the edge weight; missing or non-numeric
830    /// values fall back to `1.0`. `None` treats every edge as weight `1.0`.
831    pub weight_prop: Option<String>,
832    /// Drop edges whose resolved weight is below this threshold before the
833    /// algorithm runs. Applied whether or not `weight_prop` is set.
834    pub min_weight: Option<f64>,
835    /// Modularity resolution parameter (`γ`). Default `1.0`; values above 1
836    /// favor more, smaller communities; below 1 favor fewer, larger ones.
837    pub resolution: f64,
838    /// Maximum number of local-moving + aggregation passes.
839    pub max_passes: u32,
840    /// Maximum local-moving sweeps within a single pass.
841    pub max_sweeps: u32,
842    /// Wall-clock budget (milliseconds), checked once per sweep. `0` means no
843    /// budget (run to convergence or `max_passes`/`max_sweeps`).
844    pub budget_ms: u64,
845    /// Restrict membership to nodes carrying this label. Edges touching a
846    /// node outside the label set are ignored.
847    pub node_label: Option<String>,
848}
849
850impl Default for LouvainConfig {
851    fn default() -> Self {
852        Self {
853            edge_types: Vec::new(),
854            weight_prop: None,
855            min_weight: None,
856            resolution: 1.0,
857            max_passes: 10,
858            max_sweeps: 20,
859            budget_ms: 5_000,
860            node_label: None,
861        }
862    }
863}
864
865/// One detected community.
866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
867pub struct Community {
868    /// 0-based id assigned by output order (position in
869    /// [`CommunityReport::communities`]) — not stable across different runs
870    /// with a different partition shape.
871    pub id: u32,
872    /// Member node keys, sorted ascending.
873    pub members: Vec<String>,
874    /// Total weight of edges with both endpoints inside this community (each
875    /// undirected edge counted once), computed from the original edges after
876    /// `weight_prop`/`min_weight` filtering — not from the aggregated
877    /// intermediate levels the algorithm builds internally.
878    pub internal_weight: f64,
879    /// `internal_weight / (internal_weight + weight of edges leaving the
880    /// community)`. `1.0` for a community with no incident edges at all
881    /// (trivially cohesive).
882    pub cohesion: f64,
883}
884
885/// Result of [`GraphDb::communities`].
886#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
887pub struct CommunityReport {
888    /// Sorted: size descending, then smallest member key ascending on ties.
889    pub communities: Vec<Community>,
890    /// Modularity of the final partition (resolution-adjusted).
891    pub modularity: f64,
892    /// `true` if the time budget fired before local moving converged.
893    pub truncated: bool,
894}
895
896/// One level's local-moving phase: greedily reassigns each node to the
897/// neighboring community (or itself) that maximizes modularity gain, in
898/// sorted node order, sweeping until stable or `max_sweeps`.
899///
900/// Returns `(community_of, hit_budget)`. `community_of[i]` is a community id
901/// drawn from `0..n` (not necessarily contiguous). `hit_budget` is `true`
902/// when the deadline fired before local moving converged — the returned
903/// assignment is then whatever the sweeps completed before the deadline.
904fn local_moving(
905    n: usize,
906    adj: &WeightedAdj,
907    self_weight: &[f64],
908    resolution: f64,
909    max_sweeps: u32,
910    deadline: Option<Instant>,
911) -> (Vec<usize>, bool) {
912    let k: Vec<f64> = (0..n)
913        .map(|i| adj[i].iter().map(|&(_, w)| w).sum::<f64>() + 2.0 * self_weight[i])
914        .collect();
915    let m: f64 = k.iter().sum::<f64>() / 2.0;
916    let mut community_of: Vec<usize> = (0..n).collect();
917    if m <= 0.0 {
918        return (community_of, false);
919    }
920    let mut tot: Vec<f64> = k.clone();
921
922    for _sweep in 0..max_sweeps {
923        if let Some(dl) = deadline {
924            if Instant::now() >= dl {
925                return (community_of, true);
926            }
927        }
928        let mut improved = false;
929        for i in 0..n {
930            let ci = community_of[i];
931            tot[ci] -= k[i];
932
933            // Weight from i to each neighboring community, keyed by
934            // community id ascending (BTreeMap) so tie-breaking below is
935            // deterministic regardless of adjacency iteration order.
936            let mut neighbor_weights: BTreeMap<usize, f64> = BTreeMap::new();
937            for &(j, w) in &adj[i] {
938                if j == i {
939                    continue; // no self entries stored in adj; defensive
940                }
941                *neighbor_weights.entry(community_of[j]).or_insert(0.0) += w;
942            }
943
944            let gain = |c: usize, w_in: f64| -> f64 {
945                w_in / m - resolution * tot[c] * k[i] / (2.0 * m * m)
946            };
947
948            let mut best_c = ci;
949            let mut best_gain = gain(ci, neighbor_weights.get(&ci).copied().unwrap_or(0.0));
950            for (&c, &w_in) in &neighbor_weights {
951                if c == ci {
952                    continue;
953                }
954                let g = gain(c, w_in);
955                if g > best_gain + 1e-12 {
956                    best_gain = g;
957                    best_c = c;
958                }
959            }
960
961            tot[best_c] += k[i];
962            if best_c != ci {
963                community_of[i] = best_c;
964                improved = true;
965            }
966        }
967        if !improved {
968            break;
969        }
970    }
971
972    (community_of, false)
973}
974
975/// Build the next level's aggregated (super-node) graph from a completed
976/// local-moving assignment. Each distinct community becomes one super-node;
977/// edges within a community fold into its self-weight, edges crossing
978/// communities sum into the new adjacency.
979///
980/// Returns `None` when no coarsening happened (every node kept its own
981/// singleton community) — a local optimum where further aggregation would
982/// have no effect.
983fn aggregate(
984    n: usize,
985    adj: &WeightedAdj,
986    self_weight: &[f64],
987    community_of: &[usize],
988) -> Option<AggregatedLevel> {
989    let mut remap: BTreeMap<usize, usize> = BTreeMap::new();
990    let mut next_id = 0usize;
991    let mut renumbered: Vec<usize> = vec![0; n];
992    for (i, item) in renumbered.iter_mut().enumerate() {
993        let c = community_of[i];
994        let idx = *remap.entry(c).or_insert_with(|| {
995            let id = next_id;
996            next_id += 1;
997            id
998        });
999        *item = idx;
1000    }
1001    let new_n = next_id;
1002    if new_n == n {
1003        return None; // every community is a singleton: no coarsening
1004    }
1005
1006    let mut new_self_weight = vec![0.0; new_n];
1007    let mut new_adj_map: Vec<BTreeMap<usize, f64>> = vec![BTreeMap::new(); new_n];
1008    for i in 0..n {
1009        let ci = renumbered[i];
1010        new_self_weight[ci] += self_weight[i];
1011        for &(j, w) in &adj[i] {
1012            if j < i {
1013                continue; // adjacency is symmetric; process each edge once
1014            }
1015            let cj = renumbered[j];
1016            if ci == cj {
1017                new_self_weight[ci] += w;
1018            } else {
1019                *new_adj_map[ci].entry(cj).or_insert(0.0) += w;
1020                *new_adj_map[cj].entry(ci).or_insert(0.0) += w;
1021            }
1022        }
1023    }
1024
1025    let new_adj: WeightedAdj = new_adj_map
1026        .into_iter()
1027        .map(|map| map.into_iter().collect())
1028        .collect();
1029
1030    Some(AggregatedLevel {
1031        renumbered,
1032        n: new_n,
1033        adj: new_adj,
1034        self_weight: new_self_weight,
1035    })
1036}
1037
1038/// Run Louvain community detection on the unified topology (undirected).
1039///
1040/// Sums both edge directions into a single undirected weight, ignores
1041/// self-loops, and restricts membership to `config.node_label` when set
1042/// (edges touching a node outside the label set are ignored entirely).
1043/// `cohesion` and `internal_weight` are computed from the original filtered
1044/// edges, not the internal aggregated levels.
1045pub(crate) fn louvain(
1046    topo: &TopologyView,
1047    idmap: &IdMap,
1048    syms: &Interner,
1049    labels: &[u32],
1050    edge_props: &EdgePropsView,
1051    config: &LouvainConfig,
1052) -> CommunityReport {
1053    let deadline = if config.budget_ms > 0 {
1054        Some(Instant::now() + Duration::from_millis(config.budget_ms))
1055    } else {
1056        None
1057    };
1058
1059    // Node set (optionally label-restricted), sorted by key ascending so
1060    // compact index 0 is always the smallest key — local moving then
1061    // processes nodes in sorted key order at level 0, and that order
1062    // propagates deterministically into every aggregated level.
1063    let (raw_ids, raw_keys) =
1064        live_nodes_for_label(idmap, syms, labels, config.node_label.as_deref());
1065    let mut order: Vec<usize> = (0..raw_ids.len()).collect();
1066    order.sort_by(|&a, &b| raw_keys[a].cmp(&raw_keys[b]));
1067    let node_ids: Vec<u32> = order.iter().map(|&i| raw_ids[i]).collect();
1068    let node_keys: Vec<String> = order.iter().map(|&i| raw_keys[i].clone()).collect();
1069    let n0 = node_ids.len();
1070
1071    if n0 == 0 {
1072        return CommunityReport {
1073            communities: Vec::new(),
1074            modularity: 0.0,
1075            truncated: false,
1076        };
1077    }
1078
1079    let mut id_to_idx: BTreeMap<u32, usize> = BTreeMap::new();
1080    for (i, &id) in node_ids.iter().enumerate() {
1081        id_to_idx.insert(id, i);
1082    }
1083
1084    let etypes = resolve_etypes_multi(syms, topo, &config.edge_types);
1085
1086    // Collect the original (filtered) undirected weighted edges: sum both
1087    // directions into one entry per unordered compact-index pair, ignore
1088    // self-loops, ignore edges touching a node outside the label set.
1089    // Keyed by (a, b) with a < b so iteration order (and therefore floating
1090    // point summation order) is deterministic.
1091    let mut edge_weight_map: BTreeMap<(usize, usize), f64> = BTreeMap::new();
1092    for &et in &etypes {
1093        for (i, &id) in node_ids.iter().enumerate() {
1094            for &nbr in topo.neighbors(et, Direction::Out, id).as_ref() {
1095                if nbr == id {
1096                    continue; // ignore self-loops
1097                }
1098                let Some(&j) = id_to_idx.get(&nbr) else {
1099                    continue; // touches a node outside the label restriction
1100                };
1101                let Some(w) = edge_weight(
1102                    edge_props,
1103                    et,
1104                    id,
1105                    nbr,
1106                    config.weight_prop.as_deref(),
1107                    config.min_weight,
1108                ) else {
1109                    continue; // filtered by min_weight
1110                };
1111                let key = if i < j { (i, j) } else { (j, i) };
1112                *edge_weight_map.entry(key).or_insert(0.0) += w;
1113            }
1114        }
1115    }
1116
1117    let m: f64 = edge_weight_map.values().sum();
1118
1119    let mut adj: WeightedAdj = vec![Vec::new(); n0];
1120    for (&(a, b), &w) in &edge_weight_map {
1121        adj[a].push((b, w));
1122        adj[b].push((a, w));
1123    }
1124    let mut self_weight: Vec<f64> = vec![0.0; n0];
1125
1126    // owner[i] = original node i's community index at the current level.
1127    let mut owner: Vec<usize> = (0..n0).collect();
1128    let mut truncated = false;
1129    let mut n = n0;
1130
1131    if m > 0.0 {
1132        'passes: for _pass in 0..config.max_passes {
1133            let (community_of, hit_budget) = local_moving(
1134                n,
1135                &adj,
1136                &self_weight,
1137                config.resolution,
1138                config.max_sweeps,
1139                deadline,
1140            );
1141            if hit_budget {
1142                // Fold this (possibly partial) sweep's assignment straight
1143                // into owner and stop — no further aggregation, so no
1144                // renumbering is needed.
1145                owner = owner.iter().map(|&o| community_of[o]).collect();
1146                truncated = true;
1147                break 'passes;
1148            }
1149            let Some(level) = aggregate(n, &adj, &self_weight, &community_of) else {
1150                // Local optimum: no further coarsening. community_of is
1151                // already the final assignment at this level.
1152                owner = owner.iter().map(|&o| community_of[o]).collect();
1153                break 'passes;
1154            };
1155            // Compose owner directly through the new level's compact
1156            // numbering (folds community_of + remap in one step).
1157            owner = owner.iter().map(|&o| level.renumbered[o]).collect();
1158            n = level.n;
1159            adj = level.adj;
1160            self_weight = level.self_weight;
1161        }
1162    }
1163
1164    // Cohesion / modularity from the ORIGINAL filtered edges, grouped by
1165    // final community.
1166    let mut internal: BTreeMap<usize, f64> = BTreeMap::new();
1167    let mut leaving: BTreeMap<usize, f64> = BTreeMap::new();
1168    for (&(a, b), &w) in &edge_weight_map {
1169        let ca = owner[a];
1170        let cb = owner[b];
1171        if ca == cb {
1172            *internal.entry(ca).or_insert(0.0) += w;
1173        } else {
1174            *leaving.entry(ca).or_insert(0.0) += w;
1175            *leaving.entry(cb).or_insert(0.0) += w;
1176        }
1177    }
1178
1179    let mut members_by_community: BTreeMap<usize, Vec<String>> = BTreeMap::new();
1180    for (i, key) in node_keys.iter().enumerate() {
1181        members_by_community
1182            .entry(owner[i])
1183            .or_default()
1184            .push(key.clone());
1185    }
1186
1187    let modularity = if m > 0.0 {
1188        members_by_community
1189            .keys()
1190            .map(|c| {
1191                let internal_w = internal.get(c).copied().unwrap_or(0.0);
1192                let leaving_w = leaving.get(c).copied().unwrap_or(0.0);
1193                let sigma_tot = 2.0 * internal_w + leaving_w;
1194                internal_w / m - config.resolution * (sigma_tot * sigma_tot) / (4.0 * m * m)
1195            })
1196            .sum()
1197    } else {
1198        0.0
1199    };
1200
1201    let mut communities: Vec<Community> = members_by_community
1202        .into_iter()
1203        .map(|(c, mut members)| {
1204            members.sort();
1205            let internal_w = internal.get(&c).copied().unwrap_or(0.0);
1206            let leaving_w = leaving.get(&c).copied().unwrap_or(0.0);
1207            let cohesion = if internal_w + leaving_w > 0.0 {
1208                internal_w / (internal_w + leaving_w)
1209            } else {
1210                1.0
1211            };
1212            Community {
1213                id: 0, // assigned below, after sorting
1214                members,
1215                internal_weight: internal_w,
1216                cohesion,
1217            }
1218        })
1219        .collect();
1220
1221    communities.sort_by(|a, b| {
1222        b.members
1223            .len()
1224            .cmp(&a.members.len())
1225            .then_with(|| a.members[0].cmp(&b.members[0]))
1226    });
1227    for (i, c) in communities.iter_mut().enumerate() {
1228        c.id = i as u32;
1229    }
1230
1231    CommunityReport {
1232        communities,
1233        modularity,
1234        truncated,
1235    }
1236}