use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::graph::edges::EdgeKind;
use crate::graph::graph::Graph;
use crate::store::record::Record;
pub const STALE_THRESHOLD: f32 = 0.4;
pub const PROPAGATION_D1: f32 = 0.15;
pub const PROPAGATION_D2: f32 = 0.05;
pub const MAX_PROPAGATION_DEPTH: usize = 2;
pub const TOMBSTONE_THRESHOLD: f32 = 0.8;
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PropagatedStaleness {
pub value: f32,
pub source_count: u32,
pub primary_source: Option<String>,
}
pub fn compute_propagation(
file_records: &[Record],
graph: &Graph,
) -> HashMap<String, PropagatedStaleness> {
let mut result: HashMap<String, PropagatedStaleness> = HashMap::new();
for rec in file_records {
let source_staleness = rec.staleness.value;
if source_staleness < STALE_THRESHOLD {
continue;
}
if source_staleness >= TOMBSTONE_THRESHOLD {
continue;
}
let source_key = &rec.key;
let source_path = rec.key.strip_prefix("file:").unwrap_or(&rec.key);
let d1_importers = graph.neighbors_incoming(source_key, &EdgeKind::Imports);
let d1_bump = source_staleness * PROPAGATION_D1;
for importer in &d1_importers {
apply_propagation(&mut result, importer, d1_bump, source_path);
}
let d1_set: HashSet<&String> = d1_importers.iter().collect();
let d2_bump = source_staleness * PROPAGATION_D2;
for d1_importer in &d1_importers {
let d2_importers = graph.neighbors_incoming(d1_importer, &EdgeKind::Imports);
for d2_importer in &d2_importers {
if d2_importer == source_key {
continue;
}
if d1_set.contains(&d2_importer) {
continue;
}
apply_propagation(&mut result, d2_importer, d2_bump, source_path);
}
}
}
result
}
fn apply_propagation(
result: &mut HashMap<String, PropagatedStaleness>,
target_key: &str,
bump: f32,
source_path: &str,
) {
let entry = result.entry(target_key.to_string()).or_default();
entry.source_count += 1;
if bump > entry.value {
entry.value = bump;
entry.primary_source = Some(source_path.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::record::*;
use crate::store::Store;
use tempfile::TempDir;
async fn temp_graph() -> (Graph, TempDir) {
let dir = TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let g = Graph::empty(store);
(g, dir)
}
fn file_record(key: &str, staleness_value: f32) -> Record {
let now = 1_000_000u64;
let tier = StalenessScore::tier_from_value(staleness_value);
Record {
key: key.to_string(),
value: String::new(),
category: Category::File,
priority: Priority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore {
value: staleness_value,
tier,
signals: vec![],
computed_at: now,
last_record_sha: String::new(),
},
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: now,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
payload: None,
}
}
#[tokio::test]
async fn no_stale_sources_produces_no_propagation() {
let (g, _dir) = temp_graph().await;
let records = vec![
file_record("file:src/a.rs", 0.1),
file_record("file:src/b.rs", 0.2),
];
let result = compute_propagation(&records, &g);
assert!(result.is_empty());
g.close().await.unwrap();
}
#[tokio::test]
async fn stale_source_bumps_direct_importers() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.5),
];
let result = compute_propagation(&records, &g);
let a = result.get("file:src/a.rs").unwrap();
let expected = 0.5 * PROPAGATION_D1; assert!(
(a.value - expected).abs() < f32::EPSILON,
"expected {expected}, got {}",
a.value
);
assert_eq!(a.source_count, 1);
assert_eq!(a.primary_source.as_deref(), Some("src/b.rs"));
g.close().await.unwrap();
}
#[tokio::test]
async fn tombstoned_source_does_not_propagate() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.9), ];
let result = compute_propagation(&records, &g);
assert!(result.is_empty());
g.close().await.unwrap();
}
#[tokio::test]
async fn below_threshold_source_does_not_propagate() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.3), ];
let result = compute_propagation(&records, &g);
assert!(result.is_empty());
g.close().await.unwrap();
}
#[tokio::test]
async fn depth_2_cascade_uses_smaller_weight() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
g.add_edge("file:src/b.rs", EdgeKind::Imports, "file:src/c.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.0),
file_record("file:src/c.rs", 0.6),
];
let result = compute_propagation(&records, &g);
let b = result.get("file:src/b.rs").unwrap();
assert!((b.value - 0.6 * PROPAGATION_D1).abs() < f32::EPSILON);
let a = result.get("file:src/a.rs").unwrap();
assert!((a.value - 0.6 * PROPAGATION_D2).abs() < f32::EPSILON);
g.close().await.unwrap();
}
#[tokio::test]
async fn depth_3_is_excluded() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
g.add_edge("file:src/b.rs", EdgeKind::Imports, "file:src/c.rs")
.await
.unwrap();
g.add_edge("file:src/c.rs", EdgeKind::Imports, "file:src/d.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.0),
file_record("file:src/c.rs", 0.0),
file_record("file:src/d.rs", 0.5),
];
let result = compute_propagation(&records, &g);
assert!(result.contains_key("file:src/c.rs"));
assert!(result.contains_key("file:src/b.rs"));
assert!(
!result.contains_key("file:src/a.rs"),
"depth 3 should be excluded"
);
g.close().await.unwrap();
}
#[tokio::test]
async fn cycle_terminates_safely() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
g.add_edge("file:src/b.rs", EdgeKind::Imports, "file:src/a.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.5),
];
let result = compute_propagation(&records, &g);
let a = result.get("file:src/a.rs").unwrap();
assert_eq!(a.source_count, 1);
assert!((a.value - 0.5 * PROPAGATION_D1).abs() < f32::EPSILON);
g.close().await.unwrap();
}
#[tokio::test]
async fn multiple_sources_take_max_not_sum() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/c.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.5),
file_record("file:src/c.rs", 0.6),
];
let result = compute_propagation(&records, &g);
let a = result.get("file:src/a.rs").unwrap();
let expected = 0.6 * PROPAGATION_D1;
assert!(
(a.value - expected).abs() < f32::EPSILON,
"should take max not sum: expected {expected}, got {}",
a.value
);
g.close().await.unwrap();
}
#[tokio::test]
async fn source_count_increments_for_multiple_sources() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/c.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.5),
file_record("file:src/c.rs", 0.6),
];
let result = compute_propagation(&records, &g);
let a = result.get("file:src/a.rs").unwrap();
assert_eq!(a.source_count, 2);
g.close().await.unwrap();
}
#[tokio::test]
async fn primary_source_is_highest_contributor() {
let (mut g, _dir) = temp_graph().await;
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/b.rs")
.await
.unwrap();
g.add_edge("file:src/a.rs", EdgeKind::Imports, "file:src/c.rs")
.await
.unwrap();
let records = vec![
file_record("file:src/a.rs", 0.0),
file_record("file:src/b.rs", 0.5), file_record("file:src/c.rs", 0.6), ];
let result = compute_propagation(&records, &g);
let a = result.get("file:src/a.rs").unwrap();
assert_eq!(a.primary_source.as_deref(), Some("src/c.rs"));
g.close().await.unwrap();
}
}