1use rusqlite::Connection;
15
16use crate::error::Result;
17
18use super::csr::CsrGraph;
19
20pub const PAGERANK_DAMPING: f64 = 0.85;
22pub const PAGERANK_ITERS: usize = 100;
24pub const PAGERANK_EPS: f64 = 1e-6;
27
28pub fn pagerank(g: &CsrGraph, damping: f64, iters: usize) -> Vec<f64> {
34 let n = g.node_count();
35 if n == 0 {
36 return Vec::new();
37 }
38 let base = (1.0 - damping) / n as f64;
39 let mut r = vec![1.0 / n as f64; n];
40 let mut next = vec![0.0_f64; n];
41
42 for _ in 0..iters {
43 for slot in next.iter_mut() {
44 *slot = 0.0;
45 }
46
47 let mut dangling_mass = 0.0_f64;
50 for i in g.node_indices() {
51 let out_sum = g.out_weight_sum(i);
52 if out_sum <= 0.0 {
53 dangling_mass += r[i as usize];
54 continue;
55 }
56 let share = damping * r[i as usize] / out_sum;
57 for (j, w) in g.out_neighbors(i) {
58 next[j as usize] += share * w;
59 }
60 }
61
62 let uniform = base + damping * dangling_mass / n as f64;
64 for slot in next.iter_mut() {
65 *slot += uniform;
66 }
67
68 let mut delta = 0.0_f64;
70 for j in 0..n {
71 delta += (next[j] - r[j]).abs();
72 }
73 std::mem::swap(&mut r, &mut next);
74 if delta < PAGERANK_EPS {
75 break;
76 }
77 }
78
79 let sum: f64 = r.iter().sum();
81 if sum > 0.0 {
82 for v in &mut r {
83 *v /= sum;
84 }
85 }
86 r
87}
88
89pub fn pagerank_default(g: &CsrGraph) -> Vec<f64> {
91 pagerank(g, PAGERANK_DAMPING, PAGERANK_ITERS)
92}
93
94pub fn pagerank_scores(conn: &Connection) -> Result<Vec<(String, f64)>> {
97 let g = CsrGraph::build_csr(conn)?;
98 let scores = pagerank_default(&g);
99 Ok(g.node_indices()
100 .map(|i| (g.node_id(i).to_string(), scores[i as usize]))
101 .collect())
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::graph::algo::csr::CsrGraph;
108 use crate::graph::schema::init_graph_schema;
109 use crate::graph::store::{append_edge, upsert_node};
110 use crate::graph::types::{GraphEdge, GraphNode};
111 use rusqlite::Connection;
112
113 fn mem_db() -> Connection {
114 let conn = Connection::open_in_memory().unwrap();
115 init_graph_schema(&conn).unwrap();
116 conn
117 }
118
119 fn edge(id: &str, s: &str, t: &str, w: f64) -> GraphEdge {
120 GraphEdge {
121 id: id.into(),
122 source: s.into(),
123 target: t.into(),
124 relation: "related".into(),
125 weight: w,
126 ts: "2026-01-01T00:00:00Z".into(),
127 }
128 }
129
130 fn diamond_csr() -> CsrGraph {
132 let nodes: Vec<String> = ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect();
133 let edges = vec![
134 edge("e1", "A", "B", 1.0),
135 edge("e2", "A", "C", 1.0),
136 edge("e3", "B", "D", 1.0),
137 edge("e4", "C", "D", 1.0),
138 ];
139 CsrGraph::from_edges(&nodes, &edges)
140 }
141
142 #[test]
143 fn empty_graph_returns_empty() {
144 let g = CsrGraph::from_edges(&[], &[]);
145 assert!(pagerank_default(&g).is_empty());
146 }
147
148 #[test]
149 fn single_node_scores_one() {
150 let nodes: Vec<String> = vec!["A".to_string()];
151 let g = CsrGraph::from_edges(&nodes, &[]);
152 let pr = pagerank_default(&g);
153 assert_eq!(pr.len(), 1);
154 assert!((pr[0] - 1.0).abs() < 1e-9);
155 }
156
157 #[test]
158 fn diamond_sink_dominates_and_symmetric() {
159 let g = diamond_csr();
160 let pr = pagerank_default(&g);
161 let a = g.node_index("A").unwrap() as usize;
162 let b = g.node_index("B").unwrap() as usize;
163 let c = g.node_index("C").unwrap() as usize;
164 let d = g.node_index("D").unwrap() as usize;
165
166 assert!(
168 (pr.iter().sum::<f64>() - 1.0).abs() < 1e-9,
169 "scores must sum to 1.0"
170 );
171 assert!(pr.iter().all(|&v| v > 0.0));
173 assert!(pr[d] > pr[a], "sink D should outrank source A");
175 assert!(
177 (pr[b] - pr[c]).abs() < 1e-9,
178 "symmetric nodes B,C must match"
179 );
180 let max_idx = (0..pr.len())
182 .max_by(|&a, &b| {
183 pr[a]
184 .partial_cmp(&pr[b])
185 .unwrap_or(std::cmp::Ordering::Equal)
186 })
187 .unwrap();
188 assert_eq!(max_idx, d);
189 }
190
191 #[test]
192 fn dangling_mass_conserved() {
193 let nodes: Vec<String> = ["A", "B"].iter().map(|s| s.to_string()).collect();
195 let edges = vec![edge("e1", "A", "B", 1.0)];
196 let g = CsrGraph::from_edges(&nodes, &edges);
197 let pr = pagerank_default(&g);
198 assert!((pr.iter().sum::<f64>() - 1.0).abs() < 1e-9);
199 let a = g.node_index("A").unwrap() as usize;
201 let b = g.node_index("B").unwrap() as usize;
202 assert!(pr[b] > pr[a]);
203 }
204
205 #[test]
206 fn weighted_edges_flow_more_rank() {
207 let nodes: Vec<String> = ["A", "P", "Q", "Sp", "Sq"]
210 .iter()
211 .map(|s| s.to_string())
212 .collect();
213 let edges = vec![
214 edge("e1", "A", "P", 0.9), edge("e2", "A", "Q", 0.1), edge("e3", "P", "Sp", 1.0),
217 edge("e4", "Q", "Sq", 1.0),
218 ];
219 let g = CsrGraph::from_edges(&nodes, &edges);
220 let pr = pagerank_default(&g);
221 let sp = g.node_index("Sp").unwrap() as usize;
222 let sq = g.node_index("Sq").unwrap() as usize;
223 assert!(
224 pr[sp] > pr[sq],
225 "sink on the stronger A→P edge should outrank the weaker A→Q sink"
226 );
227 assert!((pr.iter().sum::<f64>() - 1.0).abs() < 1e-9);
228 }
229
230 #[test]
231 fn pagerank_scores_from_sqlite() {
232 let conn = mem_db();
233 for id in ["A", "B", "C"] {
234 let n = GraphNode {
235 id: id.into(),
236 node_type: "concept".into(),
237 title: id.into(),
238 body: String::new(),
239 tags: vec![],
240 projects: vec![],
241 agents: vec![],
242 created: "2026-01-01T00:00:00Z".into(),
243 updated: "2026-01-01T00:00:00Z".into(),
244 importance: 0.5,
245 access_count: 0,
246 accessed_at: String::new(),
247 };
248 upsert_node(&conn, &n).unwrap();
249 }
250 append_edge(&conn, &edge("e1", "A", "B", 1.0)).unwrap();
251 append_edge(&conn, &edge("e2", "A", "C", 1.0)).unwrap();
252
253 let scores = pagerank_scores(&conn).unwrap();
254 assert_eq!(scores.len(), 3);
255 let map: std::collections::HashMap<String, f64> = scores.into_iter().collect();
256 let sum: f64 = map.values().sum();
257 assert!((sum - 1.0).abs() < 1e-9);
258 }
259}