pub const DEFAULT_SEED_K: usize = 10;
pub const DEFAULT_DAMPING: f32 = 0.85;
pub fn ppr_multiplier(ppr_score: f32, weight: f32) -> f32 {
1.0 + weight * ppr_score.clamp(0.0, 1.0)
}
#[cfg(feature = "knowledge")]
pub fn compute_code_ppr(
store: &mut quipu::Store,
candidates: &[(String, f32)],
repo: &str,
seed_k: usize,
damping: f32,
) -> std::collections::HashMap<String, f32> {
use crate::knowledge::coupling::{co_changed_with_iri, code_module_iri};
use std::collections::HashMap;
if candidates.is_empty() {
return HashMap::new();
}
let mut iri_to_file: HashMap<String, String> = HashMap::new();
for (file, _) in candidates {
iri_to_file.insert(code_module_iri(repo, file), file.clone());
}
let mut sorted: Vec<&(String, f32)> = candidates.iter().collect();
sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let seeds: Vec<String> = sorted
.iter()
.take(seed_k.max(1))
.map(|(file, _)| code_module_iri(repo, file))
.collect();
let input = serde_json::json!({
"algorithm": "ppr",
"predicate": co_changed_with_iri(),
"seeds": seeds,
"damping": damping,
"limit": candidates.len().max(50),
});
let out = match quipu::tool_project(store, &input) {
Ok(o) => o,
Err(_) => return HashMap::new(),
};
let mut scores: HashMap<String, f32> = HashMap::new();
if let Some(arr) = out.get("results").and_then(|v| v.as_array()) {
for r in arr {
if let (Some(iri), Some(s)) = (
r.get("entity").and_then(|v| v.as_str()),
r.get("score").and_then(|v| v.as_f64()),
) {
if let Some(file) = iri_to_file.get(iri) {
scores.insert(file.clone(), s as f32);
}
}
}
}
let max = scores.values().copied().fold(0.0f32, f32::max);
if max > 0.0 {
for v in scores.values_mut() {
*v /= max;
}
}
scores
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ppr_multiplier_zero_weight_is_identity() {
assert_eq!(ppr_multiplier(1.0, 0.0), 1.0);
assert_eq!(ppr_multiplier(0.5, 0.0), 1.0);
}
#[test]
fn test_ppr_multiplier_bounded() {
assert!((ppr_multiplier(1.0, 0.3) - 1.3).abs() < f32::EPSILON);
assert!((ppr_multiplier(5.0, 0.3) - 1.3).abs() < f32::EPSILON);
assert!((ppr_multiplier(-1.0, 0.3) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_ppr_multiplier_monotonic() {
let w = 0.4;
assert!(ppr_multiplier(0.2, w) < ppr_multiplier(0.8, w));
}
#[cfg(feature = "knowledge")]
#[test]
fn test_compute_code_ppr_favors_coupled_over_unrelated() {
use crate::knowledge::coupling::code_module_iri;
let mut store = quipu::Store::open_in_memory().unwrap();
let repo = "myrepo";
let seed = code_module_iri(repo, "src/seed.rs");
let coupled = code_module_iri(repo, "src/coupled.rs");
let turtle = format!(
"@prefix bobbin: <{}> .\n\
<{seed}> bobbin:co_changed_with <{coupled}> .\n\
<{coupled}> bobbin:co_changed_with <{seed}> .\n",
crate::knowledge::coupling::ONTOLOGY_NS
);
quipu::tool_knot(
&mut store,
&serde_json::json!({
"turtle": turtle,
"timestamp": "2026-01-01T00:00:00Z",
"actor": "test",
"source": "test",
}),
)
.unwrap();
let candidates = vec![
("src/seed.rs".to_string(), 1.0_f32),
("src/coupled.rs".to_string(), 0.1_f32),
("src/unrelated.rs".to_string(), 0.1_f32),
];
let scores = compute_code_ppr(&mut store, &candidates, repo, 1, 0.85);
let coupled_s = scores.get("src/coupled.rs").copied().unwrap_or(0.0);
let unrelated_s = scores.get("src/unrelated.rs").copied().unwrap_or(0.0);
assert!(
coupled_s > unrelated_s,
"PPR seeded at seed.rs should rank coupled.rs ({coupled_s}) above unrelated.rs ({unrelated_s})"
);
assert_eq!(ppr_multiplier(coupled_s, 0.0), 1.0);
}
}