Skip to main content

mempill_core/application/
query_history.rs

1#![allow(missing_docs)]
2//! QueryHistoryUseCase — application layer read path for the history timeline.
3//!
4//! Read-only: no Txn opened, no writes. Returns all claims on a subject-line ordered
5//! by the canonical ordering key, with each entry tagged Current or Superseded.
6//!
7//! ## Correctness guarantee
8//!
9//! `Current` / `Superseded` is derived from `is_live` in the SAME `truth_engine::fold`
10//! call that `query_memory` uses (with `now` from the boundary), so `history()` and
11//! `recall()` / `query_memory` are guaranteed to agree on which entry is current.
12//!
13//! ## Effective-window computation
14//!
15//! `valid_until` for entry i = the canonical ordering key of entry i+1.
16//! The last (open-ended / current) entry has `valid_until = None`.
17//! This logic is extracted into the pure function `compute_effective_windows` so it
18//! can be unit-tested in isolation.
19
20use std::sync::Arc;
21
22use chrono::{DateTime, Utc};
23use mempill_types::{Claim, ClaimRef, HistoryEntryStatus, ProvenanceLabel, ExternalKind};
24
25use crate::{
26    application::ingest_claim::build_latest_disposition_map,
27    config::EngineConfig,
28    engine::truth_engine,
29    error::MemError,
30    ports::{PersistencePort, VectorPort},
31};
32
33use super::dto::{HistoryEntry, QueryHistoryRequest, QueryHistoryResponse};
34
35// ── Ordering key (pure) ───────────────────────────────────────────────────────
36
37/// Compute the canonical ordering key for a claim — mirrors `truth_engine::ordering_key`
38/// exactly so the sort order here matches the fold sort order.
39fn ordering_key_dt(claim: &Claim, config: &EngineConfig) -> DateTime<Utc> {
40    if claim.valid_time().valid_time_confidence >= config.valid_time_confidence_threshold {
41        claim.valid_time().start.unwrap_or(claim.transaction_time().0)
42    } else {
43        claim.transaction_time().0
44    }
45}
46
47// ── Provenance formatting ─────────────────────────────────────────────────────
48
49/// Format a `ProvenanceLabel` as a human-readable string.
50/// Identical to `provenance_label_str` in `mempill-facade/src/ergonomic.rs`.
51fn format_provenance(p: &ProvenanceLabel) -> String {
52    match p {
53        ProvenanceLabel::External(ExternalKind::UserAsserted) => {
54            "External/UserAsserted".to_owned()
55        }
56        ProvenanceLabel::External(ExternalKind::ExternalFirstHand) => {
57            "External/ExternalFirstHand".to_owned()
58        }
59        ProvenanceLabel::RecallReEntry => "RecallReEntry".to_owned(),
60        ProvenanceLabel::ModelDerived => "ModelDerived".to_owned(),
61        _ => format!("{p:?}"),
62    }
63}
64
65// ── Pure helper: compute effective valid_until windows ────────────────────────
66
67/// Compute the effective `valid_until` for each claim in the sorted timeline.
68///
69/// The timeline must be pre-sorted by the canonical ordering key (oldest first).
70///
71/// Rule: entry i's `valid_until` = the canonical ordering key of entry i+1.
72///       The last entry (most recent / open-ended) has `valid_until = None`.
73///
74/// This function is PURE (no I/O, no clock) and is tested independently.
75pub fn compute_effective_windows(
76    sorted: &[&Claim],
77    config: &EngineConfig,
78) -> Vec<Option<DateTime<Utc>>> {
79    let n = sorted.len();
80    let mut windows = Vec::with_capacity(n);
81    for i in 0..n {
82        if i + 1 < n {
83            // Successor's canonical ordering key closes this entry's window.
84            windows.push(Some(ordering_key_dt(sorted[i + 1], config)));
85        } else {
86            // Last entry — open-ended.
87            windows.push(None);
88        }
89    }
90    windows
91}
92
93// ── Use-case ──────────────────────────────────────────────────────────────────
94
95/// Use-case: retrieve the full ordered history timeline for a (subject, predicate) line.
96///
97/// Generic over persistence and vector ports (vector is unused; compile-time seam only).
98pub struct QueryHistoryUseCase<P, V>
99where
100    P: PersistencePort + Send + Sync + 'static,
101    V: VectorPort + Send + Sync + 'static,
102{
103    persistence: Arc<P>,
104    #[allow(dead_code)]
105    vector: Option<Arc<V>>,
106    config: EngineConfig,
107}
108
109impl<P, V> QueryHistoryUseCase<P, V>
110where
111    P: PersistencePort + Send + Sync + 'static,
112    V: VectorPort + Send + Sync + 'static,
113{
114    pub fn new(persistence: Arc<P>, vector: Option<Arc<V>>, config: EngineConfig) -> Self {
115        Self { persistence, vector, config }
116    }
117
118    /// Read path: no Txn (read-only). TruthEngine fold → history timeline DTO.
119    ///
120    /// `now` is injected by the EngineHandle (DETERMINISM — no clock reads here).
121    pub fn execute_with_time(
122        &self,
123        req: QueryHistoryRequest,
124        now: DateTime<Utc>,
125    ) -> Result<QueryHistoryResponse, MemError> {
126        // Load all claims for the subject-line (including superseded ones).
127        // AUDIT path: pass None to return the full claim history regardless of tx-time.
128        // query_history is a historical audit view — it must show every claim ever ingested,
129        // not just those visible at a particular tx-time point.
130        let claims = self.persistence
131            .load_subject_line(&req.agent_id, &req.subject, &req.predicate, None)
132            .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
133
134        if claims.is_empty() {
135            return Ok(QueryHistoryResponse { entries: vec![] });
136        }
137
138        // Load ledger scoped to the claims on this subject-line — no agent-wide cap,
139        // always complete regardless of total agent ledger size.
140        let claim_refs: Vec<_> = claims.iter().map(|c| c.claim_ref().clone()).collect();
141        // query_history shows the full ledger history for a subject-line — no tx-time cutoff.
142        // Passing None preserves the existing behaviour: all entries visible regardless of
143        // recorded_at, which is correct for the audit/history use-case.
144        let all_ledger = self.persistence
145            .load_ledger_for_claims(&req.agent_id, &claim_refs, None)
146            .map_err(|e| MemError::Persistence { source: Box::new(e) })?;
147        let latest_disposition = build_latest_disposition_map(&all_ledger);
148
149        // Canonical fold — SAME call as query_memory so Current/Superseded agrees with recall.
150        let fold = truth_engine::fold(
151            claims.clone(),
152            |cref| {
153                self.persistence
154                    .load_validity_assertions_for(&req.agent_id, cref)
155                    .unwrap_or_default()
156            },
157            now,
158            None, // valid_at_instant: None = use as_of_tx_time for instant-selection (future wave adds query param)
159            &self.config,
160            &latest_disposition,
161        );
162
163        // Build a set of live claim refs (those that are Current).
164        let live_refs: std::collections::HashSet<&ClaimRef> = fold
165            .live_claims
166            .iter()
167            .map(|cs| cs.claim.claim_ref())
168            .collect();
169
170        // Sort all claims by canonical ordering key (oldest first) — same sort as fold.
171        let mut sorted_claims = claims;
172        sorted_claims.sort_by(|a, b| {
173            let ka = ordering_key_dt(a, &self.config);
174            let kb = ordering_key_dt(b, &self.config);
175            ka.cmp(&kb)
176                .then(a.transaction_time().0.cmp(&b.transaction_time().0))
177                .then(a.claim_ref().0.as_u128().cmp(&b.claim_ref().0.as_u128()))
178        });
179
180        // Compute effective valid_until windows.
181        let refs: Vec<&Claim> = sorted_claims.iter().collect();
182        let windows = compute_effective_windows(&refs, &self.config);
183
184        // Map each claim to a HistoryEntry.
185        let entries: Vec<HistoryEntry> = sorted_claims
186            .iter()
187            .zip(windows)
188            .map(|(claim, valid_until)| {
189                let status = if live_refs.contains(claim.claim_ref()) {
190                    HistoryEntryStatus::Current
191                } else {
192                    HistoryEntryStatus::Superseded
193                };
194                HistoryEntry {
195                    claim_ref: claim.claim_ref().clone(),
196                    value: claim.fact().value.clone(),
197                    valid_from: claim.valid_time().start,
198                    valid_until,
199                    status,
200                    provenance: format_provenance(claim.provenance()),
201                    value_confidence: claim.confidence().value_confidence,
202                }
203            })
204            .collect();
205
206        Ok(QueryHistoryResponse { entries })
207    }
208
209    /// Convenience wrapper that stamps now internally (for direct calls outside EngineHandle).
210    pub fn execute(&self, req: QueryHistoryRequest) -> Result<QueryHistoryResponse, MemError> {
211        self.execute_with_time(req, Utc::now())
212    }
213}
214
215// ── Unit tests ────────────────────────────────────────────────────────────────
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::config::EngineConfig;
221    use crate::noop::NoOpVector;
222    use crate::ports::persistence::Txn;
223    use chrono::TimeZone;
224    use mempill_types::{
225        AgentId, Cardinality, Claim, ClaimEdge, ClaimRef, Confidence, Criticality,
226        ExternalAnchor, ExternalKind, Fact, LedgerEntry, ProvenanceLabel, TransactionTime,
227        ValidTime, ValidityAssertion,
228    };
229    use std::sync::Mutex;
230
231    // ── Minimal mock store ────────────────────────────────────────────────────
232
233    struct MockTxn(AgentId);
234    impl Txn for MockTxn {
235        fn agent_id(&self) -> &AgentId { &self.0 }
236    }
237
238    #[derive(Debug, thiserror::Error)]
239    #[error("mock")]
240    struct MockErr;
241
242    #[derive(Default)]
243    struct MockStore {
244        claims: Mutex<Vec<Claim>>,
245        assertions: Mutex<Vec<ValidityAssertion>>,
246    }
247
248    impl PersistencePort for MockStore {
249        type Transaction = MockTxn;
250        type Error = MockErr;
251        fn begin_atomic(&self, aid: &AgentId) -> Result<MockTxn, MockErr> {
252            Ok(MockTxn(aid.clone()))
253        }
254        fn append_claim(&self, _t: &mut MockTxn, c: &Claim) -> Result<ClaimRef, MockErr> {
255            self.claims.lock().unwrap().push(c.clone());
256            Ok(c.claim_ref().clone())
257        }
258        fn append_validity_assertion(
259            &self,
260            _t: &mut MockTxn,
261            a: &ValidityAssertion,
262        ) -> Result<(), MockErr> {
263            self.assertions.lock().unwrap().push(a.clone());
264            Ok(())
265        }
266        fn append_ledger_entry(
267            &self,
268            _t: &mut MockTxn,
269            _e: &LedgerEntry,
270        ) -> Result<(), MockErr> {
271            Ok(())
272        }
273        fn append_claim_edge(
274            &self,
275            _t: &mut MockTxn,
276            _e: &ClaimEdge,
277        ) -> Result<(), MockErr> {
278            Ok(())
279        }
280        fn commit(&self, _t: MockTxn) -> Result<(), MockErr> { Ok(()) }
281        fn rollback(&self, _t: MockTxn) -> Result<(), MockErr> { Ok(()) }
282        fn load_subject_line(
283            &self,
284            _aid: &AgentId,
285            subject: &str,
286            predicate: &str,
287            _as_of_tx_time: Option<chrono::DateTime<chrono::Utc>>,
288        ) -> Result<Vec<Claim>, MockErr> {
289            let claims = self.claims.lock().unwrap();
290            Ok(claims
291                .iter()
292                .filter(|c| {
293                    c.fact().subject == subject && c.fact().predicate == predicate
294                })
295                .cloned()
296                .collect())
297        }
298        fn load_claim(
299            &self,
300            _aid: &AgentId,
301            r: &ClaimRef,
302        ) -> Result<Option<Claim>, MockErr> {
303            let claims = self.claims.lock().unwrap();
304            Ok(claims.iter().find(|c| c.claim_ref() == r).cloned())
305        }
306        fn load_validity_assertions_for(
307            &self,
308            _aid: &AgentId,
309            r: &ClaimRef,
310        ) -> Result<Vec<ValidityAssertion>, MockErr> {
311            let assertions = self.assertions.lock().unwrap();
312            Ok(assertions
313                .iter()
314                .filter(|a| &a.target_claim == r)
315                .cloned()
316                .collect())
317        }
318        fn load_ledger(
319            &self,
320            _aid: &AgentId,
321            _from: Option<&mempill_types::TransactionTime>,
322            _lim: usize,
323        ) -> Result<Vec<LedgerEntry>, MockErr> {
324            Ok(vec![])
325        }
326        fn load_ledger_for_claims(
327            &self,
328            _aid: &AgentId,
329            _refs: &[ClaimRef],
330            _as_of: Option<chrono::DateTime<chrono::Utc>>,
331        ) -> Result<Vec<LedgerEntry>, MockErr> {
332            Ok(vec![])
333        }
334        fn load_edges_for(
335            &self,
336            _aid: &AgentId,
337            _r: &ClaimRef,
338        ) -> Result<Vec<ClaimEdge>, MockErr> {
339            Ok(vec![])
340        }
341        fn load_injected_claims(
342            &self,
343            _aid: &AgentId,
344        ) -> Result<Vec<ClaimRef>, MockErr> {
345            Ok(vec![])
346        }
347        fn load_lineage(
348            &self,
349            _aid: &AgentId,
350            _r: &ClaimRef,
351        ) -> Result<Vec<ClaimEdge>, MockErr> {
352            Ok(vec![])
353        }
354        fn list_predicates_for_subject(&self, _aid: &AgentId, _s: &str, _as_of: Option<chrono::DateTime<chrono::Utc>>) -> Result<Vec<String>, MockErr> { Ok(vec![]) }
355    }
356
357    // ── Helpers ───────────────────────────────────────────────────────────────
358
359    fn agent() -> AgentId {
360        AgentId("test-agent".into())
361    }
362
363    #[allow(clippy::too_many_arguments)]
364    // reason: test helper mirrors the full Claim constructor — grouping into a struct would obscure call sites
365    fn make_claim(
366        agent_id: &AgentId,
367        subject: &str,
368        predicate: &str,
369        value: serde_json::Value,
370        tx: DateTime<Utc>,
371        vt_start: Option<DateTime<Utc>>,
372        vt_end: Option<DateTime<Utc>>,
373        vt_confidence: f32,
374    ) -> Claim {
375        Claim::new(
376            ClaimRef::new_random(),
377            agent_id.clone(),
378            Fact { subject: subject.into(), predicate: predicate.into(), value },
379            Cardinality::Functional,
380            ProvenanceLabel::External(ExternalKind::UserAsserted),
381            ExternalAnchor { nearest_external_anchor: None, derivation_depth: 0 },
382            TransactionTime(tx),
383            ValidTime { start: vt_start, end: vt_end, valid_time_confidence: vt_confidence , start_granularity: None, end_granularity: None},
384            Confidence { value_confidence: 0.9, valid_time_confidence: vt_confidence },
385            Criticality::Medium,
386            vec![],
387            None,
388            None,
389        )
390    }
391
392    fn uc(store: Arc<MockStore>) -> QueryHistoryUseCase<MockStore, NoOpVector> {
393        QueryHistoryUseCase::new(store, None::<Arc<NoOpVector>>, EngineConfig::default())
394    }
395
396    // ── Test 1: empty subject-line → empty entries ────────────────────────────
397
398    #[test]
399    fn empty_subject_line_returns_empty_entries() {
400        let store = Arc::new(MockStore::default());
401        let uc = uc(Arc::clone(&store));
402        let now = Utc::now();
403        let req = QueryHistoryRequest {
404            agent_id: agent(),
405            subject: "nobody".into(),
406            predicate: "nothing".into(),
407        };
408        let resp = uc.execute_with_time(req, now).unwrap();
409        assert!(resp.entries.is_empty(), "no claims → empty history");
410        assert!(resp.current().is_none(), "no current entry");
411    }
412
413    // ── Test 2: single claim → 1 entry with status Current ───────────────────
414
415    #[test]
416    fn single_claim_returns_one_current_entry() {
417        let store = Arc::new(MockStore::default());
418        let agent = agent();
419        let tx = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
420        let claim = make_claim(&agent, "acme", "ceo", serde_json::json!("Alice"), tx, None, None, 0.0);
421        store.claims.lock().unwrap().push(claim.clone());
422
423        let uc = uc(Arc::clone(&store));
424        let now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
425        let resp = uc.execute_with_time(
426            QueryHistoryRequest { agent_id: agent, subject: "acme".into(), predicate: "ceo".into() },
427            now,
428        ).unwrap();
429
430        assert_eq!(resp.entries.len(), 1, "one claim → one entry");
431        assert_eq!(resp.entries[0].status, HistoryEntryStatus::Current);
432        assert_eq!(resp.entries[0].value, serde_json::json!("Alice"));
433        assert!(resp.entries[0].valid_until.is_none(), "single entry has no successor → open-ended");
434    }
435
436    // ── Test 3: succession ordering — two claims, older first ────────────────
437
438    #[test]
439    fn succession_ordering_oldest_first() {
440        let store = Arc::new(MockStore::default());
441        let agent = agent();
442        let t1 = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
443        let t2 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
444
445        // Insert in reverse order to verify sort is not insertion-order
446        let claim2 = make_claim(&agent, "acme", "ceo", serde_json::json!("Bob"), t2, None, None, 0.0);
447        let claim1 = make_claim(&agent, "acme", "ceo", serde_json::json!("Alice"), t1, None, None, 0.0);
448        store.claims.lock().unwrap().push(claim2);
449        store.claims.lock().unwrap().push(claim1);
450
451        let uc = uc(Arc::clone(&store));
452        let now = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
453        let resp = uc.execute_with_time(
454            QueryHistoryRequest { agent_id: agent, subject: "acme".into(), predicate: "ceo".into() },
455            now,
456        ).unwrap();
457
458        assert_eq!(resp.entries.len(), 2);
459        assert_eq!(resp.entries[0].value, serde_json::json!("Alice"), "oldest first");
460        assert_eq!(resp.entries[1].value, serde_json::json!("Bob"), "newer second");
461    }
462
463    // ── Test 4: effective-window correctness ──────────────────────────────────
464
465    #[test]
466    fn effective_window_successor_closes_prior_entry() {
467        let config = EngineConfig::default();
468        let agent = agent();
469        let t1 = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
470        let t2 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
471
472        let c1 = make_claim(&agent, "a", "b", serde_json::json!("v1"), t1, None, None, 0.0);
473        let c2 = make_claim(&agent, "a", "b", serde_json::json!("v2"), t2, None, None, 0.0);
474
475        let sorted: Vec<&Claim> = vec![&c1, &c2];
476        let windows = compute_effective_windows(&sorted, &config);
477
478        // c1's valid_until = ordering key of c2 (= t2 since low confidence uses tx_time)
479        assert_eq!(windows[0], Some(t2), "c1 closed by c2's ordering key");
480        // c2 is last → open-ended
481        assert_eq!(windows[1], None, "last entry is open-ended");
482    }
483
484    // ── Test 5: status-vs-recall consistency ──────────────────────────────────
485
486    #[test]
487    fn current_entry_value_matches_recall_primary() {
488        let store = Arc::new(MockStore::default());
489        let agent = agent();
490        let t1 = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
491        let t2 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
492
493        let c1 = make_claim(&agent, "acme", "ceo", serde_json::json!("Alice"), t1, None, None, 0.0);
494        let c2 = make_claim(&agent, "acme", "ceo", serde_json::json!("Bob"), t2, None, None, 0.0);
495        store.claims.lock().unwrap().push(c1);
496        store.claims.lock().unwrap().push(c2);
497
498        let uc = uc(Arc::clone(&store));
499        let now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
500        let resp = uc.execute_with_time(
501            QueryHistoryRequest { agent_id: agent, subject: "acme".into(), predicate: "ceo".into() },
502            now,
503        ).unwrap();
504
505        // With two conflicting functional claims (no valid_time), has_conflict=true.
506        // Both are "live" in the fold sense (contested). The test verifies at least one Current entry.
507        let current_entries: Vec<_> = resp.entries.iter().filter(|e| e.status == HistoryEntryStatus::Current).collect();
508        assert!(!current_entries.is_empty(), "at least one Current entry must exist");
509    }
510
511    // ── Test 6: high-confidence ordering key uses valid_time_start ────────────
512
513    #[test]
514    fn high_confidence_ordering_key_uses_valid_time_start() {
515        let config = EngineConfig::default(); // threshold = 0.7
516        let agent = agent();
517
518        // claim A: tx_time late, vt_start early, high confidence → orders by vt_start
519        let tx_late = Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap();
520        let vt_early = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
521        let claim_a = make_claim(&agent, "x", "y", serde_json::json!("A"), tx_late, Some(vt_early), None, 0.9);
522
523        // claim B: tx_time early, no vt_start, low confidence → orders by tx_time
524        let tx_early = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
525        let claim_b = make_claim(&agent, "x", "y", serde_json::json!("B"), tx_early, None, None, 0.0);
526
527        // A should sort before B because A's ordering key = vt_early (2020) < B's tx_early (2023)
528        let key_a = ordering_key_dt(&claim_a, &config);
529        let key_b = ordering_key_dt(&claim_b, &config);
530        assert!(key_a < key_b, "high-confidence A (vt=2020) must precede B (tx=2023)");
531
532        // Verify compute_effective_windows puts A's valid_until = B's key
533        let sorted: Vec<&Claim> = vec![&claim_a, &claim_b];
534        let windows = compute_effective_windows(&sorted, &config);
535        assert_eq!(windows[0], Some(key_b), "A's valid_until = B's ordering key");
536        assert_eq!(windows[1], None, "B is last → open-ended");
537    }
538
539    // ── Test 7: reinstated/edge case — no live claims → all Superseded ────────
540
541    #[test]
542    fn all_claims_bounded_returns_all_superseded() {
543        use mempill_types::{AssertionKind, ValidityAssertion};
544        use uuid::Uuid;
545
546        let store = Arc::new(MockStore::default());
547        let agent = agent();
548        let tx = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
549        let bound_at = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap();
550
551        let claim = make_claim(&agent, "acme", "ceo", serde_json::json!("Alice"), tx, None, None, 0.0);
552        let claim_ref = claim.claim_ref().clone();
553
554        let assertion = ValidityAssertion {
555            assertion_ref: Uuid::new_v4(),
556            agent_id: agent.clone(),
557            target_claim: claim_ref.clone(),
558            kind: AssertionKind::Bound { bound_at },
559            provenance: ProvenanceLabel::External(ExternalKind::UserAsserted),
560            confidence: Confidence { value_confidence: 1.0, valid_time_confidence: 1.0 },
561            asserted_at: TransactionTime(bound_at),
562        };
563
564        store.claims.lock().unwrap().push(claim);
565        store.assertions.lock().unwrap().push(assertion);
566
567        let uc = uc(Arc::clone(&store));
568        // Query well after the bound
569        let now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
570        let resp = uc.execute_with_time(
571            QueryHistoryRequest { agent_id: agent, subject: "acme".into(), predicate: "ceo".into() },
572            now,
573        ).unwrap();
574
575        assert_eq!(resp.entries.len(), 1, "one claim in history");
576        assert_eq!(
577            resp.entries[0].status,
578            HistoryEntryStatus::Superseded,
579            "bounded claim must be Superseded"
580        );
581        assert!(resp.current().is_none(), "no current entry when all claims are bounded");
582    }
583
584    // ── Tests for compute_effective_windows (pure function) ───────────────────
585
586    #[test]
587    fn compute_effective_windows_empty() {
588        let config = EngineConfig::default();
589        let windows = compute_effective_windows(&[], &config);
590        assert!(windows.is_empty());
591    }
592
593    #[test]
594    fn compute_effective_windows_single() {
595        let config = EngineConfig::default();
596        let agent = agent();
597        let tx = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
598        let c = make_claim(&agent, "a", "b", serde_json::json!("v"), tx, None, None, 0.0);
599        let sorted = vec![&c];
600        let windows = compute_effective_windows(&sorted, &config);
601        assert_eq!(windows.len(), 1);
602        assert_eq!(windows[0], None, "single claim → open-ended");
603    }
604
605    #[test]
606    fn compute_effective_windows_three_entries() {
607        let config = EngineConfig::default();
608        let agent = agent();
609        let t1 = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
610        let t2 = Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap();
611        let t3 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
612
613        let c1 = make_claim(&agent, "a", "b", serde_json::json!("v1"), t1, None, None, 0.0);
614        let c2 = make_claim(&agent, "a", "b", serde_json::json!("v2"), t2, None, None, 0.0);
615        let c3 = make_claim(&agent, "a", "b", serde_json::json!("v3"), t3, None, None, 0.0);
616
617        let sorted = vec![&c1, &c2, &c3];
618        let windows = compute_effective_windows(&sorted, &config);
619
620        assert_eq!(windows.len(), 3);
621        // Each entry closes at successor's tx_time (low confidence)
622        assert_eq!(windows[0], Some(t2));
623        assert_eq!(windows[1], Some(t3));
624        assert_eq!(windows[2], None);
625    }
626
627    // ── Additional: provenance format ─────────────────────────────────────────
628
629    #[test]
630    fn provenance_formatted_correctly_in_entry() {
631        let store = Arc::new(MockStore::default());
632        let agent = agent();
633        let tx = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
634        let claim = make_claim(&agent, "acme", "ceo", serde_json::json!("Alice"), tx, None, None, 0.0);
635        store.claims.lock().unwrap().push(claim);
636
637        let uc = uc(Arc::clone(&store));
638        let now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
639        let resp = uc.execute_with_time(
640            QueryHistoryRequest { agent_id: agent, subject: "acme".into(), predicate: "ceo".into() },
641            now,
642        ).unwrap();
643
644        assert_eq!(resp.entries[0].provenance, "External/UserAsserted");
645    }
646}