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, Eq)]
340pub enum Observation {
341 Corroborating,
343 Contradicting,
345}
346
347impl Observation {
348 #[must_use]
352 pub fn renews_decay_anchor(self) -> bool {
353 matches!(self, Self::Corroborating)
354 }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq)]
366pub struct EdgeEvidence {
367 evidence: Evidence,
368 self_reinforcements: i64,
369}
370
371impl EdgeEvidence {
372 #[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 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 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 pub fn contradict(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
412 self.evidence.contradict(weights.for_provenance(provenance));
413 }
414
415 #[must_use]
417 pub fn evidence(self) -> Evidence {
418 self.evidence
419 }
420
421 #[must_use]
423 pub fn self_reinforcements(self) -> i64 {
424 self.self_reinforcements
425 }
426}
427
428pub const DEFAULT_HALF_LIFE_DAYS: f64 = 90.0;
431
432pub const DECAY_FLOOR: f64 = 0.05;
434
435#[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
459pub 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, }
479}
480
481use super::util::parse_datetime as parse_datetime_value;
482
483#[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 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 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 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 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 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 assert!(approx_eq(result, 0.364), "got {}", result);
543 }
544
545 #[test]
546 fn evidence_accumulates_across_observations() {
547 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 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 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 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 #[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 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 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 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 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 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 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 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 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}