Skip to main content

steeldb/
evidence.rs

1//! **Dempster–Shafer evidence combination** (paper §4.1–4.2).
2//!
3//! The rest of the engine reads one corpus, so a token's belief interval can be computed by counting polarity
4//! bitmaps directly ([`crate::index::InfonIndex::belief_interval`]). That is not the same operation as fusing
5//! *two independent sources* — a sensor reading and a supplier schedule, or two documents that disagree — and
6//! it cannot express the thing the paper cares about most:
7//!
8//! > they fail to distinguish between **ignorance** (lack of evidence) and **conflict** (contradictory
9//! > evidence)
10//!
11//! Ignorance is a wide interval: `[0, 1]`, nobody said. Conflict is two sources each confident and pointing
12//! opposite ways. Both are "uncertain" and they demand different responses — the first wants more data, the
13//! second means one source is wrong and averaging them silently invents a consensus that no source holds.
14//!
15//! So combination carries a conflict mass `K`, and a threshold guard refuses to fuse when `K` is too high
16//! rather than normalising the disagreement away. That refusal is the point: Dempster's rule divides by
17//! `1 − K`, so as sources approach total disagreement the normaliser approaches zero and the result becomes
18//! arbitrary while still looking like a confident number.
19//!
20//! Focal sets are bitmaps of situation ids, so `B ∩ C` is a hardware AND and `B ∩ C = ∅` is a population
21//! count against zero, exactly as §4.2 specifies.
22
23use crate::bitmap::Postings;
24
25/// A Basic Belief Assignment: mass distributed over focal sets.
26///
27/// Invariants from §4.1: `m(∅) = 0`, and the masses sum to 1. Both are checked on construction rather than
28/// assumed, because a mass function that does not sum to 1 produces belief values that look plausible and are
29/// meaningless.
30#[derive(Debug, Clone)]
31pub struct Mass<B: Postings> {
32    /// `(focal set, mass)`, no empty sets, masses summing to 1
33    focals: Vec<(B, f64)>,
34}
35
36/// Why a combination was refused.
37#[derive(Debug, Clone, PartialEq)]
38pub enum EvidenceError {
39    /// a focal set was empty, which §4.1 forbids
40    EmptyFocalSet,
41    /// masses did not sum to 1 (within tolerance)
42    NotNormalised { total: f64 },
43    /// negative or non-finite mass
44    BadMass { value: f64 },
45    /// the sources contradict each other beyond the caller's threshold — the guard of §4.2
46    ConflictExceeded { conflict: f64, threshold: f64 },
47    /// total conflict: every pair of focal sets is disjoint, so Dempster's rule divides by zero
48    TotalConflict,
49}
50
51impl std::fmt::Display for EvidenceError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            EvidenceError::EmptyFocalSet => write!(f, "a focal set is empty; m(∅) must be 0"),
55            EvidenceError::NotNormalised { total } => {
56                write!(f, "masses sum to {total:.6}, not 1")
57            }
58            EvidenceError::BadMass { value } => write!(f, "mass {value} is negative or not finite"),
59            EvidenceError::ConflictExceeded { conflict, threshold } => write!(
60                f,
61                "evidential conflict K={conflict:.4} exceeds the threshold {threshold:.4}; \
62                 the sources disagree too much to combine"
63            ),
64            EvidenceError::TotalConflict => {
65                write!(f, "total conflict (K=1): the sources share no possibility, so fusion is undefined")
66            }
67        }
68    }
69}
70
71impl std::error::Error for EvidenceError {}
72
73impl<B: Postings> Mass<B> {
74    /// Build a mass function, checking the §4.1 invariants.
75    pub fn new(focals: Vec<(B, f64)>) -> Result<Self, EvidenceError> {
76        let mut total = 0.0;
77        for (set, m) in &focals {
78            if !m.is_finite() || *m < 0.0 {
79                return Err(EvidenceError::BadMass { value: *m });
80            }
81            if set.is_empty() {
82                return Err(EvidenceError::EmptyFocalSet);
83            }
84            total += *m;
85        }
86        if (total - 1.0).abs() > 1e-6 {
87            return Err(EvidenceError::NotNormalised { total });
88        }
89        Ok(Mass { focals })
90    }
91
92    /// All mass on one set — a single source asserting one possibility with full confidence.
93    pub fn certain(set: B) -> Result<Self, EvidenceError> {
94        Mass::new(vec![(set, 1.0)])
95    }
96
97    /// All mass on the frame itself: the honest representation of knowing nothing. This is the state a
98    /// point-estimate probability cannot express, and combining it with anything returns that thing unchanged.
99    pub fn vacuous(frame: B) -> Result<Self, EvidenceError> {
100        Mass::new(vec![(frame, 1.0)])
101    }
102
103    pub fn focals(&self) -> &[(B, f64)] {
104        &self.focals
105    }
106
107    /// `Bel(A) = Σ_{B ⊆ A} m(B)` — mass that commits *entirely* to A. The lower bound.
108    pub fn belief(&self, a: &B) -> f64 {
109        self.focals
110            .iter()
111            // B ⊆ A iff B has nothing outside A
112            .filter(|(set, _)| set.and_not(a).is_empty())
113            .map(|(_, m)| *m)
114            .sum()
115    }
116
117    /// `Pl(A) = Σ_{B ∩ A ≠ ∅} m(B)` — mass not ruling A out. The upper bound.
118    pub fn plausibility(&self, a: &B) -> f64 {
119        self.focals
120            .iter()
121            .filter(|(set, _)| !set.and(a).is_empty())
122            .map(|(_, m)| *m)
123            .sum()
124    }
125
126    /// The evidential bound `[Bel(A), Pl(A)]`.
127    pub fn interval(&self, a: &B) -> (f64, f64) {
128        (self.belief(a), self.plausibility(a))
129    }
130
131    /// How much of the interval is pure ignorance: `Pl − Bel`.
132    pub fn ignorance(&self, a: &B) -> f64 {
133        (self.plausibility(a) - self.belief(a)).max(0.0)
134    }
135}
136
137/// Conflict mass `K = Σ_{B ∩ C = ∅} m₁(B)·m₂(C)` (§4.2).
138///
139/// The share of the two sources' joint mass that lands on impossible combinations. `K = 0` means they are
140/// compatible; `K = 1` means every pairing is contradictory.
141pub fn conflict<B: Postings>(m1: &Mass<B>, m2: &Mass<B>) -> f64 {
142    let mut k = 0.0;
143    for (b, mb) in m1.focals() {
144        for (c, mc) in m2.focals() {
145            // B ∩ C = ∅ — an AND followed by a zero population count
146            if b.and(c).is_empty() {
147                k += mb * mc;
148            }
149        }
150    }
151    k.clamp(0.0, 1.0)
152}
153
154/// Dempster's rule of combination with the conflict guard of §4.2.
155///
156/// `(m₁ ⊕ m₂)(A) = (1/(1−K)) · Σ_{B ∩ C = A} m₁(B)·m₂(C)`
157///
158/// Refuses rather than normalising when `K` exceeds `max_conflict`. That refusal is the whole reason the
159/// metric is computed: the `1/(1−K)` factor grows without bound as sources diverge, so a near-total
160/// disagreement yields a confident-looking number derived from almost nothing. Returning an error hands the
161/// caller a fact it can act on instead.
162///
163/// Pass `max_conflict = 1.0` to combine regardless, which is textbook Dempster behaviour.
164pub fn combine<B: Postings>(
165    m1: &Mass<B>,
166    m2: &Mass<B>,
167    max_conflict: f64,
168) -> Result<Mass<B>, EvidenceError> {
169    let k = conflict(m1, m2);
170    if k >= 1.0 - 1e-12 {
171        return Err(EvidenceError::TotalConflict);
172    }
173    if k > max_conflict {
174        return Err(EvidenceError::ConflictExceeded { conflict: k, threshold: max_conflict });
175    }
176
177    // accumulate mass onto each distinct intersection
178    let scale = 1.0 / (1.0 - k);
179    let mut out: Vec<(B, f64)> = Vec::new();
180    for (b, mb) in m1.focals() {
181        for (c, mc) in m2.focals() {
182            let inter = b.and(c);
183            if inter.is_empty() {
184                continue; // counted in K
185            }
186            let add = mb * mc * scale;
187            // merge into an existing focal set with the same members, so the result stays a proper BBA
188            match out.iter_mut().find(|(s, _)| s.and_not(&inter).is_empty() && inter.and_not(s).is_empty()) {
189                Some((_, m)) => *m += add,
190                None => out.push((inter, add)),
191            }
192        }
193    }
194    Mass::new(out)
195}
196
197/// Combine a stream of sources left to right, stopping at the first pair that exceeds the threshold.
198///
199/// Sequential rather than all-at-once because that is how the guard stays useful: it reports *which* source
200/// introduced the disagreement, rather than only that the set as a whole is inconsistent.
201pub fn combine_all<B: Postings>(
202    sources: &[Mass<B>],
203    max_conflict: f64,
204) -> Result<Mass<B>, (usize, EvidenceError)> {
205    let mut iter = sources.iter();
206    let Some(first) = iter.next() else {
207        return Err((0, EvidenceError::NotNormalised { total: 0.0 }));
208    };
209    let mut acc = first.clone();
210    for (i, next) in iter.enumerate() {
211        acc = combine(&acc, next, max_conflict).map_err(|e| (i + 1, e))?;
212    }
213    Ok(acc)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::bitmap::RoarPostings as P;
220
221    fn set(ids: &[u32]) -> P {
222        P::from_sorted(ids)
223    }
224
225    #[test]
226    fn invariants_are_checked_not_assumed() {
227        assert_eq!(Mass::new(vec![(set(&[1]), 0.5)]).unwrap_err(), EvidenceError::NotNormalised { total: 0.5 });
228        assert_eq!(Mass::new(vec![(set(&[]), 1.0)]).unwrap_err(), EvidenceError::EmptyFocalSet);
229        assert!(matches!(
230            Mass::new(vec![(set(&[1]), -1.0), (set(&[2]), 2.0)]).unwrap_err(),
231            EvidenceError::BadMass { .. }
232        ));
233    }
234
235    #[test]
236    fn belief_and_plausibility_bracket_the_truth() {
237        // 0.6 says "definitely situation 1", 0.4 says "1 or 2, not sure which"
238        let m = Mass::new(vec![(set(&[1]), 0.6), (set(&[1, 2]), 0.4)]).unwrap();
239        let a = set(&[1]);
240        // only the first focal set is a subset of {1}; both intersect it
241        assert!((m.belief(&a) - 0.6).abs() < 1e-9);
242        assert!((m.plausibility(&a) - 1.0).abs() < 1e-9);
243        assert!((m.ignorance(&a) - 0.4).abs() < 1e-9);
244    }
245
246    #[test]
247    fn ignorance_and_conflict_are_different_states() {
248        let frame = set(&[1, 2]);
249        // ignorance: everything on the frame — [0, 1] for either outcome
250        let unknown = Mass::vacuous(frame.clone()).unwrap();
251        assert_eq!(unknown.interval(&set(&[1])), (0.0, 1.0));
252
253        // conflict: two sources each certain, of opposite things
254        let yes = Mass::certain(set(&[1])).unwrap();
255        let no = Mass::certain(set(&[2])).unwrap();
256        assert!((conflict(&yes, &no) - 1.0).abs() < 1e-9, "disjoint certainties are total conflict");
257        // and the vacuous source conflicts with nothing
258        assert!(conflict(&unknown, &yes).abs() < 1e-9);
259    }
260
261    #[test]
262    fn combining_with_ignorance_changes_nothing() {
263        // the neutral element of Dempster's rule: fusing "I don't know" must not move the belief
264        let frame = set(&[1, 2, 3]);
265        let src = Mass::new(vec![(set(&[1]), 0.7), (frame.clone(), 0.3)]).unwrap();
266        let fused = combine(&src, &Mass::vacuous(frame).unwrap(), 1.0).unwrap();
267        let a = set(&[1]);
268        assert!((fused.belief(&a) - src.belief(&a)).abs() < 1e-9);
269        assert!((fused.plausibility(&a) - src.plausibility(&a)).abs() < 1e-9);
270    }
271
272    #[test]
273    fn agreeing_sources_sharpen_the_interval() {
274        let frame = set(&[1, 2, 3]);
275        let s1 = Mass::new(vec![(set(&[1]), 0.6), (frame.clone(), 0.4)]).unwrap();
276        let s2 = Mass::new(vec![(set(&[1]), 0.6), (frame.clone(), 0.4)]).unwrap();
277        let a = set(&[1]);
278        let fused = combine(&s1, &s2, 1.0).unwrap();
279        // two independent sources leaning the same way should believe it MORE than either alone
280        assert!(fused.belief(&a) > s1.belief(&a), "{} vs {}", fused.belief(&a), s1.belief(&a));
281        assert!(fused.ignorance(&a) < s1.ignorance(&a), "ignorance must shrink");
282    }
283
284    #[test]
285    fn total_conflict_is_refused_rather_than_divided_by_zero() {
286        let yes = Mass::certain(set(&[1])).unwrap();
287        let no = Mass::certain(set(&[2])).unwrap();
288        // textbook Dempster would divide by 1 - K = 0 here
289        assert_eq!(combine(&yes, &no, 1.0).unwrap_err(), EvidenceError::TotalConflict);
290    }
291
292    #[test]
293    fn the_guard_refuses_before_the_normaliser_gets_extreme() {
294        let frame = set(&[1, 2]);
295        // mostly-opposed sources: high but not total conflict
296        let s1 = Mass::new(vec![(set(&[1]), 0.9), (frame.clone(), 0.1)]).unwrap();
297        let s2 = Mass::new(vec![(set(&[2]), 0.9), (frame.clone(), 0.1)]).unwrap();
298        let k = conflict(&s1, &s2);
299        assert!(k > 0.7, "expected high conflict, got {k}");
300
301        // permissive: combines, but the result rests on a tiny share of the joint mass
302        assert!(combine(&s1, &s2, 1.0).is_ok());
303        // guarded: refused, and the error carries the number so a caller can report it
304        match combine(&s1, &s2, 0.5).unwrap_err() {
305            EvidenceError::ConflictExceeded { conflict, threshold } => {
306                assert!((conflict - k).abs() < 1e-9);
307                assert!((threshold - 0.5).abs() < 1e-9);
308            }
309            other => panic!("expected ConflictExceeded, got {other:?}"),
310        }
311    }
312
313    #[test]
314    fn combine_all_reports_which_source_broke_it() {
315        let frame = set(&[1, 2]);
316        let ok1 = Mass::new(vec![(set(&[1]), 0.8), (frame.clone(), 0.2)]).unwrap();
317        let ok2 = Mass::new(vec![(set(&[1]), 0.7), (frame.clone(), 0.3)]).unwrap();
318        let bad = Mass::new(vec![(set(&[2]), 0.95), (frame.clone(), 0.05)]).unwrap();
319
320        assert!(combine_all(&[ok1.clone(), ok2.clone()], 0.5).is_ok());
321        let (idx, err) = combine_all(&[ok1, ok2, bad], 0.5).unwrap_err();
322        assert_eq!(idx, 2, "the third source is the one that disagrees");
323        assert!(matches!(err, EvidenceError::ConflictExceeded { .. }));
324    }
325
326    #[test]
327    fn the_result_is_still_a_valid_mass_function() {
328        let frame = set(&[1, 2, 3, 4]);
329        let s1 = Mass::new(vec![(set(&[1, 2]), 0.5), (set(&[2, 3]), 0.3), (frame.clone(), 0.2)]).unwrap();
330        let s2 = Mass::new(vec![(set(&[2, 3]), 0.6), (frame, 0.4)]).unwrap();
331        let fused = combine(&s1, &s2, 1.0).unwrap();
332        let total: f64 = fused.focals().iter().map(|(_, m)| *m).sum();
333        assert!((total - 1.0).abs() < 1e-9, "masses must renormalise to 1, got {total}");
334        assert!(fused.focals().iter().all(|(s, _)| !s.is_empty()), "no empty focal sets");
335    }
336}