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//! [`Database::load_subgraph`](crate::Database::load_subgraph) refuses to build
31//! a graph containing one, so the failure is loud at the boundary rather than
32//! quiet in the result.
33
34use std::cmp::{Ordering, Reverse};
35use std::collections::{BTreeMap, BTreeSet, BinaryHeap, VecDeque};
36
37use super::subgraph::Subgraph;
38
39/// The message every entry assert carries.
40///
41/// [`Subgraph`]'s type-level docs state the closure invariant and say that
42/// "every algorithm in [`super::algorithms`] is written assuming it and none of
43/// them re-checks". [`Subgraph::is_closed`]'s own rustdoc has claimed since
44/// 0.6.0 that it is "used by tests and `debug_assert`s" — and no `debug_assert`
45/// existed anywhere in `src/`.
46///
47/// 0.10.0 (W4.8) writes them rather than softening the sentence. A live
48/// assumption that no assertion covers is one refactor away from being a silent
49/// wrong answer instead of a panic, and the invariant has failed once already
50/// (defect Z, Wave 1: a retired neighbour left an `EdgeRef` pointing at a node
51/// the loader had filtered out). `is_closed` is O(V + E) and these are
52/// `debug_assert`s, so release builds pay nothing.
53const CLOSURE: &str = "Subgraph closure invariant violated on entry: adjacency \
54                       references a node that is not in `nodes`. Every algorithm \
55                       here assumes closure and none re-checks it — see \
56                       `Subgraph`'s type docs and `drop_dangling_adjacency`.";
57
58/// A total order over `f64` so distances can live in a `BinaryHeap`.
59///
60/// `f64` is only `PartialOrd` because `NaN` compares false against everything,
61/// which is exactly the case that would corrupt a heap's invariant silently.
62/// `total_cmp` is the IEEE-754 total order: it never returns `Equal` for
63/// distinct bit patterns, so the heap stays well-ordered even if a `NaN` weight
64/// reaches it.
65#[derive(Debug, Clone, Copy, PartialEq)]
66struct OrdF64(f64);
67
68impl Eq for OrdF64 {}
69
70impl Ord for OrdF64 {
71    fn cmp(&self, other: &Self) -> Ordering {
72        self.0.total_cmp(&other.0)
73    }
74}
75
76impl PartialOrd for OrdF64 {
77    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
78        Some(self.cmp(other))
79    }
80}
81
82/// Dijkstra's algorithm for shortest path distances (§5.4).
83///
84/// Returns node id -> shortest distance from `start`, including `start` at 0.0.
85/// Unreachable nodes are absent rather than present at infinity.
86pub fn dijkstra(graph: &Subgraph, start: &str) -> BTreeMap<String, f64> {
87    debug_assert!(graph.is_closed(), "{CLOSURE}");
88    let mut dist = BTreeMap::new();
89    let mut heap = BinaryHeap::new();
90
91    if !graph.contains_node(start) {
92        return dist;
93    }
94
95    dist.insert(start.to_string(), 0.0);
96    heap.push(Reverse((OrdF64(0.0), start.to_string())));
97
98    while let Some(Reverse((OrdF64(d), node))) = heap.pop() {
99        // A stale entry: this node was reached again more cheaply after this
100        // entry was pushed. Settle it once, at its best distance.
101        if d > *dist.get(&node).unwrap_or(&f64::INFINITY) {
102            continue;
103        }
104
105        for edge in graph.out_edges(&node) {
106            let next = edge.node(graph);
107            let new_dist = d + edge.weight();
108
109            if new_dist < *dist.get(next).unwrap_or(&f64::INFINITY) {
110                dist.insert(next.to_string(), new_dist);
111                heap.push(Reverse((OrdF64(new_dist), next.to_string())));
112            }
113        }
114    }
115
116    dist
117}
118
119/// A* search from `start` to `goal` (§5.4).
120///
121/// Returns the total cost and the full path inclusive of both endpoints, or
122/// `None` when `goal` is unreachable. `heuristic` must be admissible — it must
123/// never overestimate the remaining cost — or the path returned is a path but
124/// not necessarily the shortest one.
125pub fn astar<F>(
126    graph: &Subgraph,
127    start: &str,
128    goal: &str,
129    heuristic: F,
130) -> Option<(f64, Vec<String>)>
131where
132    F: Fn(&str, &str) -> f64,
133{
134    debug_assert!(graph.is_closed(), "{CLOSURE}");
135    if !graph.contains_node(start) || !graph.contains_node(goal) {
136        return None;
137    }
138
139    let mut g_score: BTreeMap<String, f64> = BTreeMap::new();
140    let mut came_from: BTreeMap<String, String> = BTreeMap::new();
141    let mut heap = BinaryHeap::new();
142
143    g_score.insert(start.to_string(), 0.0);
144    heap.push(Reverse((OrdF64(heuristic(start, goal)), start.to_string())));
145
146    while let Some(Reverse((OrdF64(f_score), current))) = heap.pop() {
147        let current_g = g_score[&current];
148
149        if current == goal {
150            return Some((current_g, reconstruct(&came_from, goal, graph.node_count())));
151        }
152
153        // A stale entry, superseded by a cheaper route to the same node.
154        if f_score > current_g + heuristic(&current, goal) {
155            continue;
156        }
157
158        for edge in graph.out_edges(&current) {
159            let neighbor = edge.node(graph);
160            let tentative_g = current_g + edge.weight();
161
162            if tentative_g < *g_score.get(neighbor).unwrap_or(&f64::INFINITY) {
163                // `start` never gets a predecessor, so `reconstruct` cannot
164                // walk into a cycle at the head of the path.
165                if neighbor != start {
166                    came_from.insert(neighbor.to_string(), current.clone());
167                }
168                g_score.insert(neighbor.to_string(), tentative_g);
169                let f = tentative_g + heuristic(neighbor, goal);
170                heap.push(Reverse((OrdF64(f), neighbor.to_string())));
171            }
172        }
173    }
174
175    None
176}
177
178/// Walk the predecessor chain back from `goal`, forwards.
179///
180/// `limit` bounds the walk at the node count. The chain cannot exceed that on a
181/// well-formed `came_from`, so exceeding it means the map has a cycle; the walk
182/// stops rather than hanging.
183fn reconstruct(came_from: &BTreeMap<String, String>, goal: &str, limit: usize) -> Vec<String> {
184    let mut path = vec![goal.to_string()];
185    let mut curr = goal.to_string();
186    while let Some(prev) = came_from.get(&curr) {
187        if path.len() > limit {
188            break;
189        }
190        path.push(prev.clone());
191        curr = prev.clone();
192    }
193    path.reverse();
194    path
195}
196
197/// Strongly connected components by Kosaraju's algorithm (§5.4).
198///
199/// Both passes use an explicit stack. Recursion would put the traversal depth on
200/// the call stack, and a knowledge graph is deep enough for that to be a real
201/// overflow rather than a theoretical one.
202///
203/// Components come back in a canonical form — each component sorted, and the
204/// components ordered by their first element — so the result is comparable
205/// across runs without the caller having to normalise it.
206pub fn scc(graph: &Subgraph) -> Vec<Vec<String>> {
207    debug_assert!(graph.is_closed(), "{CLOSURE}");
208    let mut visited = BTreeSet::new();
209    let mut order = Vec::new();
210
211    // Pass 1: post-order finish times on the graph as given.
212    for node in graph.node_ids() {
213        if visited.contains(node) {
214            continue;
215        }
216        let mut stack = vec![(node.to_string(), false)];
217        while let Some((curr, exhausted)) = stack.pop() {
218            if exhausted {
219                order.push(curr);
220                continue;
221            }
222            if visited.contains(&curr) {
223                continue;
224            }
225            visited.insert(curr.clone());
226            // Re-pushed beneath its children, so it finishes after them.
227            stack.push((curr.clone(), true));
228
229            for edge in graph.out_edges(&curr) {
230                if !visited.contains(edge.node(graph)) {
231                    stack.push((edge.node(graph).to_string(), false));
232                }
233            }
234        }
235    }
236
237    // Pass 2: the transpose, in decreasing finish time.
238    visited.clear();
239    let mut components = Vec::new();
240
241    for node in order.into_iter().rev() {
242        if visited.contains(&node) {
243            continue;
244        }
245        let mut comp = Vec::new();
246        let mut stack = vec![node];
247
248        while let Some(curr) = stack.pop() {
249            if visited.contains(&curr) {
250                continue;
251            }
252            visited.insert(curr.clone());
253            comp.push(curr.clone());
254
255            for edge in graph.in_edges(&curr) {
256                if !visited.contains(edge.node(graph)) {
257                    stack.push(edge.node(graph).to_string());
258                }
259            }
260        }
261        comp.sort();
262        components.push(comp);
263    }
264
265    components.sort();
266    components
267}
268
269/// k-core decomposition: the maximal induced subgraph in which every node has
270/// degree at least `k` (§5.4).
271///
272/// Treats the graph as undirected, summing in- and out-degree. Parallel edges
273/// count once each — a node held in by three edges to one neighbour has degree
274/// three, which is what makes this a multigraph core.
275pub fn k_core(graph: &Subgraph, k: usize) -> BTreeSet<String> {
276    debug_assert!(graph.is_closed(), "{CLOSURE}");
277    let mut degree: BTreeMap<String, usize> = graph
278        .node_ids()
279        .map(|n| (n.to_string(), graph.degree(n)))
280        .collect();
281
282    let mut queue: VecDeque<String> = degree
283        .iter()
284        .filter(|(_, &d)| d < k)
285        .map(|(n, _)| n.clone())
286        .collect();
287
288    let mut removed = BTreeSet::new();
289
290    while let Some(node) = queue.pop_front() {
291        if removed.contains(&node) {
292            continue;
293        }
294        removed.insert(node.clone());
295
296        let neighbours = graph
297            .out_edges(&node)
298            .iter()
299            .chain(graph.in_edges(&node).iter());
300
301        for edge in neighbours {
302            // `-=` rather than `saturating_sub`, deliberately.
303            //
304            // The arithmetic is exact: an edge (u,v) appears once in `out_adj[u]`
305            // and once in `in_adj[v]`, and `degree` counts both, so removing
306            // every neighbour decrements a node exactly to zero and never past
307            // it. That holds for self-loops and parallel edges too. Since the
308            // subtraction cannot underflow on a well-formed `Subgraph`, letting
309            // it panic turns the invariant into an assertion — an `in_adj` that
310            // has drifted out of step with `out_adj` fails here loudly instead
311            // of being absorbed into a plausible wrong core.
312            if let Some(d) = degree.get_mut(edge.node(graph)) {
313                *d -= 1;
314                if *d < k && !removed.contains(edge.node(graph)) {
315                    queue.push_back(edge.node(graph).to_string());
316                }
317            }
318        }
319    }
320
321    graph
322        .node_ids()
323        .filter(|n| !removed.contains(*n))
324        .map(str::to_string)
325        .collect()
326}
327
328/// Newman-Girvan modularity of a partition, treating the graph as undirected.
329///
330/// Exists so `louvain` can be tested against what it claims to maximise rather
331/// than against its own output. A community detector that returns one node per
332/// community satisfies "modularity did not decrease from the singleton
333/// partition" by being that partition; measuring Q is what tells the two apart.
334pub fn modularity(graph: &Subgraph, communities: &BTreeMap<String, usize>) -> f64 {
335    let m = graph.total_weight();
336    if m == 0.0 {
337        return 0.0;
338    }
339
340    // Sum of weights of edges inside each community, and of degrees within it.
341    let mut internal: BTreeMap<usize, f64> = BTreeMap::new();
342    let mut total_deg: BTreeMap<usize, f64> = BTreeMap::new();
343
344    for node in graph.node_ids() {
345        let Some(&c) = communities.get(node) else {
346            continue;
347        };
348        *total_deg.entry(c).or_insert(0.0) += graph.weighted_degree(node);
349
350        for edge in graph.out_edges(node) {
351            if communities.get(edge.node(graph)) == Some(&c) {
352                *internal.entry(c).or_insert(0.0) += edge.weight();
353            }
354        }
355    }
356
357    total_deg
358        .iter()
359        .map(|(c, deg)| {
360            let inside = internal.get(c).copied().unwrap_or(0.0);
361            (inside / m) - (deg / (2.0 * m)).powi(2)
362        })
363        .sum()
364}
365
366/// Maximum sweeps before `louvain` gives up moving nodes.
367///
368/// Greedy modularity ascent terminates in exact arithmetic because every
369/// accepted move strictly increases Q. In floating point a move worth `+1e-17`
370/// can be undone next sweep by one worth `+1e-17`, and the loop oscillates. The
371/// epsilon below makes that rare and this cap makes it bounded.
372const LOUVAIN_MAX_SWEEPS: usize = 100;
373
374/// A move must beat this to be taken, so float noise cannot drive a sweep.
375const LOUVAIN_MIN_GAIN: f64 = 1e-12;
376
377/// Louvain community detection, local-moving phase (§5.4).
378///
379/// Returns node id -> community index. Communities are renumbered densely from
380/// zero in order of first appearance, so the result is stable and comparable.
381///
382/// This is phase one of the two-phase Louvain method: nodes are moved greedily
383/// to whichever neighbouring community most increases modularity, repeatedly,
384/// until no move helps. It does *not* then aggregate each community into a
385/// single node and recurse, which is what the full method does to find coarser
386/// structure.
387///
388/// # Why the aggregation phase is absent, and it is not the reason given before
389///
390/// Through 0.7.0 this note said the aggregation phase *"would matter on graphs
391/// far larger than the byte budget admits"*. [D-115] raised what the budget
392/// admits by 5.8×–6.8×, so the claim was re-measured against the new ceiling —
393/// and it is **false**. `examples/louvain_aggregation_probe.rs` finds two-phase
394/// returning a different partition from 6,144 nodes upward, well inside the
395/// budget, with the gap widening as the graph grows.
396///
397/// What the difference *is* settles it. On `clustered` — cliques joined by one
398/// bridge each, where the right answer is known — phase-one recovers the ground
399/// truth **exactly** at every size up to the ceiling, and two-phase scores a
400/// higher Q by **merging whole cliques**: two per community at 512 cliques,
401/// four at 4,096, never splitting one. Its Q also exceeds the ground truth's.
402/// That is the modularity resolution limit (Fortunato & Barthélemy): on a large
403/// graph the objective prefers a partition coarser than the true one, so
404/// optimising it harder moves away from the answer rather than towards it.
405///
406/// So the aggregation phase is declined because at the sizes this crate serves
407/// it changes a correct answer into a merged one — not because it would make no
408/// difference. `modularity_prefers_a_merged_partition_over_the_true_one_at_scale`
409/// pins the fact underneath that without needing a two-phase implementation
410/// here: the merged partition outscores the truth, so a Q comparison cannot be
411/// the criterion.
412///
413/// [D-115]: ../../docs/architecture/s13-decision-register.md
414pub fn louvain(graph: &Subgraph) -> BTreeMap<String, usize> {
415    debug_assert!(graph.is_closed(), "{CLOSURE}");
416    let m = graph.total_weight();
417
418    // Every node its own community: the only sensible answer with no edges, and
419    // the baseline the modularity gain is measured against.
420    let mut comm: BTreeMap<String, usize> = graph
421        .node_ids()
422        .enumerate()
423        .map(|(i, n)| (n.to_string(), i))
424        .collect();
425
426    if m == 0.0 {
427        return comm;
428    }
429
430    let mut sigma_tot: BTreeMap<usize, f64> = BTreeMap::new();
431    for node in graph.node_ids() {
432        *sigma_tot.entry(comm[node]).or_insert(0.0) += graph.weighted_degree(node);
433    }
434
435    for _ in 0..LOUVAIN_MAX_SWEEPS {
436        let mut moved = false;
437
438        for node in graph.node_ids() {
439            let curr_comm = comm[node];
440            let k_i = graph.weighted_degree(node);
441
442            // Withdraw the node before scoring, so staying put is scored on the
443            // same footing as moving.
444            *sigma_tot.get_mut(&curr_comm).unwrap() -= k_i;
445
446            // Weight from this node into each neighbouring community.
447            let mut k_i_c: BTreeMap<usize, f64> = BTreeMap::new();
448            for edge in graph.out_edges(node).iter().chain(graph.in_edges(node)) {
449                if edge.node(graph) == node {
450                    continue; // a self-loop joins no community
451                }
452                *k_i_c.entry(comm[edge.node(graph)]).or_insert(0.0) += edge.weight();
453            }
454
455            // dQ = k_i_in/m - (sigma_tot * k_i)/(2m^2), the standard reduced
456            // form. Iterating a BTreeMap makes the scan order the community
457            // index, so a tie resolves to the lowest index rather than to
458            // whatever the hasher seeded this process with.
459            let mut best_comm = curr_comm;
460            let mut best_gain = LOUVAIN_MIN_GAIN;
461
462            for (&c, k_i_in) in &k_i_c {
463                let tot = sigma_tot.get(&c).copied().unwrap_or(0.0);
464                let gain = (k_i_in / m) - (tot * k_i / (2.0 * m * m));
465                if gain > best_gain {
466                    best_gain = gain;
467                    best_comm = c;
468                }
469            }
470
471            *sigma_tot.entry(best_comm).or_insert(0.0) += k_i;
472
473            if best_comm != curr_comm {
474                comm.insert(node.to_string(), best_comm);
475                moved = true;
476            }
477        }
478
479        if !moved {
480            break;
481        }
482    }
483
484    renumber(comm)
485}
486
487/// Compact community indices to `0..n` in order of first appearance.
488fn renumber(comm: BTreeMap<String, usize>) -> BTreeMap<String, usize> {
489    let mut dense: BTreeMap<usize, usize> = BTreeMap::new();
490    let mut next = 0;
491    comm.into_iter()
492        .map(|(node, c)| {
493            let id = *dense.entry(c).or_insert_with(|| {
494                let id = next;
495                next += 1;
496                id
497            });
498            (node, id)
499        })
500        .collect()
501}