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