const DAMPING: f64 = 0.85;
const ITERATIONS: usize = 30;
const EPSILON: f64 = 1e-9;
pub struct Graph {
pub n: usize,
pub out_edges: Vec<Vec<usize>>,
}
impl Graph {
pub fn new(n: usize) -> Self {
Self { n, out_edges: vec![Vec::new(); n] }
}
pub fn add_edge(&mut self, from: usize, to: usize) {
if from == to || from >= self.n || to >= self.n { return; }
if !self.out_edges[from].contains(&to) {
self.out_edges[from].push(to);
}
}
pub fn in_degree(&self) -> Vec<usize> {
let mut d = vec![0usize; self.n];
for outs in &self.out_edges {
for &t in outs {
d[t] += 1;
}
}
d
}
pub fn pagerank(&self, personalisation: &[f64]) -> Vec<f64> {
if self.n == 0 { return vec![]; }
let total: f64 = personalisation.iter().sum();
let prior: Vec<f64> = if total > 0.0 {
personalisation.iter().map(|p| p / total).collect()
} else {
vec![1.0 / self.n as f64; self.n]
};
let mut rank = prior.clone();
for _ in 0..ITERATIONS {
let mut next = vec![0.0f64; self.n];
let mut dangling = 0.0f64;
for (i, outs) in self.out_edges.iter().enumerate() {
if outs.is_empty() {
dangling += rank[i];
continue;
}
let share = rank[i] / outs.len() as f64;
for &t in outs {
next[t] += share;
}
}
let mut delta = 0.0;
for i in 0..self.n {
let v = DAMPING * (next[i] + dangling * prior[i]) + (1.0 - DAMPING) * prior[i];
delta += (v - rank[i]).abs();
next[i] = v;
}
rank = next;
if delta < EPSILON { break; }
}
rank
}
}
pub fn prior(rel: &str, symbol_count: usize, lines: usize) -> f64 {
let base = 1.0;
let entry = if is_entry_point(rel) { 2.5 } else { 0.0 };
let substance = (symbol_count as f64).sqrt() * 0.6 + (lines as f64).sqrt() * 0.05;
let depth_penalty = 1.0 / (1.0 + rel.matches('/').count() as f64 * 0.15);
let test_penalty = if is_test(rel) { 0.25 } else { 1.0 };
(base + entry + substance) * depth_penalty * test_penalty
}
pub fn is_entry_point(rel: &str) -> bool {
let base = rel.rsplit('/').next().unwrap_or(rel);
matches!(
base,
"main.rs" | "lib.rs" | "mod.rs" | "main.go" | "main.py" | "__init__.py"
| "index.ts" | "index.js" | "index.tsx" | "app.ts" | "app.py"
| "Main.java" | "Application.java" | "cli.rs" | "server.ts"
)
}
pub fn is_test(rel: &str) -> bool {
let lower = rel.to_ascii_lowercase();
lower.starts_with("test/") || lower.starts_with("tests/")
|| lower.contains("/test/") || lower.contains("/tests/")
|| lower.contains("__tests__")
|| lower.ends_with("_test.go") || lower.ends_with("_test.py")
|| lower.ends_with(".test.ts") || lower.ends_with(".test.js")
|| lower.ends_with(".spec.ts") || lower.ends_with(".spec.js")
|| lower.ends_with("test.java")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn depended_upon_files_outrank_leaves() {
let mut g = Graph::new(3);
g.add_edge(0, 2);
g.add_edge(1, 2);
let r = g.pagerank(&[1.0, 1.0, 1.0]);
assert!(r[2] > r[0] && r[2] > r[1], "hub should outrank leaves: {r:?}");
}
#[test]
fn ranks_sum_to_one() {
let mut g = Graph::new(4);
g.add_edge(0, 1);
g.add_edge(1, 2);
let r = g.pagerank(&[1.0; 4]);
let sum: f64 = r.iter().sum();
assert!((sum - 1.0).abs() < 1e-6, "sum was {sum}");
}
#[test]
fn personalisation_breaks_ties() {
let g = Graph::new(2); let r = g.pagerank(&[3.0, 1.0]);
assert!(r[0] > r[1]);
}
#[test]
fn entry_points_and_tests_are_recognised() {
assert!(is_entry_point("src/main.rs"));
assert!(!is_entry_point("src/api/routes.rs"));
assert!(is_test("tests/api_test.rs"));
assert!(is_test("web/__tests__/a.ts"));
assert!(!is_test("src/api/routes.rs"));
}
#[test]
fn prior_prefers_substance_and_shallow_paths() {
assert!(prior("src/main.rs", 5, 200) > prior("src/a/b/c/util.rs", 5, 200));
assert!(prior("src/api.rs", 20, 400) > prior("src/api.rs", 1, 400));
assert!(prior("src/api.rs", 20, 400) > prior("tests/api.rs", 20, 400));
}
}