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                continue;
375            }
376            by_window
377                .entry(candidate.subject_predicate().to_string())
378                .or_default()
379                .push(fingerprint.clone());
380        }
381
382        for (_, fingerprints) in by_window {
383            if fingerprints.len() < 2 {
384                continue;
385            }
386            // An explicit correction collapses its window whatever the
387            // predicate is named: the user said outright that one replaces the
388            // other.
389            let corrected = fingerprints.iter().any(|f| {
390                state.candidates[f].mutation_intent == Some(crate::core::MutationIntent::Correct)
391            });
392            let single_valued = fingerprints
393                .iter()
394                .any(|f| is_single_valued(state.candidates[f].predicate.as_str()));
395            if !corrected && !single_valued {
396                continue;
397            }
398            let winner = fingerprints
399                .iter()
400                .max_by(|a, b| {
401                    let ca = &state.candidates[*a];
402                    let cb = &state.candidates[*b];
403                    ca.explicitness
404                        .cmp(&cb.explicitness)
405                        .then_with(|| ca.last_seen_turn.cmp(&cb.last_seen_turn))
406                })
407                .cloned()
408                .expect("non-empty window");
409
410            for fingerprint in fingerprints {
411                if fingerprint == winner {
412                    continue;
413                }
414                if let Some(candidate) = state.candidates.get_mut(&fingerprint)
415                    && candidate.status != SessionCandidateStatus::Suppressed
416                {
417                    candidate.status = SessionCandidateStatus::Suppressed;
418                    report.suppressed += 1;
419                }
420            }
421        }
422
423        // Promote observations that have earned in-session trust.
424        for candidate in state.candidates.values_mut() {
425            if candidate.status == SessionCandidateStatus::Observed
426                && (candidate.distinct_turns >= 2
427                    || candidate.confidence >= self.config.minimum_observation_confidence * 2.0)
428            {
429                candidate.status = SessionCandidateStatus::StagedForReconciliation;
430                report.staged += 1;
431            }
432        }
433
434        report.candidates = state.candidates.len();
435        state.revision += 1;
436        report
437    }
438}
439
440/// What one micro-reconciliation pass did.
441#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
442pub struct MicroReconciliationReport {
443    /// Candidates suppressed by a later contradiction.
444    pub suppressed: usize,
445    /// Candidates promoted to staging.
446    pub staged: usize,
447    /// Total candidates after the pass.
448    pub candidates: usize,
449}
450
451#[async_trait]
452impl SessionLedger for InMemorySessionLedger {
453    async fn append_observation(
454        &self,
455        observation: MemoryObservation,
456    ) -> Result<LedgerOutcome, MemoryError> {
457        if self.inner.read().sealed {
458            return Err(MemoryError::PolicyRefused(
459                "session ledger is sealed".to_string(),
460            ));
461        }
462
463        let persistence = match admit_observation(&observation, &self.config) {
464            AdmissionVerdict::Accept(persistence) => persistence,
465            AdmissionVerdict::Reject(reason) => return Ok(LedgerOutcome::Rejected(reason)),
466        };
467
468        let now = Utc::now();
469        let fingerprint = observation.fingerprint();
470        let mut state = self.inner.write();
471        state.revision += 1;
472
473        match state.candidates.get_mut(&fingerprint) {
474            Some(existing) => {
475                existing.absorb(&observation, now);
476                Ok(LedgerOutcome::Reinforced {
477                    fingerprint,
478                    evidence_count: existing.evidence.len(),
479                })
480            }
481            None => {
482                state.candidates.insert(
483                    fingerprint.clone(),
484                    SessionCandidate::from_observation(&observation, persistence, now),
485                );
486                Ok(LedgerOutcome::Created(fingerprint))
487            }
488        }
489    }
490
491    async fn snapshot(&self) -> Result<SessionLedgerSnapshot, MemoryError> {
492        let state = self.inner.read();
493        Ok(SessionLedgerSnapshot {
494            session_id: self.session_id.clone(),
495            candidates: state.candidates.values().cloned().collect(),
496            revision: state.revision,
497        })
498    }
499
500    async fn seal(&self) -> Result<SealedSessionLedger, MemoryError> {
501        let mut state = self.inner.write();
502        state.sealed = true;
503        Ok(SealedSessionLedger {
504            session_id: self.session_id.clone(),
505            candidates: state
506                .candidates
507                .values()
508                .filter(|c| {
509                    !matches!(
510                        c.status,
511                        SessionCandidateStatus::Rejected | SessionCandidateStatus::Suppressed
512                    )
513                })
514                .cloned()
515                .collect(),
516            sealed_at: Utc::now(),
517        })
518    }
519}
520
521/// The predicates that are not really predicates: an extractor's "something
522/// about the user, unclassified" buckets.
523///
524/// A second value in a named window — `dietary_identity`, `residence`,
525/// `employer` — contradicts the first. A second value in one of these is
526/// usually just a second fact.
527fn is_single_valued(predicate: &str) -> bool {
528    !matches!(predicate, "preference" | "routine")
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::core::{CanonicalPredicate, EntityRef, SpeakerAttribution, TranscriptEvidence};
535
536    fn observation(
537        predicate: &str,
538        value: &str,
539        turn: u64,
540        explicitness: Explicitness,
541    ) -> MemoryObservation {
542        MemoryObservation {
543            observation_id: ObservationId::generate(),
544            session_id: SessionId::new("ses_1"),
545            turn_id: TurnId(turn),
546            subject: EntityRef::user(),
547            predicate: CanonicalPredicate::new(predicate),
548            value: MemoryValue::Text(value.to_string()),
549            canonical_statement: format!("The user is {value}."),
550            kind: MemoryKind::Preference,
551            explicitness,
552            confidence: 0.9,
553            persistence: ProposedPersistence::Durable,
554            temporal_scope: TemporalScope::Persistent,
555            valid_from: None,
556            expected_expiry: None,
557            transcript_evidence: TranscriptEvidence::new(format!("I am {value}")),
558            speaker_attribution: SpeakerAttribution::User,
559            sensitivity: SensitivityClass::Normal,
560            mutation_intent: None,
561            search_terms: Vec::new(),
562        }
563    }
564
565    fn ledger() -> InMemorySessionLedger {
566        InMemorySessionLedger::new(SessionId::new("ses_1"), IngestionConfig::default())
567    }
568
569    #[tokio::test]
570    async fn the_same_fact_stated_twice_becomes_one_candidate_with_two_evidences() {
571        let ledger = ledger();
572        let first = ledger
573            .append_observation(observation(
574                "dietary_identity",
575                "pescatarian",
576                1,
577                Explicitness::ExplicitStatement,
578            ))
579            .await
580            .unwrap();
581        assert!(matches!(first, LedgerOutcome::Created(_)));
582
583        let second = ledger
584            .append_observation(observation(
585                "dietary_identity",
586                "pescatarian",
587                4,
588                Explicitness::ExplicitStatement,
589            ))
590            .await
591            .unwrap();
592        assert!(matches!(
593            second,
594            LedgerOutcome::Reinforced {
595                evidence_count: 2,
596                ..
597            }
598        ));
599        assert_eq!(ledger.len(), 1);
600        assert_eq!(ledger.usable_candidates()[0].distinct_turns, 2);
601    }
602
603    #[tokio::test]
604    async fn refused_observations_never_enter_the_ledger() {
605        let ledger = ledger();
606        let mut bystander = observation(
607            "dietary_identity",
608            "vegan",
609            1,
610            Explicitness::ExplicitStatement,
611        );
612        bystander.speaker_attribution = SpeakerAttribution::Bystander;
613
614        let outcome = ledger.append_observation(bystander).await.unwrap();
615        assert_eq!(
616            outcome,
617            LedgerOutcome::Rejected(DiscardReason::SpeakerNotUser)
618        );
619        assert!(ledger.is_empty());
620    }
621
622    #[tokio::test]
623    async fn a_later_contradiction_suppresses_the_earlier_candidate() {
624        let ledger = ledger();
625        ledger
626            .append_observation(observation(
627                "dietary_identity",
628                "vegetarian",
629                1,
630                Explicitness::ExplicitStatement,
631            ))
632            .await
633            .unwrap();
634        ledger
635            .append_observation(observation(
636                "dietary_identity",
637                "pescatarian",
638                5,
639                Explicitness::ExplicitStatement,
640            ))
641            .await
642            .unwrap();
643
644        let report = ledger.micro_reconcile();
645        assert_eq!(report.suppressed, 1);
646
647        let usable = ledger.usable_candidates();
648        assert_eq!(usable.len(), 1);
649        assert_eq!(usable[0].canonical_statement, "The user is pescatarian.");
650    }
651
652    #[tokio::test]
653    async fn an_explicit_correction_beats_a_more_recent_inference() {
654        let ledger = ledger();
655        ledger
656            .append_observation(observation(
657                "dietary_identity",
658                "pescatarian",
659                1,
660                Explicitness::ExplicitStatement,
661            ))
662            .await
663            .unwrap();
664        // A later *inference* must not displace what the user actually said.
665        ledger
666            .append_observation(observation(
667                "dietary_identity",
668                "vegan",
669                6,
670                Explicitness::WeakInference,
671            ))
672            .await
673            .unwrap();
674
675        ledger.micro_reconcile();
676        let usable = ledger.usable_candidates();
677        assert_eq!(usable.len(), 1);
678        assert_eq!(usable[0].canonical_statement, "The user is pescatarian.");
679    }
680
681    #[tokio::test]
682    async fn repeated_inference_earns_staging_but_not_more() {
683        let ledger = ledger();
684        for turn in 1..=2 {
685            ledger
686                .append_observation(observation(
687                    "exercise_routine",
688                    "morning gym",
689                    turn,
690                    Explicitness::StrongImplication,
691                ))
692                .await
693                .unwrap();
694        }
695        let report = ledger.micro_reconcile();
696        assert_eq!(report.staged, 1);
697        assert_eq!(
698            ledger.usable_candidates()[0].status,
699            SessionCandidateStatus::StagedForReconciliation
700        );
701    }
702
703    #[tokio::test]
704    async fn sealing_drops_suppressed_and_rejected_candidates_and_stops_writes() {
705        let ledger = ledger();
706        ledger
707            .append_observation(observation(
708                "dietary_identity",
709                "vegetarian",
710                1,
711                Explicitness::ExplicitStatement,
712            ))
713            .await
714            .unwrap();
715        ledger
716            .append_observation(observation(
717                "dietary_identity",
718                "pescatarian",
719                5,
720                Explicitness::ExplicitStatement,
721            ))
722            .await
723            .unwrap();
724        ledger.micro_reconcile();
725
726        let sealed = ledger.seal().await.unwrap();
727        assert_eq!(sealed.candidates.len(), 1);
728
729        let err = ledger
730            .append_observation(observation(
731                "dietary_identity",
732                "vegan",
733                9,
734                Explicitness::ExplicitStatement,
735            ))
736            .await
737            .unwrap_err();
738        assert!(matches!(err, MemoryError::PolicyRefused(_)));
739    }
740
741    #[tokio::test]
742    async fn confidence_aggregates_rather_than_accumulates() {
743        let ledger = ledger();
744        for turn in 1..=4 {
745            ledger
746                .append_observation(observation(
747                    "beverage_preference",
748                    "flat white",
749                    turn,
750                    Explicitness::ExplicitStatement,
751                ))
752                .await
753                .unwrap();
754        }
755        let candidate = &ledger.usable_candidates()[0];
756        assert!(candidate.confidence <= 0.95);
757        assert!(candidate.confidence >= 0.9);
758    }
759}