Skip to main content

recall_echo/graph/
confidence.rs

1//! Bayesian confidence model for relationship edges.
2//!
3//! Uses Beta-Binomial conjugate prior with pseudocount 10.
4//! Confidence moves slowly per observation but accumulates with repeated evidence.
5
6use serde::{Deserialize, Serialize};
7
8/// Pseudocount total for the Beta-Binomial prior.
9/// ~10 observations to overwhelm the prior.
10const PSEUDOCOUNT: f64 = 10.0;
11
12/// How a relationship was established — determines initial confidence prior.
13#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum ExtractionContext {
16    Explicit,      // 0.9
17    Inferred,      // 0.6
18    Speculative,   // 0.3
19    Authoritative, // 1.0
20}
21
22impl ExtractionContext {
23    /// Initial confidence prior for this extraction context.
24    #[must_use]
25    pub fn prior(self) -> f64 {
26        match self {
27            Self::Authoritative => 1.0,
28            Self::Explicit => 0.9,
29            Self::Inferred => 0.6,
30            Self::Speculative => 0.3,
31        }
32    }
33}
34
35impl std::str::FromStr for ExtractionContext {
36    type Err = String;
37
38    fn from_str(s: &str) -> Result<Self, Self::Err> {
39        match s.to_lowercase().as_str() {
40            "explicit" => Ok(Self::Explicit),
41            "inferred" => Ok(Self::Inferred),
42            "speculative" => Ok(Self::Speculative),
43            "authoritative" => Ok(Self::Authoritative),
44            other => Err(format!("unknown extraction context: {other}")),
45        }
46    }
47}
48
49/// Bayesian update using Beta-Binomial conjugate prior.
50///
51/// Given a current confidence (interpreted as alpha / (alpha + beta) with
52/// total pseudocount), updates the posterior by adding one observation.
53///
54/// - `corroborate = true`: alpha += 1 (evidence supports the relationship)
55/// - `corroborate = false`: beta += 1 (evidence contradicts the relationship)
56#[must_use]
57pub fn bayesian_update(current_confidence: f64, corroborate: bool) -> f64 {
58    let alpha = current_confidence * PSEUDOCOUNT;
59    let beta = PSEUDOCOUNT - alpha;
60
61    if corroborate {
62        (alpha + 1.0) / (alpha + beta + 1.0)
63    } else {
64        alpha / (alpha + beta + 1.0)
65    }
66}
67
68/// Default half-life for temporal decay (days).
69/// At 90 days without reinforcement, effective confidence halves.
70pub const DEFAULT_HALF_LIFE_DAYS: f64 = 90.0;
71
72/// Minimum effective confidence floor — decay never goes below this.
73pub const DECAY_FLOOR: f64 = 0.05;
74
75/// Compute effective confidence after temporal decay.
76///
77/// Formula: `effective = stored × 0.5^(days_since_reinforced / half_life)`
78///
79/// - `stored_confidence`: the Bayesian posterior (stored in DB)
80/// - `days_since_reinforced`: days since `last_reinforced` (or `valid_from` if never reinforced)
81/// - `half_life_days`: how many days until confidence halves (default: 90)
82///
83/// Returns at least `DECAY_FLOOR` (0.05) — relationships never fully disappear through decay alone.
84#[must_use]
85pub fn temporal_decay(
86    stored_confidence: f64,
87    days_since_reinforced: f64,
88    half_life_days: f64,
89) -> f64 {
90    if days_since_reinforced <= 0.0 {
91        return stored_confidence;
92    }
93
94    let decay_factor = 0.5_f64.powf(days_since_reinforced / half_life_days);
95    let effective = stored_confidence * decay_factor;
96    effective.max(DECAY_FLOOR)
97}
98
99/// Compute effective confidence for a relationship, using `last_reinforced` or `valid_from` as anchor.
100///
101/// This is the convenience wrapper that parses datetime values and calls `temporal_decay`.
102pub fn effective_confidence(
103    stored_confidence: f64,
104    last_reinforced: Option<&serde_json::Value>,
105    valid_from: &serde_json::Value,
106    now: &chrono::DateTime<chrono::Utc>,
107) -> f64 {
108    let anchor = last_reinforced
109        .and_then(parse_datetime_value)
110        .or_else(|| parse_datetime_value(valid_from));
111
112    match anchor {
113        Some(dt) => {
114            let days = (*now - dt).num_hours() as f64 / 24.0;
115            temporal_decay(stored_confidence, days, DEFAULT_HALF_LIFE_DAYS)
116        }
117        None => stored_confidence, // Can't compute decay without a timestamp
118    }
119}
120
121use super::util::parse_datetime as parse_datetime_value;
122
123/// Compound confidence along a multi-hop path.
124///
125/// Returns the product of edge confidences. An empty path returns 1.0.
126#[must_use]
127pub fn path_confidence(edge_confidences: &[f64]) -> f64 {
128    edge_confidences.iter().product()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn approx_eq(a: f64, b: f64) -> bool {
136        (a - b).abs() < 0.001
137    }
138
139    #[test]
140    fn bayesian_update_corroborate_0_6() {
141        let result = bayesian_update(0.6, true);
142        // alpha=6, beta=4 -> (6+1)/(10+1) = 7/11 ≈ 0.636
143        assert!(approx_eq(result, 0.636), "got {}", result);
144    }
145
146    #[test]
147    fn bayesian_update_contradict_0_6() {
148        let result = bayesian_update(0.6, false);
149        // alpha=6, beta=4 -> 6/(10+1) = 6/11 ≈ 0.545
150        assert!(approx_eq(result, 0.545), "got {}", result);
151    }
152
153    #[test]
154    fn bayesian_update_corroborate_0_9() {
155        let result = bayesian_update(0.9, true);
156        // alpha=9, beta=1 -> (9+1)/(10+1) = 10/11 ≈ 0.909
157        assert!(approx_eq(result, 0.909), "got {}", result);
158    }
159
160    #[test]
161    fn bayesian_update_contradict_0_9() {
162        let result = bayesian_update(0.9, false);
163        // alpha=9, beta=1 -> 9/(10+1) = 9/11 ≈ 0.818
164        assert!(approx_eq(result, 0.818), "got {}", result);
165    }
166
167    #[test]
168    fn bayesian_update_corroborate_0_3() {
169        let result = bayesian_update(0.3, true);
170        // alpha=3, beta=7 -> (3+1)/(10+1) = 4/11 ≈ 0.364
171        assert!(approx_eq(result, 0.364), "got {}", result);
172    }
173
174    #[test]
175    fn path_confidence_two_edges() {
176        let result = path_confidence(&[0.8, 0.7]);
177        assert!(approx_eq(result, 0.56), "got {}", result);
178    }
179
180    #[test]
181    fn path_confidence_empty() {
182        assert_eq!(path_confidence(&[]), 1.0);
183    }
184
185    #[test]
186    fn extraction_context_priors() {
187        assert_eq!(ExtractionContext::Authoritative.prior(), 1.0);
188        assert_eq!(ExtractionContext::Explicit.prior(), 0.9);
189        assert_eq!(ExtractionContext::Inferred.prior(), 0.6);
190        assert_eq!(ExtractionContext::Speculative.prior(), 0.3);
191    }
192
193    #[test]
194    fn temporal_decay_zero_days() {
195        let result = temporal_decay(0.9, 0.0, 90.0);
196        assert!(approx_eq(result, 0.9), "got {}", result);
197    }
198
199    #[test]
200    fn temporal_decay_one_half_life() {
201        // After exactly 90 days, confidence should halve
202        let result = temporal_decay(0.6, 90.0, 90.0);
203        assert!(approx_eq(result, 0.3), "got {}", result);
204    }
205
206    #[test]
207    fn temporal_decay_two_half_lives() {
208        // After 180 days, confidence should quarter
209        let result = temporal_decay(0.8, 180.0, 90.0);
210        assert!(approx_eq(result, 0.2), "got {}", result);
211    }
212
213    #[test]
214    fn temporal_decay_floor() {
215        // After many half-lives, should hit the floor
216        let result = temporal_decay(0.3, 900.0, 90.0);
217        assert!(approx_eq(result, DECAY_FLOOR), "got {}", result);
218    }
219
220    #[test]
221    fn temporal_decay_negative_days() {
222        // Negative days (future timestamp) should return stored confidence
223        let result = temporal_decay(0.7, -5.0, 90.0);
224        assert!(approx_eq(result, 0.7), "got {}", result);
225    }
226
227    #[test]
228    fn temporal_decay_high_confidence_still_decays() {
229        // Even 1.0 confidence decays
230        let result = temporal_decay(1.0, 90.0, 90.0);
231        assert!(approx_eq(result, 0.5), "got {}", result);
232    }
233
234    #[test]
235    fn effective_confidence_with_last_reinforced() {
236        let now = chrono::Utc::now();
237        let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
238        let valid_from_long_ago = (now - chrono::Duration::days(365)).to_rfc3339();
239
240        let last_reinforced = serde_json::Value::String(ninety_days_ago);
241        let valid_from = serde_json::Value::String(valid_from_long_ago);
242
243        // Should use last_reinforced (90 days) not valid_from (365 days)
244        let result = effective_confidence(0.6, Some(&last_reinforced), &valid_from, &now);
245        assert!(
246            approx_eq(result, 0.3),
247            "got {} (expected ~0.3, one half-life from last_reinforced)",
248            result
249        );
250    }
251
252    #[test]
253    fn effective_confidence_falls_back_to_valid_from() {
254        let now = chrono::Utc::now();
255        let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
256        let valid_from = serde_json::Value::String(ninety_days_ago);
257
258        // No last_reinforced — should use valid_from
259        let result = effective_confidence(0.6, None, &valid_from, &now);
260        assert!(
261            approx_eq(result, 0.3),
262            "got {} (expected ~0.3, one half-life from valid_from)",
263            result
264        );
265    }
266
267    #[test]
268    fn effective_confidence_no_parseable_date() {
269        let now = chrono::Utc::now();
270        let bad_date = serde_json::Value::String("not-a-date".to_string());
271
272        // Unparseable dates should return stored confidence unchanged
273        let result = effective_confidence(0.8, None, &bad_date, &now);
274        assert!(approx_eq(result, 0.8), "got {}", result);
275    }
276
277    #[test]
278    fn extraction_context_from_str() {
279        assert_eq!(
280            "explicit".parse::<ExtractionContext>().unwrap(),
281            ExtractionContext::Explicit
282        );
283        assert_eq!(
284            "inferred".parse::<ExtractionContext>().unwrap(),
285            ExtractionContext::Inferred
286        );
287        assert_eq!(
288            "speculative".parse::<ExtractionContext>().unwrap(),
289            ExtractionContext::Speculative
290        );
291        assert_eq!(
292            "authoritative".parse::<ExtractionContext>().unwrap(),
293            ExtractionContext::Authoritative
294        );
295        assert!("unknown".parse::<ExtractionContext>().is_err());
296    }
297}