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)]
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
232/// Result of [`GraphDb::pagerank`].
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct PageRankReport {
235    /// Node keys and their PageRank scores.  Sorted: score descending, key
236    /// ascending on ties (deterministic).
237    pub scores: Vec<(String, f64)>,
238    /// `true` if the algorithm converged before `max_iters` and before any time
239    /// budget fired.  `false` means scores are still valid but partial — more
240    /// iterations would refine them.
241    pub converged: bool,
242}
243
244/// Direction semantics for algo methods (mirrors `Dir` but serializable).
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(rename_all = "lowercase")]
247pub enum AlgoDir {
248    /// Follow outgoing edges only (standard directed PageRank / out-degree).
249    Out,
250    /// Follow incoming edges only (in-degree / authority score).
251    In,
252    /// Sum of out-degree and in-degree (a reciprocal pair counts 2 per endpoint).
253    /// Not the neighbour-set union `|N_out ∪ N_in|`.
254    Both,
255}
256
257impl From<Dir> for AlgoDir {
258    fn from(d: Dir) -> Self {
259        match d {
260            Dir::Out => AlgoDir::Out,
261            Dir::In => AlgoDir::In,
262            Dir::Both => AlgoDir::Both,
263        }
264    }
265}
266
267/// Run PageRank on the unified topology.
268///
269/// Returns a [`PageRankReport`] with scores sorted descending (ties: key asc).
270pub(crate) fn pagerank(
271    topo: &TopologyView,
272    idmap: &IdMap,
273    syms: &Interner,
274    labels: &[u32],
275    edge_props: &EdgePropsView,
276    config: &PageRankConfig,
277) -> PageRankReport {
278    let weighted = config.weight_prop.is_some() || config.min_weight.is_some();
279    let deadline = if config.budget_ms > 0 {
280        Some(Instant::now() + Duration::from_millis(config.budget_ms))
281    } else {
282        None
283    };
284
285    let (node_ids, node_keys) = live_nodes(idmap, labels);
286    let n = node_ids.len();
287
288    if n == 0 {
289        return PageRankReport {
290            scores: Vec::new(),
291            converged: true,
292        };
293    }
294
295    // Map internal id → compact index for fast array access.
296    let max_id = topo.etypes().count(); // just an upper bound check hint
297    let _ = max_id;
298    let mut id_to_idx: BTreeMap<u32, usize> = BTreeMap::new();
299    for (i, &id) in node_ids.iter().enumerate() {
300        id_to_idx.insert(id, i);
301    }
302
303    // Resolve etype filter.
304    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
305        None => {
306            // Edge type specified but not in graph → no edges, PR is uniform.
307            let score = 1.0 / n as f64;
308            let mut scores: Vec<(String, f64)> =
309                node_keys.iter().map(|k| (k.clone(), score)).collect();
310            scores.sort_by(|(ka, sa), (kb, sb)| {
311                sb.partial_cmp(sa)
312                    .unwrap_or(std::cmp::Ordering::Equal)
313                    .then(ka.cmp(kb))
314            });
315            return PageRankReport {
316                scores,
317                converged: true,
318            };
319        }
320        Some(f) => f,
321    };
322
323    let etypes = etypes_filtered(topo, etype_filter);
324
325    // Build adjacency list (compact index): for each compact node, which
326    // compact nodes does it "send" rank to (based on direction), and with
327    // what weight.  Unweighted mode dedups parallel edges to the same
328    // neighbor (matches pre-weight behavior exactly); weighted mode sums the
329    // resolved weight of every qualifying edge instance (accumulating across
330    // parallel edges / multiple edge types).
331    let mut send_to: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
332
333    for &et in &etypes {
334        for (i, &id) in node_ids.iter().enumerate() {
335            let dirs: &[Direction] = match config.direction {
336                AlgoDir::Out => &[Direction::Out],
337                AlgoDir::In => &[Direction::In],
338                AlgoDir::Both => &[Direction::Out, Direction::In],
339            };
340            for &dir in dirs {
341                for &nbr in topo.neighbors(et, dir, id).as_ref() {
342                    let Some(&j) = id_to_idx.get(&nbr) else {
343                        continue;
344                    };
345                    if weighted {
346                        let Some(w) = edge_weight(
347                            edge_props,
348                            et,
349                            id,
350                            nbr,
351                            config.weight_prop.as_deref(),
352                            config.min_weight,
353                        ) else {
354                            continue; // filtered by min_weight
355                        };
356                        if let Some(entry) = send_to[i].iter_mut().find(|(k, _)| *k == j) {
357                            entry.1 += w;
358                        } else {
359                            send_to[i].push((j, w));
360                        }
361                    } else if !send_to[i].iter().any(|(k, _)| *k == j) {
362                        send_to[i].push((j, 1.0));
363                    }
364                }
365            }
366        }
367    }
368
369    // Build receive_from[j] = list of (i, share) that send to j, where share
370    // is i's outgoing weight to j normalized by i's total outgoing weight.
371    // Also track dangling nodes (no outgoing weight).
372    let mut receive_from: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
373    let mut dangling: Vec<usize> = Vec::new();
374
375    for (i, send) in send_to.iter().enumerate() {
376        let out_weight: f64 = send.iter().map(|(_, w)| w).sum();
377        if send.is_empty() || out_weight <= 0.0 {
378            dangling.push(i);
379        } else {
380            for &(j, w) in send {
381                receive_from[j].push((i, w / out_weight));
382            }
383        }
384    }
385
386    // Power iteration.
387    let nf = n as f64;
388    let d = config.damping;
389    let teleport = (1.0 - d) / nf;
390    let mut pr: Vec<f64> = vec![1.0 / nf; n];
391    let mut converged = false;
392
393    for _iter in 0..config.max_iters {
394        // Check time budget between iterations.
395        if let Some(dl) = deadline {
396            if Instant::now() >= dl {
397                break;
398            }
399        }
400
401        // Sum PR leaked by dangling nodes → distribute uniformly.
402        let dangling_sum: f64 = dangling.iter().map(|&i| pr[i]).sum::<f64>() * d / nf;
403
404        let mut new_pr = vec![teleport + dangling_sum; n];
405        for j in 0..n {
406            let received: f64 = receive_from[j].iter().map(|&(i, w)| pr[i] * w).sum();
407            new_pr[j] += d * received;
408        }
409
410        // Check convergence: L1 norm.
411        let delta: f64 = pr
412            .iter()
413            .zip(new_pr.iter())
414            .map(|(a, b)| (a - b).abs())
415            .sum();
416        pr = new_pr;
417
418        if delta < config.tol {
419            converged = true;
420            break;
421        }
422    }
423
424    // Sort: score desc, key asc on ties.
425    let mut scores: Vec<(String, f64)> = node_keys.into_iter().zip(pr).collect();
426    scores.sort_by(|(ka, sa), (kb, sb)| {
427        sb.partial_cmp(sa)
428            .unwrap_or(std::cmp::Ordering::Equal)
429            .then(ka.cmp(kb))
430    });
431
432    PageRankReport { scores, converged }
433}
434
435// ---------------------------------------------------------------------------
436// Weakly-connected components (WCC)
437// ---------------------------------------------------------------------------
438
439/// Configuration for [`GraphDb::connected_components`].
440#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(default)]
442pub struct WccConfig {
443    /// Restrict edges to this type. `None` uses all edge types.
444    pub edge_type: Option<String>,
445    /// Wall-clock budget (milliseconds) for the HTTP server endpoint.
446    pub budget_ms: u64,
447    /// Read this edge property as the edge weight; missing or non-numeric
448    /// values fall back to `1.0`. Only used together with `min_weight` — WCC
449    /// itself is unweighted, but a weighted edge can still be filtered out.
450    pub weight_prop: Option<String>,
451    /// Drop edges whose resolved weight is below this threshold before the
452    /// algorithm runs. Applied whether or not `weight_prop` is set.
453    pub min_weight: Option<f64>,
454}
455
456impl Default for WccConfig {
457    fn default() -> Self {
458        Self {
459            edge_type: None,
460            budget_ms: 5_000,
461            weight_prop: None,
462            min_weight: None,
463        }
464    }
465}
466
467/// Result of [`GraphDb::connected_components`].
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct WccReport {
470    /// Each live node and the key of the smallest member of its component
471    /// (deterministic component identifier).  Sorted by (component_id, key).
472    pub components: Vec<(String, String)>,
473    /// `true` if the time budget fired before all nodes were processed.
474    pub truncated: bool,
475}
476
477/// Union-Find with path compression and union by rank.
478struct UnionFind {
479    parent: Vec<usize>,
480    rank: Vec<u8>,
481}
482
483impl UnionFind {
484    fn new(n: usize) -> Self {
485        Self {
486            parent: (0..n).collect(),
487            rank: vec![0; n],
488        }
489    }
490
491    fn find(&mut self, mut x: usize) -> usize {
492        while self.parent[x] != x {
493            self.parent[x] = self.parent[self.parent[x]]; // path halving
494            x = self.parent[x];
495        }
496        x
497    }
498
499    fn union(&mut self, a: usize, b: usize) {
500        let ra = self.find(a);
501        let rb = self.find(b);
502        if ra == rb {
503            return;
504        }
505        match self.rank[ra].cmp(&self.rank[rb]) {
506            std::cmp::Ordering::Less => self.parent[ra] = rb,
507            std::cmp::Ordering::Greater => self.parent[rb] = ra,
508            std::cmp::Ordering::Equal => {
509                self.parent[rb] = ra;
510                self.rank[ra] += 1;
511            }
512        }
513    }
514}
515
516/// Run weakly-connected components on the unified topology (undirected).
517///
518/// Component ID is the smallest member key in each component (deterministic).
519pub(crate) fn wcc(
520    topo: &TopologyView,
521    idmap: &IdMap,
522    syms: &Interner,
523    labels: &[u32],
524    edge_props: &EdgePropsView,
525    config: &WccConfig,
526) -> WccReport {
527    let weighted = config.weight_prop.is_some() || config.min_weight.is_some();
528    let deadline = if config.budget_ms > 0 {
529        Some(Instant::now() + Duration::from_millis(config.budget_ms))
530    } else {
531        None
532    };
533
534    let (node_ids, node_keys) = live_nodes(idmap, labels);
535    let n = node_ids.len();
536
537    if n == 0 {
538        return WccReport {
539            components: Vec::new(),
540            truncated: false,
541        };
542    }
543
544    // Map internal id → compact index.
545    let mut id_to_idx: BTreeMap<u32, usize> = BTreeMap::new();
546    for (i, &id) in node_ids.iter().enumerate() {
547        id_to_idx.insert(id, i);
548    }
549
550    // Resolve etype filter.
551    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
552        None => {
553            // No edges of this type → every node is its own component.
554            let mut components: Vec<(String, String)> =
555                node_keys.iter().map(|k| (k.clone(), k.clone())).collect();
556            components.sort();
557            return WccReport {
558                components,
559                truncated: false,
560            };
561        }
562        Some(f) => f,
563    };
564
565    let etypes = etypes_filtered(topo, etype_filter);
566
567    let mut uf = UnionFind::new(n);
568    let mut truncated = false;
569
570    // Union all edges (both directions — WCC treats graph as undirected).
571    'outer: for &et in &etypes {
572        for (i, &id) in node_ids.iter().enumerate() {
573            if let Some(dl) = deadline {
574                if Instant::now() >= dl {
575                    truncated = true;
576                    break 'outer;
577                }
578            }
579            // Out-edges: union i with each out-neighbor.
580            for &nbr in topo.neighbors(et, Direction::Out, id).as_ref() {
581                if let Some(&j) = id_to_idx.get(&nbr) {
582                    if weighted
583                        && edge_weight(
584                            edge_props,
585                            et,
586                            id,
587                            nbr,
588                            config.weight_prop.as_deref(),
589                            config.min_weight,
590                        )
591                        .is_none()
592                    {
593                        continue; // filtered by min_weight
594                    }
595                    uf.union(i, j);
596                }
597            }
598            // In-edges handled by the mirror out-edge from the other side,
599            // but we also cover them here for safety (e.g., self-loops, or
600            // nodes with only in-edges for a filtered etype).
601            for &nbr in topo.neighbors(et, Direction::In, id).as_ref() {
602                if let Some(&j) = id_to_idx.get(&nbr) {
603                    if weighted
604                        && edge_weight(
605                            edge_props,
606                            et,
607                            nbr,
608                            id,
609                            config.weight_prop.as_deref(),
610                            config.min_weight,
611                        )
612                        .is_none()
613                    {
614                        continue; // filtered by min_weight
615                    }
616                    uf.union(i, j);
617                }
618            }
619        }
620    }
621
622    // Determine component representative: smallest key per root.
623    let mut root_min_key: BTreeMap<usize, &str> = BTreeMap::new();
624    for (i, key_str) in node_keys.iter().enumerate() {
625        let root = uf.find(i);
626        let key = key_str.as_str();
627        let entry = root_min_key.entry(root).or_insert(key);
628        if key < *entry {
629            *entry = key;
630        }
631    }
632
633    let mut components: Vec<(String, String)> = node_keys
634        .iter()
635        .enumerate()
636        .map(|(i, key_str)| {
637            let root = uf.find(i);
638            let comp_id = root_min_key[&root].to_string();
639            (key_str.clone(), comp_id)
640        })
641        .collect();
642    components.sort_by(|(ka, ca), (kb, cb)| ca.cmp(cb).then(ka.cmp(kb)));
643
644    WccReport {
645        components,
646        truncated,
647    }
648}
649
650// ---------------------------------------------------------------------------
651// Degree centrality
652// ---------------------------------------------------------------------------
653
654/// Configuration for [`GraphDb::degree_centrality`].
655#[derive(Debug, Clone, Serialize, Deserialize)]
656#[serde(default)]
657pub struct DegreeConfig {
658    /// Restrict edges to this type. `None` counts all edge types.
659    pub edge_type: Option<String>,
660    /// Which edges to count per node.
661    pub direction: AlgoDir,
662    /// Wall-clock budget (milliseconds) for the HTTP server endpoint.
663    pub budget_ms: u64,
664    /// Read this edge property as the edge weight; missing or non-numeric
665    /// values fall back to `1.0`. Degree stays an unweighted count of the
666    /// edges that survive `min_weight` filtering — this does not weight the
667    /// count itself.
668    pub weight_prop: Option<String>,
669    /// Drop edges whose resolved weight is below this threshold before the
670    /// algorithm runs. Applied whether or not `weight_prop` is set.
671    pub min_weight: Option<f64>,
672}
673
674impl Default for DegreeConfig {
675    fn default() -> Self {
676        Self {
677            edge_type: None,
678            direction: AlgoDir::Both,
679            budget_ms: 5_000,
680            weight_prop: None,
681            min_weight: None,
682        }
683    }
684}
685
686/// Result of [`GraphDb::degree_centrality`].
687#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct DegreeReport {
689    /// Node keys and their degree.  Sorted: degree descending, key ascending on ties.
690    pub scores: Vec<(String, u64)>,
691    /// `true` if the time budget fired before all nodes were processed.
692    pub truncated: bool,
693}
694
695/// Compute degree centrality for all live nodes.
696pub(crate) fn degree_centrality(
697    topo: &TopologyView,
698    idmap: &IdMap,
699    syms: &Interner,
700    labels: &[u32],
701    edge_props: &EdgePropsView,
702    config: &DegreeConfig,
703) -> DegreeReport {
704    let weighted = config.weight_prop.is_some() || config.min_weight.is_some();
705    let deadline = if config.budget_ms > 0 {
706        Some(Instant::now() + Duration::from_millis(config.budget_ms))
707    } else {
708        None
709    };
710
711    let (node_ids, node_keys) = live_nodes(idmap, labels);
712    let n = node_ids.len();
713
714    if n == 0 {
715        return DegreeReport {
716            scores: Vec::new(),
717            truncated: false,
718        };
719    }
720
721    // Resolve etype filter.
722    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
723        None => {
724            // No edges of this type → all degrees are 0.
725            let scores = node_keys.iter().map(|k| (k.clone(), 0u64)).collect();
726            return DegreeReport {
727                scores,
728                truncated: false,
729            };
730        }
731        Some(f) => f,
732    };
733
734    let etypes = etypes_filtered(topo, etype_filter);
735    let mut degrees: Vec<u64> = vec![0u64; n];
736    let mut truncated = false;
737
738    for (i, &id) in node_ids.iter().enumerate() {
739        if let Some(dl) = deadline {
740            if Instant::now() >= dl {
741                truncated = true;
742                break;
743            }
744        }
745        for &et in &etypes {
746            let dirs: &[Direction] = match config.direction {
747                AlgoDir::Out => &[Direction::Out],
748                AlgoDir::In => &[Direction::In],
749                AlgoDir::Both => &[Direction::Out, Direction::In],
750            };
751            for &dir in dirs {
752                if !weighted {
753                    degrees[i] += topo.neighbors(et, dir, id).len() as u64;
754                    continue;
755                }
756                for &nbr in topo.neighbors(et, dir, id).as_ref() {
757                    let (src, dst) = match dir {
758                        Direction::Out => (id, nbr),
759                        Direction::In => (nbr, id),
760                    };
761                    if edge_weight(
762                        edge_props,
763                        et,
764                        src,
765                        dst,
766                        config.weight_prop.as_deref(),
767                        config.min_weight,
768                    )
769                    .is_some()
770                    {
771                        degrees[i] += 1;
772                    }
773                }
774            }
775        }
776    }
777
778    let mut scores: Vec<(String, u64)> = node_keys.into_iter().zip(degrees).collect();
779    scores.sort_by(|(ka, da), (kb, db)| db.cmp(da).then(ka.cmp(kb)));
780
781    DegreeReport { scores, truncated }
782}
783
784// ---------------------------------------------------------------------------
785// Louvain community detection
786// ---------------------------------------------------------------------------
787
788/// Weighted undirected adjacency list: `adj[i]` is `(neighbor, weight)` pairs
789/// for compact node index `i`. Never contains a self-entry (`i == neighbor`)
790/// — internal/self weight is tracked separately (see `local_moving`,
791/// `aggregate`).
792type WeightedAdj = Vec<Vec<(usize, f64)>>;
793
794/// The next (coarser) level's graph, built by [`aggregate`].
795struct AggregatedLevel {
796    /// `renumbered[i]` is node `i`'s (at the level just aggregated) new,
797    /// compact `0..n` community id — callers compose this directly into
798    /// their own node→community mapping.
799    renumbered: Vec<usize>,
800    n: usize,
801    adj: WeightedAdj,
802    self_weight: Vec<f64>,
803}
804
805/// Configuration for [`GraphDb::communities`].
806#[derive(Debug, Clone, Serialize, Deserialize)]
807#[serde(default)]
808pub struct LouvainConfig {
809    /// Restrict to the union of these edge types. Empty means all edge types
810    /// (manual + rule-derived, via the unified topology).
811    pub edge_types: Vec<String>,
812    /// Read this edge property as the edge weight; missing or non-numeric
813    /// values fall back to `1.0`. `None` treats every edge as weight `1.0`.
814    pub weight_prop: Option<String>,
815    /// Drop edges whose resolved weight is below this threshold before the
816    /// algorithm runs. Applied whether or not `weight_prop` is set.
817    pub min_weight: Option<f64>,
818    /// Modularity resolution parameter (`γ`). Default `1.0`; values above 1
819    /// favor more, smaller communities; below 1 favor fewer, larger ones.
820    pub resolution: f64,
821    /// Maximum number of local-moving + aggregation passes.
822    pub max_passes: u32,
823    /// Maximum local-moving sweeps within a single pass.
824    pub max_sweeps: u32,
825    /// Wall-clock budget (milliseconds), checked once per sweep. `0` means no
826    /// budget (run to convergence or `max_passes`/`max_sweeps`).
827    pub budget_ms: u64,
828    /// Restrict membership to nodes carrying this label. Edges touching a
829    /// node outside the label set are ignored.
830    pub node_label: Option<String>,
831}
832
833impl Default for LouvainConfig {
834    fn default() -> Self {
835        Self {
836            edge_types: Vec::new(),
837            weight_prop: None,
838            min_weight: None,
839            resolution: 1.0,
840            max_passes: 10,
841            max_sweeps: 20,
842            budget_ms: 5_000,
843            node_label: None,
844        }
845    }
846}
847
848/// One detected community.
849#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
850pub struct Community {
851    /// 0-based id assigned by output order (position in
852    /// [`CommunityReport::communities`]) — not stable across different runs
853    /// with a different partition shape.
854    pub id: u32,
855    /// Member node keys, sorted ascending.
856    pub members: Vec<String>,
857    /// Total weight of edges with both endpoints inside this community (each
858    /// undirected edge counted once), computed from the original edges after
859    /// `weight_prop`/`min_weight` filtering — not from the aggregated
860    /// intermediate levels the algorithm builds internally.
861    pub internal_weight: f64,
862    /// `internal_weight / (internal_weight + weight of edges leaving the
863    /// community)`. `1.0` for a community with no incident edges at all
864    /// (trivially cohesive).
865    pub cohesion: f64,
866}
867
868/// Result of [`GraphDb::communities`].
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
870pub struct CommunityReport {
871    /// Sorted: size descending, then smallest member key ascending on ties.
872    pub communities: Vec<Community>,
873    /// Modularity of the final partition (resolution-adjusted).
874    pub modularity: f64,
875    /// `true` if the time budget fired before local moving converged.
876    pub truncated: bool,
877}
878
879/// One level's local-moving phase: greedily reassigns each node to the
880/// neighboring community (or itself) that maximizes modularity gain, in
881/// sorted node order, sweeping until stable or `max_sweeps`.
882///
883/// Returns `(community_of, hit_budget)`. `community_of[i]` is a community id
884/// drawn from `0..n` (not necessarily contiguous). `hit_budget` is `true`
885/// when the deadline fired before local moving converged — the returned
886/// assignment is then whatever the sweeps completed before the deadline.
887fn local_moving(
888    n: usize,
889    adj: &WeightedAdj,
890    self_weight: &[f64],
891    resolution: f64,
892    max_sweeps: u32,
893    deadline: Option<Instant>,
894) -> (Vec<usize>, bool) {
895    let k: Vec<f64> = (0..n)
896        .map(|i| adj[i].iter().map(|&(_, w)| w).sum::<f64>() + 2.0 * self_weight[i])
897        .collect();
898    let m: f64 = k.iter().sum::<f64>() / 2.0;
899    let mut community_of: Vec<usize> = (0..n).collect();
900    if m <= 0.0 {
901        return (community_of, false);
902    }
903    let mut tot: Vec<f64> = k.clone();
904
905    for _sweep in 0..max_sweeps {
906        if let Some(dl) = deadline {
907            if Instant::now() >= dl {
908                return (community_of, true);
909            }
910        }
911        let mut improved = false;
912        for i in 0..n {
913            let ci = community_of[i];
914            tot[ci] -= k[i];
915
916            // Weight from i to each neighboring community, keyed by
917            // community id ascending (BTreeMap) so tie-breaking below is
918            // deterministic regardless of adjacency iteration order.
919            let mut neighbor_weights: BTreeMap<usize, f64> = BTreeMap::new();
920            for &(j, w) in &adj[i] {
921                if j == i {
922                    continue; // no self entries stored in adj; defensive
923                }
924                *neighbor_weights.entry(community_of[j]).or_insert(0.0) += w;
925            }
926
927            let gain = |c: usize, w_in: f64| -> f64 {
928                w_in / m - resolution * tot[c] * k[i] / (2.0 * m * m)
929            };
930
931            let mut best_c = ci;
932            let mut best_gain = gain(ci, neighbor_weights.get(&ci).copied().unwrap_or(0.0));
933            for (&c, &w_in) in &neighbor_weights {
934                if c == ci {
935                    continue;
936                }
937                let g = gain(c, w_in);
938                if g > best_gain + 1e-12 {
939                    best_gain = g;
940                    best_c = c;
941                }
942            }
943
944            tot[best_c] += k[i];
945            if best_c != ci {
946                community_of[i] = best_c;
947                improved = true;
948            }
949        }
950        if !improved {
951            break;
952        }
953    }
954
955    (community_of, false)
956}
957
958/// Build the next level's aggregated (super-node) graph from a completed
959/// local-moving assignment. Each distinct community becomes one super-node;
960/// edges within a community fold into its self-weight, edges crossing
961/// communities sum into the new adjacency.
962///
963/// Returns `None` when no coarsening happened (every node kept its own
964/// singleton community) — a local optimum where further aggregation would
965/// have no effect.
966fn aggregate(
967    n: usize,
968    adj: &WeightedAdj,
969    self_weight: &[f64],
970    community_of: &[usize],
971) -> Option<AggregatedLevel> {
972    let mut remap: BTreeMap<usize, usize> = BTreeMap::new();
973    let mut next_id = 0usize;
974    let mut renumbered: Vec<usize> = vec![0; n];
975    for (i, item) in renumbered.iter_mut().enumerate() {
976        let c = community_of[i];
977        let idx = *remap.entry(c).or_insert_with(|| {
978            let id = next_id;
979            next_id += 1;
980            id
981        });
982        *item = idx;
983    }
984    let new_n = next_id;
985    if new_n == n {
986        return None; // every community is a singleton: no coarsening
987    }
988
989    let mut new_self_weight = vec![0.0; new_n];
990    let mut new_adj_map: Vec<BTreeMap<usize, f64>> = vec![BTreeMap::new(); new_n];
991    for i in 0..n {
992        let ci = renumbered[i];
993        new_self_weight[ci] += self_weight[i];
994        for &(j, w) in &adj[i] {
995            if j < i {
996                continue; // adjacency is symmetric; process each edge once
997            }
998            let cj = renumbered[j];
999            if ci == cj {
1000                new_self_weight[ci] += w;
1001            } else {
1002                *new_adj_map[ci].entry(cj).or_insert(0.0) += w;
1003                *new_adj_map[cj].entry(ci).or_insert(0.0) += w;
1004            }
1005        }
1006    }
1007
1008    let new_adj: WeightedAdj = new_adj_map
1009        .into_iter()
1010        .map(|map| map.into_iter().collect())
1011        .collect();
1012
1013    Some(AggregatedLevel {
1014        renumbered,
1015        n: new_n,
1016        adj: new_adj,
1017        self_weight: new_self_weight,
1018    })
1019}
1020
1021/// Run Louvain community detection on the unified topology (undirected).
1022///
1023/// Sums both edge directions into a single undirected weight, ignores
1024/// self-loops, and restricts membership to `config.node_label` when set
1025/// (edges touching a node outside the label set are ignored entirely).
1026/// `cohesion` and `internal_weight` are computed from the original filtered
1027/// edges, not the internal aggregated levels.
1028pub(crate) fn louvain(
1029    topo: &TopologyView,
1030    idmap: &IdMap,
1031    syms: &Interner,
1032    labels: &[u32],
1033    edge_props: &EdgePropsView,
1034    config: &LouvainConfig,
1035) -> CommunityReport {
1036    let deadline = if config.budget_ms > 0 {
1037        Some(Instant::now() + Duration::from_millis(config.budget_ms))
1038    } else {
1039        None
1040    };
1041
1042    // Node set (optionally label-restricted), sorted by key ascending so
1043    // compact index 0 is always the smallest key — local moving then
1044    // processes nodes in sorted key order at level 0, and that order
1045    // propagates deterministically into every aggregated level.
1046    let (raw_ids, raw_keys) =
1047        live_nodes_for_label(idmap, syms, labels, config.node_label.as_deref());
1048    let mut order: Vec<usize> = (0..raw_ids.len()).collect();
1049    order.sort_by(|&a, &b| raw_keys[a].cmp(&raw_keys[b]));
1050    let node_ids: Vec<u32> = order.iter().map(|&i| raw_ids[i]).collect();
1051    let node_keys: Vec<String> = order.iter().map(|&i| raw_keys[i].clone()).collect();
1052    let n0 = node_ids.len();
1053
1054    if n0 == 0 {
1055        return CommunityReport {
1056            communities: Vec::new(),
1057            modularity: 0.0,
1058            truncated: false,
1059        };
1060    }
1061
1062    let mut id_to_idx: BTreeMap<u32, usize> = BTreeMap::new();
1063    for (i, &id) in node_ids.iter().enumerate() {
1064        id_to_idx.insert(id, i);
1065    }
1066
1067    let etypes = resolve_etypes_multi(syms, topo, &config.edge_types);
1068
1069    // Collect the original (filtered) undirected weighted edges: sum both
1070    // directions into one entry per unordered compact-index pair, ignore
1071    // self-loops, ignore edges touching a node outside the label set.
1072    // Keyed by (a, b) with a < b so iteration order (and therefore floating
1073    // point summation order) is deterministic.
1074    let mut edge_weight_map: BTreeMap<(usize, usize), f64> = BTreeMap::new();
1075    for &et in &etypes {
1076        for (i, &id) in node_ids.iter().enumerate() {
1077            for &nbr in topo.neighbors(et, Direction::Out, id).as_ref() {
1078                if nbr == id {
1079                    continue; // ignore self-loops
1080                }
1081                let Some(&j) = id_to_idx.get(&nbr) else {
1082                    continue; // touches a node outside the label restriction
1083                };
1084                let Some(w) = edge_weight(
1085                    edge_props,
1086                    et,
1087                    id,
1088                    nbr,
1089                    config.weight_prop.as_deref(),
1090                    config.min_weight,
1091                ) else {
1092                    continue; // filtered by min_weight
1093                };
1094                let key = if i < j { (i, j) } else { (j, i) };
1095                *edge_weight_map.entry(key).or_insert(0.0) += w;
1096            }
1097        }
1098    }
1099
1100    let m: f64 = edge_weight_map.values().sum();
1101
1102    let mut adj: WeightedAdj = vec![Vec::new(); n0];
1103    for (&(a, b), &w) in &edge_weight_map {
1104        adj[a].push((b, w));
1105        adj[b].push((a, w));
1106    }
1107    let mut self_weight: Vec<f64> = vec![0.0; n0];
1108
1109    // owner[i] = original node i's community index at the current level.
1110    let mut owner: Vec<usize> = (0..n0).collect();
1111    let mut truncated = false;
1112    let mut n = n0;
1113
1114    if m > 0.0 {
1115        'passes: for _pass in 0..config.max_passes {
1116            let (community_of, hit_budget) = local_moving(
1117                n,
1118                &adj,
1119                &self_weight,
1120                config.resolution,
1121                config.max_sweeps,
1122                deadline,
1123            );
1124            if hit_budget {
1125                // Fold this (possibly partial) sweep's assignment straight
1126                // into owner and stop — no further aggregation, so no
1127                // renumbering is needed.
1128                owner = owner.iter().map(|&o| community_of[o]).collect();
1129                truncated = true;
1130                break 'passes;
1131            }
1132            let Some(level) = aggregate(n, &adj, &self_weight, &community_of) else {
1133                // Local optimum: no further coarsening. community_of is
1134                // already the final assignment at this level.
1135                owner = owner.iter().map(|&o| community_of[o]).collect();
1136                break 'passes;
1137            };
1138            // Compose owner directly through the new level's compact
1139            // numbering (folds community_of + remap in one step).
1140            owner = owner.iter().map(|&o| level.renumbered[o]).collect();
1141            n = level.n;
1142            adj = level.adj;
1143            self_weight = level.self_weight;
1144        }
1145    }
1146
1147    // Cohesion / modularity from the ORIGINAL filtered edges, grouped by
1148    // final community.
1149    let mut internal: BTreeMap<usize, f64> = BTreeMap::new();
1150    let mut leaving: BTreeMap<usize, f64> = BTreeMap::new();
1151    for (&(a, b), &w) in &edge_weight_map {
1152        let ca = owner[a];
1153        let cb = owner[b];
1154        if ca == cb {
1155            *internal.entry(ca).or_insert(0.0) += w;
1156        } else {
1157            *leaving.entry(ca).or_insert(0.0) += w;
1158            *leaving.entry(cb).or_insert(0.0) += w;
1159        }
1160    }
1161
1162    let mut members_by_community: BTreeMap<usize, Vec<String>> = BTreeMap::new();
1163    for (i, key) in node_keys.iter().enumerate() {
1164        members_by_community
1165            .entry(owner[i])
1166            .or_default()
1167            .push(key.clone());
1168    }
1169
1170    let modularity = if m > 0.0 {
1171        members_by_community
1172            .keys()
1173            .map(|c| {
1174                let internal_w = internal.get(c).copied().unwrap_or(0.0);
1175                let leaving_w = leaving.get(c).copied().unwrap_or(0.0);
1176                let sigma_tot = 2.0 * internal_w + leaving_w;
1177                internal_w / m - config.resolution * (sigma_tot * sigma_tot) / (4.0 * m * m)
1178            })
1179            .sum()
1180    } else {
1181        0.0
1182    };
1183
1184    let mut communities: Vec<Community> = members_by_community
1185        .into_iter()
1186        .map(|(c, mut members)| {
1187            members.sort();
1188            let internal_w = internal.get(&c).copied().unwrap_or(0.0);
1189            let leaving_w = leaving.get(&c).copied().unwrap_or(0.0);
1190            let cohesion = if internal_w + leaving_w > 0.0 {
1191                internal_w / (internal_w + leaving_w)
1192            } else {
1193                1.0
1194            };
1195            Community {
1196                id: 0, // assigned below, after sorting
1197                members,
1198                internal_weight: internal_w,
1199                cohesion,
1200            }
1201        })
1202        .collect();
1203
1204    communities.sort_by(|a, b| {
1205        b.members
1206            .len()
1207            .cmp(&a.members.len())
1208            .then_with(|| a.members[0].cmp(&b.members[0]))
1209    });
1210    for (i, c) in communities.iter_mut().enumerate() {
1211        c.id = i as u32;
1212    }
1213
1214    CommunityReport {
1215        communities,
1216        modularity,
1217        truncated,
1218    }
1219}