1use serde::{Deserialize, Serialize};
19
20pub const PRIOR_CONCENTRATION: f64 = 10.0;
23
24pub const DEFAULT_EVIDENCE_WEIGHT: f64 = 1.0;
31
32pub const DEFAULT_WEIGHT_EXTERNAL: f64 = 1.0;
34
35pub const DEFAULT_WEIGHT_USER: f64 = 0.8;
37
38pub const DEFAULT_WEIGHT_SELF: f64 = 0.05;
40
41#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ExtractionContext {
45 Explicit, Inferred, Speculative, Authoritative, }
50
51impl ExtractionContext {
52 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum Provenance {
91 External,
94 User,
96 #[default]
100 #[serde(rename = "self")]
101 SelfGenerated,
102}
103
104impl Provenance {
105 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
158#[serde(default)]
159pub struct ProvenanceWeights {
160 pub weight_external: f64,
162 pub weight_user: f64,
164 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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq)]
211pub struct Evidence {
212 alpha: f64,
213 beta: f64,
214}
215
216impl Evidence {
217 #[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 #[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 #[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 pub fn corroborate(&mut self, weight: f64) {
256 self.alpha += sanitize_weight(weight);
257 }
258
259 pub fn contradict(&mut self, weight: f64) {
261 self.beta += sanitize_weight(weight);
262 }
263
264 #[must_use]
266 pub fn alpha(self) -> f64 {
267 self.alpha
268 }
269
270 #[must_use]
272 pub fn beta(self) -> f64 {
273 self.beta
274 }
275
276 #[must_use]
281 pub fn concentration(self) -> f64 {
282 self.alpha + self.beta
283 }
284
285 #[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 #[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
311fn sanitize_count(count: f64) -> f64 {
313 if count.is_finite() && count > 0.0 {
314 count
315 } else {
316 0.0
317 }
318}
319
320fn sanitize_weight(weight: f64) -> f64 {
323 if weight.is_finite() && weight > 0.0 {
324 weight
325 } else {
326 0.0
327 }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq)]
339pub struct EdgeEvidence {
340 evidence: Evidence,
341 self_reinforcements: i64,
342}
343
344impl EdgeEvidence {
345 #[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 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 pub fn contradict(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
368 self.evidence.contradict(weights.for_provenance(provenance));
369 }
370
371 #[must_use]
373 pub fn evidence(self) -> Evidence {
374 self.evidence
375 }
376
377 #[must_use]
379 pub fn self_reinforcements(self) -> i64 {
380 self.self_reinforcements
381 }
382}
383
384pub const DEFAULT_HALF_LIFE_DAYS: f64 = 90.0;
387
388pub const DECAY_FLOOR: f64 = 0.05;
390
391#[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
415pub 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, }
435}
436
437use super::util::parse_datetime as parse_datetime_value;
438
439#[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 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 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 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 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 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 assert!(approx_eq(result, 0.364), "got {}", result);
499 }
500
501 #[test]
502 fn evidence_accumulates_across_observations() {
503 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 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 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 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 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 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 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 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 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 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 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 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}