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