1use std::cmp::{Ordering, Reverse};
12use std::collections::BinaryHeap;
13
14use rusqlite::Connection;
15
16use crate::error::Result;
17
18use super::csr::CsrGraph;
19
20pub const SHORTEST_PATH_W_MIN: f64 = 1e-9;
22
23#[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
43fn 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
59pub 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
99pub 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
149pub 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 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 let ids: Vec<u32> = res.iter().map(|(i, _)| *i).collect();
214 assert!(ids.contains(&a));
215 assert_eq!(res.len(), 3);
216 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 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 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 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 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}