Skip to main content

recall_echo/graph/
confidence.rs

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