use serde::{Deserialize, Serialize};
pub const PRIOR_CONCENTRATION: f64 = 10.0;
pub const DEFAULT_EVIDENCE_WEIGHT: f64 = 1.0;
pub const DEFAULT_WEIGHT_EXTERNAL: f64 = 1.0;
pub const DEFAULT_WEIGHT_USER: f64 = 0.8;
pub const DEFAULT_WEIGHT_SELF: f64 = 0.05;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtractionContext {
Explicit, Inferred, Speculative, Authoritative, }
impl ExtractionContext {
#[must_use]
pub fn prior(self) -> f64 {
match self {
Self::Authoritative => 1.0,
Self::Explicit => 0.9,
Self::Inferred => 0.6,
Self::Speculative => 0.3,
}
}
}
impl std::str::FromStr for ExtractionContext {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"explicit" => Ok(Self::Explicit),
"inferred" => Ok(Self::Inferred),
"speculative" => Ok(Self::Speculative),
"authoritative" => Ok(Self::Authoritative),
other => Err(format!("unknown extraction context: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Provenance {
External,
User,
#[default]
#[serde(rename = "self")]
SelfGenerated,
}
impl Provenance {
#[must_use]
pub fn from_stored(stored: Option<&str>) -> Self {
stored
.and_then(|s| s.parse().ok())
.unwrap_or(Self::SelfGenerated)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::External => "external",
Self::User => "user",
Self::SelfGenerated => "self",
}
}
}
impl std::str::FromStr for Provenance {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"external" | "document" => Ok(Self::External),
"user" | "human" => Ok(Self::User),
"self" | "agent" | "self-generated" => Ok(Self::SelfGenerated),
other => Err(format!("unknown provenance: {other}")),
}
}
}
impl std::fmt::Display for Provenance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProvenanceWeights {
pub weight_external: f64,
pub weight_user: f64,
pub weight_self: f64,
}
impl Default for ProvenanceWeights {
fn default() -> Self {
Self {
weight_external: DEFAULT_WEIGHT_EXTERNAL,
weight_user: DEFAULT_WEIGHT_USER,
weight_self: DEFAULT_WEIGHT_SELF,
}
}
}
impl ProvenanceWeights {
#[must_use]
pub fn uniform(weight: f64) -> Self {
Self {
weight_external: weight,
weight_user: weight,
weight_self: weight,
}
}
#[must_use]
pub fn for_provenance(&self, provenance: Provenance) -> f64 {
match provenance {
Provenance::External => self.weight_external,
Provenance::User => self.weight_user,
Provenance::SelfGenerated => self.weight_self,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Evidence {
alpha: f64,
beta: f64,
}
impl Evidence {
#[must_use]
pub fn from_prior(mean: f64) -> Self {
let mean = mean.clamp(0.0, 1.0);
Self {
alpha: mean * PRIOR_CONCENTRATION,
beta: (1.0 - mean) * PRIOR_CONCENTRATION,
}
}
#[must_use]
pub fn from_counts(alpha: f64, beta: f64) -> Self {
Self {
alpha: sanitize_count(alpha),
beta: sanitize_count(beta),
}
}
#[must_use]
pub fn from_stored(alpha: Option<f64>, beta: Option<f64>, confidence: f64) -> Self {
match (alpha, beta) {
(Some(a), Some(b)) => Self::from_counts(a, b),
_ => Self::from_prior(confidence),
}
}
pub fn corroborate(&mut self, weight: f64) {
self.alpha += sanitize_weight(weight);
}
pub fn contradict(&mut self, weight: f64) {
self.beta += sanitize_weight(weight);
}
#[must_use]
pub fn alpha(self) -> f64 {
self.alpha
}
#[must_use]
pub fn beta(self) -> f64 {
self.beta
}
#[must_use]
pub fn concentration(self) -> f64 {
self.alpha + self.beta
}
#[must_use]
pub fn mean(self) -> f64 {
let total = self.concentration();
if total <= 0.0 {
return 0.5;
}
self.alpha / total
}
#[must_use]
pub fn variance(self) -> f64 {
let total = self.concentration();
if total <= 0.0 {
return 0.0;
}
(self.alpha * self.beta) / (total * total * (total + 1.0))
}
}
fn sanitize_count(count: f64) -> f64 {
if count.is_finite() && count > 0.0 {
count
} else {
0.0
}
}
fn sanitize_weight(weight: f64) -> f64 {
if weight.is_finite() && weight > 0.0 {
weight
} else {
0.0
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EdgeEvidence {
evidence: Evidence,
self_reinforcements: i64,
}
impl EdgeEvidence {
#[must_use]
pub fn new(evidence: Evidence, self_reinforcements: i64) -> Self {
Self {
evidence,
self_reinforcements: self_reinforcements.max(0),
}
}
pub fn corroborate(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
self.evidence
.corroborate(weights.for_provenance(provenance));
if provenance == Provenance::SelfGenerated {
self.self_reinforcements += 1;
}
}
pub fn contradict(&mut self, provenance: Provenance, weights: &ProvenanceWeights) {
self.evidence.contradict(weights.for_provenance(provenance));
}
#[must_use]
pub fn evidence(self) -> Evidence {
self.evidence
}
#[must_use]
pub fn self_reinforcements(self) -> i64 {
self.self_reinforcements
}
}
pub const DEFAULT_HALF_LIFE_DAYS: f64 = 90.0;
pub const DECAY_FLOOR: f64 = 0.05;
#[must_use]
pub fn temporal_decay(
stored_confidence: f64,
days_since_reinforced: f64,
half_life_days: f64,
) -> f64 {
if days_since_reinforced <= 0.0 {
return stored_confidence;
}
let decay_factor = 0.5_f64.powf(days_since_reinforced / half_life_days);
let effective = stored_confidence * decay_factor;
effective.max(DECAY_FLOOR)
}
pub fn effective_confidence(
stored_confidence: f64,
last_reinforced: Option<&serde_json::Value>,
valid_from: &serde_json::Value,
now: &chrono::DateTime<chrono::Utc>,
) -> f64 {
let anchor = last_reinforced
.and_then(parse_datetime_value)
.or_else(|| parse_datetime_value(valid_from));
match anchor {
Some(dt) => {
let days = (*now - dt).num_hours() as f64 / 24.0;
temporal_decay(stored_confidence, days, DEFAULT_HALF_LIFE_DAYS)
}
None => stored_confidence, }
}
use super::util::parse_datetime as parse_datetime_value;
#[must_use]
pub fn path_confidence(edge_confidences: &[f64]) -> f64 {
edge_confidences.iter().product()
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f64, b: f64) -> bool {
(a - b).abs() < 0.001
}
fn one_observation(mean: f64, corroborate: bool) -> Evidence {
let mut evidence = Evidence::from_prior(mean);
if corroborate {
evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
} else {
evidence.contradict(DEFAULT_EVIDENCE_WEIGHT);
}
evidence
}
#[test]
fn corroborate_from_prior_0_6() {
let result = one_observation(0.6, true).mean();
assert!(approx_eq(result, 0.636), "got {}", result);
}
#[test]
fn contradict_from_prior_0_6() {
let result = one_observation(0.6, false).mean();
assert!(approx_eq(result, 0.545), "got {}", result);
}
#[test]
fn corroborate_from_prior_0_9() {
let result = one_observation(0.9, true).mean();
assert!(approx_eq(result, 0.909), "got {}", result);
}
#[test]
fn contradict_from_prior_0_9() {
let result = one_observation(0.9, false).mean();
assert!(approx_eq(result, 0.818), "got {}", result);
}
#[test]
fn corroborate_from_prior_0_3() {
let result = one_observation(0.3, true).mean();
assert!(approx_eq(result, 0.364), "got {}", result);
}
#[test]
fn evidence_accumulates_across_observations() {
let mut evidence = Evidence::from_prior(0.6);
assert!(approx_eq(evidence.mean(), 0.600));
evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
assert!(approx_eq(evidence.mean(), 0.636), "step 1: {evidence:?}");
evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
assert!(approx_eq(evidence.mean(), 0.667), "step 2: {evidence:?}");
evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
assert!(approx_eq(evidence.mean(), 0.692), "step 3: {evidence:?}");
evidence.contradict(DEFAULT_EVIDENCE_WEIGHT);
assert!(approx_eq(evidence.mean(), 0.643), "step 4: {evidence:?}");
assert!(approx_eq(evidence.alpha(), 9.0));
assert!(approx_eq(evidence.beta(), 5.0));
assert!(approx_eq(evidence.concentration(), 14.0));
}
#[test]
fn variance_narrows_with_corroboration() {
let after = |n: usize| {
let mut evidence = Evidence::from_prior(0.6);
for _ in 0..n {
evidence.corroborate(DEFAULT_EVIDENCE_WEIGHT);
}
evidence.variance()
};
assert!(
after(5) < after(1),
"5 obs: {} vs 1: {}",
after(5),
after(1)
);
assert!(
after(50) < after(5),
"50 obs: {} vs 5: {}",
after(50),
after(5)
);
}
#[test]
fn concentration_grows_by_observation_weight() {
let mut evidence = Evidence::from_prior(0.5);
assert!(approx_eq(evidence.concentration(), PRIOR_CONCENTRATION));
evidence.corroborate(0.05);
evidence.contradict(0.8);
assert!(approx_eq(evidence.alpha(), 5.05), "got {evidence:?}");
assert!(approx_eq(evidence.beta(), 5.8), "got {evidence:?}");
assert!(approx_eq(
evidence.concentration(),
PRIOR_CONCENTRATION + 0.85
));
}
#[test]
fn non_positive_weights_record_nothing() {
let mut evidence = Evidence::from_prior(0.6);
evidence.corroborate(-1.0);
evidence.contradict(f64::NAN);
assert!(approx_eq(evidence.alpha(), 6.0));
assert!(approx_eq(evidence.beta(), 4.0));
}
#[test]
fn from_stored_prefers_persisted_counts() {
let persisted = Evidence::from_stored(Some(56.0), Some(4.0), 0.6);
assert!(approx_eq(persisted.concentration(), 60.0));
assert!(approx_eq(persisted.mean(), 56.0 / 60.0));
}
#[test]
fn from_stored_falls_back_to_prior_when_unmigrated() {
let legacy = Evidence::from_stored(None, None, 0.6);
assert!(approx_eq(legacy.alpha(), 6.0));
assert!(approx_eq(legacy.beta(), 4.0));
assert!(approx_eq(legacy.mean(), 0.6));
}
#[test]
fn empty_evidence_is_maximally_uncertain() {
let empty = Evidence::from_counts(0.0, 0.0);
assert!(approx_eq(empty.mean(), 0.5));
assert_eq!(empty.variance(), 0.0);
}
#[test]
fn corrupt_counts_are_clamped() {
let corrupt = Evidence::from_counts(-3.0, f64::INFINITY);
assert_eq!(corrupt.alpha(), 0.0);
assert_eq!(corrupt.beta(), 0.0);
}
#[test]
fn default_weights_rank_independence_above_repetition() {
let weights = ProvenanceWeights::default();
assert!(approx_eq(
weights.for_provenance(Provenance::External),
DEFAULT_WEIGHT_EXTERNAL
));
assert!(approx_eq(
weights.for_provenance(Provenance::User),
DEFAULT_WEIGHT_USER
));
assert!(approx_eq(
weights.for_provenance(Provenance::SelfGenerated),
DEFAULT_WEIGHT_SELF
));
assert!(weights.weight_external > weights.weight_user);
assert!(weights.weight_user > weights.weight_self);
}
#[test]
fn uniform_weights_are_provenance_blind() {
let weights = ProvenanceWeights::uniform(DEFAULT_EVIDENCE_WEIGHT);
for provenance in [
Provenance::External,
Provenance::User,
Provenance::SelfGenerated,
] {
assert_eq!(
weights.for_provenance(provenance),
DEFAULT_EVIDENCE_WEIGHT,
"{provenance} must weigh the same as every other class"
);
}
}
#[test]
fn provenance_parses_and_renders() {
assert_eq!("external".parse::<Provenance>(), Ok(Provenance::External));
assert_eq!("User".parse::<Provenance>(), Ok(Provenance::User));
assert_eq!(
" SELF ".parse::<Provenance>(),
Ok(Provenance::SelfGenerated)
);
assert!("mostly-true".parse::<Provenance>().is_err());
assert_eq!(Provenance::External.to_string(), "external");
assert_eq!(Provenance::User.to_string(), "user");
assert_eq!(Provenance::SelfGenerated.to_string(), "self");
}
#[test]
fn stored_provenance_defaults_to_self() {
assert_eq!(Provenance::from_stored(None), Provenance::SelfGenerated);
assert_eq!(
Provenance::from_stored(Some("nonsense")),
Provenance::SelfGenerated
);
assert_eq!(
Provenance::from_stored(Some("external")),
Provenance::External
);
}
#[test]
fn provenance_serde_uses_wire_names() {
for (provenance, wire) in [
(Provenance::External, "\"external\""),
(Provenance::User, "\"user\""),
(Provenance::SelfGenerated, "\"self\""),
] {
assert_eq!(serde_json::to_string(&provenance).unwrap(), wire);
assert_eq!(
serde_json::from_str::<Provenance>(wire).unwrap(),
provenance
);
}
}
#[test]
fn self_corroboration_is_counted_separately_from_confidence() {
let weights = ProvenanceWeights::default();
let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
for _ in 0..3 {
edge.corroborate(Provenance::SelfGenerated, &weights);
}
edge.corroborate(Provenance::External, &weights);
edge.contradict(Provenance::SelfGenerated, &weights);
assert_eq!(
edge.self_reinforcements(),
3,
"only self-corroboration is coherence"
);
assert!(approx_eq(edge.evidence().alpha(), 6.0 + 0.15 + 1.0));
assert!(approx_eq(edge.evidence().beta(), 4.0 + 0.05));
}
#[test]
fn external_contradiction_outweighs_accumulated_self_corroboration() {
let weights = ProvenanceWeights::default();
let mut edge = EdgeEvidence::new(Evidence::from_prior(0.6), 0);
let before = edge.evidence().mean();
for _ in 0..20 {
edge.corroborate(Provenance::SelfGenerated, &weights);
}
let after_coherence = edge.evidence().mean();
assert!(after_coherence > before);
assert_eq!(edge.self_reinforcements(), 20);
edge.contradict(Provenance::External, &weights);
assert!(
edge.evidence().mean() < before,
"one external contradiction must undo the whole coherence run: {} vs {before}",
edge.evidence().mean()
);
}
#[test]
fn negative_stored_tally_is_clamped() {
let edge = EdgeEvidence::new(Evidence::from_prior(0.5), -7);
assert_eq!(edge.self_reinforcements(), 0);
}
#[test]
fn path_confidence_two_edges() {
let result = path_confidence(&[0.8, 0.7]);
assert!(approx_eq(result, 0.56), "got {}", result);
}
#[test]
fn path_confidence_empty() {
assert_eq!(path_confidence(&[]), 1.0);
}
#[test]
fn extraction_context_priors() {
assert_eq!(ExtractionContext::Authoritative.prior(), 1.0);
assert_eq!(ExtractionContext::Explicit.prior(), 0.9);
assert_eq!(ExtractionContext::Inferred.prior(), 0.6);
assert_eq!(ExtractionContext::Speculative.prior(), 0.3);
}
#[test]
fn temporal_decay_zero_days() {
let result = temporal_decay(0.9, 0.0, 90.0);
assert!(approx_eq(result, 0.9), "got {}", result);
}
#[test]
fn temporal_decay_one_half_life() {
let result = temporal_decay(0.6, 90.0, 90.0);
assert!(approx_eq(result, 0.3), "got {}", result);
}
#[test]
fn temporal_decay_two_half_lives() {
let result = temporal_decay(0.8, 180.0, 90.0);
assert!(approx_eq(result, 0.2), "got {}", result);
}
#[test]
fn temporal_decay_floor() {
let result = temporal_decay(0.3, 900.0, 90.0);
assert!(approx_eq(result, DECAY_FLOOR), "got {}", result);
}
#[test]
fn temporal_decay_negative_days() {
let result = temporal_decay(0.7, -5.0, 90.0);
assert!(approx_eq(result, 0.7), "got {}", result);
}
#[test]
fn temporal_decay_high_confidence_still_decays() {
let result = temporal_decay(1.0, 90.0, 90.0);
assert!(approx_eq(result, 0.5), "got {}", result);
}
#[test]
fn effective_confidence_with_last_reinforced() {
let now = chrono::Utc::now();
let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
let valid_from_long_ago = (now - chrono::Duration::days(365)).to_rfc3339();
let last_reinforced = serde_json::Value::String(ninety_days_ago);
let valid_from = serde_json::Value::String(valid_from_long_ago);
let result = effective_confidence(0.6, Some(&last_reinforced), &valid_from, &now);
assert!(
approx_eq(result, 0.3),
"got {} (expected ~0.3, one half-life from last_reinforced)",
result
);
}
#[test]
fn effective_confidence_falls_back_to_valid_from() {
let now = chrono::Utc::now();
let ninety_days_ago = (now - chrono::Duration::days(90)).to_rfc3339();
let valid_from = serde_json::Value::String(ninety_days_ago);
let result = effective_confidence(0.6, None, &valid_from, &now);
assert!(
approx_eq(result, 0.3),
"got {} (expected ~0.3, one half-life from valid_from)",
result
);
}
#[test]
fn effective_confidence_no_parseable_date() {
let now = chrono::Utc::now();
let bad_date = serde_json::Value::String("not-a-date".to_string());
let result = effective_confidence(0.8, None, &bad_date, &now);
assert!(approx_eq(result, 0.8), "got {}", result);
}
#[test]
fn extraction_context_from_str() {
assert_eq!(
"explicit".parse::<ExtractionContext>().unwrap(),
ExtractionContext::Explicit
);
assert_eq!(
"inferred".parse::<ExtractionContext>().unwrap(),
ExtractionContext::Inferred
);
assert_eq!(
"speculative".parse::<ExtractionContext>().unwrap(),
ExtractionContext::Speculative
);
assert_eq!(
"authoritative".parse::<ExtractionContext>().unwrap(),
ExtractionContext::Authoritative
);
assert!("unknown".parse::<ExtractionContext>().is_err());
}
}