Skip to main content

gemini_memory_rs/ingestion/
ledger.rs

1//! The session candidate ledger.
2//!
3//! Observations accumulate here before anything becomes canonical. The ledger
4//! is what makes a crash survivable, what turns three mentions of the same
5//! thing into one candidate with three pieces of evidence, and what
6//! consolidation reads at the end of a session.
7
8use async_trait::async_trait;
9use chrono::{DateTime, Datelike, Utc};
10use parking_lot::RwLock;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashSet};
13
14use crate::core::{
15    AdmissionVerdict, DiscardReason, Explicitness, FactFingerprint, IngestionConfig, MemoryError,
16    MemoryKind, MemoryObservation, MemoryValue, MutationIntent, ObservationId, ProposedPersistence,
17    SensitivityClass, SessionId, TemporalScope, TurnId, admit_observation, aggregate_confidence,
18};
19
20/// Where a candidate stands within the session.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SessionCandidateStatus {
24    /// Seen once; not yet trusted enough to answer with.
25    Observed,
26    /// Usable in this conversation.
27    ActiveSessionFact,
28    /// Will be offered to post-session reconciliation.
29    StagedForReconciliation,
30    /// Contradicted by something the user said later in the session.
31    Suppressed,
32    /// Refused by policy.
33    Rejected,
34    /// An explicit command awaiting durable commit.
35    PendingExplicitCommit,
36}
37
38impl SessionCandidateStatus {
39    /// Whether a candidate in this state may be retrieved during the session.
40    pub fn is_usable(self) -> bool {
41        matches!(
42            self,
43            Self::ActiveSessionFact | Self::StagedForReconciliation | Self::PendingExplicitCommit
44        )
45    }
46}
47
48/// One piece of evidence behind a candidate.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct ObservationEvidence {
51    /// Which observation.
52    pub observation_id: ObservationId,
53    /// Which turn it came from.
54    pub turn_id: TurnId,
55    /// How directly it was stated.
56    pub explicitness: Explicitness,
57    /// Extractor confidence.
58    pub confidence: f32,
59    /// The utterance behind it.
60    pub utterance: String,
61    /// When it was observed.
62    pub observed_at: DateTime<Utc>,
63}
64
65/// A proposed memory accumulating evidence within a session.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct SessionCandidate {
68    /// Deduplication key.
69    pub fingerprint: FactFingerprint,
70    /// Subject of the fact.
71    pub subject: crate::core::EntityRef,
72    /// Canonical predicate.
73    pub predicate: crate::core::CanonicalPredicate,
74    /// Value side.
75    pub value: MemoryValue,
76    /// Natural-language rendering.
77    pub canonical_statement: String,
78    /// Proposed kind.
79    pub kind: MemoryKind,
80    /// Expected persistence.
81    pub temporal_scope: TemporalScope,
82    /// Supporting evidence.
83    pub evidence: Vec<ObservationEvidence>,
84    /// Distinct turns that produced evidence.
85    pub distinct_turns: usize,
86    /// First turn the candidate appeared on.
87    pub first_seen_turn: TurnId,
88    /// Most recent turn the candidate appeared on.
89    pub last_seen_turn: TurnId,
90    /// Aggregated confidence.
91    pub confidence: f32,
92    /// Strongest explicitness across the evidence.
93    pub explicitness: Explicitness,
94    /// Retention policy assigned at admission.
95    pub proposed_persistence: ProposedPersistence,
96    /// Lifecycle state.
97    pub status: SessionCandidateStatus,
98    /// Explicit command carried by the evidence, if any.
99    pub mutation_intent: Option<MutationIntent>,
100    /// Terms this fact may later be searched by, merged across its evidence.
101    pub search_terms: Vec<String>,
102    /// Privacy classification, carried from the observation.
103    ///
104    /// Dropping this here would let an explicit health or religious statement
105    /// reach the corpus with ordinary privacy metadata, silently bypassing
106    /// every sensitivity-dependent rule downstream.
107    pub sensitivity: SensitivityClass,
108    /// When the fact stops holding, for episodic candidates.
109    pub expected_expiry: Option<DateTime<Utc>>,
110}
111
112impl SessionCandidate {
113    fn from_observation(
114        observation: &MemoryObservation,
115        persistence: ProposedPersistence,
116        now: DateTime<Utc>,
117    ) -> Self {
118        let evidence = ObservationEvidence {
119            observation_id: observation.observation_id.clone(),
120            turn_id: observation.turn_id,
121            explicitness: observation.explicitness,
122            confidence: observation.confidence,
123            utterance: observation.transcript_evidence.utterance.clone(),
124            observed_at: now,
125        };
126        let status = status_for(persistence, observation);
127        Self {
128            fingerprint: observation.fingerprint(),
129            subject: observation.subject.clone(),
130            predicate: observation.predicate.clone(),
131            value: observation.value.clone(),
132            canonical_statement: observation.canonical_statement.clone(),
133            kind: observation.kind,
134            temporal_scope: observation.temporal_scope,
135            evidence: vec![evidence],
136            distinct_turns: 1,
137            first_seen_turn: observation.turn_id,
138            last_seen_turn: observation.turn_id,
139            confidence: observation.confidence,
140            explicitness: observation.explicitness,
141            proposed_persistence: persistence,
142            status,
143            mutation_intent: observation.mutation_intent,
144            search_terms: observation.search_terms.clone(),
145            sensitivity: observation.sensitivity,
146            expected_expiry: observation.expected_expiry,
147        }
148    }
149
150    fn absorb(&mut self, observation: &MemoryObservation, now: DateTime<Utc>) {
151        if self
152            .evidence
153            .iter()
154            .any(|e| e.observation_id == observation.observation_id)
155        {
156            return;
157        }
158        let new_turn = observation.turn_id != self.last_seen_turn;
159        self.evidence.push(ObservationEvidence {
160            observation_id: observation.observation_id.clone(),
161            turn_id: observation.turn_id,
162            explicitness: observation.explicitness,
163            confidence: observation.confidence,
164            utterance: observation.transcript_evidence.utterance.clone(),
165            observed_at: now,
166        });
167        if new_turn {
168            self.distinct_turns += 1;
169        }
170        self.last_seen_turn = observation.turn_id;
171        self.explicitness = self.explicitness.max(observation.explicitness);
172        self.confidence = aggregate_confidence(
173            &self
174                .evidence
175                .iter()
176                .map(|e| (e.confidence, e.explicitness))
177                .collect::<Vec<_>>(),
178        );
179        if observation.mutation_intent.is_some() {
180            self.mutation_intent = observation.mutation_intent;
181        }
182        // The most restrictive classification any evidence carried wins.
183        self.sensitivity = self.sensitivity.max(observation.sensitivity);
184        // Search terms accumulate: each restatement may add the vocabulary of
185        // a different language or a different way of asking.
186        for term in &observation.search_terms {
187            if !self
188                .search_terms
189                .iter()
190                .any(|t| t.eq_ignore_ascii_case(term))
191            {
192                self.search_terms.push(term.clone());
193            }
194        }
195        if self.status == SessionCandidateStatus::Observed && self.explicitness.is_explicit() {
196            self.status = SessionCandidateStatus::ActiveSessionFact;
197        }
198    }
199
200    /// Distinct calendar days the evidence spans.
201    pub fn distinct_days(&self) -> u32 {
202        self.evidence
203            .iter()
204            .map(|e| (e.observed_at.year(), e.observed_at.ordinal()))
205            .collect::<HashSet<_>>()
206            .len() as u32
207    }
208
209    /// The `subject|predicate` window used to find contradictions.
210    pub fn subject_predicate(&self) -> &str {
211        self.fingerprint.subject_predicate()
212    }
213}
214
215fn status_for(
216    persistence: ProposedPersistence,
217    observation: &MemoryObservation,
218) -> SessionCandidateStatus {
219    if observation.mutation_intent.is_some() {
220        return SessionCandidateStatus::PendingExplicitCommit;
221    }
222    match persistence {
223        ProposedPersistence::Durable | ProposedPersistence::Episodic => {
224            SessionCandidateStatus::ActiveSessionFact
225        }
226        ProposedPersistence::SessionOnly => SessionCandidateStatus::ActiveSessionFact,
227        ProposedPersistence::Staged => SessionCandidateStatus::Observed,
228        ProposedPersistence::Discard => SessionCandidateStatus::Rejected,
229    }
230}
231
232/// What happened when an observation was offered to the ledger.
233#[derive(Debug, Clone, PartialEq)]
234pub enum LedgerOutcome {
235    /// A new candidate was created.
236    Created(FactFingerprint),
237    /// Evidence was added to an existing candidate.
238    Reinforced {
239        /// The candidate reinforced.
240        fingerprint: FactFingerprint,
241        /// Evidence count after the merge.
242        evidence_count: usize,
243    },
244    /// The observation was refused by policy.
245    Rejected(DiscardReason),
246}
247
248/// A ledger sealed against further writes.
249#[derive(Debug, Clone)]
250pub struct SealedSessionLedger {
251    /// The session it belongs to.
252    pub session_id: SessionId,
253    /// Candidates carried into consolidation.
254    pub candidates: Vec<SessionCandidate>,
255    /// When it was sealed.
256    pub sealed_at: DateTime<Utc>,
257}
258
259/// A snapshot of the ledger mid-session.
260#[derive(Debug, Clone)]
261pub struct SessionLedgerSnapshot {
262    /// The session it belongs to.
263    pub session_id: SessionId,
264    /// Every candidate, in fingerprint order.
265    pub candidates: Vec<SessionCandidate>,
266    /// Ledger revision.
267    pub revision: u64,
268}
269
270/// Accumulates observations for a logical session.
271#[async_trait]
272pub trait SessionLedger: Send + Sync {
273    /// Offer an observation to the ledger.
274    async fn append_observation(
275        &self,
276        observation: MemoryObservation,
277    ) -> Result<LedgerOutcome, MemoryError>;
278
279    /// Read the current candidate set.
280    async fn snapshot(&self) -> Result<SessionLedgerSnapshot, MemoryError>;
281
282    /// Close the ledger and hand its candidates to consolidation.
283    async fn seal(&self) -> Result<SealedSessionLedger, MemoryError>;
284}
285
286/// The in-process ledger.
287#[derive(Debug)]
288pub struct InMemorySessionLedger {
289    session_id: SessionId,
290    config: IngestionConfig,
291    inner: RwLock<LedgerState>,
292}
293
294#[derive(Debug, Default)]
295struct LedgerState {
296    candidates: BTreeMap<FactFingerprint, SessionCandidate>,
297    revision: u64,
298    sealed: bool,
299}
300
301impl InMemorySessionLedger {
302    /// A ledger for one session.
303    pub fn new(session_id: SessionId, config: IngestionConfig) -> Self {
304        Self {
305            session_id,
306            config,
307            inner: RwLock::new(LedgerState::default()),
308        }
309    }
310
311    /// The session this ledger belongs to.
312    pub fn session_id(&self) -> &SessionId {
313        &self.session_id
314    }
315
316    /// The ledger revision, bumped on every accepted observation.
317    pub fn revision(&self) -> u64 {
318        self.inner.read().revision
319    }
320
321    /// How many candidates are held.
322    pub fn len(&self) -> usize {
323        self.inner.read().candidates.len()
324    }
325
326    /// Whether the ledger holds no candidates.
327    pub fn is_empty(&self) -> bool {
328        self.inner.read().candidates.is_empty()
329    }
330
331    /// Candidates usable for retrieval right now.
332    pub fn usable_candidates(&self) -> Vec<SessionCandidate> {
333        self.inner
334            .read()
335            .candidates
336            .values()
337            .filter(|c| c.status.is_usable())
338            .cloned()
339            .collect()
340    }
341
342    /// Merge duplicates, resolve in-session contradictions, and re-evaluate
343    /// statuses (§18).
344    ///
345    /// Runs on a cadence during the session. Deliberately local: it compares
346    /// candidates against each other, never against the durable corpus.
347    pub fn micro_reconcile(&self) -> MicroReconciliationReport {
348        let mut state = self.inner.write();
349        let mut report = MicroReconciliationReport::default();
350
351        // Within one `subject|predicate` window, the newest explicit statement
352        // wins and older competing values are suppressed. A user who says
353        // "actually, pescatarian" has not left two beliefs behind.
354        //
355        // Only where the window is single-valued, though. `dietary_identity`
356        // holds one answer, so a second value contradicts the first; `routine`
357        // and `preference` are the buckets an extractor uses when it could not
358        // say *which* attribute a fact is about, so two values there are
359        // usually two facts. Collapsing those lost data mid-conversation: "I've
360        // started a pottery class on Thursdays" and "I always take the metro to
361        // work" both land on `user|routine`, and the second silently deleted
362        // the first — measured, recallable on turn 1 and gone by turn 2.
363        //
364        // The residual case is "I prefer window seats" followed by "actually,
365        // aisle": a real correction inside an unclassified window, which now
366        // leaves both. That is a symptom of the coarse vocabulary rather than
367        // of this rule — a model-backed extractor writes `seat_preference` and
368        // the window is single-valued again — and leaving two facts is the
369        // better failure, because the alternative deletes one the user never
370        // retracted.
371        let mut by_window: BTreeMap<String, Vec<FactFingerprint>> = BTreeMap::new();
372        for (fingerprint, candidate) in state.candidates.iter() {
373            if candidate.status == SessionCandidateStatus::Rejected
374                || matches!(
375                    candidate.mutation_intent,
376                    Some(
377                        crate::core::MutationIntent::Forget
378                            | crate::core::MutationIntent::Delete
379                            | crate::core::MutationIntent::List
380                    )
381                )
382            {
383                continue;
384            }
385            by_window
386                .entry(candidate.subject_predicate().to_string())
387                .or_default()
388                .push(fingerprint.clone());
389        }
390
391        for (_, fingerprints) in by_window {
392            if fingerprints.len() < 2 {
393                continue;
394            }
395            // An explicit correction collapses its window whatever the
396            // predicate is named: the user said outright that one replaces the
397            // other.
398            let corrected = fingerprints.iter().any(|f| {
399                state.candidates[f].mutation_intent == Some(crate::core::MutationIntent::Correct)
400            });
401            let single_valued = fingerprints
402                .iter()
403                .any(|f| is_single_valued(state.candidates[f].predicate.as_str()));
404            if !corrected && !single_valued {
405                continue;
406            }
407            let winner = fingerprints
408                .iter()
409                .max_by(|a, b| {
410                    let ca = &state.candidates[*a];
411                    let cb = &state.candidates[*b];
412                    ca.explicitness
413                        .cmp(&cb.explicitness)
414                        .then_with(|| ca.last_seen_turn.cmp(&cb.last_seen_turn))
415                })
416                .cloned()
417                .expect("non-empty window");
418
419            for fingerprint in fingerprints {
420                if fingerprint == winner {
421                    continue;
422                }
423                if let Some(candidate) = state.candidates.get_mut(&fingerprint)
424                    && candidate.status != SessionCandidateStatus::Suppressed
425                {
426                    candidate.status = SessionCandidateStatus::Suppressed;
427                    report.suppressed += 1;
428                }
429            }
430        }
431
432        // Promote observations that have earned in-session trust.
433        for candidate in state.candidates.values_mut() {
434            if candidate.status == SessionCandidateStatus::Observed
435                && (candidate.distinct_turns >= 2
436                    || candidate.confidence >= self.config.minimum_observation_confidence * 2.0)
437            {
438                candidate.status = SessionCandidateStatus::StagedForReconciliation;
439                report.staged += 1;
440            }
441        }
442
443        report.candidates = state.candidates.len();
444        state.revision += 1;
445        report
446    }
447}
448
449/// What one micro-reconciliation pass did.
450#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
451pub struct MicroReconciliationReport {
452    /// Candidates suppressed by a later contradiction.
453    pub suppressed: usize,
454    /// Candidates promoted to staging.
455    pub staged: usize,
456    /// Total candidates after the pass.
457    pub candidates: usize,
458}
459
460#[async_trait]
461impl SessionLedger for InMemorySessionLedger {
462    async fn append_observation(
463        &self,
464        observation: MemoryObservation,
465    ) -> Result<LedgerOutcome, MemoryError> {
466        if self.inner.read().sealed {
467            return Err(MemoryError::PolicyRefused(
468                "session ledger is sealed".to_string(),
469            ));
470        }
471
472        let persistence = match admit_observation(&observation, &self.config) {
473            AdmissionVerdict::Accept(persistence) => persistence,
474            AdmissionVerdict::Reject(reason) => return Ok(LedgerOutcome::Rejected(reason)),
475        };
476
477        let now = Utc::now();
478        let fingerprint = observation.fingerprint();
479        let mut state = self.inner.write();
480        state.revision += 1;
481
482        match state.candidates.get_mut(&fingerprint) {
483            Some(existing) => {
484                existing.absorb(&observation, now);
485                Ok(LedgerOutcome::Reinforced {
486                    fingerprint,
487                    evidence_count: existing.evidence.len(),
488                })
489            }
490            None => {
491                state.candidates.insert(
492                    fingerprint.clone(),
493                    SessionCandidate::from_observation(&observation, persistence, now),
494                );
495                Ok(LedgerOutcome::Created(fingerprint))
496            }
497        }
498    }
499
500    async fn snapshot(&self) -> Result<SessionLedgerSnapshot, MemoryError> {
501        let state = self.inner.read();
502        Ok(SessionLedgerSnapshot {
503            session_id: self.session_id.clone(),
504            candidates: state.candidates.values().cloned().collect(),
505            revision: state.revision,
506        })
507    }
508
509    async fn seal(&self) -> Result<SealedSessionLedger, MemoryError> {
510        let mut state = self.inner.write();
511        state.sealed = true;
512        Ok(SealedSessionLedger {
513            session_id: self.session_id.clone(),
514            candidates: state
515                .candidates
516                .values()
517                .filter(|c| {
518                    !matches!(
519                        c.status,
520                        SessionCandidateStatus::Rejected | SessionCandidateStatus::Suppressed
521                    )
522                })
523                .cloned()
524                .collect(),
525            sealed_at: Utc::now(),
526        })
527    }
528}
529
530/// The predicates that are not really predicates: an extractor's "something
531/// about the user, unclassified" buckets.
532///
533/// A second value in a named window — `dietary_identity`, `residence`,
534/// `employer` — contradicts the first. A second value in one of these is
535/// usually just a second fact.
536fn is_single_valued(predicate: &str) -> bool {
537    !matches!(predicate, "preference" | "routine")
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::core::{CanonicalPredicate, EntityRef, SpeakerAttribution, TranscriptEvidence};
544
545    fn observation(
546        predicate: &str,
547        value: &str,
548        turn: u64,
549        explicitness: Explicitness,
550    ) -> MemoryObservation {
551        MemoryObservation {
552            observation_id: ObservationId::generate(),
553            session_id: SessionId::new("ses_1"),
554            turn_id: TurnId(turn),
555            subject: EntityRef::user(),
556            predicate: CanonicalPredicate::new(predicate),
557            value: MemoryValue::Text(value.to_string()),
558            canonical_statement: format!("The user is {value}."),
559            kind: MemoryKind::Preference,
560            explicitness,
561            confidence: 0.9,
562            persistence: ProposedPersistence::Durable,
563            temporal_scope: TemporalScope::Persistent,
564            valid_from: None,
565            expected_expiry: None,
566            transcript_evidence: TranscriptEvidence::new(format!("I am {value}")),
567            speaker_attribution: SpeakerAttribution::User,
568            sensitivity: SensitivityClass::Normal,
569            mutation_intent: None,
570            search_terms: Vec::new(),
571        }
572    }
573
574    fn ledger() -> InMemorySessionLedger {
575        InMemorySessionLedger::new(SessionId::new("ses_1"), IngestionConfig::default())
576    }
577
578    #[tokio::test]
579    async fn the_same_fact_stated_twice_becomes_one_candidate_with_two_evidences() {
580        let ledger = ledger();
581        let first = ledger
582            .append_observation(observation(
583                "dietary_identity",
584                "pescatarian",
585                1,
586                Explicitness::ExplicitStatement,
587            ))
588            .await
589            .unwrap();
590        assert!(matches!(first, LedgerOutcome::Created(_)));
591
592        let second = ledger
593            .append_observation(observation(
594                "dietary_identity",
595                "pescatarian",
596                4,
597                Explicitness::ExplicitStatement,
598            ))
599            .await
600            .unwrap();
601        assert!(matches!(
602            second,
603            LedgerOutcome::Reinforced {
604                evidence_count: 2,
605                ..
606            }
607        ));
608        assert_eq!(ledger.len(), 1);
609        assert_eq!(ledger.usable_candidates()[0].distinct_turns, 2);
610    }
611
612    #[tokio::test]
613    async fn refused_observations_never_enter_the_ledger() {
614        let ledger = ledger();
615        let mut bystander = observation(
616            "dietary_identity",
617            "vegan",
618            1,
619            Explicitness::ExplicitStatement,
620        );
621        bystander.speaker_attribution = SpeakerAttribution::Bystander;
622
623        let outcome = ledger.append_observation(bystander).await.unwrap();
624        assert_eq!(
625            outcome,
626            LedgerOutcome::Rejected(DiscardReason::SpeakerNotUser)
627        );
628        assert!(ledger.is_empty());
629    }
630
631    #[tokio::test]
632    async fn a_later_contradiction_suppresses_the_earlier_candidate() {
633        let ledger = ledger();
634        ledger
635            .append_observation(observation(
636                "dietary_identity",
637                "vegetarian",
638                1,
639                Explicitness::ExplicitStatement,
640            ))
641            .await
642            .unwrap();
643        ledger
644            .append_observation(observation(
645                "dietary_identity",
646                "pescatarian",
647                5,
648                Explicitness::ExplicitStatement,
649            ))
650            .await
651            .unwrap();
652
653        let report = ledger.micro_reconcile();
654        assert_eq!(report.suppressed, 1);
655
656        let usable = ledger.usable_candidates();
657        assert_eq!(usable.len(), 1);
658        assert_eq!(usable[0].canonical_statement, "The user is pescatarian.");
659    }
660
661    #[tokio::test]
662    async fn an_explicit_correction_beats_a_more_recent_inference() {
663        let ledger = ledger();
664        ledger
665            .append_observation(observation(
666                "dietary_identity",
667                "pescatarian",
668                1,
669                Explicitness::ExplicitStatement,
670            ))
671            .await
672            .unwrap();
673        // A later *inference* must not displace what the user actually said.
674        ledger
675            .append_observation(observation(
676                "dietary_identity",
677                "vegan",
678                6,
679                Explicitness::WeakInference,
680            ))
681            .await
682            .unwrap();
683
684        ledger.micro_reconcile();
685        let usable = ledger.usable_candidates();
686        assert_eq!(usable.len(), 1);
687        assert_eq!(usable[0].canonical_statement, "The user is pescatarian.");
688    }
689
690    #[tokio::test]
691    async fn repeated_inference_earns_staging_but_not_more() {
692        let ledger = ledger();
693        for turn in 1..=2 {
694            ledger
695                .append_observation(observation(
696                    "exercise_routine",
697                    "morning gym",
698                    turn,
699                    Explicitness::StrongImplication,
700                ))
701                .await
702                .unwrap();
703        }
704        let report = ledger.micro_reconcile();
705        assert_eq!(report.staged, 1);
706        assert_eq!(
707            ledger.usable_candidates()[0].status,
708            SessionCandidateStatus::StagedForReconciliation
709        );
710    }
711
712    #[tokio::test]
713    async fn sealing_drops_suppressed_and_rejected_candidates_and_stops_writes() {
714        let ledger = ledger();
715        ledger
716            .append_observation(observation(
717                "dietary_identity",
718                "vegetarian",
719                1,
720                Explicitness::ExplicitStatement,
721            ))
722            .await
723            .unwrap();
724        ledger
725            .append_observation(observation(
726                "dietary_identity",
727                "pescatarian",
728                5,
729                Explicitness::ExplicitStatement,
730            ))
731            .await
732            .unwrap();
733        ledger.micro_reconcile();
734
735        let sealed = ledger.seal().await.unwrap();
736        assert_eq!(sealed.candidates.len(), 1);
737
738        let err = ledger
739            .append_observation(observation(
740                "dietary_identity",
741                "vegan",
742                9,
743                Explicitness::ExplicitStatement,
744            ))
745            .await
746            .unwrap_err();
747        assert!(matches!(err, MemoryError::PolicyRefused(_)));
748    }
749
750    #[tokio::test]
751    async fn confidence_aggregates_rather_than_accumulates() {
752        let ledger = ledger();
753        for turn in 1..=4 {
754            ledger
755                .append_observation(observation(
756                    "beverage_preference",
757                    "flat white",
758                    turn,
759                    Explicitness::ExplicitStatement,
760                ))
761                .await
762                .unwrap();
763        }
764        let candidate = &ledger.usable_candidates()[0];
765        assert!(candidate.confidence <= 0.95);
766        assert!(candidate.confidence >= 0.9);
767    }
768}