Skip to main content

recall_echo/graph/
confidence.rs

1//! Bayesian confidence model for relationship edges.
2//!
3//! Uses a Beta-Binomial conjugate prior. The pseudo-counts (`alpha`, `beta`)
4//! are **persisted on the edge**, so evidence accumulates: the posterior after
5//! fifty corroborations is a different distribution from the posterior after
6//! five, and its variance is smaller. The stored `confidence` field is the
7//! posterior mean — a derived value kept in sync with the counts on every
8//! write, so read paths can keep scoring on the mean alone.
9//!
10//! A new edge (or an edge from a store predating evidence persistence) starts
11//! at [`PRIOR_CONCENTRATION`]: the mean is preserved and the concentration is
12//! honestly low.
13
14use serde::{Deserialize, Serialize};
15
16/// Total pseudo-count of the Beta prior an edge starts from.
17/// ~10 observations to overwhelm the prior.
18pub const PRIOR_CONCENTRATION: f64 = 10.0;
19
20/// Evidence weight of a single observation whose provenance is not modelled:
21/// one observation, one count.
22///
23/// Now that observations carry a [`Provenance`], this is the provenance-blind
24/// reference behavior — what [`ProvenanceWeights::uniform`] reproduces, and
25/// what the differential test measures against.
26pub const DEFAULT_EVIDENCE_WEIGHT: f64 = 1.0;
27
28/// Default evidence weight of an observation from an independent source.
29pub const DEFAULT_WEIGHT_EXTERNAL: f64 = 1.0;
30
31/// Default evidence weight of an observation authored by the human.
32pub const DEFAULT_WEIGHT_USER: f64 = 0.8;
33
34/// Default evidence weight of the agent restating itself.
35pub const DEFAULT_WEIGHT_SELF: f64 = 0.05;
36
37/// How a relationship was established — determines initial confidence prior.
38#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ExtractionContext {
41    Explicit,      // 0.9
42    Inferred,      // 0.6
43    Speculative,   // 0.3
44    Authoritative, // 1.0
45}
46
47impl ExtractionContext {
48    /// Initial confidence prior for this extraction context.
49    #[must_use]
50    pub fn prior(self) -> f64 {
51        match self {
52            Self::Authoritative => 1.0,
53            Self::Explicit => 0.9,
54            Self::Inferred => 0.6,
55            Self::Speculative => 0.3,
56        }
57    }
58}
59
60impl std::str::FromStr for ExtractionContext {
61    type Err = String;
62
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        match s.to_lowercase().as_str() {
65            "explicit" => Ok(Self::Explicit),
66            "inferred" => Ok(Self::Inferred),
67            "speculative" => Ok(Self::Speculative),
68            "authoritative" => Ok(Self::Authoritative),
69            other => Err(format!("unknown extraction context: {other}")),
70        }
71    }
72}
73
74// ── Provenance ───────────────────────────────────────────────────────
75//
76// Who authored the text an observation came from. Recorded at write time
77// because it cannot be recovered afterwards: nothing in a store of unlabelled
78// episodes distinguishes an independent report from the agent restating
79// itself. Collapsing three classes into two at scoring time is always
80// possible; splitting one class back into three is not.
81
82/// The authorship class of an episode, and of every confidence-moving
83/// observation drawn from it.
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum Provenance {
87    /// Ingested documents, web content, tool output — sources independent of
88    /// the agent.
89    External,
90    /// Statements authored by the human in conversation.
91    User,
92    /// The agent's own summaries, reflections and re-assertions — and the
93    /// default for anything unlabelled, so unknown evidence never earns full
94    /// weight.
95    #[default]
96    #[serde(rename = "self")]
97    SelfGenerated,
98}
99
100impl Provenance {
101    /// The class of a value read from the store, which may be absent (an
102    /// episode written before provenance existed) or unrecognised (written by
103    /// a newer build, or by hand).
104    ///
105    /// Both resolve to [`Provenance::SelfGenerated`]: a legacy store never
106    /// gains confidence from backfilled data.
107    #[must_use]
108    pub fn from_stored(stored: Option<&str>) -> Self {
109        stored
110            .and_then(|s| s.parse().ok())
111            .unwrap_or(Self::SelfGenerated)
112    }
113
114    /// The string persisted on an episode and accepted on the CLI.
115    #[must_use]
116    pub fn as_str(self) -> &'static str {
117        match self {
118            Self::External => "external",
119            Self::User => "user",
120            Self::SelfGenerated => "self",
121        }
122    }
123}
124
125impl std::str::FromStr for Provenance {
126    type Err = String;
127
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        match s.trim().to_lowercase().as_str() {
130            "external" | "document" => Ok(Self::External),
131            "user" | "human" => Ok(Self::User),
132            "self" | "agent" | "self-generated" => Ok(Self::SelfGenerated),
133            other => Err(format!("unknown provenance: {other}")),
134        }
135    }
136}
137
138impl std::fmt::Display for Provenance {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.write_str(self.as_str())
141    }
142}
143
144/// Evidence weight of one observation, by provenance class.
145///
146/// Corroboration adds the weight to α, contradiction adds it to β. The
147/// defaults say an independent source counts fully, the human counts nearly
148/// fully, and the agent restating itself counts for almost nothing — which is
149/// the point of the whole mechanism: repetition by a single source is
150/// coherence, not evidence.
151///
152/// Maps to the `[graph.provenance]` section of `.recall-echo.toml`.
153#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
154#[serde(default)]
155pub struct ProvenanceWeights {
156    /// Weight of an [`Provenance::External`] observation. Default `1.0`.
157    pub weight_external: f64,
158    /// Weight of a [`Provenance::User`] observation. Default `0.8`.
159    pub weight_user: f64,
160    /// Weight of a [`Provenance::SelfGenerated`] observation. Default `0.05`.
161    pub weight_self: f64,
162}
163
164impl Default for ProvenanceWeights {
165    fn default() -> Self {
166        Self {
167            weight_external: DEFAULT_WEIGHT_EXTERNAL,
168            weight_user: DEFAULT_WEIGHT_USER,
169            weight_self: DEFAULT_WEIGHT_SELF,
170        }
171    }
172}
173
174impl ProvenanceWeights {
175    /// Weight every class identically — the provenance-blind escape hatch.
176    ///
177    /// `uniform(DEFAULT_EVIDENCE_WEIGHT)` reproduces pre-provenance behavior
178    /// exactly, which is what makes provenance weighting differentially
179    /// testable.
180    #[must_use]
181    pub fn uniform(weight: f64) -> Self {
182        Self {
183            weight_external: weight,
184            weight_user: weight,
185            weight_self: weight,
186        }
187    }
188
189    /// Evidence weight of one observation authored by `provenance`.
190    #[must_use]
191    pub fn for_provenance(&self, provenance: Provenance) -> f64 {
192        match provenance {
193            Provenance::External => self.weight_external,
194            Provenance::User => self.weight_user,
195            Provenance::SelfGenerated => self.weight_self,
196        }
197    }
198}
199
200/// The accumulated evidence for one relationship: the pseudo-counts of its
201/// Beta posterior.
202///
203/// `alpha` counts corroboration, `beta` counts contradiction. Both are
204/// weighted sums, not integers — an observation contributes its provenance
205/// weight. Counts are non-negative by construction.
206#[derive(Debug, Clone, Copy, PartialEq)]
207pub struct Evidence {
208    alpha: f64,
209    beta: f64,
210}
211
212impl Evidence {
213    /// Evidence for an edge that has only a mean: split [`PRIOR_CONCENTRATION`]
214    /// between the two counts so the mean is preserved exactly.
215    ///
216    /// This is the shape a brand-new edge starts in, and the shape the schema
217    /// migration backfills legacy edges into.
218    #[must_use]
219    pub fn from_prior(mean: f64) -> Self {
220        let mean = mean.clamp(0.0, 1.0);
221        Self {
222            alpha: mean * PRIOR_CONCENTRATION,
223            beta: (1.0 - mean) * PRIOR_CONCENTRATION,
224        }
225    }
226
227    /// Evidence from persisted counts. Non-finite or negative counts are
228    /// clamped to zero — a corrupt count must not produce a nonsense mean.
229    #[must_use]
230    pub fn from_counts(alpha: f64, beta: f64) -> Self {
231        Self {
232            alpha: sanitize_count(alpha),
233            beta: sanitize_count(beta),
234        }
235    }
236
237    /// Evidence for an edge as read from the store.
238    ///
239    /// Uses the persisted counts when present; falls back to
240    /// [`Evidence::from_prior`] over the stored mean when they are absent —
241    /// the shape of an edge on a store whose migration has not run yet.
242    #[must_use]
243    pub fn from_stored(alpha: Option<f64>, beta: Option<f64>, confidence: f64) -> Self {
244        match (alpha, beta) {
245            (Some(a), Some(b)) => Self::from_counts(a, b),
246            _ => Self::from_prior(confidence),
247        }
248    }
249
250    /// Record supporting evidence of the given weight.
251    pub fn corroborate(&mut self, weight: f64) {
252        self.alpha += sanitize_weight(weight);
253    }
254
255    /// Record contradicting evidence of the given weight.
256    pub fn contradict(&mut self, weight: f64) {
257        self.beta += sanitize_weight(weight);
258    }
259
260    /// Corroboration pseudo-count.
261    #[must_use]
262    pub fn alpha(self) -> f64 {
263        self.alpha
264    }
265
266    /// Contradiction pseudo-count.
267    #[must_use]
268    pub fn beta(self) -> f64 {
269        self.beta
270    }
271
272    /// Total evidence weight behind this edge (`alpha + beta`).
273    ///
274    /// This is what never grew in the pre-Phase-1 model: it is the difference
275    /// between "believed at 0.9" and "believed at 0.9 for good reason".
276    #[must_use]
277    pub fn concentration(self) -> f64 {
278        self.alpha + self.beta
279    }
280
281    /// Posterior mean — the value stored as the edge's `confidence`.
282    ///
283    /// With no evidence in either direction the mean is 0.5 (maximal ignorance).
284    #[must_use]
285    pub fn mean(self) -> f64 {
286        let total = self.concentration();
287        if total <= 0.0 {
288            return 0.5;
289        }
290        self.alpha / total
291    }
292
293    /// Posterior variance — `αβ / ((α+β)²(α+β+1))`.
294    ///
295    /// Strictly decreasing in the amount of evidence at a fixed mean, which is
296    /// how "corroborated fifty times" is told apart from "corroborated once".
297    #[must_use]
298    pub fn variance(self) -> f64 {
299        let total = self.concentration();
300        if total <= 0.0 {
301            return 0.0;
302        }
303        (self.alpha * self.beta) / (total * total * (total + 1.0))
304    }
305}
306
307/// Clamp a persisted count into the non-negative reals.
308fn sanitize_count(count: f64) -> f64 {
309    if count.is_finite() && count > 0.0 {
310        count
311    } else {
312        0.0
313    }
314}
315
316/// Clamp an observation weight; non-positive or non-finite weights record
317/// nothing rather than eroding accumulated evidence.
318fn sanitize_weight(weight: f64) -> f64 {
319    if weight.is_finite() && weight > 0.0 {
320        weight
321    } else {
322        0.0
323    }
324}
325
326/// Everything one edge persists about why it is believed: the Beta counts
327/// that move confidence, and the coherence counter that must not.
328///
329/// A self-authored corroboration is weighted into α like any other
330/// observation — at the (normally tiny) self weight — *and* tallied in
331/// `self_reinforcements`. Keeping the tally separate is what lets "believed
332/// because three independent sources said so" stay distinguishable from
333/// "believed because the agent has said it thirty times".
334#[derive(Debug, Clone, Copy, PartialEq)]
335pub struct EdgeEvidence {
336    evidence: Evidence,
337    self_reinforcements: i64,
338}
339
340impl EdgeEvidence {
341    /// Evidence state as read from an edge. A negative stored tally (only
342    /// reachable by hand-editing the store) is treated as zero.
343    #[must_use]
344    pub fn new(evidence: Evidence, self_reinforcements: i64) -> Self {
345        Self {
346            evidence,
347            self_reinforcements: self_reinforcements.max(0),
348        }
349    }
350
351    /// Record corroboration authored by `provenance`.
352    pub fn corroborate(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
353        self.evidence
354            .corroborate(weights.for_provenance(provenance));
355        if provenance == Provenance::SelfGenerated {
356            self.self_reinforcements += 1;
357        }
358    }
359
360    /// Record contradiction authored by `provenance`.
361    ///
362    /// Contradicting yourself is not coherence: the tally does not move.
363    pub fn contradict(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
364        self.evidence.contradict(weights.for_provenance(provenance));
365    }
366
367    /// The Beta pseudo-counts.
368    #[must_use]
369    pub fn evidence(self) -> Evidence {
370        self.evidence
371    }
372
373    /// How many corroborations the agent produced itself.
374    #[must_use]
375    pub fn self_reinforcements(self) -> i64 {
376        self.self_reinforcements
377    }
378}
379
380/// Default half-life for temporal decay (days).
381/// At 90 days without reinforcement, effective confidence halves.
382pub const DEFAULT_HALF_LIFE_DAYS: f64 = 90.0;
383
384/// Minimum effective confidence floor — decay never goes below this.
385pub const DECAY_FLOOR: f64 = 0.05;
386
387/// Compute effective confidence after temporal decay.
388///
389/// Formula: `effective = stored × 0.5^(days_since_reinforced / half_life)`
390///
391/// - `stored_confidence`: the Bayesian posterior (stored in DB)
392/// - `days_since_reinforced`: days since `last_reinforced` (or `valid_from` if never reinforced)
393/// - `half_life_days`: how many days until confidence halves (default: 90)
394///
395/// Returns at least `DECAY_FLOOR` (0.05) — relationships never fully disappear through decay alone.
396#[must_use]
397pub fn temporal_decay(
398    stored_confidence: f64,
399    days_since_reinforced: f64,
400    half_life_days: f64,
401) -> f64 {
402    if days_since_reinforced <= 0.0 {
403        return stored_confidence;
404    }
405
406    let decay_factor = 0.5_f64.powf(days_since_reinforced / half_life_days);
407    let effective = stored_confidence * decay_factor;
408    effective.max(DECAY_FLOOR)
409}
410
411/// Compute effective confidence for a relationship, using `last_reinforced` or `valid_from` as anchor.
412///
413/// This is the convenience wrapper that parses datetime values and calls `temporal_decay`.
414pub fn effective_confidence(
415    stored_confidence: f64,
416    last_reinforced: Option<&serde_json::Value>,
417    valid_from: &serde_json::Value,
418    now: &chrono::DateTime<chrono::Utc>,
419) -> f64 {
420    let anchor = last_reinforced
421        .and_then(parse_datetime_value)
422        .or_else(|| parse_datetime_value(valid_from));
423
424    match anchor {
425        Some(dt) => {
426            let days = (*now - dt).num_hours() as f64 / 24.0;
427            temporal_decay(stored_confidence, days, DEFAULT_HALF_LIFE_DAYS)
428        }
429        None => stored_confidence, // Can't compute decay without a timestamp
430    }
431}
432
433use super::util::parse_datetime as parse_datetime_value;
434
435/// Compound confidence along a multi-hop path.
436///
437/// Returns the product of edge confidences. An empty path returns 1.0.
438#[must_use]
439pub fn path_confidence(edge_confidences: &[f64]) -> f64 {
440    edge_confidences.iter().product()
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    fn approx_eq(a: f64, b: f64) -> bool {
448        (a - b).abs() < 0.001
449    }
450
451    /// One weighted observation on evidence derived from a bare mean.
452    fn one_observation(mean: f64, corroborate: bool) -> Evidence {
453        let mut evidence = Evidence::from_prior(mean);
454        if corroborate {
455            evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
456        } else {
457            evidence.contradict(DEFAULT_EVIDENCE_WEIGHT);
458        }
459        evidence
460    }
461
462    #[test]
463    fn corroborate_from_prior_0_6() {
464        let result = one_observation(0.6, true).mean();
465        // alpha=6, beta=4 -> (6+1)/(10+1) = 7/11 ≈ 0.636
466        assert!(approx_eq(result, 0.636), "got {}", result);
467    }
468
469    #[test]
470    fn contradict_from_prior_0_6() {
471        let result = one_observation(0.6, false).mean();
472        // alpha=6, beta=4 -> 6/(10+1) = 6/11 ≈ 0.545
473        assert!(approx_eq(result, 0.545), "got {}", result);
474    }
475
476    #[test]
477    fn corroborate_from_prior_0_9() {
478        let result = one_observation(0.9, true).mean();
479        // alpha=9, beta=1 -> (9+1)/(10+1) = 10/11 ≈ 0.909
480        assert!(approx_eq(result, 0.909), "got {}", result);
481    }
482
483    #[test]
484    fn contradict_from_prior_0_9() {
485        let result = one_observation(0.9, false).mean();
486        // alpha=9, beta=1 -> 9/(10+1) = 9/11 ≈ 0.818
487        assert!(approx_eq(result, 0.818), "got {}", result);
488    }
489
490    #[test]
491    fn corroborate_from_prior_0_3() {
492        let result = one_observation(0.3, true).mean();
493        // alpha=3, beta=7 -> (3+1)/(10+1) = 4/11 ≈ 0.364
494        assert!(approx_eq(result, 0.364), "got {}", result);
495    }
496
497    #[test]
498    fn evidence_accumulates_across_observations() {
499        // docs/bayesian-confidence.md, worked example: an Inferred fact (0.6)
500        // corroborated three times, then contradicted once.
501        let mut evidence = Evidence::from_prior(0.6);
502        assert!(approx_eq(evidence.mean(), 0.600));
503
504        evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
505        assert!(approx_eq(evidence.mean(), 0.636), "step 1: {evidence:?}");
506        evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
507        assert!(approx_eq(evidence.mean(), 0.667), "step 2: {evidence:?}");
508        evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
509        assert!(approx_eq(evidence.mean(), 0.692), "step 3: {evidence:?}");
510        evidence.contradict(DEFAULT_EVIDENCE_WEIGHT);
511        assert!(approx_eq(evidence.mean(), 0.643), "step 4: {evidence:?}");
512
513        assert!(approx_eq(evidence.alpha(), 9.0));
514        assert!(approx_eq(evidence.beta(), 5.0));
515        assert!(approx_eq(evidence.concentration(), 14.0));
516    }
517
518    #[test]
519    fn variance_narrows_with_corroboration() {
520        // AC1: more evidence at a comparable mean is a tighter posterior.
521        let after = |n: usize| {
522            let mut evidence = Evidence::from_prior(0.6);
523            for _ in 0..n {
524                evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
525            }
526            evidence.variance()
527        };
528
529        assert!(
530            after(5) < after(1),
531            "5 obs: {} vs 1: {}",
532            after(5),
533            after(1)
534        );
535        assert!(
536            after(50) < after(5),
537            "50 obs: {} vs 5: {}",
538            after(50),
539            after(5)
540        );
541    }
542
543    #[test]
544    fn concentration_grows_by_observation_weight() {
545        let mut evidence = Evidence::from_prior(0.5);
546        assert!(approx_eq(evidence.concentration(), PRIOR_CONCENTRATION));
547
548        evidence.corroborate(0.05);
549        evidence.contradict(0.8);
550
551        assert!(approx_eq(evidence.alpha(), 5.05), "got {evidence:?}");
552        assert!(approx_eq(evidence.beta(), 5.8), "got {evidence:?}");
553        assert!(approx_eq(
554            evidence.concentration(),
555            PRIOR_CONCENTRATION + 0.85
556        ));
557    }
558
559    #[test]
560    fn non_positive_weights_record_nothing() {
561        let mut evidence = Evidence::from_prior(0.6);
562        evidence.corroborate(-1.0);
563        evidence.contradict(f64::NAN);
564
565        assert!(approx_eq(evidence.alpha(), 6.0));
566        assert!(approx_eq(evidence.beta(), 4.0));
567    }
568
569    #[test]
570    fn from_stored_prefers_persisted_counts() {
571        let persisted = Evidence::from_stored(Some(56.0), Some(4.0), 0.6);
572        assert!(approx_eq(persisted.concentration(), 60.0));
573        assert!(approx_eq(persisted.mean(), 56.0 / 60.0));
574    }
575
576    #[test]
577    fn from_stored_falls_back_to_prior_when_unmigrated() {
578        let legacy = Evidence::from_stored(None, None, 0.6);
579        assert!(approx_eq(legacy.alpha(), 6.0));
580        assert!(approx_eq(legacy.beta(), 4.0));
581        assert!(approx_eq(legacy.mean(), 0.6));
582    }
583
584    #[test]
585    fn empty_evidence_is_maximally_uncertain() {
586        let empty = Evidence::from_counts(0.0, 0.0);
587        assert!(approx_eq(empty.mean(), 0.5));
588        assert_eq!(empty.variance(), 0.0);
589    }
590
591    #[test]
592    fn corrupt_counts_are_clamped() {
593        let corrupt = Evidence::from_counts(-3.0, f64::INFINITY);
594        assert_eq!(corrupt.alpha(), 0.0);
595        assert_eq!(corrupt.beta(), 0.0);
596    }
597
598    #[test]
599    fn default_weights_rank_independence_above_repetition() {
600        let weights = ProvenanceWeights::default();
601        assert!(approx_eq(
602            weights.for_provenance(Provenance::External),
603            DEFAULT_WEIGHT_EXTERNAL
604        ));
605        assert!(approx_eq(
606            weights.for_provenance(Provenance::User),
607            DEFAULT_WEIGHT_USER
608        ));
609        assert!(approx_eq(
610            weights.for_provenance(Provenance::SelfGenerated),
611            DEFAULT_WEIGHT_SELF
612        ));
613        assert!(weights.weight_external > weights.weight_user);
614        assert!(weights.weight_user > weights.weight_self);
615    }
616
617    #[test]
618    fn uniform_weights_are_provenance_blind() {
619        let weights = ProvenanceWeights::uniform(DEFAULT_EVIDENCE_WEIGHT);
620        for provenance in [
621            Provenance::External,
622            Provenance::User,
623            Provenance::SelfGenerated,
624        ] {
625            assert_eq!(
626                weights.for_provenance(provenance),
627                DEFAULT_EVIDENCE_WEIGHT,
628                "{provenance} must weigh the same as every other class"
629            );
630        }
631    }
632
633    #[test]
634    fn provenance_parses_and_renders() {
635        assert_eq!("external".parse::<Provenance>(), Ok(Provenance::External));
636        assert_eq!("User".parse::<Provenance>(), Ok(Provenance::User));
637        assert_eq!(
638            " SELF ".parse::<Provenance>(),
639            Ok(Provenance::SelfGenerated)
640        );
641        assert!("mostly-true".parse::<Provenance>().is_err());
642
643        assert_eq!(Provenance::External.to_string(), "external");
644        assert_eq!(Provenance::User.to_string(), "user");
645        assert_eq!(Provenance::SelfGenerated.to_string(), "self");
646    }
647
648    #[test]
649    fn stored_provenance_defaults_to_self() {
650        // AC7: absent and unrecognised both land on the conservative class.
651        assert_eq!(Provenance::from_stored(None), Provenance::SelfGenerated);
652        assert_eq!(
653            Provenance::from_stored(Some("nonsense")),
654            Provenance::SelfGenerated
655        );
656        assert_eq!(
657            Provenance::from_stored(Some("external")),
658            Provenance::External
659        );
660    }
661
662    #[test]
663    fn provenance_serde_uses_wire_names() {
664        for (provenance, wire) in [
665            (Provenance::External, "\"external\""),
666            (Provenance::User, "\"user\""),
667            (Provenance::SelfGenerated, "\"self\""),
668        ] {
669            assert_eq!(serde_json::to_string(&provenance).unwrap(), wire);
670            assert_eq!(
671                serde_json::from_str::<Provenance>(wire).unwrap(),
672                provenance
673            );
674        }
675    }
676
677    #[test]
678    fn self_corroboration_is_counted_separately_from_confidence() {
679        let weights = ProvenanceWeights::default();
680        let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
681
682        for _ in 0..3 {
683            edge.corroborate(Provenance::SelfGenerated, &weights);
684        }
685        edge.corroborate(Provenance::External, &weights);
686        edge.contradict(Provenance::SelfGenerated, &weights);
687
688        assert_eq!(
689            edge.self_reinforcements(),
690            3,
691            "only self-corroboration is coherence"
692        );
693        assert!(approx_eq(edge.evidence().alpha(), 6.0 + 0.15 + 1.0));
694        assert!(approx_eq(edge.evidence().beta(), 4.0 + 0.05));
695    }
696
697    #[test]
698    fn external_contradiction_outweighs_accumulated_self_corroboration() {
699        // AC2: twenty self-corroborations are erased by a single independent
700        // contradiction at the default weights.
701        let weights = ProvenanceWeights::default();
702        let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
703        let before = edge.evidence().mean();
704
705        for _ in 0..20 {
706            edge.corroborate(Provenance::SelfGenerated, &weights);
707        }
708        let after_coherence = edge.evidence().mean();
709        assert!(after_coherence > before);
710        assert_eq!(edge.self_reinforcements(), 20);
711
712        edge.contradict(Provenance::External, &weights);
713        assert!(
714            edge.evidence().mean() < before,
715            "one external contradiction must undo the whole coherence run: {} vs {before}",
716            edge.evidence().mean()
717        );
718    }
719
720    #[test]
721    fn negative_stored_tally_is_clamped() {
722        let edge = EdgeEvidence::new(Evidence::from_prior(0.5), -7);
723        assert_eq!(edge.self_reinforcements(), 0);
724    }
725
726    #[test]
727    fn path_confidence_two_edges() {
728        let result = path_confidence(&[0.8, 0.7]);
729        assert!(approx_eq(result, 0.56), "got {}", result);
730    }
731
732    #[test]
733    fn path_confidence_empty() {
734        assert_eq!(path_confidence(&[]), 1.0);
735    }
736
737    #[test]
738    fn extraction_context_priors() {
739        assert_eq!(ExtractionContext::Authoritative.prior(), 1.0);
740        assert_eq!(ExtractionContext::Explicit.prior(), 0.9);
741        assert_eq!(ExtractionContext::Inferred.prior(), 0.6);
742        assert_eq!(ExtractionContext::Speculative.prior(), 0.3);
743    }
744
745    #[test]
746    fn temporal_decay_zero_days() {
747        let result = temporal_decay(0.9, 0.0, 90.0);
748        assert!(approx_eq(result, 0.9), "got {}", result);
749    }
750
751    #[test]
752    fn temporal_decay_one_half_life() {
753        // After exactly 90 days, confidence should halve
754        let result = temporal_decay(0.6, 90.0, 90.0);
755        assert!(approx_eq(result, 0.3), "got {}", result);
756    }
757
758    #[test]
759    fn temporal_decay_two_half_lives() {
760        // After 180 days, confidence should quarter
761        let result = temporal_decay(0.8, 180.0, 90.0);
762        assert!(approx_eq(result, 0.2), "got {}", result);
763    }
764
765    #[test]
766    fn temporal_decay_floor() {
767        // After many half-lives, should hit the floor
768        let result = temporal_decay(0.3, 900.0, 90.0);
769        assert!(approx_eq(result, DECAY_FLOOR), "got {}", result);
770    }
771
772    #[test]
773    fn temporal_decay_negative_days() {
774        // Negative days (future timestamp) should return stored confidence
775        let result = temporal_decay(0.7, -5.0, 90.0);
776        assert!(approx_eq(result, 0.7), "got {}", result);
777    }
778
779    #[test]
780    fn temporal_decay_high_confidence_still_decays() {
781        // Even 1.0 confidence decays
782        let result = temporal_decay(1.0, 90.0, 90.0);
783        assert!(approx_eq(result, 0.5), "got {}", result);
784    }
785
786    #[test]
787    fn effective_confidence_with_last_reinforced() {
788        let now = chrono::Utc::now();
789        let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
790        let valid_from_long_ago = (now - chrono::Duration::days(365)).to_rfc3339();
791
792        let last_reinforced = serde_json::Value::String(ninety_days_ago);
793        let valid_from = serde_json::Value::String(valid_from_long_ago);
794
795        // Should use last_reinforced (90 days) not valid_from (365 days)
796        let result = effective_confidence(0.6, Some(&last_reinforced), &valid_from, &now);
797        assert!(
798            approx_eq(result, 0.3),
799            "got {} (expected ~0.3, one half-life from last_reinforced)",
800            result
801        );
802    }
803
804    #[test]
805    fn effective_confidence_falls_back_to_valid_from() {
806        let now = chrono::Utc::now();
807        let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
808        let valid_from = serde_json::Value::String(ninety_days_ago);
809
810        // No last_reinforced — should use valid_from
811        let result = effective_confidence(0.6, None, &valid_from, &now);
812        assert!(
813            approx_eq(result, 0.3),
814            "got {} (expected ~0.3, one half-life from valid_from)",
815            result
816        );
817    }
818
819    #[test]
820    fn effective_confidence_no_parseable_date() {
821        let now = chrono::Utc::now();
822        let bad_date = serde_json::Value::String("not-a-date".to_string());
823
824        // Unparseable dates should return stored confidence unchanged
825        let result = effective_confidence(0.8, None, &bad_date, &now);
826        assert!(approx_eq(result, 0.8), "got {}", result);
827    }
828
829    #[test]
830    fn extraction_context_from_str() {
831        assert_eq!(
832            "explicit".parse::<ExtractionContext>().unwrap(),
833            ExtractionContext::Explicit
834        );
835        assert_eq!(
836            "inferred".parse::<ExtractionContext>().unwrap(),
837            ExtractionContext::Inferred
838        );
839        assert_eq!(
840            "speculative".parse::<ExtractionContext>().unwrap(),
841            ExtractionContext::Speculative
842        );
843        assert_eq!(
844            "authoritative".parse::<ExtractionContext>().unwrap(),
845            ExtractionContext::Authoritative
846        );
847        assert!("unknown".parse::<ExtractionContext>().is_err());
848    }
849}