Skip to main content

contextqa_core/
confidence.rs

1//! Confidence scoring module — transforms raw dependency data into actionable intelligence.
2//!
3//! Implements three approaches from the research synthesis:
4//! 1. Noisy-OR fusion (Google Knowledge Vault, KDD 2014) — for multi-source evidence
5//! 2. Temporal decay (TempValid framework, ACL 2024) — confidence is not timeless
6//! 3. Bayesian update — for incorporating new evidence
7//!
8//! A static dependency graph says "A depends on B."
9//! A probabilistic one says "A depends on B with 0.87 confidence based on
10//! static analysis (0.92) and test coverage (0.75), decaying at 0.01/day
11//! since last verification."
12
13use serde::{Deserialize, Serialize};
14
15/// Noisy-OR model for combining independent evidence sources.
16///
17/// P(edge) = 1 - Π(1 - P(source_i))
18///
19/// Better than simple multiplication for redundant paths — when multiple
20/// independent sources confirm a dependency, confidence compounds rather
21/// than multiplies pessimistically.
22///
23/// Reference: Google Knowledge Vault (KDD 2014)
24pub fn noisy_or(confidences: &[f64]) -> f64 {
25    if confidences.is_empty() {
26        return 0.0;
27    }
28    1.0 - confidences
29        .iter()
30        .map(|c| 1.0 - c.clamp(0.0, 1.0))
31        .product::<f64>()
32}
33
34/// Temporal confidence decay using exponential model.
35///
36/// confidence(t) = base_confidence × exp(-λ × days_since_verification)
37///
38/// Decay rates vary by dependency type:
39/// - Import statements:  λ ≈ 0.001  (very slow, imports rarely become invalid)
40/// - Call graph edges:   λ ≈ 0.005  (slow, call relationships are stable)
41/// - Test coverage:      λ ≈ 0.01   (moderate, tests may become stale)
42/// - Heuristic inferences: λ ≈ 0.05 (moderate-fast)
43/// - API traffic patterns: λ ≈ 0.1  (fast, traffic changes frequently)
44///
45/// Reference: TempValid framework (ACL 2024)
46pub fn temporal_decay(base_confidence: f64, lambda: f64, days_since_verification: f64) -> f64 {
47    let decay_factor = (-lambda * days_since_verification).exp();
48    (base_confidence * decay_factor).clamp(0.0, 1.0)
49}
50
51/// Simple Bayesian update: combine prior belief with new evidence.
52///
53/// P(H|E) = P(E|H) × P(H) / P(E)
54///
55/// Simplified: uses likelihood ratio to update prior.
56pub fn bayesian_update(prior: f64, likelihood_ratio: f64) -> f64 {
57    let odds = prior / (1.0 - prior + f64::EPSILON);
58    let posterior_odds = odds * likelihood_ratio;
59    let posterior = posterior_odds / (1.0 + posterior_odds);
60    posterior.clamp(0.0, 1.0)
61}
62
63/// Propagate confidence through a transitive chain.
64///
65/// For a path A → B → C, the confidence of the indirect dependency A → C
66/// depends on the approach:
67///
68/// - Simple multiplication: P(A→C) = P(A→B) × P(B→C)
69///   Fast but pessimistic — each hop reduces confidence multiplicatively.
70///
71/// - Noisy-OR over all paths: better for redundant paths
72///   P(A→C) = 1 - Π(1 - P(path_i)) where P(path_i) = product of edges
73pub fn chain_confidence(edge_confidences: &[f64]) -> f64 {
74    edge_confidences.iter().product()
75}
76
77/// Combined multi-path confidence: given multiple paths between A and C,
78/// compute the overall confidence using noisy-OR over each path's
79/// chain confidence.
80pub fn multi_path_confidence(paths: &[Vec<f64>]) -> f64 {
81    let path_confidences: Vec<f64> = paths.iter().map(|path| chain_confidence(path)).collect();
82    noisy_or(&path_confidences)
83}
84
85/// Confidence report for explaining to humans.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ConfidenceReport {
88    pub raw_score: f64,
89    pub decayed_score: f64,
90    pub tier: String,
91    pub sources: Vec<SourceContribution>,
92    pub days_since_verified: f64,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct SourceContribution {
97    pub source: String,
98    pub individual_confidence: f64,
99    pub observed_at: String,
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_noisy_or_single_source() {
108        assert!((noisy_or(&[0.8]) - 0.8).abs() < f64::EPSILON);
109    }
110
111    #[test]
112    fn test_noisy_or_compounds_agreement() {
113        // Two sources at 0.8: 1 - (0.2 × 0.2) = 0.96
114        let result = noisy_or(&[0.8, 0.8]);
115        assert!((result - 0.96).abs() < 0.001);
116    }
117
118    #[test]
119    fn test_noisy_or_empty() {
120        assert_eq!(noisy_or(&[]), 0.0);
121    }
122
123    #[test]
124    fn test_temporal_decay() {
125        // After 0 days, no decay
126        assert!((temporal_decay(0.95, 0.01, 0.0) - 0.95).abs() < 0.001);
127
128        // After 30 days with λ=0.01: 0.95 × exp(-0.3) ≈ 0.703
129        let decayed = temporal_decay(0.95, 0.01, 30.0);
130        assert!(decayed < 0.95);
131        assert!(decayed > 0.5);
132
133        // After 365 days with λ=0.001 (import): 0.95 × exp(-0.365) ≈ 0.659
134        // Imports decay very slowly
135        let import_decay = temporal_decay(0.95, 0.001, 365.0);
136        assert!(import_decay > 0.6);
137    }
138
139    #[test]
140    fn test_bayesian_update() {
141        // Prior 0.5 with strong confirming evidence (LR=10)
142        let posterior = bayesian_update(0.5, 10.0);
143        assert!(posterior > 0.9);
144
145        // Prior 0.5 with disconfirming evidence (LR=0.1)
146        let posterior = bayesian_update(0.5, 0.1);
147        assert!(posterior < 0.15);
148    }
149
150    #[test]
151    fn test_chain_confidence() {
152        // 3-hop chain: 0.95 × 0.90 × 0.80 = 0.684
153        let chain = chain_confidence(&[0.95, 0.90, 0.80]);
154        assert!((chain - 0.684).abs() < 0.001);
155    }
156
157    #[test]
158    fn test_multi_path() {
159        // Two paths from A to C:
160        // Path 1: 0.9 × 0.8 = 0.72
161        // Path 2: 0.7 × 0.6 = 0.42
162        // Noisy-OR: 1 - (1-0.72)(1-0.42) = 1 - 0.28×0.58 = 0.8376
163        let result = multi_path_confidence(&[vec![0.9, 0.8], vec![0.7, 0.6]]);
164        assert!((result - 0.8376).abs() < 0.001);
165    }
166}