Skip to main content

macrame/graph/
algorithms.rs

1//! In-memory graph algorithms operating on a loaded [`Subgraph`] (§5.4).
2//!
3//! Pure CPU, synchronous, no external dependencies (D-039).
4//!
5//! # Determinism
6//!
7//! Every function here is a deterministic function of the [`Subgraph`] value:
8//! the same graph yields the same answer, byte for byte, on every run and every
9//! platform. That is not automatic, and it is the reason this module reaches for
10//! `BTreeMap`/`BTreeSet` in places where a `HashMap` would be the reflexive
11//! choice:
12//!
13//! * `Subgraph`'s maps are ordered, so node iteration order is the ULID order.
14//! * Returns are ordered too. A `HashSet<String>` return would push the
15//!   nondeterminism onto the caller — Rust's default hasher is seeded per
16//!   process, so a caller iterating the result to write it back would emit rows
17//!   in a different order on every run.
18//! * Ties are broken explicitly, never by iteration order. Two heap entries with
19//!   equal distance are ordered by node id; two communities with equal
20//!   modularity gain resolve to the lower community index.
21//!
22//! Without all three, `FakeClock` fixes the clock and the analytics still drift.
23//!
24//! # Edge weights must be non-negative
25//!
26//! `dijkstra` and `astar` assume `weight >= 0`; that is what makes a settled
27//! node final. The schema does not enforce it (`weight REAL NOT NULL`, no
28//! CHECK), so a negative weight is storable today and would yield a silently
29//! wrong shortest path. Both functions therefore bound their own work and
30//! [`Subgraph::load`] refuses to build a graph containing one, so the failure is
31//! loud at the boundary rather than quiet in the result.
32
33use std::cmp::{Ordering, Reverse};
34use std::collections::{BTreeMap, BTreeSet, BinaryHeap, VecDeque};
35
36use super::subgraph::Subgraph;
37
38/// A total order over `f64` so distances can live in a `BinaryHeap`.
39///
40/// `f64` is only `PartialOrd` because `NaN` compares false against everything,
41/// which is exactly the case that would corrupt a heap's invariant silently.
42/// `total_cmp` is the IEEE-754 total order: it never returns `Equal` for
43/// distinct bit patterns, so the heap stays well-ordered even if a `NaN` weight
44/// reaches it.
45#[derive(Debug, Clone, Copy, PartialEq)]
46struct OrdF64(f64);
47
48impl Eq for OrdF64 {}
49
50impl Ord for OrdF64 {
51    fn cmp(&self, other: &Self) -> Ordering {
52        self.0.total_cmp(&other.0)
53    }
54}
55
56impl PartialOrd for OrdF64 {
57    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
58        Some(self.cmp(other))
59    }
60}
61
62/// Dijkstra's algorithm for shortest path distances (§5.4).
63///
64/// Returns node id -> shortest distance from `start`, including `start` at 0.0.
65/// Unreachable nodes are absent rather than present at infinity.
66pub fn dijkstra(graph: &Subgraph, start: &str) -> BTreeMap<String, f64> {
67    let mut dist = BTreeMap::new();
68    let mut heap = BinaryHeap::new();
69
70    if !graph.nodes.contains_key(start) {
71        return dist;
72    }
73
74    dist.insert(start.to_string(), 0.0);
75    heap.push(Reverse((OrdF64(0.0), start.to_string())));
76
77    while let Some(Reverse((OrdF64(d), node))) = heap.pop() {
78        // A stale entry: this node was reached again more cheaply after this
79        // entry was pushed. Settle it once, at its best distance.
80        if d > *dist.get(&node).unwrap_or(&f64::INFINITY) {
81            continue;
82        }
83
84        for edge in graph.out_edges(&node) {
85            let next = &edge.node;
86            let new_dist = d + edge.weight;
87
88            if new_dist < *dist.get(next).unwrap_or(&f64::INFINITY) {
89                dist.insert(next.clone(), new_dist);
90                heap.push(Reverse((OrdF64(new_dist), next.clone())));
91            }
92        }
93    }
94
95    dist
96}
97
98/// A* search from `start` to `goal` (§5.4).
99///
100/// Returns the total cost and the full path inclusive of both endpoints, or
101/// `None` when `goal` is unreachable. `heuristic` must be admissible — it must
102/// never overestimate the remaining cost — or the path returned is a path but
103/// not necessarily the shortest one.
104pub fn astar<F>(
105    graph: &Subgraph,
106    start: &str,
107    goal: &str,
108    heuristic: F,
109) -> Option<(f64, Vec<String>)>
110where
111    F: Fn(&str, &str) -> f64,
112{
113    if !graph.nodes.contains_key(start) || !graph.nodes.contains_key(goal) {
114        return None;
115    }
116
117    let mut g_score: BTreeMap<String, f64> = BTreeMap::new();
118    let mut came_from: BTreeMap<String, String> = BTreeMap::new();
119    let mut heap = BinaryHeap::new();
120
121    g_score.insert(start.to_string(), 0.0);
122    heap.push(Reverse((OrdF64(heuristic(start, goal)), start.to_string())));
123
124    while let Some(Reverse((OrdF64(f_score), current))) = heap.pop() {
125        let current_g = g_score[&current];
126
127        if current == goal {
128            return Some((current_g, reconstruct(&came_from, goal, graph.nodes.len())));
129        }
130
131        // A stale entry, superseded by a cheaper route to the same node.
132        if f_score > current_g + heuristic(&current, goal) {
133            continue;
134        }
135
136        for edge in graph.out_edges(&current) {
137            let neighbor = &edge.node;
138            let tentative_g = current_g + edge.weight;
139
140            if tentative_g < *g_score.get(neighbor).unwrap_or(&f64::INFINITY) {
141                // `start` never gets a predecessor, so `reconstruct` cannot
142                // walk into a cycle at the head of the path.
143                if neighbor.as_str() != start {
144                    came_from.insert(neighbor.clone(), current.clone());
145                }
146                g_score.insert(neighbor.clone(), tentative_g);
147                let f = tentative_g + heuristic(neighbor, goal);
148                heap.push(Reverse((OrdF64(f), neighbor.clone())));
149            }
150        }
151    }
152
153    None
154}
155
156/// Walk the predecessor chain back from `goal`, forwards.
157///
158/// `limit` bounds the walk at the node count. The chain cannot exceed that on a
159/// well-formed `came_from`, so exceeding it means the map has a cycle; the walk
160/// stops rather than hanging.
161fn reconstruct(came_from: &BTreeMap<String, String>, goal: &str, limit: usize) -> Vec<String> {
162    let mut path = vec![goal.to_string()];
163    let mut curr = goal.to_string();
164    while let Some(prev) = came_from.get(&curr) {
165        if path.len() > limit {
166            break;
167        }
168        path.push(prev.clone());
169        curr = prev.clone();
170    }
171    path.reverse();
172    path
173}
174
175/// Strongly connected components by Kosaraju's algorithm (§5.4).
176///
177/// Both passes use an explicit stack. Recursion would put the traversal depth on
178/// the call stack, and a knowledge graph is deep enough for that to be a real
179/// overflow rather than a theoretical one.
180///
181/// Components come back in a canonical form — each component sorted, and the
182/// components ordered by their first element — so the result is comparable
183/// across runs without the caller having to normalise it.
184pub fn scc(graph: &Subgraph) -> Vec<Vec<String>> {
185    let mut visited = BTreeSet::new();
186    let mut order = Vec::new();
187
188    // Pass 1: post-order finish times on the graph as given.
189    for node in graph.nodes.keys() {
190        if visited.contains(node) {
191            continue;
192        }
193        let mut stack = vec![(node.clone(), false)];
194        while let Some((curr, exhausted)) = stack.pop() {
195            if exhausted {
196                order.push(curr);
197                continue;
198            }
199            if visited.contains(&curr) {
200                continue;
201            }
202            visited.insert(curr.clone());
203            // Re-pushed beneath its children, so it finishes after them.
204            stack.push((curr.clone(), true));
205
206            for edge in graph.out_edges(&curr) {
207                if !visited.contains(&edge.node) {
208                    stack.push((edge.node.clone(), false));
209                }
210            }
211        }
212    }
213
214    // Pass 2: the transpose, in decreasing finish time.
215    visited.clear();
216    let mut components = Vec::new();
217
218    for node in order.into_iter().rev() {
219        if visited.contains(&node) {
220            continue;
221        }
222        let mut comp = Vec::new();
223        let mut stack = vec![node];
224
225        while let Some(curr) = stack.pop() {
226            if visited.contains(&curr) {
227                continue;
228            }
229            visited.insert(curr.clone());
230            comp.push(curr.clone());
231
232            for edge in graph.in_edges(&curr) {
233                if !visited.contains(&edge.node) {
234                    stack.push(edge.node.clone());
235                }
236            }
237        }
238        comp.sort();
239        components.push(comp);
240    }
241
242    components.sort();
243    components
244}
245
246/// k-core decomposition: the maximal induced subgraph in which every node has
247/// degree at least `k` (§5.4).
248///
249/// Treats the graph as undirected, summing in- and out-degree. Parallel edges
250/// count once each — a node held in by three edges to one neighbour has degree
251/// three, which is what makes this a multigraph core.
252pub fn k_core(graph: &Subgraph, k: usize) -> BTreeSet<String> {
253    let mut degree: BTreeMap<String, usize> = graph
254        .nodes
255        .keys()
256        .map(|n| (n.clone(), graph.degree(n)))
257        .collect();
258
259    let mut queue: VecDeque<String> = degree
260        .iter()
261        .filter(|(_, &d)| d < k)
262        .map(|(n, _)| n.clone())
263        .collect();
264
265    let mut removed = BTreeSet::new();
266
267    while let Some(node) = queue.pop_front() {
268        if removed.contains(&node) {
269            continue;
270        }
271        removed.insert(node.clone());
272
273        let neighbours = graph
274            .out_edges(&node)
275            .iter()
276            .chain(graph.in_edges(&node).iter());
277
278        for edge in neighbours {
279            // `-=` rather than `saturating_sub`, deliberately.
280            //
281            // The arithmetic is exact: an edge (u,v) appears once in `out_adj[u]`
282            // and once in `in_adj[v]`, and `degree` counts both, so removing
283            // every neighbour decrements a node exactly to zero and never past
284            // it. That holds for self-loops and parallel edges too. Since the
285            // subtraction cannot underflow on a well-formed `Subgraph`, letting
286            // it panic turns the invariant into an assertion — an `in_adj` that
287            // has drifted out of step with `out_adj` fails here loudly instead
288            // of being absorbed into a plausible wrong core.
289            if let Some(d) = degree.get_mut(&edge.node) {
290                *d -= 1;
291                if *d < k && !removed.contains(&edge.node) {
292                    queue.push_back(edge.node.clone());
293                }
294            }
295        }
296    }
297
298    graph
299        .nodes
300        .keys()
301        .filter(|n| !removed.contains(*n))
302        .cloned()
303        .collect()
304}
305
306/// Newman-Girvan modularity of a partition, treating the graph as undirected.
307///
308/// Exists so `louvain` can be tested against what it claims to maximise rather
309/// than against its own output. A community detector that returns one node per
310/// community satisfies "modularity did not decrease from the singleton
311/// partition" by being that partition; measuring Q is what tells the two apart.
312pub fn modularity(graph: &Subgraph, communities: &BTreeMap<String, usize>) -> f64 {
313    let m = graph.total_weight();
314    if m == 0.0 {
315        return 0.0;
316    }
317
318    // Sum of weights of edges inside each community, and of degrees within it.
319    let mut internal: BTreeMap<usize, f64> = BTreeMap::new();
320    let mut total_deg: BTreeMap<usize, f64> = BTreeMap::new();
321
322    for node in graph.nodes.keys() {
323        let Some(&c) = communities.get(node) else {
324            continue;
325        };
326        *total_deg.entry(c).or_insert(0.0) += graph.weighted_degree(node);
327
328        for edge in graph.out_edges(node) {
329            if communities.get(&edge.node) == Some(&c) {
330                *internal.entry(c).or_insert(0.0) += edge.weight;
331            }
332        }
333    }
334
335    total_deg
336        .iter()
337        .map(|(c, deg)| {
338            let inside = internal.get(c).copied().unwrap_or(0.0);
339            (inside / m) - (deg / (2.0 * m)).powi(2)
340        })
341        .sum()
342}
343
344/// Maximum sweeps before `louvain` gives up moving nodes.
345///
346/// Greedy modularity ascent terminates in exact arithmetic because every
347/// accepted move strictly increases Q. In floating point a move worth `+1e-17`
348/// can be undone next sweep by one worth `+1e-17`, and the loop oscillates. The
349/// epsilon below makes that rare and this cap makes it bounded.
350const LOUVAIN_MAX_SWEEPS: usize = 100;
351
352/// A move must beat this to be taken, so float noise cannot drive a sweep.
353const LOUVAIN_MIN_GAIN: f64 = 1e-12;
354
355/// Louvain community detection, local-moving phase (§5.4).
356///
357/// Returns node id -> community index. Communities are renumbered densely from
358/// zero in order of first appearance, so the result is stable and comparable.
359///
360/// This is phase one of the two-phase Louvain method: nodes are moved greedily
361/// to whichever neighbouring community most increases modularity, repeatedly,
362/// until no move helps. It does *not* then aggregate each community into a
363/// single node and recurse, which is what the full method does to find coarser
364/// structure. For subgraph-sized analytics the local moving phase is the part
365/// that carries the signal; the aggregation phase would matter on graphs far
366/// larger than the byte budget admits.
367pub fn louvain(graph: &Subgraph) -> BTreeMap<String, usize> {
368    let m = graph.total_weight();
369
370    // Every node its own community: the only sensible answer with no edges, and
371    // the baseline the modularity gain is measured against.
372    let mut comm: BTreeMap<String, usize> = graph
373        .nodes
374        .keys()
375        .enumerate()
376        .map(|(i, n)| (n.clone(), i))
377        .collect();
378
379    if m == 0.0 {
380        return comm;
381    }
382
383    let mut sigma_tot: BTreeMap<usize, f64> = BTreeMap::new();
384    for node in graph.nodes.keys() {
385        *sigma_tot.entry(comm[node]).or_insert(0.0) += graph.weighted_degree(node);
386    }
387
388    for _ in 0..LOUVAIN_MAX_SWEEPS {
389        let mut moved = false;
390
391        for node in graph.nodes.keys() {
392            let curr_comm = comm[node];
393            let k_i = graph.weighted_degree(node);
394
395            // Withdraw the node before scoring, so staying put is scored on the
396            // same footing as moving.
397            *sigma_tot.get_mut(&curr_comm).unwrap() -= k_i;
398
399            // Weight from this node into each neighbouring community.
400            let mut k_i_c: BTreeMap<usize, f64> = BTreeMap::new();
401            for edge in graph.out_edges(node).iter().chain(graph.in_edges(node)) {
402                if &edge.node == node {
403                    continue; // a self-loop joins no community
404                }
405                *k_i_c.entry(comm[&edge.node]).or_insert(0.0) += edge.weight;
406            }
407
408            // dQ = k_i_in/m - (sigma_tot * k_i)/(2m^2), the standard reduced
409            // form. Iterating a BTreeMap makes the scan order the community
410            // index, so a tie resolves to the lowest index rather than to
411            // whatever the hasher seeded this process with.
412            let mut best_comm = curr_comm;
413            let mut best_gain = LOUVAIN_MIN_GAIN;
414
415            for (&c, k_i_in) in &k_i_c {
416                let tot = sigma_tot.get(&c).copied().unwrap_or(0.0);
417                let gain = (k_i_in / m) - (tot * k_i / (2.0 * m * m));
418                if gain > best_gain {
419                    best_gain = gain;
420                    best_comm = c;
421                }
422            }
423
424            *sigma_tot.entry(best_comm).or_insert(0.0) += k_i;
425
426            if best_comm != curr_comm {
427                comm.insert(node.clone(), best_comm);
428                moved = true;
429            }
430        }
431
432        if !moved {
433            break;
434        }
435    }
436
437    renumber(comm)
438}
439
440/// Compact community indices to `0..n` in order of first appearance.
441fn renumber(comm: BTreeMap<String, usize>) -> BTreeMap<String, usize> {
442    let mut dense: BTreeMap<usize, usize> = BTreeMap::new();
443    let mut next = 0;
444    comm.into_iter()
445        .map(|(node, c)| {
446            let id = *dense.entry(c).or_insert_with(|| {
447                let id = next;
448                next += 1;
449                id
450            });
451            (node, id)
452        })
453        .collect()
454}