1use crate::bitmap::Postings;
24
25#[derive(Debug, Clone)]
31pub struct Mass<B: Postings> {
32 focals: Vec<(B, f64)>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub enum EvidenceError {
39 EmptyFocalSet,
41 NotNormalised { total: f64 },
43 BadMass { value: f64 },
45 ConflictExceeded { conflict: f64, threshold: f64 },
47 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 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 pub fn certain(set: B) -> Result<Self, EvidenceError> {
94 Mass::new(vec![(set, 1.0)])
95 }
96
97 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 pub fn belief(&self, a: &B) -> f64 {
109 self.focals
110 .iter()
111 .filter(|(set, _)| set.and_not(a).is_empty())
113 .map(|(_, m)| *m)
114 .sum()
115 }
116
117 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 pub fn interval(&self, a: &B) -> (f64, f64) {
128 (self.belief(a), self.plausibility(a))
129 }
130
131 pub fn ignorance(&self, a: &B) -> f64 {
133 (self.plausibility(a) - self.belief(a)).max(0.0)
134 }
135}
136
137pub 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 if b.and(c).is_empty() {
147 k += mb * mc;
148 }
149 }
150 }
151 k.clamp(0.0, 1.0)
152}
153
154pub 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 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; }
186 let add = mb * mc * scale;
187 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
197pub 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 let m = Mass::new(vec![(set(&[1]), 0.6), (set(&[1, 2]), 0.4)]).unwrap();
239 let a = set(&[1]);
240 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 let unknown = Mass::vacuous(frame.clone()).unwrap();
251 assert_eq!(unknown.interval(&set(&[1])), (0.0, 1.0));
252
253 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 assert!(conflict(&unknown, &yes).abs() < 1e-9);
259 }
260
261 #[test]
262 fn combining_with_ignorance_changes_nothing() {
263 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 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 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 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 assert!(combine(&s1, &s2, 1.0).is_ok());
303 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}