use std::fmt;
use uqa_scoring::{EvidenceLogit, PosteriorProbability, PriorLogit};
#[derive(Debug, Clone, PartialEq)]
pub enum BayesianEvidenceFusionError {
InvalidBaseRate(f64),
NonFiniteEvidenceSum,
}
impl fmt::Display for BayesianEvidenceFusionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidBaseRate(base_rate) => write!(
formatter,
"base_rate must be finite and strictly between 0 and 1, got {base_rate}"
),
Self::NonFiniteEvidenceSum => {
formatter.write_str("prior and evidence logits produced a non-finite sum")
}
}
}
}
impl std::error::Error for BayesianEvidenceFusionError {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BayesianEvidenceFusion {
prior: PriorLogit,
}
impl Default for BayesianEvidenceFusion {
fn default() -> Self {
Self {
prior: PriorLogit::neutral(),
}
}
}
impl BayesianEvidenceFusion {
pub fn new(base_rate: f64) -> Result<Self, BayesianEvidenceFusionError> {
let prior = PriorLogit::from_probability(base_rate)
.map_err(|_| BayesianEvidenceFusionError::InvalidBaseRate(base_rate))?;
Ok(Self { prior })
}
pub const fn from_prior_logit(prior: PriorLogit) -> Self {
Self { prior }
}
pub const fn prior_logit(self) -> PriorLogit {
self.prior
}
pub fn fuse(
&self,
evidence: &[EvidenceLogit],
) -> Result<PosteriorProbability, BayesianEvidenceFusionError> {
let mut posterior_logit = self.prior.value();
for item in evidence {
posterior_logit += item.value();
if !posterior_logit.is_finite() {
return Err(BayesianEvidenceFusionError::NonFiniteEvidenceSum);
}
}
PosteriorProbability::from_logit(posterior_logit)
.map_err(|_| BayesianEvidenceFusionError::NonFiniteEvidenceSum)
}
}
#[cfg(test)]
mod tests {
use uqa_scoring::{logit, sigmoid};
use super::*;
fn approx_eq(left: f64, right: f64) {
assert!((left - right).abs() < 1e-12, "expected {left} ~= {right}");
}
#[test]
fn neutral_evidence_leaves_every_prior_unchanged() {
for prior in [0.01, 0.1, 0.5, 0.9, 0.99] {
let fusion = BayesianEvidenceFusion::new(prior).unwrap();
let posterior = fusion
.fuse(&[EvidenceLogit::neutral(), EvidenceLogit::neutral()])
.unwrap();
approx_eq(posterior.value(), prior);
}
}
#[test]
fn signed_evidence_is_summed_without_gating_or_scaling() {
let fusion = BayesianEvidenceFusion::new(0.05).unwrap();
let evidence = [
EvidenceLogit::new(1.25).unwrap(),
EvidenceLogit::new(-0.4).unwrap(),
EvidenceLogit::new(0.15).unwrap(),
];
let expected = sigmoid(logit(0.05) + 1.25 - 0.4 + 0.15);
approx_eq(fusion.fuse(&evidence).unwrap().value(), expected);
}
#[test]
fn empty_evidence_returns_the_prior() {
let fusion = BayesianEvidenceFusion::new(0.2).unwrap();
approx_eq(fusion.fuse(&[]).unwrap().value(), 0.2);
}
#[test]
fn invalid_base_rates_and_overflow_are_errors() {
for invalid in [f64::NAN, f64::NEG_INFINITY, 0.0, 1.0] {
assert!(BayesianEvidenceFusion::new(invalid).is_err());
}
let fusion = BayesianEvidenceFusion::default();
let evidence = [
EvidenceLogit::new(f64::MAX).unwrap(),
EvidenceLogit::new(f64::MAX).unwrap(),
];
assert_eq!(
fusion.fuse(&evidence),
Err(BayesianEvidenceFusionError::NonFiniteEvidenceSum)
);
}
}