Skip to main content

oxibrain_core/
fold.rs

1//! The temporal fold (DESIGN §6). A pure function that turns assertions into
2//! current-slice beliefs. Operates at the (subject, predicate) GROUP level —
3//! not per-statement — because Functional/Supersede predicates close intervals
4//! across different objects (different StatementIds) sharing the same
5//! subject+predicate (spec deviation D1).
6
7use crate::confidence::{CalibrationTable, ConfidenceComponents, calibrate};
8use crate::interval::{Interval, clip, merge_overlapping, overlaps};
9use crate::knowledge::{
10    Assertion, Belief, BeliefStatus, Polarity, Statement, StatementId, Support,
11};
12use crate::registry::{Cardinality, Invalidation, PredicateDef, Temporality};
13use crate::types::TrustTier;
14use oxibrain_ports::Timestamp;
15
16/// A statement and its assertions — input to the fold for one (subject, predicate) group.
17#[derive(Debug, Clone)]
18pub struct StatementEntry {
19    pub statement: Statement,
20    pub assertions: Vec<Assertion>,
21}
22
23/// Per-statement view after transaction-time filtering and polarity partitioning.
24struct VisibleStmt {
25    stmt: Statement,
26    affirm: Vec<Interval>,
27    assertions: Vec<Assertion>, // visible ones, for support
28}
29
30/// Fold a (subject, predicate) group into current-slice beliefs.
31///
32/// `at` is the transaction-time cutoff: only assertions with
33/// `recorded_at <= at && (retracted_at.is_none() || retracted_at > at)` are visible.
34///
35/// Pure function. Output is sorted by (statement_id, valid_from).
36pub fn fold(
37    def: &PredicateDef,
38    group: &[StatementEntry],
39    at: Timestamp,
40    calibration: &CalibrationTable,
41) -> Vec<Belief> {
42    // ── Step 1: Filter by transaction time, partition by polarity per statement. ──
43
44    let mut visible: Vec<VisibleStmt> = Vec::new();
45    for entry in group {
46        let vis: Vec<&Assertion> = entry
47            .assertions
48            .iter()
49            .filter(|a| {
50                a.recorded_at <= at && (a.retracted_at.is_none() || a.retracted_at.unwrap() > at)
51            })
52            .collect();
53        if vis.is_empty() {
54            continue;
55        }
56
57        let mut affirm: Vec<Interval> = vis
58            .iter()
59            .filter(|a| a.polarity == Polarity::Affirm)
60            .map(|a| Interval::new(a.claimed_from, a.claimed_to))
61            .collect();
62        let deny: Vec<Interval> = vis
63            .iter()
64            .filter(|a| a.polarity == Polarity::Deny)
65            .map(|a| Interval::new(a.claimed_from, a.claimed_to))
66            .collect();
67
68        // Merge overlapping affirming intervals.
69        merge_overlapping(&mut affirm);
70
71        // Apply denials: clip affirming intervals.
72        for d in &deny {
73            affirm = clip(&affirm, d);
74        }
75
76        visible.push(VisibleStmt {
77            stmt: entry.statement.clone(),
78            affirm,
79            assertions: vis.into_iter().cloned().collect(),
80        });
81    }
82
83    if visible.is_empty() {
84        return Vec::new();
85    }
86
87    // ── Step 2: Apply cross-object rules. ──
88    let beliefs = match (def.cardinality, def.invalidation, def.temporality) {
89        // MultiValued: per-statement, no cross-object effect.
90        (Cardinality::MultiValued, _, _) => {
91            fold_independent(&visible, calibration, def.confidence_prior)
92        }
93
94        // Functional + Static → contradiction on 2+ overlapping objects.
95        (Cardinality::Functional, _, Temporality::Static) => {
96            fold_contradiction(&visible, calibration, def.confidence_prior)
97        }
98
99        // Functional + Supersede + Interval/Point → newer supersedes older.
100        (Cardinality::Functional, Invalidation::Supersede, _) => {
101            fold_supersede(&visible, calibration, def.confidence_prior)
102        }
103
104        // Functional + ExplicitOnly → both stay Active (no auto-close).
105        (Cardinality::Functional, Invalidation::ExplicitOnly, _) => {
106            fold_independent(&visible, calibration, def.confidence_prior)
107        }
108
109        // Functional + Coexist → treat as MultiValued.
110        (Cardinality::Functional, Invalidation::Coexist, _) => {
111            fold_independent(&visible, calibration, def.confidence_prior)
112        }
113    };
114
115    // ── Step 3: Sort output by (statement_id, valid_from). ──
116    let mut beliefs = beliefs;
117    beliefs.sort_by(|a, b| (&a.statement, a.valid_from).cmp(&(&b.statement, b.valid_from)));
118    beliefs
119}
120
121/// Compute belief confidence from supporting assertions (DESIGN §6.5).
122/// Pure function of the assertion set — deterministic.
123fn belief_confidence(
124    assertions: &[Assertion],
125    support: &Support,
126    calibration: &CalibrationTable,
127    prior: f32,
128) -> f32 {
129    // Manual declarations (no extractor) bypass at 1.0.
130    let is_declaration = assertions.iter().all(|a| a.extractor.is_none());
131    if is_declaration {
132        return 1.0;
133    }
134
135    // Raw: max assertion confidence, multiplied by the predicate-level prior
136    // (§5.5). Hearsay predicates (e.g. allegedly_employed_by) carry a prior
137    // < 1.0 so beliefs are automatically down-weighted — no pipeline
138    // special-casing (P4: semantics in the registry).
139    let raw = assertions
140        .iter()
141        .map(|a| a.confidence)
142        .fold(0.0_f32, f32::max)
143        * prior;
144
145    // Calibrated: per-extractor multiplier from eval harness (default 0.8).
146    let extractor_id = assertions
147        .iter()
148        .filter_map(|a| a.extractor.as_deref())
149        .next()
150        .unwrap_or("unknown");
151    let calibrated = calibrate(extractor_id, calibration);
152
153    // Corroboration: saturating in distinct supporting episodes.
154    let n = support.distinct_episodes.max(1) as f32;
155    let corroboration = (1.0 - (-0.3 * n).exp()).clamp(0.5, 1.0);
156
157    // Trust: weighted by episode trust tier.
158    let trust = if support.trust_weights.is_empty() {
159        1.0
160    } else {
161        let total: u32 = support.trust_weights.iter().map(|(_, c)| *c).sum();
162        if total == 0 {
163            1.0
164        } else {
165            let weighted: f32 = support
166                .trust_weights
167                .iter()
168                .map(|(tier, count)| {
169                    let w = match tier {
170                        TrustTier::Trusted => 1.0,
171                        TrustTier::SemiTrusted => 0.7,
172                        TrustTier::Untrusted => 0.3,
173                    };
174                    w * *count as f32
175                })
176                .sum();
177            (weighted / total as f32).clamp(0.3, 1.0)
178        }
179    };
180
181    // Recency: fixed at 1.0 for v1 — needs reference time parameter.
182    let recency = 1.0;
183
184    ConfidenceComponents {
185        raw,
186        calibrated,
187        corroboration,
188        trust,
189        recency,
190    }
191    .combine()
192}
193
194/// Per-statement fold: each object's affirming intervals become Active beliefs.
195fn fold_independent(
196    visible: &[VisibleStmt],
197    calibration: &CalibrationTable,
198    prior: f32,
199) -> Vec<Belief> {
200    let mut beliefs = Vec::new();
201    for vs in visible {
202        let support = compute_support(&vs.assertions);
203        let conf = belief_confidence(&vs.assertions, &support, calibration, prior);
204        for iv in &vs.affirm {
205            beliefs.push(Belief {
206                statement: vs.stmt.id.clone(),
207                valid_from: iv.start,
208                valid_to: iv.end,
209                support: support.clone(),
210                confidence: conf,
211                status: BeliefStatus::Active,
212            });
213        }
214    }
215    beliefs
216}
217
218/// Contradiction fold: for Static+Functional, all overlapping objects are Contradicted.
219fn fold_contradiction(
220    visible: &[VisibleStmt],
221    calibration: &CalibrationTable,
222    prior: f32,
223) -> Vec<Belief> {
224    // If only one object has affirming intervals, it's Active (no contradiction).
225    let affirming: Vec<&VisibleStmt> = visible.iter().filter(|vs| !vs.affirm.is_empty()).collect();
226    if affirming.len() <= 1 {
227        return fold_independent(visible, calibration, prior);
228    }
229
230    // Check for pairwise overlaps across different statements.
231    // An object is Contradicted if ANY of its intervals overlaps with another object's interval.
232    let mut contradicted: Vec<&str> = Vec::new(); // statement ids
233    for i in 0..affirming.len() {
234        for j in (i + 1)..affirming.len() {
235            let a = &affirming[i];
236            let b = &affirming[j];
237            let overlap = a
238                .affirm
239                .iter()
240                .any(|ai| b.affirm.iter().any(|bi| overlaps(ai, bi)));
241            if overlap {
242                if !contradicted.contains(&a.stmt.id.as_str()) {
243                    contradicted.push(&a.stmt.id);
244                }
245                if !contradicted.contains(&b.stmt.id.as_str()) {
246                    contradicted.push(&b.stmt.id);
247                }
248            }
249        }
250    }
251
252    let mut beliefs = Vec::new();
253    for vs in visible {
254        let support = compute_support(&vs.assertions);
255        let conf = belief_confidence(&vs.assertions, &support, calibration, prior);
256        let is_contradicted = contradicted.contains(&vs.stmt.id.as_str());
257        for iv in &vs.affirm {
258            beliefs.push(Belief {
259                statement: vs.stmt.id.clone(),
260                valid_from: iv.start,
261                valid_to: iv.end,
262                support: support.clone(),
263                confidence: conf,
264                status: if is_contradicted {
265                    BeliefStatus::Contradicted
266                } else {
267                    BeliefStatus::Active
268                },
269            });
270        }
271    }
272    beliefs
273}
274
275/// Supersession fold: for Functional/Supersede/Interval, newer objects close older ones.
276fn fold_supersede(
277    visible: &[VisibleStmt],
278    calibration: &CalibrationTable,
279    prior: f32,
280) -> Vec<Belief> {
281    // Collect (statement_id, interval) pairs across all objects.
282    let mut all: Vec<(StatementId, Interval)> = Vec::new();
283    for vs in visible {
284        for iv in &vs.affirm {
285            all.push((vs.stmt.id.clone(), *iv));
286        }
287    }
288
289    // Sort by (start, statement_id) for deterministic processing.
290    all.sort_by(|a, b| (&a.1.start, &a.0).cmp(&(&b.1.start, &b.0)));
291
292    let mut beliefs: Vec<Belief> = Vec::new();
293    struct Active {
294        stmt: StatementId,
295        start: Timestamp,
296        end: Timestamp,
297    }
298
299    let mut current: Option<Active> = None;
300
301    for (stmt_id, iv) in &all {
302        let vs = visible
303            .iter()
304            .find(|vs| &vs.stmt.id == stmt_id)
305            .expect("statement exists in group");
306        let support = compute_support(&vs.assertions);
307        let conf = belief_confidence(&vs.assertions, &support, calibration, prior);
308
309        match &current {
310            None => {
311                beliefs.push(Belief {
312                    statement: stmt_id.clone(),
313                    valid_from: iv.start,
314                    valid_to: iv.end,
315                    support,
316                    confidence: conf,
317                    status: BeliefStatus::Active,
318                });
319                current = Some(Active {
320                    stmt: stmt_id.clone(),
321                    start: iv.start,
322                    end: iv.end,
323                });
324            }
325            Some(cur) if cur.stmt == *stmt_id => {
326                beliefs.push(Belief {
327                    statement: stmt_id.clone(),
328                    valid_from: iv.start,
329                    valid_to: iv.end,
330                    support,
331                    confidence: conf,
332                    status: BeliefStatus::Active,
333                });
334                if iv.end > cur.end {
335                    current = Some(Active {
336                        stmt: stmt_id.clone(),
337                        start: cur.start,
338                        end: iv.end,
339                    });
340                }
341            }
342            Some(cur) => {
343                if iv.start == cur.start {
344                    if let Some(last) = beliefs.last_mut() {
345                        if last.statement == cur.stmt && last.status == BeliefStatus::Active {
346                            last.status = BeliefStatus::Contradicted;
347                        }
348                    }
349                    beliefs.push(Belief {
350                        statement: stmt_id.clone(),
351                        valid_from: iv.start,
352                        valid_to: iv.end,
353                        support,
354                        confidence: conf,
355                        status: BeliefStatus::Contradicted,
356                    });
357                } else {
358                    if let Some(last) = beliefs.last_mut() {
359                        if last.statement == cur.stmt
360                            && last.status == BeliefStatus::Active
361                            && last.valid_to >= iv.start
362                        {
363                            last.valid_to = Timestamp(iv.start.millis() - 1);
364                            last.status = BeliefStatus::Superseded;
365                        }
366                    }
367                    beliefs.push(Belief {
368                        statement: stmt_id.clone(),
369                        valid_from: iv.start,
370                        valid_to: iv.end,
371                        support,
372                        confidence: conf,
373                        status: BeliefStatus::Active,
374                    });
375                }
376                current = Some(Active {
377                    stmt: stmt_id.clone(),
378                    start: iv.start,
379                    end: iv.end,
380                });
381            }
382        }
383    }
384
385    beliefs
386}
387
388/// Compute support from visible assertions.
389fn compute_support(assertions: &[Assertion]) -> Support {
390    use std::collections::BTreeMap;
391
392    let affirm_count = assertions
393        .iter()
394        .filter(|a| a.polarity == Polarity::Affirm)
395        .count() as u32;
396    let deny_count = assertions
397        .iter()
398        .filter(|a| a.polarity == Polarity::Deny)
399        .count() as u32;
400
401    // Distinct episodes per trust tier. Trust is per-episode, not per-assertion,
402    // so we deduplicate by episode id first.
403    let mut episode_trust: BTreeMap<&str, TrustTier> = BTreeMap::new();
404    for a in assertions {
405        episode_trust.entry(a.episode.as_str()).or_insert(a.trust);
406    }
407
408    let mut trusted = 0u32;
409    let mut semi = 0u32;
410    let mut untrusted = 0u32;
411    for tier in episode_trust.values() {
412        match tier {
413            TrustTier::Trusted => trusted += 1,
414            TrustTier::SemiTrusted => semi += 1,
415            TrustTier::Untrusted => untrusted += 1,
416        }
417    }
418
419    let mut trust_weights = Vec::new();
420    if trusted > 0 {
421        trust_weights.push((TrustTier::Trusted, trusted));
422    }
423    if semi > 0 {
424        trust_weights.push((TrustTier::SemiTrusted, semi));
425    }
426    if untrusted > 0 {
427        trust_weights.push((TrustTier::Untrusted, untrusted));
428    }
429
430    Support {
431        affirm_count,
432        deny_count,
433        distinct_episodes: episode_trust.len() as u32,
434        trust_weights,
435    }
436}
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::knowledge::Object;
441    use crate::registry::{Cardinality, Invalidation, ObjectKind, PredicateDef, Temporality};
442    use oxibrain_ports::{TIME_MAX, TIME_MIN, Timestamp};
443
444    fn ts(m: i64) -> Timestamp {
445        Timestamp(m)
446    }
447
448    fn make_assertion(
449        stmt: &str,
450        episode: &str,
451        polarity: Polarity,
452        from: Timestamp,
453        to: Timestamp,
454    ) -> Assertion {
455        Assertion {
456            id: format!("a_{stmt}_{episode}"),
457            statement: stmt.into(),
458            episode: episode.into(),
459            extractor: None,
460            polarity,
461            claimed_from: from,
462            claimed_to: to,
463            confidence: 1.0,
464            recorded_at: ts(1),
465            retracted_at: None,
466            trust: TrustTier::Trusted,
467        }
468    }
469
470    fn make_stmt(id: &str, subj: &str, pred: &str, obj_id: &str) -> Statement {
471        Statement {
472            id: id.into(),
473            space: "s1".into(),
474            subject: subj.into(),
475            predicate: pred.into(),
476            object: Object::Entity(obj_id.into()),
477        }
478    }
479
480    fn def_employed() -> PredicateDef {
481        PredicateDef {
482            name: "employed_by".into(),
483            object_kind: ObjectKind::Entity(["Organization"].into()),
484            subject_types: vec!["Person".into()],
485            cardinality: Cardinality::Functional,
486            temporality: Temporality::Interval,
487            invalidation: Invalidation::Supersede,
488            symmetric: false,
489            inverse_of: None,
490            description: "".into(),
491            examples: vec![],
492            deprecated_by: None,
493            profile_relevant: false,
494            confidence_prior: 1.0,
495        }
496    }
497
498    fn def_born_in() -> PredicateDef {
499        PredicateDef {
500            name: "born_in".into(),
501            object_kind: ObjectKind::Entity(["Place"].into()),
502            subject_types: vec!["Person".into()],
503            cardinality: Cardinality::Functional,
504            temporality: Temporality::Static,
505            invalidation: Invalidation::Supersede,
506            symmetric: false,
507            inverse_of: None,
508            description: "".into(),
509            examples: vec![],
510            deprecated_by: None,
511            profile_relevant: false,
512            confidence_prior: 1.0,
513        }
514    }
515
516    fn def_works_on() -> PredicateDef {
517        PredicateDef {
518            name: "works_on".into(),
519            object_kind: ObjectKind::Entity(["Project"].into()),
520            subject_types: vec!["Person".into()],
521            cardinality: Cardinality::MultiValued,
522            temporality: Temporality::Interval,
523            invalidation: Invalidation::Coexist,
524            symmetric: false,
525            inverse_of: None,
526            description: "".into(),
527            examples: vec![],
528            deprecated_by: None,
529            profile_relevant: false,
530            confidence_prior: 1.0,
531        }
532    }
533
534    // ── Basic fold: single assertion → one Active belief. ──
535    #[test]
536    fn single_affirm_is_active() {
537        let stmt = make_stmt("st1", "e1", "employed_by", "acme");
538        let group = vec![StatementEntry {
539            statement: stmt,
540            assertions: vec![make_assertion(
541                "st1",
542                "ep1",
543                Polarity::Affirm,
544                ts(100),
545                TIME_MAX,
546            )],
547        }];
548        let beliefs = fold(
549            &def_employed(),
550            &group,
551            TIME_MAX,
552            &CalibrationTable::default(),
553        );
554        assert_eq!(beliefs.len(), 1);
555        assert_eq!(beliefs[0].status, BeliefStatus::Active);
556        assert_eq!(beliefs[0].valid_from, ts(100));
557    }
558
559    // ── Hearsay predicates produce lower confidence (§5.5, 10.9). ──
560    #[test]
561    fn hearsay_prior_lowers_confidence() {
562        let stmt = make_stmt("st1", "e1", "allegedly_employed_by", "acme");
563        // Use an assertion WITH an extractor so the declaration bypass
564        // (return 1.0) does not fire — the prior multiplier must apply.
565        let ext_assertion = Assertion {
566            id: "a_st1_ep1".into(),
567            statement: "st1".into(),
568            episode: "ep1".into(),
569            extractor: Some("ext1".into()),
570            polarity: Polarity::Affirm,
571            claimed_from: ts(100),
572            claimed_to: TIME_MAX,
573            confidence: 0.9,
574            recorded_at: ts(1),
575            retracted_at: None,
576            trust: TrustTier::Trusted,
577        };
578        let group = vec![StatementEntry {
579            statement: stmt,
580            assertions: vec![ext_assertion],
581        }];
582        // Hearsay predicate with confidence_prior = 0.3
583        let mut hearsay_def = def_employed();
584        hearsay_def.confidence_prior = 0.3;
585        let hearsay_beliefs = fold(&hearsay_def, &group, TIME_MAX, &CalibrationTable::default());
586
587        // Normal predicate with confidence_prior = 1.0
588        let normal_beliefs = fold(
589            &def_employed(),
590            &group,
591            TIME_MAX,
592            &CalibrationTable::default(),
593        );
594
595        assert_eq!(hearsay_beliefs.len(), 1);
596        assert_eq!(normal_beliefs.len(), 1);
597        // The hearsay belief must have strictly lower confidence.
598        assert!(
599            hearsay_beliefs[0].confidence < normal_beliefs[0].confidence,
600            "hearsay confidence {} should be < normal confidence {}",
601            hearsay_beliefs[0].confidence,
602            normal_beliefs[0].confidence
603        );
604    }
605
606    // ── Supersession: two employers, second supersedes first. ──
607    #[test]
608    fn supersession_closes_previous() {
609        let stmt_a = make_stmt("st_a", "e1", "employed_by", "acme");
610        let stmt_b = make_stmt("st_b", "e1", "employed_by", "globex");
611        let group = vec![
612            StatementEntry {
613                statement: stmt_a,
614                assertions: vec![make_assertion(
615                    "st_a",
616                    "ep1",
617                    Polarity::Affirm,
618                    ts(100),
619                    TIME_MAX,
620                )],
621            },
622            StatementEntry {
623                statement: stmt_b,
624                assertions: vec![make_assertion(
625                    "st_b",
626                    "ep2",
627                    Polarity::Affirm,
628                    ts(200),
629                    TIME_MAX,
630                )],
631            },
632        ];
633        let beliefs = fold(
634            &def_employed(),
635            &group,
636            TIME_MAX,
637            &CalibrationTable::default(),
638        );
639        // Acme: [100, 199] Superseded. Globex: [200, MAX] Active.
640        let acme = beliefs
641            .iter()
642            .find(|b| b.statement == "st_a")
643            .expect("acme belief");
644        let globex = beliefs
645            .iter()
646            .find(|b| b.statement == "st_b")
647            .expect("globex belief");
648        assert_eq!(acme.status, BeliefStatus::Superseded);
649        assert_eq!(acme.valid_to, ts(199));
650        assert_eq!(globex.status, BeliefStatus::Active);
651        assert_eq!(globex.valid_from, ts(200));
652    }
653
654    // ── Contradiction: two birthplaces for Static predicate. ──
655    #[test]
656    fn static_two_values_contradicted() {
657        let stmt_a = make_stmt("st_a", "e1", "born_in", "seoul");
658        let stmt_b = make_stmt("st_b", "e1", "born_in", "busan");
659        let group = vec![
660            StatementEntry {
661                statement: stmt_a,
662                assertions: vec![make_assertion(
663                    "st_a",
664                    "ep1",
665                    Polarity::Affirm,
666                    TIME_MIN,
667                    TIME_MAX,
668                )],
669            },
670            StatementEntry {
671                statement: stmt_b,
672                assertions: vec![make_assertion(
673                    "st_b",
674                    "ep2",
675                    Polarity::Affirm,
676                    TIME_MIN,
677                    TIME_MAX,
678                )],
679            },
680        ];
681        let beliefs = fold(
682            &def_born_in(),
683            &group,
684            TIME_MAX,
685            &CalibrationTable::default(),
686        );
687        assert_eq!(beliefs.len(), 2);
688        assert!(
689            beliefs
690                .iter()
691                .all(|b| b.status == BeliefStatus::Contradicted)
692        );
693    }
694
695    // ── Coexist: two projects for MultiValued predicate. ──
696    #[test]
697    fn multivalued_coexist() {
698        let stmt_a = make_stmt("st_a", "e1", "works_on", "px");
699        let stmt_b = make_stmt("st_b", "e1", "works_on", "py");
700        let group = vec![
701            StatementEntry {
702                statement: stmt_a,
703                assertions: vec![make_assertion(
704                    "st_a",
705                    "ep1",
706                    Polarity::Affirm,
707                    ts(100),
708                    TIME_MAX,
709                )],
710            },
711            StatementEntry {
712                statement: stmt_b,
713                assertions: vec![make_assertion(
714                    "st_b",
715                    "ep2",
716                    Polarity::Affirm,
717                    ts(100),
718                    TIME_MAX,
719                )],
720            },
721        ];
722        let beliefs = fold(
723            &def_works_on(),
724            &group,
725            TIME_MAX,
726            &CalibrationTable::default(),
727        );
728        assert_eq!(beliefs.len(), 2);
729        assert!(beliefs.iter().all(|b| b.status == BeliefStatus::Active));
730    }
731
732    // ── Denial clips affirming interval. ──
733    #[test]
734    fn denial_clips() {
735        let stmt = make_stmt("st1", "e1", "works_on", "px");
736        let group = vec![StatementEntry {
737            statement: stmt,
738            assertions: vec![
739                make_assertion("st1", "ep1", Polarity::Affirm, ts(100), ts(500)),
740                Assertion {
741                    id: "deny1".into(),
742                    statement: "st1".into(),
743                    episode: "ep2".into(),
744                    extractor: None,
745                    polarity: Polarity::Deny,
746                    claimed_from: ts(200),
747                    claimed_to: ts(300),
748                    confidence: 1.0,
749                    recorded_at: ts(2),
750                    retracted_at: None,
751                    trust: TrustTier::Trusted,
752                },
753            ],
754        }];
755        let beliefs = fold(
756            &def_works_on(),
757            &group,
758            TIME_MAX,
759            &CalibrationTable::default(),
760        );
761        // Affirming [100, 500] clipped by denial [200, 300] → [100, 199] and [301, 500].
762        assert_eq!(beliefs.len(), 2);
763        assert_eq!(beliefs[0].valid_from, ts(100));
764        assert_eq!(beliefs[0].valid_to, ts(199));
765        assert_eq!(beliefs[1].valid_from, ts(301));
766        assert_eq!(beliefs[1].valid_to, ts(500));
767    }
768
769    // ── Retracted assertion is filtered out. ──
770    #[test]
771    fn retracted_assertion_invisible() {
772        let stmt = make_stmt("st1", "e1", "employed_by", "acme");
773        let group = vec![StatementEntry {
774            statement: stmt,
775            assertions: vec![Assertion {
776                id: "a1".into(),
777                statement: "st1".into(),
778                episode: "ep1".into(),
779                extractor: None,
780                polarity: Polarity::Affirm,
781                claimed_from: ts(100),
782                claimed_to: TIME_MAX,
783                confidence: 1.0,
784                recorded_at: ts(1),
785                retracted_at: Some(ts(5)), // retracted before `at`
786                trust: TrustTier::Trusted,
787            }],
788        }];
789        let beliefs = fold(
790            &def_employed(),
791            &group,
792            ts(10),
793            &CalibrationTable::default(),
794        );
795        assert!(
796            beliefs.is_empty(),
797            "retracted assertion should produce no belief"
798        );
799    }
800
801    // ── Output is sorted by (statement_id, valid_from). ──
802    #[test]
803    fn output_sorted() {
804        let stmt_a = make_stmt("st_a", "e1", "works_on", "px");
805        let stmt_b = make_stmt("st_b", "e1", "works_on", "py");
806        let group = vec![
807            StatementEntry {
808                statement: stmt_b,
809                assertions: vec![make_assertion(
810                    "st_b",
811                    "ep2",
812                    Polarity::Affirm,
813                    ts(200),
814                    TIME_MAX,
815                )],
816            },
817            StatementEntry {
818                statement: stmt_a,
819                assertions: vec![make_assertion(
820                    "st_a",
821                    "ep1",
822                    Polarity::Affirm,
823                    ts(100),
824                    TIME_MAX,
825                )],
826            },
827        ];
828        let beliefs = fold(
829            &def_works_on(),
830            &group,
831            TIME_MAX,
832            &CalibrationTable::default(),
833        );
834        assert_eq!(beliefs[0].statement, "st_a");
835        assert_eq!(beliefs[1].statement, "st_b");
836    }
837
838    // ── Empty group → empty output. ──
839    #[test]
840    fn empty_group() {
841        let beliefs = fold(&def_employed(), &[], TIME_MAX, &CalibrationTable::default());
842        assert!(beliefs.is_empty());
843    }
844}