1use serde::{Deserialize, Serialize};
15
16pub const PRIOR_CONCENTRATION: f64 = 10.0;
19
20pub const DEFAULT_EVIDENCE_WEIGHT: f64 = 1.0;
27
28pub const DEFAULT_WEIGHT_EXTERNAL: f64 = 1.0;
30
31pub const DEFAULT_WEIGHT_USER: f64 = 0.8;
33
34pub const DEFAULT_WEIGHT_SELF: f64 = 0.05;
36
37#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ExtractionContext {
41 Explicit, Inferred, Speculative, Authoritative, }
46
47impl ExtractionContext {
48 #[must_use]
50 pub fn prior(self) -> f64 {
51 match self {
52 Self::Authoritative => 1.0,
53 Self::Explicit => 0.9,
54 Self::Inferred => 0.6,
55 Self::Speculative => 0.3,
56 }
57 }
58}
59
60impl std::str::FromStr for ExtractionContext {
61 type Err = String;
62
63 fn from_str(s: &str) -> Result<Self, Self::Err> {
64 match s.to_lowercase().as_str() {
65 "explicit" => Ok(Self::Explicit),
66 "inferred" => Ok(Self::Inferred),
67 "speculative" => Ok(Self::Speculative),
68 "authoritative" => Ok(Self::Authoritative),
69 other => Err(format!("unknown extraction context: {other}")),
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum Provenance {
87 External,
90 User,
92 #[default]
96 #[serde(rename = "self")]
97 SelfGenerated,
98}
99
100impl Provenance {
101 #[must_use]
108 pub fn from_stored(stored: Option<&str>) -> Self {
109 stored
110 .and_then(|s| s.parse().ok())
111 .unwrap_or(Self::SelfGenerated)
112 }
113
114 #[must_use]
116 pub fn as_str(self) -> &'static str {
117 match self {
118 Self::External => "external",
119 Self::User => "user",
120 Self::SelfGenerated => "self",
121 }
122 }
123}
124
125impl std::str::FromStr for Provenance {
126 type Err = String;
127
128 fn from_str(s: &str) -> Result<Self, Self::Err> {
129 match s.trim().to_lowercase().as_str() {
130 "external" | "document" => Ok(Self::External),
131 "user" | "human" => Ok(Self::User),
132 "self" | "agent" | "self-generated" => Ok(Self::SelfGenerated),
133 other => Err(format!("unknown provenance: {other}")),
134 }
135 }
136}
137
138impl std::fmt::Display for Provenance {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.write_str(self.as_str())
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
154#[serde(default)]
155pub struct ProvenanceWeights {
156 pub weight_external: f64,
158 pub weight_user: f64,
160 pub weight_self: f64,
162}
163
164impl Default for ProvenanceWeights {
165 fn default() -> Self {
166 Self {
167 weight_external: DEFAULT_WEIGHT_EXTERNAL,
168 weight_user: DEFAULT_WEIGHT_USER,
169 weight_self: DEFAULT_WEIGHT_SELF,
170 }
171 }
172}
173
174impl ProvenanceWeights {
175 #[must_use]
181 pub fn uniform(weight: f64) -> Self {
182 Self {
183 weight_external: weight,
184 weight_user: weight,
185 weight_self: weight,
186 }
187 }
188
189 #[must_use]
191 pub fn for_provenance(&self, provenance: Provenance) -> f64 {
192 match provenance {
193 Provenance::External => self.weight_external,
194 Provenance::User => self.weight_user,
195 Provenance::SelfGenerated => self.weight_self,
196 }
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq)]
207pub struct Evidence {
208 alpha: f64,
209 beta: f64,
210}
211
212impl Evidence {
213 #[must_use]
219 pub fn from_prior(mean: f64) -> Self {
220 let mean = mean.clamp(0.0, 1.0);
221 Self {
222 alpha: mean * PRIOR_CONCENTRATION,
223 beta: (1.0 - mean) * PRIOR_CONCENTRATION,
224 }
225 }
226
227 #[must_use]
230 pub fn from_counts(alpha: f64, beta: f64) -> Self {
231 Self {
232 alpha: sanitize_count(alpha),
233 beta: sanitize_count(beta),
234 }
235 }
236
237 #[must_use]
243 pub fn from_stored(alpha: Option<f64>, beta: Option<f64>, confidence: f64) -> Self {
244 match (alpha, beta) {
245 (Some(a), Some(b)) => Self::from_counts(a, b),
246 _ => Self::from_prior(confidence),
247 }
248 }
249
250 pub fn corroborate(&mut self, weight: f64) {
252 self.alpha += sanitize_weight(weight);
253 }
254
255 pub fn contradict(&mut self, weight: f64) {
257 self.beta += sanitize_weight(weight);
258 }
259
260 #[must_use]
262 pub fn alpha(self) -> f64 {
263 self.alpha
264 }
265
266 #[must_use]
268 pub fn beta(self) -> f64 {
269 self.beta
270 }
271
272 #[must_use]
277 pub fn concentration(self) -> f64 {
278 self.alpha + self.beta
279 }
280
281 #[must_use]
285 pub fn mean(self) -> f64 {
286 let total = self.concentration();
287 if total <= 0.0 {
288 return 0.5;
289 }
290 self.alpha / total
291 }
292
293 #[must_use]
298 pub fn variance(self) -> f64 {
299 let total = self.concentration();
300 if total <= 0.0 {
301 return 0.0;
302 }
303 (self.alpha * self.beta) / (total * total * (total + 1.0))
304 }
305}
306
307fn sanitize_count(count: f64) -> f64 {
309 if count.is_finite() && count > 0.0 {
310 count
311 } else {
312 0.0
313 }
314}
315
316fn sanitize_weight(weight: f64) -> f64 {
319 if weight.is_finite() && weight > 0.0 {
320 weight
321 } else {
322 0.0
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq)]
335pub struct EdgeEvidence {
336 evidence: Evidence,
337 self_reinforcements: i64,
338}
339
340impl EdgeEvidence {
341 #[must_use]
344 pub fn new(evidence: Evidence, self_reinforcements: i64) -> Self {
345 Self {
346 evidence,
347 self_reinforcements: self_reinforcements.max(0),
348 }
349 }
350
351 pub fn corroborate(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
353 self.evidence
354 .corroborate(weights.for_provenance(provenance));
355 if provenance == Provenance::SelfGenerated {
356 self.self_reinforcements += 1;
357 }
358 }
359
360 pub fn contradict(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
364 self.evidence.contradict(weights.for_provenance(provenance));
365 }
366
367 #[must_use]
369 pub fn evidence(self) -> Evidence {
370 self.evidence
371 }
372
373 #[must_use]
375 pub fn self_reinforcements(self) -> i64 {
376 self.self_reinforcements
377 }
378}
379
380pub const DEFAULT_HALF_LIFE_DAYS: f64 = 90.0;
383
384pub const DECAY_FLOOR: f64 = 0.05;
386
387#[must_use]
397pub fn temporal_decay(
398 stored_confidence: f64,
399 days_since_reinforced: f64,
400 half_life_days: f64,
401) -> f64 {
402 if days_since_reinforced <= 0.0 {
403 return stored_confidence;
404 }
405
406 let decay_factor = 0.5_f64.powf(days_since_reinforced / half_life_days);
407 let effective = stored_confidence * decay_factor;
408 effective.max(DECAY_FLOOR)
409}
410
411pub fn effective_confidence(
415 stored_confidence: f64,
416 last_reinforced: Option<&serde_json::Value>,
417 valid_from: &serde_json::Value,
418 now: &chrono::DateTime<chrono::Utc>,
419) -> f64 {
420 let anchor = last_reinforced
421 .and_then(parse_datetime_value)
422 .or_else(|| parse_datetime_value(valid_from));
423
424 match anchor {
425 Some(dt) => {
426 let days = (*now - dt).num_hours() as f64 / 24.0;
427 temporal_decay(stored_confidence, days, DEFAULT_HALF_LIFE_DAYS)
428 }
429 None => stored_confidence, }
431}
432
433use super::util::parse_datetime as parse_datetime_value;
434
435#[must_use]
439pub fn path_confidence(edge_confidences: &[f64]) -> f64 {
440 edge_confidences.iter().product()
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 fn approx_eq(a: f64, b: f64) -> bool {
448 (a - b).abs() < 0.001
449 }
450
451 fn one_observation(mean: f64, corroborate: bool) -> Evidence {
453 let mut evidence = Evidence::from_prior(mean);
454 if corroborate {
455 evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
456 } else {
457 evidence.contradict(DEFAULT_EVIDENCE_WEIGHT);
458 }
459 evidence
460 }
461
462 #[test]
463 fn corroborate_from_prior_0_6() {
464 let result = one_observation(0.6, true).mean();
465 assert!(approx_eq(result, 0.636), "got {}", result);
467 }
468
469 #[test]
470 fn contradict_from_prior_0_6() {
471 let result = one_observation(0.6, false).mean();
472 assert!(approx_eq(result, 0.545), "got {}", result);
474 }
475
476 #[test]
477 fn corroborate_from_prior_0_9() {
478 let result = one_observation(0.9, true).mean();
479 assert!(approx_eq(result, 0.909), "got {}", result);
481 }
482
483 #[test]
484 fn contradict_from_prior_0_9() {
485 let result = one_observation(0.9, false).mean();
486 assert!(approx_eq(result, 0.818), "got {}", result);
488 }
489
490 #[test]
491 fn corroborate_from_prior_0_3() {
492 let result = one_observation(0.3, true).mean();
493 assert!(approx_eq(result, 0.364), "got {}", result);
495 }
496
497 #[test]
498 fn evidence_accumulates_across_observations() {
499 let mut evidence = Evidence::from_prior(0.6);
502 assert!(approx_eq(evidence.mean(), 0.600));
503
504 evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
505 assert!(approx_eq(evidence.mean(), 0.636), "step 1: {evidence:?}");
506 evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
507 assert!(approx_eq(evidence.mean(), 0.667), "step 2: {evidence:?}");
508 evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
509 assert!(approx_eq(evidence.mean(), 0.692), "step 3: {evidence:?}");
510 evidence.contradict(DEFAULT_EVIDENCE_WEIGHT);
511 assert!(approx_eq(evidence.mean(), 0.643), "step 4: {evidence:?}");
512
513 assert!(approx_eq(evidence.alpha(), 9.0));
514 assert!(approx_eq(evidence.beta(), 5.0));
515 assert!(approx_eq(evidence.concentration(), 14.0));
516 }
517
518 #[test]
519 fn variance_narrows_with_corroboration() {
520 let after = |n: usize| {
522 let mut evidence = Evidence::from_prior(0.6);
523 for _ in 0..n {
524 evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
525 }
526 evidence.variance()
527 };
528
529 assert!(
530 after(5) < after(1),
531 "5 obs: {} vs 1: {}",
532 after(5),
533 after(1)
534 );
535 assert!(
536 after(50) < after(5),
537 "50 obs: {} vs 5: {}",
538 after(50),
539 after(5)
540 );
541 }
542
543 #[test]
544 fn concentration_grows_by_observation_weight() {
545 let mut evidence = Evidence::from_prior(0.5);
546 assert!(approx_eq(evidence.concentration(), PRIOR_CONCENTRATION));
547
548 evidence.corroborate(0.05);
549 evidence.contradict(0.8);
550
551 assert!(approx_eq(evidence.alpha(), 5.05), "got {evidence:?}");
552 assert!(approx_eq(evidence.beta(), 5.8), "got {evidence:?}");
553 assert!(approx_eq(
554 evidence.concentration(),
555 PRIOR_CONCENTRATION + 0.85
556 ));
557 }
558
559 #[test]
560 fn non_positive_weights_record_nothing() {
561 let mut evidence = Evidence::from_prior(0.6);
562 evidence.corroborate(-1.0);
563 evidence.contradict(f64::NAN);
564
565 assert!(approx_eq(evidence.alpha(), 6.0));
566 assert!(approx_eq(evidence.beta(), 4.0));
567 }
568
569 #[test]
570 fn from_stored_prefers_persisted_counts() {
571 let persisted = Evidence::from_stored(Some(56.0), Some(4.0), 0.6);
572 assert!(approx_eq(persisted.concentration(), 60.0));
573 assert!(approx_eq(persisted.mean(), 56.0 / 60.0));
574 }
575
576 #[test]
577 fn from_stored_falls_back_to_prior_when_unmigrated() {
578 let legacy = Evidence::from_stored(None, None, 0.6);
579 assert!(approx_eq(legacy.alpha(), 6.0));
580 assert!(approx_eq(legacy.beta(), 4.0));
581 assert!(approx_eq(legacy.mean(), 0.6));
582 }
583
584 #[test]
585 fn empty_evidence_is_maximally_uncertain() {
586 let empty = Evidence::from_counts(0.0, 0.0);
587 assert!(approx_eq(empty.mean(), 0.5));
588 assert_eq!(empty.variance(), 0.0);
589 }
590
591 #[test]
592 fn corrupt_counts_are_clamped() {
593 let corrupt = Evidence::from_counts(-3.0, f64::INFINITY);
594 assert_eq!(corrupt.alpha(), 0.0);
595 assert_eq!(corrupt.beta(), 0.0);
596 }
597
598 #[test]
599 fn default_weights_rank_independence_above_repetition() {
600 let weights = ProvenanceWeights::default();
601 assert!(approx_eq(
602 weights.for_provenance(Provenance::External),
603 DEFAULT_WEIGHT_EXTERNAL
604 ));
605 assert!(approx_eq(
606 weights.for_provenance(Provenance::User),
607 DEFAULT_WEIGHT_USER
608 ));
609 assert!(approx_eq(
610 weights.for_provenance(Provenance::SelfGenerated),
611 DEFAULT_WEIGHT_SELF
612 ));
613 assert!(weights.weight_external > weights.weight_user);
614 assert!(weights.weight_user > weights.weight_self);
615 }
616
617 #[test]
618 fn uniform_weights_are_provenance_blind() {
619 let weights = ProvenanceWeights::uniform(DEFAULT_EVIDENCE_WEIGHT);
620 for provenance in [
621 Provenance::External,
622 Provenance::User,
623 Provenance::SelfGenerated,
624 ] {
625 assert_eq!(
626 weights.for_provenance(provenance),
627 DEFAULT_EVIDENCE_WEIGHT,
628 "{provenance} must weigh the same as every other class"
629 );
630 }
631 }
632
633 #[test]
634 fn provenance_parses_and_renders() {
635 assert_eq!("external".parse::<Provenance>(), Ok(Provenance::External));
636 assert_eq!("User".parse::<Provenance>(), Ok(Provenance::User));
637 assert_eq!(
638 " SELF ".parse::<Provenance>(),
639 Ok(Provenance::SelfGenerated)
640 );
641 assert!("mostly-true".parse::<Provenance>().is_err());
642
643 assert_eq!(Provenance::External.to_string(), "external");
644 assert_eq!(Provenance::User.to_string(), "user");
645 assert_eq!(Provenance::SelfGenerated.to_string(), "self");
646 }
647
648 #[test]
649 fn stored_provenance_defaults_to_self() {
650 assert_eq!(Provenance::from_stored(None), Provenance::SelfGenerated);
652 assert_eq!(
653 Provenance::from_stored(Some("nonsense")),
654 Provenance::SelfGenerated
655 );
656 assert_eq!(
657 Provenance::from_stored(Some("external")),
658 Provenance::External
659 );
660 }
661
662 #[test]
663 fn provenance_serde_uses_wire_names() {
664 for (provenance, wire) in [
665 (Provenance::External, "\"external\""),
666 (Provenance::User, "\"user\""),
667 (Provenance::SelfGenerated, "\"self\""),
668 ] {
669 assert_eq!(serde_json::to_string(&provenance).unwrap(), wire);
670 assert_eq!(
671 serde_json::from_str::<Provenance>(wire).unwrap(),
672 provenance
673 );
674 }
675 }
676
677 #[test]
678 fn self_corroboration_is_counted_separately_from_confidence() {
679 let weights = ProvenanceWeights::default();
680 let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
681
682 for _ in 0..3 {
683 edge.corroborate(Provenance::SelfGenerated, &weights);
684 }
685 edge.corroborate(Provenance::External, &weights);
686 edge.contradict(Provenance::SelfGenerated, &weights);
687
688 assert_eq!(
689 edge.self_reinforcements(),
690 3,
691 "only self-corroboration is coherence"
692 );
693 assert!(approx_eq(edge.evidence().alpha(), 6.0 + 0.15 + 1.0));
694 assert!(approx_eq(edge.evidence().beta(), 4.0 + 0.05));
695 }
696
697 #[test]
698 fn external_contradiction_outweighs_accumulated_self_corroboration() {
699 let weights = ProvenanceWeights::default();
702 let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
703 let before = edge.evidence().mean();
704
705 for _ in 0..20 {
706 edge.corroborate(Provenance::SelfGenerated, &weights);
707 }
708 let after_coherence = edge.evidence().mean();
709 assert!(after_coherence > before);
710 assert_eq!(edge.self_reinforcements(), 20);
711
712 edge.contradict(Provenance::External, &weights);
713 assert!(
714 edge.evidence().mean() < before,
715 "one external contradiction must undo the whole coherence run: {} vs {before}",
716 edge.evidence().mean()
717 );
718 }
719
720 #[test]
721 fn negative_stored_tally_is_clamped() {
722 let edge = EdgeEvidence::new(Evidence::from_prior(0.5), -7);
723 assert_eq!(edge.self_reinforcements(), 0);
724 }
725
726 #[test]
727 fn path_confidence_two_edges() {
728 let result = path_confidence(&[0.8, 0.7]);
729 assert!(approx_eq(result, 0.56), "got {}", result);
730 }
731
732 #[test]
733 fn path_confidence_empty() {
734 assert_eq!(path_confidence(&[]), 1.0);
735 }
736
737 #[test]
738 fn extraction_context_priors() {
739 assert_eq!(ExtractionContext::Authoritative.prior(), 1.0);
740 assert_eq!(ExtractionContext::Explicit.prior(), 0.9);
741 assert_eq!(ExtractionContext::Inferred.prior(), 0.6);
742 assert_eq!(ExtractionContext::Speculative.prior(), 0.3);
743 }
744
745 #[test]
746 fn temporal_decay_zero_days() {
747 let result = temporal_decay(0.9, 0.0, 90.0);
748 assert!(approx_eq(result, 0.9), "got {}", result);
749 }
750
751 #[test]
752 fn temporal_decay_one_half_life() {
753 let result = temporal_decay(0.6, 90.0, 90.0);
755 assert!(approx_eq(result, 0.3), "got {}", result);
756 }
757
758 #[test]
759 fn temporal_decay_two_half_lives() {
760 let result = temporal_decay(0.8, 180.0, 90.0);
762 assert!(approx_eq(result, 0.2), "got {}", result);
763 }
764
765 #[test]
766 fn temporal_decay_floor() {
767 let result = temporal_decay(0.3, 900.0, 90.0);
769 assert!(approx_eq(result, DECAY_FLOOR), "got {}", result);
770 }
771
772 #[test]
773 fn temporal_decay_negative_days() {
774 let result = temporal_decay(0.7, -5.0, 90.0);
776 assert!(approx_eq(result, 0.7), "got {}", result);
777 }
778
779 #[test]
780 fn temporal_decay_high_confidence_still_decays() {
781 let result = temporal_decay(1.0, 90.0, 90.0);
783 assert!(approx_eq(result, 0.5), "got {}", result);
784 }
785
786 #[test]
787 fn effective_confidence_with_last_reinforced() {
788 let now = chrono::Utc::now();
789 let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
790 let valid_from_long_ago = (now - chrono::Duration::days(365)).to_rfc3339();
791
792 let last_reinforced = serde_json::Value::String(ninety_days_ago);
793 let valid_from = serde_json::Value::String(valid_from_long_ago);
794
795 let result = effective_confidence(0.6, Some(&last_reinforced), &valid_from, &now);
797 assert!(
798 approx_eq(result, 0.3),
799 "got {} (expected ~0.3, one half-life from last_reinforced)",
800 result
801 );
802 }
803
804 #[test]
805 fn effective_confidence_falls_back_to_valid_from() {
806 let now = chrono::Utc::now();
807 let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
808 let valid_from = serde_json::Value::String(ninety_days_ago);
809
810 let result = effective_confidence(0.6, None, &valid_from, &now);
812 assert!(
813 approx_eq(result, 0.3),
814 "got {} (expected ~0.3, one half-life from valid_from)",
815 result
816 );
817 }
818
819 #[test]
820 fn effective_confidence_no_parseable_date() {
821 let now = chrono::Utc::now();
822 let bad_date = serde_json::Value::String("not-a-date".to_string());
823
824 let result = effective_confidence(0.8, None, &bad_date, &now);
826 assert!(approx_eq(result, 0.8), "got {}", result);
827 }
828
829 #[test]
830 fn extraction_context_from_str() {
831 assert_eq!(
832 "explicit".parse::<ExtractionContext>().unwrap(),
833 ExtractionContext::Explicit
834 );
835 assert_eq!(
836 "inferred".parse::<ExtractionContext>().unwrap(),
837 ExtractionContext::Inferred
838 );
839 assert_eq!(
840 "speculative".parse::<ExtractionContext>().unwrap(),
841 ExtractionContext::Speculative
842 );
843 assert_eq!(
844 "authoritative".parse::<ExtractionContext>().unwrap(),
845 ExtractionContext::Authoritative
846 );
847 assert!("unknown".parse::<ExtractionContext>().is_err());
848 }
849}