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/// What one observation says about a claim.
331///
332/// The two directions differ by more than the sign of a count. Corroboration is
333/// the belief being *seen again*, so it restarts temporal decay; a
334/// contradiction is not, so it must leave the decay anchor exactly where it
335/// stands. Carrying that as a value — rather than leaving each write site to
336/// remember it — is what stops "this is wrong" from making a stale edge more
337/// visible than it was: an edge stored at 0.6 and decayed to 0.3 would
338/// otherwise come back at 0.545, undecayed, *because* it was corrected.
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum Observation {
341    /// The claim was stated again.
342    Corroborating,
343    /// The claim was denied.
344    Contradicting,
345}
346
347impl Observation {
348    /// Whether recording this observation restarts temporal decay.
349    ///
350    /// Only corroboration does. This is the single place that rule lives.
351    #[must_use]
352    pub fn renews_decay_anchor(self) -> bool {
353        matches!(self, Self::Corroborating)
354    }
355}
356
357/// Everything one edge persists about why it is believed: the Beta counts
358/// that move confidence, and the coherence counter that must not.
359///
360/// A self-authored corroboration is weighted into α like any other
361/// observation — at the (normally tiny) self weight — *and* tallied in
362/// `self_reinforcements`. Keeping the tally separate is what lets "believed
363/// because three independent sources said so" stay distinguishable from
364/// "believed because the agent has said it thirty times".
365#[derive(Debug, Clone, Copy, PartialEq)]
366pub struct EdgeEvidence {
367    evidence: Evidence,
368    self_reinforcements: i64,
369}
370
371impl EdgeEvidence {
372    /// Evidence state as read from an edge. A negative stored tally (only
373    /// reachable by hand-editing the store) is treated as zero.
374    #[must_use]
375    pub fn new(evidence: Evidence, self_reinforcements: i64) -> Self {
376        Self {
377            evidence,
378            self_reinforcements: self_reinforcements.max(0),
379        }
380    }
381
382    /// Record one observation authored by `provenance`.
383    ///
384    /// The direction is a value here for the same reason it is one at the write
385    /// path: the caller decides it once, and both the counts and the decay
386    /// anchor follow from that single decision.
387    pub fn record(
388        &mut self,
389        observation: Observation,
390        provenance: Provenance,
391        weights: &ProvenanceWeights,
392    ) {
393        match observation {
394            Observation::Corroborating => self.corroborate(provenance, weights),
395            Observation::Contradicting => self.contradict(provenance, weights),
396        }
397    }
398
399    /// Record corroboration authored by `provenance`.
400    pub fn corroborate(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
401        self.evidence
402            .corroborate(weights.for_provenance(provenance));
403        if provenance == Provenance::SelfGenerated {
404            self.self_reinforcements += 1;
405        }
406    }
407
408    /// Record contradiction authored by `provenance`.
409    ///
410    /// Contradicting yourself is not coherence: the tally does not move.
411    pub fn contradict(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
412        self.evidence.contradict(weights.for_provenance(provenance));
413    }
414
415    /// The Beta pseudo-counts.
416    #[must_use]
417    pub fn evidence(self) -> Evidence {
418        self.evidence
419    }
420
421    /// How many corroborations the agent produced itself.
422    #[must_use]
423    pub fn self_reinforcements(self) -> i64 {
424        self.self_reinforcements
425    }
426}
427
428/// Default half-life for temporal decay (days).
429/// At 90 days without reinforcement, effective confidence halves.
430pub const DEFAULT_HALF_LIFE_DAYS: f64 = 90.0;
431
432/// Minimum effective confidence floor — decay never goes below this.
433pub const DECAY_FLOOR: f64 = 0.05;
434
435/// Compute effective confidence after temporal decay.
436///
437/// Formula: `effective = stored × 0.5^(days_since_reinforced / half_life)`
438///
439/// - `stored_confidence`: the Bayesian posterior (stored in DB)
440/// - `days_since_reinforced`: days since `last_reinforced` (or `valid_from` if never reinforced)
441/// - `half_life_days`: how many days until confidence halves (default: 90)
442///
443/// Returns at least `DECAY_FLOOR` (0.05) — relationships never fully disappear through decay alone.
444#[must_use]
445pub fn temporal_decay(
446    stored_confidence: f64,
447    days_since_reinforced: f64,
448    half_life_days: f64,
449) -> f64 {
450    if days_since_reinforced <= 0.0 {
451        return stored_confidence;
452    }
453
454    let decay_factor = 0.5_f64.powf(days_since_reinforced / half_life_days);
455    let effective = stored_confidence * decay_factor;
456    effective.max(DECAY_FLOOR)
457}
458
459/// Compute effective confidence for a relationship, using `last_reinforced` or `valid_from` as anchor.
460///
461/// This is the convenience wrapper that parses datetime values and calls `temporal_decay`.
462pub fn effective_confidence(
463    stored_confidence: f64,
464    last_reinforced: Option<&serde_json::Value>,
465    valid_from: &serde_json::Value,
466    now: &chrono::DateTime<chrono::Utc>,
467) -> f64 {
468    let anchor = last_reinforced
469        .and_then(parse_datetime_value)
470        .or_else(|| parse_datetime_value(valid_from));
471
472    match anchor {
473        Some(dt) => {
474            let days = (*now - dt).num_hours() as f64 / 24.0;
475            temporal_decay(stored_confidence, days, DEFAULT_HALF_LIFE_DAYS)
476        }
477        None => stored_confidence, // Can't compute decay without a timestamp
478    }
479}
480
481use super::util::parse_datetime as parse_datetime_value;
482
483/// Compound confidence along a multi-hop path.
484///
485/// Returns the product of edge confidences. An empty path returns 1.0.
486#[must_use]
487pub fn path_confidence(edge_confidences: &[f64]) -> f64 {
488    edge_confidences.iter().product()
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn approx_eq(a: f64, b: f64) -> bool {
496        (a - b).abs() < 0.001
497    }
498
499    /// One weighted observation on evidence derived from a bare mean.
500    fn one_observation(mean: f64, corroborate: bool) -> Evidence {
501        let mut evidence = Evidence::from_prior(mean);
502        if corroborate {
503            evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
504        } else {
505            evidence.contradict(DEFAULT_EVIDENCE_WEIGHT);
506        }
507        evidence
508    }
509
510    #[test]
511    fn corroborate_from_prior_0_6() {
512        let result = one_observation(0.6, true).mean();
513        // alpha=6, beta=4 -> (6+1)/(10+1) = 7/11 ≈ 0.636
514        assert!(approx_eq(result, 0.636), "got {}", result);
515    }
516
517    #[test]
518    fn contradict_from_prior_0_6() {
519        let result = one_observation(0.6, false).mean();
520        // alpha=6, beta=4 -> 6/(10+1) = 6/11 ≈ 0.545
521        assert!(approx_eq(result, 0.545), "got {}", result);
522    }
523
524    #[test]
525    fn corroborate_from_prior_0_9() {
526        let result = one_observation(0.9, true).mean();
527        // alpha=9, beta=1 -> (9+1)/(10+1) = 10/11 ≈ 0.909
528        assert!(approx_eq(result, 0.909), "got {}", result);
529    }
530
531    #[test]
532    fn contradict_from_prior_0_9() {
533        let result = one_observation(0.9, false).mean();
534        // alpha=9, beta=1 -> 9/(10+1) = 9/11 ≈ 0.818
535        assert!(approx_eq(result, 0.818), "got {}", result);
536    }
537
538    #[test]
539    fn corroborate_from_prior_0_3() {
540        let result = one_observation(0.3, true).mean();
541        // alpha=3, beta=7 -> (3+1)/(10+1) = 4/11 ≈ 0.364
542        assert!(approx_eq(result, 0.364), "got {}", result);
543    }
544
545    #[test]
546    fn evidence_accumulates_across_observations() {
547        // docs/bayesian-confidence.md, worked example: an Inferred fact (0.6)
548        // corroborated three times, then contradicted once.
549        let mut evidence = Evidence::from_prior(0.6);
550        assert!(approx_eq(evidence.mean(), 0.600));
551
552        evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
553        assert!(approx_eq(evidence.mean(), 0.636), "step 1: {evidence:?}");
554        evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
555        assert!(approx_eq(evidence.mean(), 0.667), "step 2: {evidence:?}");
556        evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
557        assert!(approx_eq(evidence.mean(), 0.692), "step 3: {evidence:?}");
558        evidence.contradict(DEFAULT_EVIDENCE_WEIGHT);
559        assert!(approx_eq(evidence.mean(), 0.643), "step 4: {evidence:?}");
560
561        assert!(approx_eq(evidence.alpha(), 9.0));
562        assert!(approx_eq(evidence.beta(), 5.0));
563        assert!(approx_eq(evidence.concentration(), 14.0));
564    }
565
566    #[test]
567    fn variance_narrows_with_corroboration() {
568        // AC1: more evidence at a comparable mean is a tighter posterior.
569        let after = |n: usize| {
570            let mut evidence = Evidence::from_prior(0.6);
571            for _ in 0..n {
572                evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
573            }
574            evidence.variance()
575        };
576
577        assert!(
578            after(5) < after(1),
579            "5 obs: {} vs 1: {}",
580            after(5),
581            after(1)
582        );
583        assert!(
584            after(50) < after(5),
585            "50 obs: {} vs 5: {}",
586            after(50),
587            after(5)
588        );
589    }
590
591    #[test]
592    fn concentration_grows_by_observation_weight() {
593        let mut evidence = Evidence::from_prior(0.5);
594        assert!(approx_eq(evidence.concentration(), PRIOR_CONCENTRATION));
595
596        evidence.corroborate(0.05);
597        evidence.contradict(0.8);
598
599        assert!(approx_eq(evidence.alpha(), 5.05), "got {evidence:?}");
600        assert!(approx_eq(evidence.beta(), 5.8), "got {evidence:?}");
601        assert!(approx_eq(
602            evidence.concentration(),
603            PRIOR_CONCENTRATION + 0.85
604        ));
605    }
606
607    #[test]
608    fn non_positive_weights_record_nothing() {
609        let mut evidence = Evidence::from_prior(0.6);
610        evidence.corroborate(-1.0);
611        evidence.contradict(f64::NAN);
612
613        assert!(approx_eq(evidence.alpha(), 6.0));
614        assert!(approx_eq(evidence.beta(), 4.0));
615    }
616
617    #[test]
618    fn from_stored_prefers_persisted_counts() {
619        let persisted = Evidence::from_stored(Some(56.0), Some(4.0), 0.6);
620        assert!(approx_eq(persisted.concentration(), 60.0));
621        assert!(approx_eq(persisted.mean(), 56.0 / 60.0));
622    }
623
624    #[test]
625    fn from_stored_falls_back_to_prior_when_unmigrated() {
626        let legacy = Evidence::from_stored(None, None, 0.6);
627        assert!(approx_eq(legacy.alpha(), 6.0));
628        assert!(approx_eq(legacy.beta(), 4.0));
629        assert!(approx_eq(legacy.mean(), 0.6));
630    }
631
632    #[test]
633    fn empty_evidence_is_maximally_uncertain() {
634        let empty = Evidence::from_counts(0.0, 0.0);
635        assert!(approx_eq(empty.mean(), 0.5));
636        assert_eq!(empty.variance(), 0.0);
637    }
638
639    #[test]
640    fn corrupt_counts_are_clamped() {
641        let corrupt = Evidence::from_counts(-3.0, f64::INFINITY);
642        assert_eq!(corrupt.alpha(), 0.0);
643        assert_eq!(corrupt.beta(), 0.0);
644    }
645
646    #[test]
647    fn default_weights_rank_independence_above_repetition() {
648        let weights = ProvenanceWeights::default();
649        assert!(approx_eq(
650            weights.for_provenance(Provenance::External),
651            DEFAULT_WEIGHT_EXTERNAL
652        ));
653        assert!(approx_eq(
654            weights.for_provenance(Provenance::User),
655            DEFAULT_WEIGHT_USER
656        ));
657        assert!(approx_eq(
658            weights.for_provenance(Provenance::SelfGenerated),
659            DEFAULT_WEIGHT_SELF
660        ));
661        assert!(weights.weight_external > weights.weight_user);
662        assert!(weights.weight_user > weights.weight_self);
663    }
664
665    #[test]
666    fn uniform_weights_are_provenance_blind() {
667        let weights = ProvenanceWeights::uniform(DEFAULT_EVIDENCE_WEIGHT);
668        for provenance in [
669            Provenance::External,
670            Provenance::User,
671            Provenance::SelfGenerated,
672        ] {
673            assert_eq!(
674                weights.for_provenance(provenance),
675                DEFAULT_EVIDENCE_WEIGHT,
676                "{provenance} must weigh the same as every other class"
677            );
678        }
679    }
680
681    #[test]
682    fn provenance_parses_and_renders() {
683        assert_eq!("external".parse::<Provenance>(), Ok(Provenance::External));
684        assert_eq!("User".parse::<Provenance>(), Ok(Provenance::User));
685        assert_eq!(
686            " SELF ".parse::<Provenance>(),
687            Ok(Provenance::SelfGenerated)
688        );
689        assert!("mostly-true".parse::<Provenance>().is_err());
690
691        assert_eq!(Provenance::External.to_string(), "external");
692        assert_eq!(Provenance::User.to_string(), "user");
693        assert_eq!(Provenance::SelfGenerated.to_string(), "self");
694    }
695
696    #[test]
697    fn stored_provenance_defaults_to_self() {
698        // AC7: absent and unrecognised both land on the conservative class.
699        assert_eq!(Provenance::from_stored(None), Provenance::SelfGenerated);
700        assert_eq!(
701            Provenance::from_stored(Some("nonsense")),
702            Provenance::SelfGenerated
703        );
704        assert_eq!(
705            Provenance::from_stored(Some("external")),
706            Provenance::External
707        );
708    }
709
710    #[test]
711    fn provenance_serde_uses_wire_names() {
712        for (provenance, wire) in [
713            (Provenance::External, "\"external\""),
714            (Provenance::User, "\"user\""),
715            (Provenance::SelfGenerated, "\"self\""),
716        ] {
717            assert_eq!(serde_json::to_string(&provenance).unwrap(), wire);
718            assert_eq!(
719                serde_json::from_str::<Provenance>(wire).unwrap(),
720                provenance
721            );
722        }
723    }
724
725    #[test]
726    fn self_corroboration_is_counted_separately_from_confidence() {
727        let weights = ProvenanceWeights::default();
728        let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
729
730        for _ in 0..3 {
731            edge.corroborate(Provenance::SelfGenerated, &weights);
732        }
733        edge.corroborate(Provenance::External, &weights);
734        edge.contradict(Provenance::SelfGenerated, &weights);
735
736        assert_eq!(
737            edge.self_reinforcements(),
738            3,
739            "only self-corroboration is coherence"
740        );
741        assert!(approx_eq(edge.evidence().alpha(), 6.0 + 0.15 + 1.0));
742        assert!(approx_eq(edge.evidence().beta(), 4.0 + 0.05));
743    }
744
745    #[test]
746    fn external_contradiction_outweighs_accumulated_self_corroboration() {
747        // AC2: twenty self-corroborations are erased by a single independent
748        // contradiction at the default weights.
749        let weights = ProvenanceWeights::default();
750        let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
751        let before = edge.evidence().mean();
752
753        for _ in 0..20 {
754            edge.corroborate(Provenance::SelfGenerated, &weights);
755        }
756        let after_coherence = edge.evidence().mean();
757        assert!(after_coherence > before);
758        assert_eq!(edge.self_reinforcements(), 20);
759
760        edge.contradict(Provenance::External, &weights);
761        assert!(
762            edge.evidence().mean() < before,
763            "one external contradiction must undo the whole coherence run: {} vs {before}",
764            edge.evidence().mean()
765        );
766    }
767
768    /// Only being seen again stops an edge decaying. Denial is not sighting.
769    #[test]
770    fn only_corroboration_renews_the_decay_anchor() {
771        assert!(Observation::Corroborating.renews_decay_anchor());
772        assert!(!Observation::Contradicting.renews_decay_anchor());
773    }
774
775    #[test]
776    fn recording_an_observation_matches_its_named_direction() {
777        let weights = ProvenanceWeights::default();
778        let apply = |observation| {
779            let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
780            edge.record(observation, Provenance::User, &weights);
781            edge
782        };
783
784        let mut corroborated = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
785        corroborated.corroborate(Provenance::User, &weights);
786        assert_eq!(apply(Observation::Corroborating), corroborated);
787
788        let mut contradicted = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
789        contradicted.contradict(Provenance::User, &weights);
790        assert_eq!(apply(Observation::Contradicting), contradicted);
791    }
792
793    #[test]
794    fn negative_stored_tally_is_clamped() {
795        let edge = EdgeEvidence::new(Evidence::from_prior(0.5), -7);
796        assert_eq!(edge.self_reinforcements(), 0);
797    }
798
799    #[test]
800    fn path_confidence_two_edges() {
801        let result = path_confidence(&[0.8, 0.7]);
802        assert!(approx_eq(result, 0.56), "got {}", result);
803    }
804
805    #[test]
806    fn path_confidence_empty() {
807        assert_eq!(path_confidence(&[]), 1.0);
808    }
809
810    #[test]
811    fn extraction_context_priors() {
812        assert_eq!(ExtractionContext::Authoritative.prior(), 1.0);
813        assert_eq!(ExtractionContext::Explicit.prior(), 0.9);
814        assert_eq!(ExtractionContext::Inferred.prior(), 0.6);
815        assert_eq!(ExtractionContext::Speculative.prior(), 0.3);
816    }
817
818    #[test]
819    fn temporal_decay_zero_days() {
820        let result = temporal_decay(0.9, 0.0, 90.0);
821        assert!(approx_eq(result, 0.9), "got {}", result);
822    }
823
824    #[test]
825    fn temporal_decay_one_half_life() {
826        // After exactly 90 days, confidence should halve
827        let result = temporal_decay(0.6, 90.0, 90.0);
828        assert!(approx_eq(result, 0.3), "got {}", result);
829    }
830
831    #[test]
832    fn temporal_decay_two_half_lives() {
833        // After 180 days, confidence should quarter
834        let result = temporal_decay(0.8, 180.0, 90.0);
835        assert!(approx_eq(result, 0.2), "got {}", result);
836    }
837
838    #[test]
839    fn temporal_decay_floor() {
840        // After many half-lives, should hit the floor
841        let result = temporal_decay(0.3, 900.0, 90.0);
842        assert!(approx_eq(result, DECAY_FLOOR), "got {}", result);
843    }
844
845    #[test]
846    fn temporal_decay_negative_days() {
847        // Negative days (future timestamp) should return stored confidence
848        let result = temporal_decay(0.7, -5.0, 90.0);
849        assert!(approx_eq(result, 0.7), "got {}", result);
850    }
851
852    #[test]
853    fn temporal_decay_high_confidence_still_decays() {
854        // Even 1.0 confidence decays
855        let result = temporal_decay(1.0, 90.0, 90.0);
856        assert!(approx_eq(result, 0.5), "got {}", result);
857    }
858
859    #[test]
860    fn effective_confidence_with_last_reinforced() {
861        let now = chrono::Utc::now();
862        let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
863        let valid_from_long_ago = (now - chrono::Duration::days(365)).to_rfc3339();
864
865        let last_reinforced = serde_json::Value::String(ninety_days_ago);
866        let valid_from = serde_json::Value::String(valid_from_long_ago);
867
868        // Should use last_reinforced (90 days) not valid_from (365 days)
869        let result = effective_confidence(0.6, Some(&last_reinforced), &valid_from, &now);
870        assert!(
871            approx_eq(result, 0.3),
872            "got {} (expected ~0.3, one half-life from last_reinforced)",
873            result
874        );
875    }
876
877    #[test]
878    fn effective_confidence_falls_back_to_valid_from() {
879        let now = chrono::Utc::now();
880        let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
881        let valid_from = serde_json::Value::String(ninety_days_ago);
882
883        // No last_reinforced — should use valid_from
884        let result = effective_confidence(0.6, None, &valid_from, &now);
885        assert!(
886            approx_eq(result, 0.3),
887            "got {} (expected ~0.3, one half-life from valid_from)",
888            result
889        );
890    }
891
892    #[test]
893    fn effective_confidence_no_parseable_date() {
894        let now = chrono::Utc::now();
895        let bad_date = serde_json::Value::String("not-a-date".to_string());
896
897        // Unparseable dates should return stored confidence unchanged
898        let result = effective_confidence(0.8, None, &bad_date, &now);
899        assert!(approx_eq(result, 0.8), "got {}", result);
900    }
901
902    #[test]
903    fn extraction_context_from_str() {
904        assert_eq!(
905            "explicit".parse::<ExtractionContext>().unwrap(),
906            ExtractionContext::Explicit
907        );
908        assert_eq!(
909            "inferred".parse::<ExtractionContext>().unwrap(),
910            ExtractionContext::Inferred
911        );
912        assert_eq!(
913            "speculative".parse::<ExtractionContext>().unwrap(),
914            ExtractionContext::Speculative
915        );
916        assert_eq!(
917            "authoritative".parse::<ExtractionContext>().unwrap(),
918            ExtractionContext::Authoritative
919        );
920        assert!("unknown".parse::<ExtractionContext>().is_err());
921    }
922}