use crate::bitmap::Postings;
#[derive(Debug, Clone)]
pub struct Mass<B: Postings> {
focals: Vec<(B, f64)>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EvidenceError {
EmptyFocalSet,
NotNormalised { total: f64 },
BadMass { value: f64 },
ConflictExceeded { conflict: f64, threshold: f64 },
TotalConflict,
}
impl std::fmt::Display for EvidenceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EvidenceError::EmptyFocalSet => write!(f, "a focal set is empty; m(∅) must be 0"),
EvidenceError::NotNormalised { total } => {
write!(f, "masses sum to {total:.6}, not 1")
}
EvidenceError::BadMass { value } => write!(f, "mass {value} is negative or not finite"),
EvidenceError::ConflictExceeded { conflict, threshold } => write!(
f,
"evidential conflict K={conflict:.4} exceeds the threshold {threshold:.4}; \
the sources disagree too much to combine"
),
EvidenceError::TotalConflict => {
write!(f, "total conflict (K=1): the sources share no possibility, so fusion is undefined")
}
}
}
}
impl std::error::Error for EvidenceError {}
impl<B: Postings> Mass<B> {
pub fn new(focals: Vec<(B, f64)>) -> Result<Self, EvidenceError> {
let mut total = 0.0;
for (set, m) in &focals {
if !m.is_finite() || *m < 0.0 {
return Err(EvidenceError::BadMass { value: *m });
}
if set.is_empty() {
return Err(EvidenceError::EmptyFocalSet);
}
total += *m;
}
if (total - 1.0).abs() > 1e-6 {
return Err(EvidenceError::NotNormalised { total });
}
Ok(Mass { focals })
}
pub fn certain(set: B) -> Result<Self, EvidenceError> {
Mass::new(vec![(set, 1.0)])
}
pub fn vacuous(frame: B) -> Result<Self, EvidenceError> {
Mass::new(vec![(frame, 1.0)])
}
pub fn focals(&self) -> &[(B, f64)] {
&self.focals
}
pub fn belief(&self, a: &B) -> f64 {
self.focals
.iter()
.filter(|(set, _)| set.and_not(a).is_empty())
.map(|(_, m)| *m)
.sum()
}
pub fn plausibility(&self, a: &B) -> f64 {
self.focals
.iter()
.filter(|(set, _)| !set.and(a).is_empty())
.map(|(_, m)| *m)
.sum()
}
pub fn interval(&self, a: &B) -> (f64, f64) {
(self.belief(a), self.plausibility(a))
}
pub fn ignorance(&self, a: &B) -> f64 {
(self.plausibility(a) - self.belief(a)).max(0.0)
}
}
pub fn conflict<B: Postings>(m1: &Mass<B>, m2: &Mass<B>) -> f64 {
let mut k = 0.0;
for (b, mb) in m1.focals() {
for (c, mc) in m2.focals() {
if b.and(c).is_empty() {
k += mb * mc;
}
}
}
k.clamp(0.0, 1.0)
}
pub fn combine<B: Postings>(
m1: &Mass<B>,
m2: &Mass<B>,
max_conflict: f64,
) -> Result<Mass<B>, EvidenceError> {
let k = conflict(m1, m2);
if k >= 1.0 - 1e-12 {
return Err(EvidenceError::TotalConflict);
}
if k > max_conflict {
return Err(EvidenceError::ConflictExceeded { conflict: k, threshold: max_conflict });
}
let scale = 1.0 / (1.0 - k);
let mut out: Vec<(B, f64)> = Vec::new();
for (b, mb) in m1.focals() {
for (c, mc) in m2.focals() {
let inter = b.and(c);
if inter.is_empty() {
continue; }
let add = mb * mc * scale;
match out.iter_mut().find(|(s, _)| s.and_not(&inter).is_empty() && inter.and_not(s).is_empty()) {
Some((_, m)) => *m += add,
None => out.push((inter, add)),
}
}
}
Mass::new(out)
}
pub fn combine_all<B: Postings>(
sources: &[Mass<B>],
max_conflict: f64,
) -> Result<Mass<B>, (usize, EvidenceError)> {
let mut iter = sources.iter();
let Some(first) = iter.next() else {
return Err((0, EvidenceError::NotNormalised { total: 0.0 }));
};
let mut acc = first.clone();
for (i, next) in iter.enumerate() {
acc = combine(&acc, next, max_conflict).map_err(|e| (i + 1, e))?;
}
Ok(acc)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bitmap::RoarPostings as P;
fn set(ids: &[u32]) -> P {
P::from_sorted(ids)
}
#[test]
fn invariants_are_checked_not_assumed() {
assert_eq!(Mass::new(vec![(set(&[1]), 0.5)]).unwrap_err(), EvidenceError::NotNormalised { total: 0.5 });
assert_eq!(Mass::new(vec![(set(&[]), 1.0)]).unwrap_err(), EvidenceError::EmptyFocalSet);
assert!(matches!(
Mass::new(vec![(set(&[1]), -1.0), (set(&[2]), 2.0)]).unwrap_err(),
EvidenceError::BadMass { .. }
));
}
#[test]
fn belief_and_plausibility_bracket_the_truth() {
let m = Mass::new(vec![(set(&[1]), 0.6), (set(&[1, 2]), 0.4)]).unwrap();
let a = set(&[1]);
assert!((m.belief(&a) - 0.6).abs() < 1e-9);
assert!((m.plausibility(&a) - 1.0).abs() < 1e-9);
assert!((m.ignorance(&a) - 0.4).abs() < 1e-9);
}
#[test]
fn ignorance_and_conflict_are_different_states() {
let frame = set(&[1, 2]);
let unknown = Mass::vacuous(frame.clone()).unwrap();
assert_eq!(unknown.interval(&set(&[1])), (0.0, 1.0));
let yes = Mass::certain(set(&[1])).unwrap();
let no = Mass::certain(set(&[2])).unwrap();
assert!((conflict(&yes, &no) - 1.0).abs() < 1e-9, "disjoint certainties are total conflict");
assert!(conflict(&unknown, &yes).abs() < 1e-9);
}
#[test]
fn combining_with_ignorance_changes_nothing() {
let frame = set(&[1, 2, 3]);
let src = Mass::new(vec![(set(&[1]), 0.7), (frame.clone(), 0.3)]).unwrap();
let fused = combine(&src, &Mass::vacuous(frame).unwrap(), 1.0).unwrap();
let a = set(&[1]);
assert!((fused.belief(&a) - src.belief(&a)).abs() < 1e-9);
assert!((fused.plausibility(&a) - src.plausibility(&a)).abs() < 1e-9);
}
#[test]
fn agreeing_sources_sharpen_the_interval() {
let frame = set(&[1, 2, 3]);
let s1 = Mass::new(vec![(set(&[1]), 0.6), (frame.clone(), 0.4)]).unwrap();
let s2 = Mass::new(vec![(set(&[1]), 0.6), (frame.clone(), 0.4)]).unwrap();
let a = set(&[1]);
let fused = combine(&s1, &s2, 1.0).unwrap();
assert!(fused.belief(&a) > s1.belief(&a), "{} vs {}", fused.belief(&a), s1.belief(&a));
assert!(fused.ignorance(&a) < s1.ignorance(&a), "ignorance must shrink");
}
#[test]
fn total_conflict_is_refused_rather_than_divided_by_zero() {
let yes = Mass::certain(set(&[1])).unwrap();
let no = Mass::certain(set(&[2])).unwrap();
assert_eq!(combine(&yes, &no, 1.0).unwrap_err(), EvidenceError::TotalConflict);
}
#[test]
fn the_guard_refuses_before_the_normaliser_gets_extreme() {
let frame = set(&[1, 2]);
let s1 = Mass::new(vec![(set(&[1]), 0.9), (frame.clone(), 0.1)]).unwrap();
let s2 = Mass::new(vec![(set(&[2]), 0.9), (frame.clone(), 0.1)]).unwrap();
let k = conflict(&s1, &s2);
assert!(k > 0.7, "expected high conflict, got {k}");
assert!(combine(&s1, &s2, 1.0).is_ok());
match combine(&s1, &s2, 0.5).unwrap_err() {
EvidenceError::ConflictExceeded { conflict, threshold } => {
assert!((conflict - k).abs() < 1e-9);
assert!((threshold - 0.5).abs() < 1e-9);
}
other => panic!("expected ConflictExceeded, got {other:?}"),
}
}
#[test]
fn combine_all_reports_which_source_broke_it() {
let frame = set(&[1, 2]);
let ok1 = Mass::new(vec![(set(&[1]), 0.8), (frame.clone(), 0.2)]).unwrap();
let ok2 = Mass::new(vec![(set(&[1]), 0.7), (frame.clone(), 0.3)]).unwrap();
let bad = Mass::new(vec![(set(&[2]), 0.95), (frame.clone(), 0.05)]).unwrap();
assert!(combine_all(&[ok1.clone(), ok2.clone()], 0.5).is_ok());
let (idx, err) = combine_all(&[ok1, ok2, bad], 0.5).unwrap_err();
assert_eq!(idx, 2, "the third source is the one that disagrees");
assert!(matches!(err, EvidenceError::ConflictExceeded { .. }));
}
#[test]
fn the_result_is_still_a_valid_mass_function() {
let frame = set(&[1, 2, 3, 4]);
let s1 = Mass::new(vec![(set(&[1, 2]), 0.5), (set(&[2, 3]), 0.3), (frame.clone(), 0.2)]).unwrap();
let s2 = Mass::new(vec![(set(&[2, 3]), 0.6), (frame, 0.4)]).unwrap();
let fused = combine(&s1, &s2, 1.0).unwrap();
let total: f64 = fused.focals().iter().map(|(_, m)| *m).sum();
assert!((total - 1.0).abs() < 1e-9, "masses must renormalise to 1, got {total}");
assert!(fused.focals().iter().all(|(s, _)| !s.is_empty()), "no empty focal sets");
}
}