lean_ctx/core/
pagerank.rs1use std::collections::{HashMap, HashSet};
7
8#[cfg(test)]
9use rusqlite::Connection;
10
11pub struct PageRankInput {
12 pub files: HashSet<String>,
13 pub forward: HashMap<String, Vec<String>>,
14}
15
16impl PageRankInput {
17 #[cfg(test)]
20 pub(crate) fn from_connection(conn: &Connection) -> Self {
21 let mut files: HashSet<String> = HashSet::new();
22 let mut forward: HashMap<String, Vec<String>> = HashMap::new();
23
24 if let Ok(mut stmt) = conn.prepare(
25 "SELECT DISTINCT p.path
26 FROM nodes n JOIN paths p ON p.id = n.file_id
27 WHERE n.kind = 'file'",
28 ) && let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0))
29 {
30 for f in rows.flatten() {
31 files.insert(f);
32 }
33 }
34
35 let edge_sql = "
36 SELECT DISTINCT p1.path, p2.path
37 FROM edges e
38 JOIN nodes n1 ON e.source_id = n1.id
39 JOIN nodes n2 ON e.target_id = n2.id
40 JOIN paths p1 ON p1.id = n1.file_id
41 JOIN paths p2 ON p2.id = n2.file_id
42 WHERE n1.kind = 'file' AND n2.kind = 'file'
43 AND n1.file_id != n2.file_id
44 ";
45 if let Ok(mut stmt) = conn.prepare(edge_sql)
46 && let Ok(rows) = stmt.query_map([], |row| {
47 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
48 })
49 {
50 for row in rows.flatten() {
51 let (src, tgt) = row;
52 forward.entry(src).or_default().push(tgt);
53 }
54 }
55
56 for deps in forward.values_mut() {
57 deps.sort();
58 deps.dedup();
59 }
60
61 Self { files, forward }
62 }
63}
64
65pub fn compute(input: &PageRankInput, damping: f64, iterations: usize) -> HashMap<String, f64> {
66 compute_personalized(input, damping, iterations, &[])
67}
68
69pub(crate) fn compute_personalized(
73 input: &PageRankInput,
74 damping: f64,
75 iterations: usize,
76 seed_files: &[String],
77) -> HashMap<String, f64> {
78 let n = input.files.len();
79 if n == 0 {
80 return HashMap::new();
81 }
82
83 let personalization: HashMap<String, f64> = if seed_files.is_empty() {
84 let uniform = 1.0 / n as f64;
85 input.files.iter().map(|f| (f.clone(), uniform)).collect()
86 } else {
87 let valid_seeds: Vec<&String> = seed_files
88 .iter()
89 .filter(|f| input.files.contains(*f))
90 .collect();
91 if valid_seeds.is_empty() {
92 let uniform = 1.0 / n as f64;
93 input.files.iter().map(|f| (f.clone(), uniform)).collect()
94 } else {
95 let weight = 1.0 / valid_seeds.len() as f64;
96 let mut p = HashMap::new();
97 for f in &valid_seeds {
98 p.insert((*f).clone(), weight);
99 }
100 p
101 }
102 };
103
104 let dangling: HashSet<&String> = input
105 .files
106 .iter()
107 .filter(|f| !input.forward.contains_key(*f) || input.forward[*f].is_empty())
108 .collect();
109
110 let init = 1.0 / n as f64;
111 let mut rank: HashMap<String, f64> = input.files.iter().map(|f| (f.clone(), init)).collect();
112
113 let eps = 1e-8;
114 for _ in 0..iterations {
115 let dangling_sum: f64 = dangling
116 .iter()
117 .map(|f| rank.get(*f).copied().unwrap_or(0.0))
118 .sum();
119
120 let mut new_rank: HashMap<String, f64> = HashMap::with_capacity(n);
121
122 for f in &input.files {
123 let teleport = personalization.get(f).copied().unwrap_or(0.0);
124 let dangling_contrib = personalization.get(f).copied().unwrap_or(0.0) * dangling_sum;
125 new_rank.insert(
126 f.clone(),
127 (1.0 - damping) * teleport + damping * dangling_contrib,
128 );
129 }
130
131 for (node, neighbors) in &input.forward {
132 if neighbors.is_empty() {
133 continue;
134 }
135 let share = rank.get(node).copied().unwrap_or(0.0) / neighbors.len() as f64;
136 for neighbor in neighbors {
137 if let Some(nr) = new_rank.get_mut(neighbor) {
138 *nr += damping * share;
139 }
140 }
141 }
142
143 let diff: f64 = input
144 .files
145 .iter()
146 .map(|f| {
147 (rank.get(f).copied().unwrap_or(0.0) - new_rank.get(f).copied().unwrap_or(0.0))
148 .abs()
149 })
150 .sum();
151 rank = new_rank;
152
153 if diff < eps {
154 break;
155 }
156 }
157
158 rank
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::core::property_graph::{CodeGraph, Edge, EdgeKind, Node};
165
166 #[test]
167 fn pagerank_basic() {
168 let g = CodeGraph::open_in_memory().unwrap();
169 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
170 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
171 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
172
173 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
174 g.upsert_edge(&Edge::new(a, c, EdgeKind::Imports)).unwrap();
175 g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
176
177 let input = PageRankInput::from_connection(g.connection());
178 let ranks = compute(&input, 0.85, 30);
179
180 assert_eq!(ranks.len(), 3);
181 let rank_c = ranks.get("c.rs").copied().unwrap_or(0.0);
182 let rank_a = ranks.get("a.rs").copied().unwrap_or(0.0);
183 assert!(
184 rank_c > rank_a,
185 "c.rs should rank higher (more incoming): c={rank_c} a={rank_a}"
186 );
187 }
188
189 #[test]
190 fn personalized_pagerank_boosts_seed() {
191 let g = CodeGraph::open_in_memory().unwrap();
192 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
193 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
194 let c = g.upsert_node(&Node::file("c.rs")).unwrap();
195
196 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
197 g.upsert_edge(&Edge::new(b, c, EdgeKind::Imports)).unwrap();
198
199 let input = PageRankInput::from_connection(g.connection());
200
201 let uniform = compute_personalized(&input, 0.85, 50, &[]);
202 let seeded = compute_personalized(&input, 0.85, 50, &["a.rs".to_string()]);
203
204 let a_uniform = uniform.get("a.rs").copied().unwrap_or(0.0);
205 let a_seeded = seeded.get("a.rs").copied().unwrap_or(0.0);
206
207 assert!(
208 a_seeded > a_uniform,
209 "seeded a.rs ({a_seeded}) should rank higher than uniform ({a_uniform})"
210 );
211 }
212
213 #[test]
214 fn early_convergence() {
215 let g = CodeGraph::open_in_memory().unwrap();
216 let a = g.upsert_node(&Node::file("a.rs")).unwrap();
217 let b = g.upsert_node(&Node::file("b.rs")).unwrap();
218 g.upsert_edge(&Edge::new(a, b, EdgeKind::Imports)).unwrap();
219 g.upsert_edge(&Edge::new(b, a, EdgeKind::Imports)).unwrap();
220
221 let input = PageRankInput::from_connection(g.connection());
222 let ranks = compute(&input, 0.85, 1000);
223 assert_eq!(ranks.len(), 2);
224 }
225}