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, IdMap, Interner};
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// ---------------------------------------------------------------------------
99// PageRank
100// ---------------------------------------------------------------------------
101
102/// Configuration for [`GraphDb::pagerank`].
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(default)]
105pub struct PageRankConfig {
106    /// Damping factor (probability of following an edge, not teleporting).
107    /// Default 0.85.
108    pub damping: f64,
109    /// Maximum number of power-iteration steps. Default 50.
110    pub max_iters: u32,
111    /// Convergence tolerance (L1 norm over all nodes). Default 1e-6.
112    pub tol: f64,
113    /// Restrict edges to this type. `None` uses all edge types (unified topology).
114    pub edge_type: Option<String>,
115    /// Edge direction to follow. `Dir::Out` follows out-edges (standard web
116    /// PageRank); `Dir::In` follows in-edges (authority scores); `Dir::Both`
117    /// treats all edges as undirected.
118    pub direction: AlgoDir,
119    /// Wall-clock budget (milliseconds) for the HTTP server endpoint.
120    /// `0` means no budget (run to convergence or `max_iters`).
121    pub budget_ms: u64,
122}
123
124impl Default for PageRankConfig {
125    fn default() -> Self {
126        Self {
127            damping: 0.85,
128            max_iters: 50,
129            tol: 1e-6,
130            edge_type: None,
131            direction: AlgoDir::Out,
132            budget_ms: 5_000,
133        }
134    }
135}
136
137/// Result of [`GraphDb::pagerank`].
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct PageRankReport {
140    /// Node keys and their PageRank scores.  Sorted: score descending, key
141    /// ascending on ties (deterministic).
142    pub scores: Vec<(String, f64)>,
143    /// `true` if the algorithm converged before `max_iters` and before any time
144    /// budget fired.  `false` means scores are still valid but partial — more
145    /// iterations would refine them.
146    pub converged: bool,
147}
148
149/// Direction semantics for algo methods (mirrors `Dir` but serializable).
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum AlgoDir {
153    /// Follow outgoing edges only (standard directed PageRank / out-degree).
154    Out,
155    /// Follow incoming edges only (in-degree / authority score).
156    In,
157    /// Treat edges as undirected: Out ∪ In.
158    Both,
159}
160
161impl From<Dir> for AlgoDir {
162    fn from(d: Dir) -> Self {
163        match d {
164            Dir::Out => AlgoDir::Out,
165            Dir::In => AlgoDir::In,
166            Dir::Both => AlgoDir::Both,
167        }
168    }
169}
170
171/// Run PageRank on the unified topology.
172///
173/// Returns a [`PageRankReport`] with scores sorted descending (ties: key asc).
174pub(crate) fn pagerank(
175    topo: &TopologyView,
176    idmap: &IdMap,
177    syms: &Interner,
178    labels: &[u32],
179    config: &PageRankConfig,
180) -> PageRankReport {
181    let deadline = if config.budget_ms > 0 {
182        Some(Instant::now() + Duration::from_millis(config.budget_ms))
183    } else {
184        None
185    };
186
187    let (node_ids, node_keys) = live_nodes(idmap, labels);
188    let n = node_ids.len();
189
190    if n == 0 {
191        return PageRankReport {
192            scores: Vec::new(),
193            converged: true,
194        };
195    }
196
197    // Map internal id → compact index for fast array access.
198    let max_id = topo.etypes().count(); // just an upper bound check hint
199    let _ = max_id;
200    let mut id_to_idx: BTreeMap<u32, usize> = BTreeMap::new();
201    for (i, &id) in node_ids.iter().enumerate() {
202        id_to_idx.insert(id, i);
203    }
204
205    // Resolve etype filter.
206    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
207        None => {
208            // Edge type specified but not in graph → no edges, PR is uniform.
209            let score = 1.0 / n as f64;
210            let mut scores: Vec<(String, f64)> =
211                node_keys.iter().map(|k| (k.clone(), score)).collect();
212            scores.sort_by(|(ka, sa), (kb, sb)| {
213                sb.partial_cmp(sa)
214                    .unwrap_or(std::cmp::Ordering::Equal)
215                    .then(ka.cmp(kb))
216            });
217            return PageRankReport {
218                scores,
219                converged: true,
220            };
221        }
222        Some(f) => f,
223    };
224
225    let etypes = etypes_filtered(topo, etype_filter);
226
227    // Build adjacency list (compact index): for each compact node,
228    // which compact nodes does it "send" rank to (based on direction)?
229    // send_to[i] = sorted list of compact indices that node i sends rank to.
230    let mut send_to: Vec<Vec<usize>> = vec![Vec::new(); n];
231
232    for &et in &etypes {
233        for (i, &id) in node_ids.iter().enumerate() {
234            match config.direction {
235                AlgoDir::Out => {
236                    // Standard: node i sends to its out-neighbors.
237                    for &nbr in topo.neighbors(et, Direction::Out, id).as_ref() {
238                        if let Some(&j) = id_to_idx.get(&nbr) {
239                            if !send_to[i].contains(&j) {
240                                send_to[i].push(j);
241                            }
242                        }
243                    }
244                }
245                AlgoDir::In => {
246                    // Authority: node i sends to its in-neighbors (reversed).
247                    for &nbr in topo.neighbors(et, Direction::In, id).as_ref() {
248                        if let Some(&j) = id_to_idx.get(&nbr) {
249                            if !send_to[i].contains(&j) {
250                                send_to[i].push(j);
251                            }
252                        }
253                    }
254                }
255                AlgoDir::Both => {
256                    // Undirected: union of out and in.
257                    for dir in [Direction::Out, Direction::In] {
258                        for &nbr in topo.neighbors(et, dir, id).as_ref() {
259                            if let Some(&j) = id_to_idx.get(&nbr) {
260                                if !send_to[i].contains(&j) {
261                                    send_to[i].push(j);
262                                }
263                            }
264                        }
265                    }
266                }
267            }
268        }
269    }
270
271    // Build receive_from[j] = list of (i, 1/out_degree(i)) that send to j.
272    // Also track dangling nodes (send_to.is_empty()).
273    let mut receive_from: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
274    let mut dangling: Vec<usize> = Vec::new();
275
276    for (i, send) in send_to.iter().enumerate() {
277        let out_deg = send.len();
278        if out_deg == 0 {
279            dangling.push(i);
280        } else {
281            let w = 1.0 / out_deg as f64;
282            for &j in send {
283                receive_from[j].push((i, w));
284            }
285        }
286    }
287
288    // Power iteration.
289    let nf = n as f64;
290    let d = config.damping;
291    let teleport = (1.0 - d) / nf;
292    let mut pr: Vec<f64> = vec![1.0 / nf; n];
293    let mut converged = false;
294
295    for _iter in 0..config.max_iters {
296        // Check time budget between iterations.
297        if let Some(dl) = deadline {
298            if Instant::now() >= dl {
299                break;
300            }
301        }
302
303        // Sum PR leaked by dangling nodes → distribute uniformly.
304        let dangling_sum: f64 = dangling.iter().map(|&i| pr[i]).sum::<f64>() * d / nf;
305
306        let mut new_pr = vec![teleport + dangling_sum; n];
307        for j in 0..n {
308            let received: f64 = receive_from[j].iter().map(|&(i, w)| pr[i] * w).sum();
309            new_pr[j] += d * received;
310        }
311
312        // Check convergence: L1 norm.
313        let delta: f64 = pr
314            .iter()
315            .zip(new_pr.iter())
316            .map(|(a, b)| (a - b).abs())
317            .sum();
318        pr = new_pr;
319
320        if delta < config.tol {
321            converged = true;
322            break;
323        }
324    }
325
326    // Sort: score desc, key asc on ties.
327    let mut scores: Vec<(String, f64)> = node_keys.into_iter().zip(pr).collect();
328    scores.sort_by(|(ka, sa), (kb, sb)| {
329        sb.partial_cmp(sa)
330            .unwrap_or(std::cmp::Ordering::Equal)
331            .then(ka.cmp(kb))
332    });
333
334    PageRankReport { scores, converged }
335}
336
337// ---------------------------------------------------------------------------
338// Weakly-connected components (WCC)
339// ---------------------------------------------------------------------------
340
341/// Configuration for [`GraphDb::connected_components`].
342#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(default)]
344pub struct WccConfig {
345    /// Restrict edges to this type. `None` uses all edge types.
346    pub edge_type: Option<String>,
347    /// Wall-clock budget (milliseconds) for the HTTP server endpoint.
348    pub budget_ms: u64,
349}
350
351impl Default for WccConfig {
352    fn default() -> Self {
353        Self {
354            edge_type: None,
355            budget_ms: 5_000,
356        }
357    }
358}
359
360/// Result of [`GraphDb::connected_components`].
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct WccReport {
363    /// Each live node and the key of the smallest member of its component
364    /// (deterministic component identifier).  Sorted by (component_id, key).
365    pub components: Vec<(String, String)>,
366    /// `true` if the time budget fired before all nodes were processed.
367    pub truncated: bool,
368}
369
370/// Union-Find with path compression and union by rank.
371struct UnionFind {
372    parent: Vec<usize>,
373    rank: Vec<u8>,
374}
375
376impl UnionFind {
377    fn new(n: usize) -> Self {
378        Self {
379            parent: (0..n).collect(),
380            rank: vec![0; n],
381        }
382    }
383
384    fn find(&mut self, mut x: usize) -> usize {
385        while self.parent[x] != x {
386            self.parent[x] = self.parent[self.parent[x]]; // path halving
387            x = self.parent[x];
388        }
389        x
390    }
391
392    fn union(&mut self, a: usize, b: usize) {
393        let ra = self.find(a);
394        let rb = self.find(b);
395        if ra == rb {
396            return;
397        }
398        match self.rank[ra].cmp(&self.rank[rb]) {
399            std::cmp::Ordering::Less => self.parent[ra] = rb,
400            std::cmp::Ordering::Greater => self.parent[rb] = ra,
401            std::cmp::Ordering::Equal => {
402                self.parent[rb] = ra;
403                self.rank[ra] += 1;
404            }
405        }
406    }
407}
408
409/// Run weakly-connected components on the unified topology (undirected).
410///
411/// Component ID is the smallest member key in each component (deterministic).
412pub(crate) fn wcc(
413    topo: &TopologyView,
414    idmap: &IdMap,
415    syms: &Interner,
416    labels: &[u32],
417    config: &WccConfig,
418) -> WccReport {
419    let deadline = if config.budget_ms > 0 {
420        Some(Instant::now() + Duration::from_millis(config.budget_ms))
421    } else {
422        None
423    };
424
425    let (node_ids, node_keys) = live_nodes(idmap, labels);
426    let n = node_ids.len();
427
428    if n == 0 {
429        return WccReport {
430            components: Vec::new(),
431            truncated: false,
432        };
433    }
434
435    // Map internal id → compact index.
436    let mut id_to_idx: BTreeMap<u32, usize> = BTreeMap::new();
437    for (i, &id) in node_ids.iter().enumerate() {
438        id_to_idx.insert(id, i);
439    }
440
441    // Resolve etype filter.
442    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
443        None => {
444            // No edges of this type → every node is its own component.
445            let mut components: Vec<(String, String)> =
446                node_keys.iter().map(|k| (k.clone(), k.clone())).collect();
447            components.sort();
448            return WccReport {
449                components,
450                truncated: false,
451            };
452        }
453        Some(f) => f,
454    };
455
456    let etypes = etypes_filtered(topo, etype_filter);
457
458    let mut uf = UnionFind::new(n);
459    let mut truncated = false;
460
461    // Union all edges (both directions — WCC treats graph as undirected).
462    'outer: for &et in &etypes {
463        for (i, &id) in node_ids.iter().enumerate() {
464            if let Some(dl) = deadline {
465                if Instant::now() >= dl {
466                    truncated = true;
467                    break 'outer;
468                }
469            }
470            // Out-edges: union i with each out-neighbor.
471            for &nbr in topo.neighbors(et, Direction::Out, id).as_ref() {
472                if let Some(&j) = id_to_idx.get(&nbr) {
473                    uf.union(i, j);
474                }
475            }
476            // In-edges handled by the mirror out-edge from the other side,
477            // but we also cover them here for safety (e.g., self-loops, or
478            // nodes with only in-edges for a filtered etype).
479            for &nbr in topo.neighbors(et, Direction::In, id).as_ref() {
480                if let Some(&j) = id_to_idx.get(&nbr) {
481                    uf.union(i, j);
482                }
483            }
484        }
485    }
486
487    // Determine component representative: smallest key per root.
488    let mut root_min_key: BTreeMap<usize, &str> = BTreeMap::new();
489    for (i, key_str) in node_keys.iter().enumerate() {
490        let root = uf.find(i);
491        let key = key_str.as_str();
492        let entry = root_min_key.entry(root).or_insert(key);
493        if key < *entry {
494            *entry = key;
495        }
496    }
497
498    let mut components: Vec<(String, String)> = node_keys
499        .iter()
500        .enumerate()
501        .map(|(i, key_str)| {
502            let root = uf.find(i);
503            let comp_id = root_min_key[&root].to_string();
504            (key_str.clone(), comp_id)
505        })
506        .collect();
507    components.sort_by(|(ka, ca), (kb, cb)| ca.cmp(cb).then(ka.cmp(kb)));
508
509    WccReport {
510        components,
511        truncated,
512    }
513}
514
515// ---------------------------------------------------------------------------
516// Degree centrality
517// ---------------------------------------------------------------------------
518
519/// Configuration for [`GraphDb::degree_centrality`].
520#[derive(Debug, Clone, Serialize, Deserialize)]
521#[serde(default)]
522pub struct DegreeConfig {
523    /// Restrict edges to this type. `None` counts all edge types.
524    pub edge_type: Option<String>,
525    /// Which edges to count per node.
526    pub direction: AlgoDir,
527    /// Wall-clock budget (milliseconds) for the HTTP server endpoint.
528    pub budget_ms: u64,
529}
530
531impl Default for DegreeConfig {
532    fn default() -> Self {
533        Self {
534            edge_type: None,
535            direction: AlgoDir::Both,
536            budget_ms: 5_000,
537        }
538    }
539}
540
541/// Result of [`GraphDb::degree_centrality`].
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct DegreeReport {
544    /// Node keys and their degree.  Sorted: degree descending, key ascending on ties.
545    pub scores: Vec<(String, u64)>,
546    /// `true` if the time budget fired before all nodes were processed.
547    pub truncated: bool,
548}
549
550/// Compute degree centrality for all live nodes.
551pub(crate) fn degree_centrality(
552    topo: &TopologyView,
553    idmap: &IdMap,
554    syms: &Interner,
555    labels: &[u32],
556    config: &DegreeConfig,
557) -> DegreeReport {
558    let deadline = if config.budget_ms > 0 {
559        Some(Instant::now() + Duration::from_millis(config.budget_ms))
560    } else {
561        None
562    };
563
564    let (node_ids, node_keys) = live_nodes(idmap, labels);
565    let n = node_ids.len();
566
567    if n == 0 {
568        return DegreeReport {
569            scores: Vec::new(),
570            truncated: false,
571        };
572    }
573
574    // Resolve etype filter.
575    let etype_filter = match resolve_etype(syms, config.edge_type.as_deref()) {
576        None => {
577            // No edges of this type → all degrees are 0.
578            let scores = node_keys.iter().map(|k| (k.clone(), 0u64)).collect();
579            return DegreeReport {
580                scores,
581                truncated: false,
582            };
583        }
584        Some(f) => f,
585    };
586
587    let etypes = etypes_filtered(topo, etype_filter);
588    let mut degrees: Vec<u64> = vec![0u64; n];
589    let mut truncated = false;
590
591    for (i, &id) in node_ids.iter().enumerate() {
592        if let Some(dl) = deadline {
593            if Instant::now() >= dl {
594                truncated = true;
595                break;
596            }
597        }
598        for &et in &etypes {
599            match config.direction {
600                AlgoDir::Out => {
601                    degrees[i] += topo.neighbors(et, Direction::Out, id).len() as u64;
602                }
603                AlgoDir::In => {
604                    degrees[i] += topo.neighbors(et, Direction::In, id).len() as u64;
605                }
606                AlgoDir::Both => {
607                    degrees[i] += topo.neighbors(et, Direction::Out, id).len() as u64;
608                    degrees[i] += topo.neighbors(et, Direction::In, id).len() as u64;
609                }
610            }
611        }
612    }
613
614    let mut scores: Vec<(String, u64)> = node_keys.into_iter().zip(degrees).collect();
615    scores.sort_by(|(ka, da), (kb, db)| db.cmp(da).then(ka.cmp(kb)));
616
617    DegreeReport { scores, truncated }
618}