Skip to main content

provable_contracts/scoring/
drift.rs

1//! Drift detection — detect stale contracts via git timestamps.
2//!
3//! A contract is "stale" if it was modified after the binding file was
4//! last updated, meaning the binding may reference an outdated version.
5//!
6//! `drift = 1.0 - (stale_contracts / total_bound_contracts)`
7
8use std::collections::HashSet;
9use std::path::Path;
10
11/// Detect which bound contracts are stale relative to a binding file.
12///
13/// Uses `git log -1 --format=%ct -- <file>` to get the last-commit
14/// timestamp for each file. A contract is stale if its commit timestamp
15/// is more recent than the binding file's commit timestamp.
16///
17/// Returns the set of stale contract stems (filenames like "softmax-kernel-v1.yaml").
18pub fn detect_stale_contracts<S: std::hash::BuildHasher>(
19    contract_dir: &Path,
20    binding_path: &Path,
21    bound_stems: &HashSet<&str, S>,
22) -> HashSet<String> {
23    let mut stale = HashSet::new();
24
25    let binding_ts = git_last_commit_ts(binding_path);
26    if binding_ts == 0 {
27        // No git history for binding — can't detect drift
28        return stale;
29    }
30
31    for stem in bound_stems {
32        let contract_path = contract_dir.join(stem);
33        if !contract_path.exists() {
34            continue;
35        }
36        let contract_ts = git_last_commit_ts(&contract_path);
37        if contract_ts > binding_ts {
38            stale.insert((*stem).to_string());
39        }
40    }
41
42    stale
43}
44
45/// Compute drift score from stale contract count.
46///
47/// `drift = 1.0 - (stale / total)` where:
48/// - `stale` = number of bound contracts modified after binding
49/// - `total` = total number of bound contracts
50///
51/// Returns 1.0 (perfect freshness) when no contracts are stale,
52/// and 0.0 when all bound contracts are stale.
53#[allow(clippy::cast_precision_loss)]
54pub fn compute_drift(stale_count: usize, total_bound: usize) -> f64 {
55    if total_bound == 0 {
56        return 1.0;
57    }
58    1.0 - (stale_count as f64 / total_bound as f64)
59}
60
61/// Get the unix timestamp of the last git commit that touched a file.
62///
63/// Returns 0 if the file is not tracked by git or git is unavailable.
64fn git_last_commit_ts(path: &Path) -> u64 {
65    let output = std::process::Command::new("git")
66        .args(["log", "-1", "--format=%ct", "--"])
67        .arg(path)
68        .output();
69
70    match output {
71        Ok(o) if o.status.success() => {
72            let s = String::from_utf8_lossy(&o.stdout);
73            s.trim().parse().unwrap_or(0)
74        }
75        _ => 0,
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn compute_drift_no_stale() {
85        assert!((compute_drift(0, 10) - 1.0).abs() < 1e-9);
86    }
87
88    #[test]
89    fn compute_drift_all_stale() {
90        assert!((compute_drift(5, 5) - 0.0).abs() < 1e-9);
91    }
92
93    #[test]
94    fn compute_drift_partial() {
95        assert!((compute_drift(2, 10) - 0.8).abs() < 1e-9);
96    }
97
98    #[test]
99    fn compute_drift_empty_total() {
100        assert!((compute_drift(0, 0) - 1.0).abs() < 1e-9);
101    }
102
103    #[test]
104    #[ignore] // Requires git repo — skipped in container CI
105    fn git_ts_for_real_contract() {
106        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
107            .join("../../contracts/softmax-kernel-v1.yaml");
108        let ts = git_last_commit_ts(&path);
109        assert!(ts > 0, "softmax contract should have a git timestamp");
110    }
111
112    #[test]
113    fn git_ts_nonexistent_file() {
114        let ts = git_last_commit_ts(Path::new("/nonexistent/file.yaml"));
115        assert_eq!(ts, 0);
116    }
117
118    #[test]
119    fn detect_stale_with_real_files() {
120        let contract_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
121        let binding_path = contract_dir.join("aprender/binding.yaml");
122
123        let mut bound = HashSet::new();
124        bound.insert("softmax-kernel-v1.yaml");
125
126        let stale = detect_stale_contracts(&contract_dir, &binding_path, &bound);
127        // Result depends on actual git history — just check it doesn't panic
128        assert!(stale.len() <= 1);
129    }
130
131    #[test]
132    fn detect_stale_no_git_binding() {
133        // Binding path with no git history → early return, no stale contracts
134        let contract_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
135        let mut bound = HashSet::new();
136        bound.insert("softmax-kernel-v1.yaml");
137        let stale = detect_stale_contracts(
138            &contract_dir,
139            Path::new("/tmp/nonexistent-binding.yaml"),
140            &bound,
141        );
142        assert!(stale.is_empty());
143    }
144
145    #[test]
146    fn detect_stale_missing_contract_file() {
147        // Bound stem that doesn't exist on disk → skip (continue branch)
148        let contract_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
149        let binding_path = contract_dir.join("aprender/binding.yaml");
150        let mut bound = HashSet::new();
151        bound.insert("nonexistent-contract-v1.yaml");
152        let stale = detect_stale_contracts(&contract_dir, &binding_path, &bound);
153        assert!(stale.is_empty());
154    }
155}