Skip to main content

llm_kernel/graph/algo/
path.rs

1//! Weighted shortest path via Dijkstra.
2//!
3//! Edge weight is relationship **strength** (higher = stronger), but shortest
4//! path minimizes **distance**. We convert with `distance = -ln(weight)`: a
5//! perfect edge (weight 1.0) costs 0, a weak edge costs more, and weight → 0
6//! tends to infinity (the edge becomes impassable). This turns products of
7//! edge strengths along a path into a sum of costs, so a path of two strong
8//! edges can legitimately beat one weak edge — matching the `shortestPath`
9//! semantics of Neo4j/GDS.
10
11use std::cmp::{Ordering, Reverse};
12use std::collections::BinaryHeap;
13
14use rusqlite::Connection;
15
16use crate::error::Result;
17
18use super::csr::CsrGraph;
19
20/// Edge weights at or below this are treated as impassable (infinite distance).
21pub const SHORTEST_PATH_W_MIN: f64 = 1e-9;
22
23/// Total-order wrapper around `f64` so it can key a min-heap. Built on
24/// `total_cmp`, so it is consistent with equality and free of the usual NaN
25/// pitfalls (we only ever store finite distances).
26#[derive(Clone, Copy, PartialEq)]
27struct OrderedFloat(f64);
28
29impl Eq for OrderedFloat {}
30
31impl Ord for OrderedFloat {
32    fn cmp(&self, other: &Self) -> Ordering {
33        self.0.total_cmp(&other.0)
34    }
35}
36
37impl PartialOrd for OrderedFloat {
38    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
39        Some(self.cmp(other))
40    }
41}
42
43/// One edge's traversal cost: `-ln(weight)`. `None` if the edge is impassable
44/// (weight below [`SHORTEST_PATH_W_MIN`] or non-positive).
45///
46/// Weight is clamped to at most `1.0`: the documented edge-weight contract is
47/// `[0, 1]` (see `GraphEdge::weight`), so a contract-violating weight > 1.0 is
48/// treated as maximal strength (cost 0) rather than producing a *negative*
49/// cost. Without this clamp Dijkstra's non-negative-edge assumption would be
50/// violated and it would silently return wrong shortest paths.
51fn edge_cost(weight: f64) -> Option<f64> {
52    if weight < SHORTEST_PATH_W_MIN {
53        return None;
54    }
55    let w = weight.min(1.0);
56    Some(-w.ln())
57}
58
59/// Dijkstra from `src`, returning `(node_index, distance)` for every reachable
60/// node, sorted by distance ascending. `src` itself appears with distance 0.0.
61///
62/// Distance is the sum of `-ln(weight)` along the cheapest path. Unreachable
63/// nodes (and an out-of-range `src`) are omitted.
64pub fn dijkstra(g: &CsrGraph, src: u32) -> Vec<(u32, f64)> {
65    let n = g.node_count();
66    let mut result = Vec::new();
67    if (src as usize) >= n {
68        return result;
69    }
70    let mut dist = vec![f64::INFINITY; n];
71    let mut visited = vec![false; n];
72    let mut heap: BinaryHeap<(Reverse<OrderedFloat>, u32)> = BinaryHeap::new();
73
74    dist[src as usize] = 0.0;
75    heap.push((Reverse(OrderedFloat(0.0)), src));
76
77    while let Some((Reverse(OrderedFloat(d)), u)) = heap.pop() {
78        if visited[u as usize] || d > dist[u as usize] {
79            continue;
80        }
81        visited[u as usize] = true;
82        result.push((u, d));
83        for (v, w) in g.out_neighbors(u) {
84            let Some(cost) = edge_cost(w) else {
85                continue;
86            };
87            let nd = d + cost;
88            if nd < dist[v as usize] {
89                dist[v as usize] = nd;
90                heap.push((Reverse(OrderedFloat(nd)), v));
91            }
92        }
93    }
94
95    result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
96    result
97}
98
99/// Cheapest path from `src` to `dst` as a node-index sequence, or `None` if
100/// unreachable (or either endpoint is out of range).
101pub fn shortest_path(g: &CsrGraph, src: u32, dst: u32) -> Option<Vec<u32>> {
102    let n = g.node_count();
103    if (src as usize) >= n || (dst as usize) >= n {
104        return None;
105    }
106    let mut dist = vec![f64::INFINITY; n];
107    let mut prev: Vec<Option<u32>> = vec![None; n];
108    let mut visited = vec![false; n];
109    let mut heap: BinaryHeap<(Reverse<OrderedFloat>, u32)> = BinaryHeap::new();
110
111    dist[src as usize] = 0.0;
112    heap.push((Reverse(OrderedFloat(0.0)), src));
113
114    while let Some((Reverse(OrderedFloat(d)), u)) = heap.pop() {
115        if u == dst {
116            break;
117        }
118        if visited[u as usize] {
119            continue;
120        }
121        visited[u as usize] = true;
122        for (v, w) in g.out_neighbors(u) {
123            let Some(cost) = edge_cost(w) else {
124                continue;
125            };
126            let nd = d + cost;
127            if nd < dist[v as usize] {
128                dist[v as usize] = nd;
129                prev[v as usize] = Some(u);
130                heap.push((Reverse(OrderedFloat(nd)), v));
131            }
132        }
133    }
134
135    if dist[dst as usize] == f64::INFINITY {
136        return None;
137    }
138
139    let mut path = vec![dst];
140    let mut cur = dst;
141    while let Some(p) = prev[cur as usize] {
142        path.push(p);
143        cur = p;
144    }
145    path.reverse();
146    Some(path)
147}
148
149/// Build a CSR snapshot from `conn` and resolve a path between two node IDs.
150pub fn shortest_path_ids(conn: &Connection, src: &str, dst: &str) -> Result<Option<Vec<String>>> {
151    let g = CsrGraph::build_csr(conn)?;
152    let (Some(s), Some(d)) = (g.node_index(src), g.node_index(dst)) else {
153        return Ok(None);
154    };
155    Ok(
156        shortest_path(&g, s, d)
157            .map(|path| path.iter().map(|&i| g.node_id(i).to_string()).collect()),
158    )
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::graph::algo::csr::CsrGraph;
165    use crate::graph::types::GraphEdge;
166
167    fn edge(id: &str, s: &str, t: &str, w: f64) -> GraphEdge {
168        GraphEdge {
169            id: id.into(),
170            source: s.into(),
171            target: t.into(),
172            relation: "related".into(),
173            weight: w,
174            ts: "2026-01-01T00:00:00Z".into(),
175        }
176    }
177
178    fn graph(nodes: &[&str], edges: &[GraphEdge]) -> CsrGraph {
179        let node_ids: Vec<String> = nodes.iter().map(|s| s.to_string()).collect();
180        CsrGraph::from_edges(&node_ids, edges)
181    }
182
183    #[test]
184    fn strong_two_hop_beats_weak_direct() {
185        // A→B (0.5), B→C (0.5): cost = -ln(0.5) - ln(0.5) = 1.386
186        // A→C (0.1):           cost = -ln(0.1)           = 2.303
187        // The two-hop path through strong edges is shorter.
188        let g = graph(
189            &["A", "B", "C"],
190            &[
191                edge("e1", "A", "B", 0.5),
192                edge("e2", "B", "C", 0.5),
193                edge("e3", "A", "C", 0.1),
194            ],
195        );
196        let a = g.node_index("A").unwrap();
197        let b = g.node_index("B").unwrap();
198        let c = g.node_index("C").unwrap();
199        let path = shortest_path(&g, a, c).expect("path exists");
200        assert_eq!(path, vec![a, b, c]);
201    }
202
203    #[test]
204    fn dijkstra_returns_sorted_reachable() {
205        let g = graph(
206            &["A", "B", "C"],
207            &[edge("e1", "A", "B", 1.0), edge("e2", "B", "C", 1.0)],
208        );
209        let a = g.node_index("A").unwrap();
210        let res = dijkstra(&g, a);
211        // Source first (dist 0), then B (dist 0), then C (dist 0) — all weight 1.0 → cost 0.
212        // A cost 0, B cost -ln(1.0)=0, C cost 0. Reachable: all three.
213        let ids: Vec<u32> = res.iter().map(|(i, _)| *i).collect();
214        assert!(ids.contains(&a));
215        assert_eq!(res.len(), 3);
216        // Sorted ascending by distance.
217        for w in res.windows(2) {
218            assert!(w[0].1 <= w[1].1 + 1e-12);
219        }
220    }
221
222    #[test]
223    fn unreachable_returns_none() {
224        // Two disconnected edges.
225        let g = graph(
226            &["A", "B", "C", "D"],
227            &[edge("e1", "A", "B", 1.0), edge("e2", "C", "D", 1.0)],
228        );
229        let a = g.node_index("A").unwrap();
230        let d = g.node_index("D").unwrap();
231        assert!(shortest_path(&g, a, d).is_none());
232    }
233
234    #[test]
235    fn self_path_is_singleton() {
236        let g = graph(&["A", "B"], &[edge("e1", "A", "B", 1.0)]);
237        let a = g.node_index("A").unwrap();
238        assert_eq!(shortest_path(&g, a, a), Some(vec![a]));
239    }
240
241    #[test]
242    fn impassable_weight_skipped() {
243        // A→B is a near-zero-weight (impassable) edge; A→C→B is the only route.
244        let g = graph(
245            &["A", "B", "C"],
246            &[
247                edge("e1", "A", "B", 1e-12),
248                edge("e2", "A", "C", 0.9),
249                edge("e3", "C", "B", 0.9),
250            ],
251        );
252        let a = g.node_index("A").unwrap();
253        let b = g.node_index("B").unwrap();
254        let c = g.node_index("C").unwrap();
255        let path = shortest_path(&g, a, b).expect("path via C exists");
256        assert_eq!(path, vec![a, c, b]);
257    }
258
259    #[test]
260    fn out_of_range_returns_none() {
261        let g = graph(&["A"], &[]);
262        // dst index 5 is out of range for a 1-node graph.
263        assert!(shortest_path(&g, 0, 5).is_none());
264        assert!(dijkstra(&g, 5).is_empty());
265    }
266
267    #[test]
268    fn weight_above_one_clamped_non_negative() {
269        // A weight of 2.0 violates the [0, 1] contract. Without clamping,
270        // -ln(2.0) ≈ -0.693 would be a negative Dijkstra cost. Clamped to 1.0
271        // it costs -ln(1.0) = 0, and no node is ever assigned a negative
272        // distance.
273        let g = graph(&["A", "B"], &[edge("e1", "A", "B", 2.0)]);
274        let a = g.node_index("A").unwrap();
275        let b = g.node_index("B").unwrap();
276        let res = dijkstra(&g, a);
277        let b_dist = res.iter().find(|(i, _)| *i == b).unwrap().1;
278        assert!(b_dist.abs() < 1e-12, "weight > 1 clamps to zero cost");
279        assert!(
280            res.iter().all(|(_, d)| *d >= -1e-12),
281            "no negative distances despite contract-violating weight"
282        );
283    }
284}