gemini_memory_rs/
engine.rs

1//! The memory engine facade.
2//!
3//! [`MemoryEngine`] owns everything that outlives a conversation — the
4//! repository, the compiled index, the event log, the extractor seams — and
5//! [`MemorySession`] owns everything that does not: the candidate ledger, the
6//! overlay, the turn counter, and the prepared snapshot the current turn is
7//! being answered from.
8//!
9//! The split is the architecture in miniature. A session may end abruptly at
10//! any moment; nothing it holds is authoritative until reconciliation writes it
11//! through the engine.
12
13use chrono::Utc;
14use parking_lot::RwLock;
15use std::sync::Arc;
16use std::time::Duration;
17
18use crate::bm25::{IndexedMemory, MemoryIndex};
19use crate::core::{
20    MemoryError, MemoryEvent, MemoryEventLog, MemoryRuntimeConfig, MemoryStatus, MutationIntent,
21    SessionEventWriter, SessionId, TurnId, UserId,
22};
23use crate::ingestion::{
24    BoundedObservationExtractor, CadenceTracker, InMemorySessionLedger, LedgerOutcome,
25    MemoryObservationExtractor, ObservationExtractionContext, RuleBasedObservationExtractor,
26    ScheduledWork, SessionLedger, SessionMemoryOverlay,
27};
28use crate::okf::{MemoryRepository, OkfRepository};
29use crate::reconcile::{MemoryCommitter, ReconciliationReport, consolidate};
30use crate::retrieval::{
31    DeterministicPlanExtractor, DeterministicPlanner, IndexHandle, KnownEntities,
32    LocalMemoryRetriever, MemoryRetriever, PreparedMemorySnapshot, RetrievalBudget,
33    RetrievalPlanExtractor, RetrievalRequest, SemanticFallback, context_for, fuse_snapshots,
34};
35use crate::transcript::GenerationGuard;
36
37/// How long a model-backed retrieval plan may take before the rule-based plan
38/// is used instead.
39pub const PLAN_EXTRACTION_TIMEOUT_MS: u64 = 4_000;
40
41/// Everything that outlives a conversation.
42pub struct MemoryEngine {
43    user: UserId,
44    config: MemoryRuntimeConfig,
45    repository: Arc<dyn MemoryRepository>,
46    canonical: Arc<IndexHandle>,
47    planner: Arc<RwLock<Arc<DeterministicPlanner>>>,
48    events: Arc<dyn MemoryEventLog>,
49    plan_extractor: Arc<RwLock<Arc<dyn RetrievalPlanExtractor>>>,
50    /// Whether the caller installed their own plan extractor.
51    caller_supplied_extractor: Arc<std::sync::atomic::AtomicBool>,
52    observation_extractor: Arc<dyn MemoryObservationExtractor>,
53    /// The optional paraphrase-tolerant backend, shared by every session.
54    semantic: Option<Arc<dyn SemanticFallback>>,
55}
56
57impl MemoryEngine {
58    /// An engine backed entirely by in-process storage.
59    ///
60    /// Suitable for tests, single-node deployments and local development; swap
61    /// the repository and event log for durable ones in production.
62    pub fn in_memory(user: UserId) -> Self {
63        Self::new(
64            user,
65            Arc::new(OkfRepository::in_memory()),
66            Arc::new(crate::core::InMemoryEventLog::new()),
67            MemoryRuntimeConfig::default(),
68        )
69    }
70
71    /// An engine over the given repository and event log.
72    pub fn new(
73        user: UserId,
74        repository: Arc<dyn MemoryRepository>,
75        events: Arc<dyn MemoryEventLog>,
76        config: MemoryRuntimeConfig,
77    ) -> Self {
78        let planner = Arc::new(DeterministicPlanner::new());
79        Self {
80            user,
81            config,
82            repository,
83            canonical: Arc::new(IndexHandle::new()),
84            planner: Arc::new(RwLock::new(planner.clone())),
85            events,
86            plan_extractor: Arc::new(RwLock::new(Arc::new(DeterministicPlanExtractor::new(
87                planner,
88            )))),
89            caller_supplied_extractor: Arc::new(std::sync::atomic::AtomicBool::new(false)),
90            observation_extractor: Arc::new(RuleBasedObservationExtractor::new()),
91            semantic: None,
92        }
93    }
94
95    /// Attach a paraphrase-tolerant retrieval backend.
96    ///
97    /// Every session opened afterwards consults it. Without this the seam was
98    /// unreachable in practice: `LocalMemoryRetriever::with_semantic_fallback`
99    /// existed, but [`MemoryEngine::begin_session`] built the retriever itself
100    /// and never called it, so no application could install one.
101    ///
102    /// The backend is asked on both the speculative and the tool path, under
103    /// the deadlines in [`crate::core::RetrievalConfig`]. A backend slower than
104    /// `immediate_semantic_timeout_ms` simply times out on the tool path and
105    /// still contributes through speculation.
106    pub fn with_semantic_fallback(mut self, fallback: Arc<dyn SemanticFallback>) -> Self {
107        self.semantic = Some(fallback);
108        self
109    }
110
111    /// Use a model-backed retrieval-plan extractor.
112    ///
113    /// The extractor is wrapped in a deadline that falls back to the rule-based
114    /// plan, so an unavailable model degrades retrieval quality rather than
115    /// stalling the pipeline.
116    pub fn with_plan_extractor(self, extractor: Arc<dyn RetrievalPlanExtractor>) -> Self {
117        // Measured: a constrained-decode plan call is ~2s on gemini-2.5-flash.
118        // The previous 500ms bound meant the model's plan was *never* used —
119        // every query silently fell back to the rule-based planner, which is
120        // English-only, so a Hinglish question retrieved nothing at all.
121        // Planning is speculative and off the response path; a generous bound
122        // costs nothing a user can perceive.
123        *self.plan_extractor.write() = Arc::new(crate::retrieval::BoundedPlanExtractor::new(
124            extractor,
125            Duration::from_millis(PLAN_EXTRACTION_TIMEOUT_MS),
126        ));
127        self.caller_supplied_extractor
128            .store(true, std::sync::atomic::Ordering::Release);
129        self
130    }
131
132    /// Adopt a refreshed planner, and rebuild the default extractor around it.
133    ///
134    /// The default extractor *owns* a planner rather than reading the shared
135    /// one, so refreshing only the shared handle would leave it planning
136    /// against a stale entity table — and a query justified solely by a known
137    /// entity would wrongly skip memory. A caller-supplied extractor is left
138    /// alone; it is not ours to replace.
139    fn install_planner(&self, planner: Arc<DeterministicPlanner>) {
140        *self.planner.write() = planner.clone();
141        if !self
142            .caller_supplied_extractor
143            .load(std::sync::atomic::Ordering::Acquire)
144        {
145            *self.plan_extractor.write() = Arc::new(DeterministicPlanExtractor::new(planner));
146        }
147    }
148
149    /// Use a model-backed observation extractor, under the configured deadline.
150    pub fn with_observation_extractor(
151        mut self,
152        extractor: Arc<dyn MemoryObservationExtractor>,
153    ) -> Self {
154        self.observation_extractor = Arc::new(BoundedObservationExtractor::new(
155            extractor,
156            Duration::from_millis(self.config.ingestion.extraction_soft_timeout_ms),
157        ));
158        self
159    }
160
161    /// The user whose memory this engine serves.
162    pub fn user(&self) -> &UserId {
163        &self.user
164    }
165
166    /// The canonical repository.
167    pub fn repository(&self) -> &Arc<dyn MemoryRepository> {
168        &self.repository
169    }
170
171    /// The event log.
172    pub fn events(&self) -> &Arc<dyn MemoryEventLog> {
173        &self.events
174    }
175
176    /// Runtime configuration.
177    pub fn config(&self) -> &MemoryRuntimeConfig {
178        &self.config
179    }
180
181    /// Compile the retrieval index from the canonical corpus.
182    ///
183    /// The index is derived and disposable; this rebuilds it from scratch, which
184    /// is also the recovery path after any index corruption or schema change.
185    pub async fn compile_index(&self) -> Result<u64, MemoryError> {
186        let records = self.repository.all(&self.user).await?;
187        let index = MemoryIndex::build(
188            records
189                .iter()
190                .filter(|m| m.status == MemoryStatus::Active)
191                .map(IndexedMemory::from_canonical),
192        );
193
194        let known = KnownEntities::from_index(&index);
195        self.canonical.replace(index);
196        self.install_planner(Arc::new(DeterministicPlanner::with_entities(known)));
197
198        // The semantic backend is brought along, for the same reason
199        // `MemorySession::recompile_canonical` brings it along: this function's
200        // whole job is to make the retrieval layer agree with the repository,
201        // and a semantic index left behind is a retrieval layer that does not.
202        //
203        // Without this the two compile paths disagreed. A session finishing a
204        // turn reconciled the backend; an engine compiling its index did not —
205        // so the startup sequence this crate documents (`restore` a persisted
206        // index, then `compile_index`) produced a lexical index in step with
207        // the corpus and a semantic one that could be missing every record
208        // added since the vectors were last written, until some later session
209        // happened to reconcile.
210        //
211        // Failure is swallowed for the reason spelled out on the session path:
212        // the durable record and the lexical index are already correct here, so
213        // failing the compile because a vector store was unreachable would turn
214        // a degraded semantic layer into a failed refresh. `semantic_ranking`
215        // drops ids that no longer resolve, so a backend left behind loses
216        // facts rather than serving wrong ones.
217        let revision = self.canonical.revision();
218        if let Some(semantic) = &self.semantic {
219            let active: Vec<(crate::core::MemoryId, String)> = records
220                .iter()
221                .filter(|m| m.status == MemoryStatus::Active)
222                .map(|m| (m.id.clone(), crate::retrieval::embedding_text(m)))
223                .collect();
224            let _ = semantic.reconcile(&active, revision).await;
225        }
226
227        let writer = SessionEventWriter::new(
228            self.events.clone(),
229            self.user.clone(),
230            SessionId::new("index"),
231        );
232        let _ = writer
233            .append(None, MemoryEvent::IndexRevisionPublished { revision })
234            .await;
235        Ok(revision)
236    }
237
238    /// Begin a logical conversation.
239    pub fn begin_session(&self, session_id: SessionId) -> MemorySession {
240        let overlay_handle = Arc::new(IndexHandle::new());
241        let mut retriever = LocalMemoryRetriever::new(
242            self.canonical.clone(),
243            overlay_handle.clone(),
244            self.config.retrieval.clone(),
245        );
246        if let Some(semantic) = &self.semantic {
247            retriever = retriever.with_semantic_fallback(semantic.clone());
248        }
249        let retriever = Arc::new(retriever);
250
251        MemorySession {
252            memory_map: RwLock::new((u64::MAX, String::new())),
253            user: self.user.clone(),
254            session_id: session_id.clone(),
255            config: self.config.clone(),
256            repository: self.repository.clone(),
257            ledger: Arc::new(InMemorySessionLedger::new(
258                session_id.clone(),
259                self.config.ingestion.clone(),
260            )),
261            overlay: RwLock::new(SessionMemoryOverlay::new()),
262            overlay_handle,
263            retriever,
264            planner: self.planner.clone(),
265            plan_extractor: self.plan_extractor.clone(),
266            caller_supplied_extractor: self.caller_supplied_extractor.clone(),
267            observation_extractor: self.observation_extractor.clone(),
268            generation: GenerationGuard::new(),
269            current_turn: RwLock::new(TurnId::ZERO),
270            prepared: RwLock::new(PreparedMemorySnapshot::empty(TurnId::ZERO)),
271            active: RwLock::new(PreparedMemorySnapshot::empty(TurnId::ZERO)),
272            cadence: RwLock::new(CadenceTracker::new(&self.config, Utc::now())),
273            events: SessionEventWriter::new(self.events.clone(), self.user.clone(), session_id),
274            canonical: self.canonical.clone(),
275        }
276    }
277
278    /// Run a promotion sweep over staged patterns.
279    pub async fn promote_patterns(&self) -> Result<usize, MemoryError> {
280        let records = self.repository.all(&self.user).await?;
281        let outcomes =
282            crate::reconcile::sweep(&records, &self.config.pattern_promotion, Utc::now());
283        let promoted = outcomes
284            .iter()
285            .filter(|o| matches!(o, crate::reconcile::PromotionOutcome::Promote(_)))
286            .count();
287        if outcomes.is_empty() {
288            return Ok(0);
289        }
290        crate::reconcile::commit_promotions(
291            &self.repository,
292            &self.user,
293            outcomes,
294            &format!("promotion-{}", Utc::now().timestamp()),
295        )
296        .await?;
297        self.compile_index().await?;
298        Ok(promoted)
299    }
300}
301
302/// Everything scoped to one logical conversation.
303pub struct MemorySession {
304    user: UserId,
305    session_id: SessionId,
306    config: MemoryRuntimeConfig,
307    repository: Arc<dyn MemoryRepository>,
308    ledger: Arc<InMemorySessionLedger>,
309    overlay: RwLock<SessionMemoryOverlay>,
310    overlay_handle: Arc<IndexHandle>,
311    retriever: Arc<LocalMemoryRetriever>,
312    planner: Arc<RwLock<Arc<DeterministicPlanner>>>,
313    plan_extractor: Arc<RwLock<Arc<dyn RetrievalPlanExtractor>>>,
314    caller_supplied_extractor: Arc<std::sync::atomic::AtomicBool>,
315    observation_extractor: Arc<dyn MemoryObservationExtractor>,
316    generation: GenerationGuard,
317    /// The turn currently in flight.
318    ///
319    /// Distinct from the active snapshot's `source_turn_id`, which names the
320    /// turn the snapshot was *prepared from* — one turn behind, and zero before
321    /// any preparation has happened. Stamping a memory command with that would
322    /// corrupt its provenance and the last-seen ordering that picks between
323    /// competing session candidates.
324    current_turn: RwLock<TurnId>,
325    prepared: RwLock<PreparedMemorySnapshot>,
326    active: RwLock<PreparedMemorySnapshot>,
327    cadence: RwLock<CadenceTracker>,
328    events: SessionEventWriter,
329    canonical: Arc<IndexHandle>,
330    /// The memory map, and the index revision it was built from.
331    ///
332    /// Cached because the map is read once per turn by the instruction
333    /// amendment, and rebuilding it is a pass over every record — at 16,000
334    /// records that is real work to repeat for an answer that only changes when
335    /// the corpus does.
336    memory_map: RwLock<(u64, String)>,
337}
338
339impl MemorySession {
340    /// The logical conversation this session represents.
341    pub fn session_id(&self) -> &SessionId {
342        &self.session_id
343    }
344
345    /// The candidate ledger.
346    pub fn ledger(&self) -> &Arc<InMemorySessionLedger> {
347        &self.ledger
348    }
349
350    /// The retriever serving this session.
351    pub fn retriever(&self) -> &Arc<LocalMemoryRetriever> {
352        &self.retriever
353    }
354
355    /// The vocabulary a model needs in order to fill `recall_context`'s
356    /// `about` and `attribute` fields.
357    ///
358    /// Put this in the system instruction. Without it a model names the right
359    /// predicate 2% of the time, which is below the 8% at which filtering
360    /// starts paying for itself; with it, 69%. It is a few hundred tokens and
361    /// bounded by the user's vocabulary rather than by how much they have
362    /// accumulated — 282 tokens at 16,000 records. See
363    /// [`crate::retrieval::vocabulary`].
364    ///
365    /// Recomputed only when the canonical index has moved, so calling this
366    /// every turn is cheap.
367    pub fn memory_map(&self) -> String {
368        let revision = self.canonical.revision();
369        {
370            let cached = self.memory_map.read();
371            if cached.0 == revision {
372                return cached.1.clone();
373            }
374        }
375        let fresh = crate::retrieval::vocabulary::memory_map_from_index(
376            &self.canonical.read(),
377            crate::retrieval::MEMORY_MAP_LIMIT,
378        );
379        *self.memory_map.write() = (revision, fresh.clone());
380        fresh
381    }
382
383    /// The runtime configuration this session was opened with.
384    pub fn config(&self) -> &MemoryRuntimeConfig {
385        &self.config
386    }
387
388    /// Freeze the prepared snapshot for a turn that is starting.
389    ///
390    /// Everything the model is answered with for this turn comes from the
391    /// snapshot taken here, so a retrieval landing mid-response cannot change
392    /// what B appears to remember halfway through a sentence.
393    pub fn begin_turn(&self, turn_id: TurnId) -> u64 {
394        let prepared = self.prepared.read().clone();
395        *self.active.write() = prepared;
396        *self.current_turn.write() = turn_id;
397        self.cadence.write().touch(Utc::now());
398        self.generation.advance()
399    }
400
401    /// The turn currently in flight.
402    pub fn current_turn(&self) -> TurnId {
403        *self.current_turn.read()
404    }
405
406    /// The snapshot the current turn is being answered from.
407    pub fn active_snapshot(&self) -> PreparedMemorySnapshot {
408        self.active.read().clone()
409    }
410
411    /// The most recently prepared snapshot, whether or not a turn is using it.
412    pub fn prepared_snapshot(&self) -> PreparedMemorySnapshot {
413        self.prepared.read().clone()
414    }
415
416    /// The generation guard, for cancelling stale speculative work.
417    pub fn generation(&self) -> &GenerationGuard {
418        &self.generation
419    }
420
421    /// Prepare context speculatively from a transcript.
422    ///
423    /// Publishes the resulting snapshot only if the conversation has not moved
424    /// on since the work started.
425    pub async fn prepare(
426        &self,
427        turn_id: TurnId,
428        transcript: &str,
429    ) -> Result<PreparedMemorySnapshot, MemoryError> {
430        let generation = self.generation.current();
431        let now = Utc::now();
432        let planner = self.planner.read().clone();
433        let context = context_for(&planner, transcript, turn_id, generation, now);
434        let extractor = self.plan_extractor.read().clone();
435        let plan = extractor.extract(context).await?;
436
437        let snapshot = self
438            .retriever
439            .prepare(RetrievalRequest { plan, now })
440            .await?;
441
442        if self.generation.is_current(generation) {
443            *self.prepared.write() = snapshot.clone();
444        }
445        Ok(snapshot)
446    }
447
448    /// Serve a `recall_context` tool call.
449    ///
450    /// Reads the frozen snapshot when it plainly covers the query, and
451    /// otherwise runs a live local search and **fuses the snapshot into it**.
452    /// The search is bounded and never reaches the network.
453    ///
454    /// The fusion is the part worth explaining. `satisfies` decides the fast
455    /// path by word overlap between the question and the prepared statements —
456    /// which is the right test for "can I skip the search entirely" and the
457    /// wrong one for "is this snapshot any good". Measured against snapshots
458    /// that *already contained the answer*, it refused 65 of 93 paraphrased
459    /// questions, and refused hardest exactly where speculation is most
460    /// valuable: 0 of 6 asked in-situ, 1 of 20 needing a step of inference.
461    /// Every one of those refusals threw away a correct answer and replaced it
462    /// with a lexical search that could not find one.
463    ///
464    /// So a refusal no longer discards the snapshot; it demotes it to one
465    /// ranking among two. That also gives the speculative path somewhere to
466    /// deliver: it runs with a 100 ms semantic budget where this path has 10,
467    /// so a remote backend's results reach the model here or nowhere.
468    pub async fn recall(&self, query: &str, turn_id: TurnId) -> serde_json::Value {
469        let now = Utc::now();
470        let active = self.active_snapshot();
471        if active.satisfies(query, now) {
472            return active.to_tool_payload();
473        }
474        let budget = RetrievalBudget::interactive_with(&self.config.retrieval);
475        match self
476            .retriever
477            .retrieve_immediate(query, turn_id, budget)
478            .await
479        {
480            Ok(live) => fuse_snapshots(
481                &live,
482                &active,
483                self.config.retrieval.max_memories,
484                self.config.retrieval.max_tokens,
485                now,
486            )
487            .to_tool_payload(),
488            // Memory failure degrades to whatever was already prepared, and to
489            // "nothing found" if that is empty. It never fails a turn.
490            Err(_) if !active.is_empty() => active.to_tool_payload(),
491            Err(_) => PreparedMemorySnapshot::empty(turn_id).to_tool_payload(),
492        }
493    }
494
495    /// Serve a `recall_context` tool call restricted to a scope or narrowed by
496    /// the caller's `about`/`attribute` hints.
497    ///
498    /// An unrestricted, unhinted recall may be answered from the frozen
499    /// snapshot. Anything else runs a live local search, because the snapshot
500    /// was prepared speculatively and knows neither the restriction nor the
501    /// hints — serving it would answer a different question quickly.
502    ///
503    /// The hints only ever reorder. See
504    /// [`RetrievalPlan::subject_hint`](crate::retrieval::RetrievalPlan::subject_hint)
505    /// for why they must not do more than that.
506    pub async fn recall_scoped(
507        &self,
508        query: &str,
509        turn_id: TurnId,
510        scope: crate::runtime::tools::RecallScope,
511        about: Option<String>,
512        attribute: Option<String>,
513    ) -> serde_json::Value {
514        let kinds = scope.kinds();
515        let hinted = about.as_ref().is_some_and(|v| !v.trim().is_empty())
516            || attribute.as_ref().is_some_and(|v| !v.trim().is_empty());
517        if kinds.is_empty() && !hinted {
518            return self.recall(query, turn_id).await;
519        }
520        match self
521            .retriever
522            .retrieve_scoped(
523                query,
524                turn_id,
525                // The session's configuration, not the default one. A
526                // deployment that sets `immediate_semantic_timeout_ms` to zero
527                // means it on every recall, and a scoped or hinted question is
528                // not a different kind of recall — it is the same recall with a
529                // narrower plan. The unscoped path above already reads the
530                // config; these two disagreeing meant a caller could disable
531                // semantic work on the tool path and still have it run,
532                // whenever the model happened to fill in `about` or a scope.
533                RetrievalBudget::interactive_with(&self.config.retrieval),
534                kinds,
535                about,
536                attribute,
537            )
538            .await
539        {
540            Ok(snapshot) => snapshot.to_tool_payload(),
541            Err(_) => PreparedMemorySnapshot::empty(turn_id).to_tool_payload(),
542        }
543    }
544
545    /// Record a finalized user turn: durable evidence, then extraction.
546    ///
547    /// The transcript event is appended before extraction runs, so a crash
548    /// between the two loses an extraction that can be retried rather than the
549    /// evidence itself.
550    pub async fn observe_final_transcript(
551        &self,
552        turn_id: TurnId,
553        transcript: &str,
554    ) -> Result<Vec<LedgerOutcome>, MemoryError> {
555        self.events
556            .append(
557                Some(turn_id),
558                MemoryEvent::FinalTranscriptRecorded {
559                    text: transcript.to_string(),
560                },
561            )
562            .await?;
563
564        let observations = match self
565            .observation_extractor
566            .extract(
567                ObservationExtractionContext::user_turn(
568                    transcript,
569                    self.session_id.clone(),
570                    turn_id,
571                    Utc::now(),
572                )
573                .with_known_predicates(self.known_predicates()),
574            )
575            .await
576        {
577            Ok(observations) => observations,
578            Err(error) => {
579                // Degrade — a failed extraction must never fail the turn — but
580                // record it. Silently returning nothing makes a broken
581                // extractor indistinguishable from a quiet conversation.
582                let _ = self
583                    .events
584                    .append(
585                        Some(turn_id),
586                        MemoryEvent::ExtractionFailed {
587                            stage: "observation".to_string(),
588                            reason: error.to_string(),
589                        },
590                    )
591                    .await;
592                Vec::new()
593            }
594        };
595
596        let mut outcomes = Vec::new();
597        for observation in observations {
598            let fingerprint = observation.fingerprint();
599            if let Some(intent) = observation.mutation_intent {
600                self.events
601                    .append(
602                        Some(turn_id),
603                        MemoryEvent::ExplicitMutationRequested {
604                            intent,
605                            statement: observation.canonical_statement.clone(),
606                        },
607                    )
608                    .await?;
609            }
610            let outcome = self.ledger.append_observation(observation).await?;
611            if let LedgerOutcome::Rejected(reason) = &outcome {
612                // The real fingerprint, so the audit trail names the fact that
613                // was refused rather than a placeholder.
614                let _ = self
615                    .events
616                    .append(
617                        Some(turn_id),
618                        MemoryEvent::ObservationRejected {
619                            fingerprint: fingerprint.clone(),
620                            reason: *reason,
621                        },
622                    )
623                    .await;
624            }
625            outcomes.push(outcome);
626        }
627
628        self.refresh_overlay().await;
629        Ok(outcomes)
630    }
631
632    /// Complete a turn and run whatever the cadence says is now due.
633    pub async fn on_turn_complete(
634        &self,
635        turn_id: TurnId,
636    ) -> Result<Vec<ScheduledWork>, MemoryError> {
637        let due = self.cadence.write().on_turn_complete(Utc::now());
638        for work in &due {
639            match work {
640                ScheduledWork::MicroReconcile => {
641                    self.ledger.micro_reconcile();
642                    self.refresh_overlay().await;
643                }
644                ScheduledWork::Checkpoint => {
645                    self.ledger.micro_reconcile();
646                    self.refresh_overlay().await;
647                    let turns = self.cadence.read().total_turns();
648                    self.events
649                        .append(turn_id.into(), MemoryEvent::SessionCheckpointed { turns })
650                        .await?;
651                }
652                ScheduledWork::SealSession => {}
653            }
654        }
655        Ok(due)
656    }
657
658    /// Whether the session has been idle long enough to seal.
659    pub fn is_idle(&self) -> bool {
660        self.cadence.read().is_idle(Utc::now())
661    }
662
663    /// Seal the session and reconcile its evidence into canonical memory.
664    ///
665    /// Idempotent by session id: reconciling twice commits once.
666    pub async fn finish(&self) -> Result<ReconciliationReport, MemoryError> {
667        self.ledger.micro_reconcile();
668        let sealed = self.ledger.seal().await?;
669        self.cadence.write().seal();
670        self.events
671            .append(
672                None,
673                MemoryEvent::SessionSealed {
674                    candidate_count: sealed.candidates.len(),
675                },
676            )
677            .await?;
678
679        let output = consolidate(&sealed);
680        let committer =
681            MemoryCommitter::new(self.repository.clone()).with_events(self.events.clone());
682        let report = committer
683            .reconcile(&self.user, output, self.session_id.as_str())
684            .await?;
685
686        if !report.is_empty() {
687            self.recompile_canonical().await?;
688        }
689        self.overlay.write().clear();
690        self.overlay_handle.replace(MemoryIndex::new());
691        self.retriever.invalidate_cache();
692        Ok(report)
693    }
694
695    /// Apply an explicit memory command from the user.
696    ///
697    /// Explicit intent takes effect in the conversation immediately and commits
698    /// durably afterwards. The durable event is appended before the overlay is
699    /// touched, so the engine never tells a user their correction was recorded
700    /// when it was not.
701    pub async fn apply_explicit_command(
702        &self,
703        intent: MutationIntent,
704        statement: &str,
705        turn_id: TurnId,
706    ) -> Result<serde_json::Value, MemoryError> {
707        self.events
708            .append(
709                Some(turn_id),
710                MemoryEvent::ExplicitMutationRequested {
711                    intent,
712                    statement: statement.to_string(),
713                },
714            )
715            .await?;
716
717        if intent == MutationIntent::List {
718            return Ok(serde_json::json!({
719                "status": "accepted",
720                "operation": "list",
721                "facts": self.known_statements(),
722            }));
723        }
724
725        let observation = explicit_observation(
726            intent,
727            statement,
728            self.resolve_target_predicate(intent, statement),
729            self.session_id.clone(),
730            turn_id,
731            Utc::now(),
732        );
733        let outcome = self.ledger.append_observation(observation).await?;
734        self.refresh_overlay().await;
735
736        let accepted = !matches!(outcome, LedgerOutcome::Rejected(_));
737        Ok(serde_json::json!({
738            "status": if accepted { "accepted" } else { "refused" },
739            "operation": operation_label(intent),
740            "effective_in_session": accepted,
741            "durable_commit": "pending",
742        }))
743    }
744
745    /// The predicate the corpus already uses for the fact a command is about.
746    ///
747    /// An explicit "remember that…" or "correct that…" arrives as a sentence,
748    /// not as a triple, and the fact it concerns already has a name in the
749    /// corpus. Finding that name is what makes the command land on the record
750    /// it means: reconciliation matches on `subject|predicate`, and so does the
751    /// in-session suppression that hides a durable fact the user has just
752    /// contradicted. A command filed under a predicate of its own supersedes
753    /// nothing and hides nothing — it becomes a second, contradicting record.
754    ///
755    /// This is the same trick `known_predicates` plays for the extraction
756    /// model, applied to the path that never asks a model anything: the
757    /// vocabulary comes from the corpus rather than from a constant.
758    ///
759    /// Deliberately conservative. A weak match is worse than no match, because
760    /// hijacking the wrong window suppresses a fact the user never mentioned, so
761    /// the hit has to clear the same score floor the assembler uses before its
762    /// predicate is adopted.
763    fn resolve_target_predicate(
764        &self,
765        intent: MutationIntent,
766        statement: &str,
767    ) -> Option<crate::core::CanonicalPredicate> {
768        use crate::bm25::Query;
769
770        // A deletion names what to remove, not a fact to restate, and it is
771        // routed by its own `memory_removal` predicate.
772        if matches!(
773            intent,
774            MutationIntent::Forget | MutationIntent::Delete | MutationIntent::List
775        ) {
776            return None;
777        }
778
779        let query = Query::new(statement)
780            .with_boost_only(crate::retrieval::non_topical_terms(statement))
781            .with_limit(1);
782        let canonical = self.canonical.read();
783        let hit = canonical.search(&query, Utc::now()).into_iter().next()?;
784        if hit.score < self.config.retrieval.minimum_candidate_score {
785            return None;
786        }
787        canonical.get(&hit.id).map(|doc| doc.predicate.clone())
788    }
789
790    /// The predicate names this user's corpus already uses.
791    ///
792    /// Offered to the extraction model so a correction lands on the predicate
793    /// it is correcting. Reconciliation matches on subject and predicate; a
794    /// model free to name each fact afresh writes `dietary_preference` in one
795    /// session and `dietary_identity` in the next, and "actually I'm
796    /// pescatarian now" becomes a second active record rather than superseding
797    /// the first. This is the entity table's trick applied to predicates: the
798    /// vocabulary comes from the corpus.
799    ///
800    /// Bounded, because it goes in a prompt. Session candidates come first —
801    /// a correction usually chases something said minutes ago.
802    pub fn known_predicates(&self) -> Vec<String> {
803        const LIMIT: usize = 60;
804        let mut out: Vec<String> = Vec::new();
805        let mut push = |predicate: &str| {
806            if out.len() < LIMIT && !predicate.is_empty() && !out.iter().any(|p| p == predicate) {
807                out.push(predicate.to_string());
808            }
809        };
810        for candidate in self.ledger.usable_candidates() {
811            push(candidate.predicate.as_str());
812        }
813        let now = Utc::now();
814        for doc in self.canonical.read().documents() {
815            if doc.is_retrievable(now) {
816                push(doc.predicate.as_str());
817            }
818        }
819        out
820    }
821
822    /// The predicate/value pairs memory can currently assert.
823    ///
824    /// Session facts shadow canonical ones: something the user said this
825    /// conversation is a better answer than something recalled from months ago.
826    pub fn known_values(&self) -> Vec<(crate::core::CanonicalPredicate, serde_json::Value)> {
827        let mut out: Vec<(crate::core::CanonicalPredicate, serde_json::Value)> = Vec::new();
828        let mut push = |predicate: crate::core::CanonicalPredicate, value: serde_json::Value| {
829            if !out.iter().any(|(p, _)| p == &predicate) {
830                out.push((predicate, value));
831            }
832        };
833
834        for candidate in self.ledger.usable_candidates() {
835            if candidate.mutation_intent == Some(MutationIntent::List) {
836                continue;
837            }
838            push(
839                candidate.predicate.clone(),
840                serde_json::Value::String(candidate.value.display()),
841            );
842        }
843        let now = Utc::now();
844        for doc in self.canonical.read().documents() {
845            if doc.is_retrievable(now) {
846                push(
847                    doc.predicate.clone(),
848                    serde_json::Value::String(doc.value.clone()),
849                );
850            }
851        }
852        out
853    }
854
855    /// Every statement currently retrievable, canonical and provisional.
856    pub fn known_statements(&self) -> Vec<String> {
857        let mut statements: Vec<String> = self
858            .canonical
859            .read()
860            .documents()
861            .map(|d| d.statement.clone())
862            .collect();
863        statements.extend(
864            self.ledger
865                .usable_candidates()
866                .iter()
867                .map(|c| c.canonical_statement.clone()),
868        );
869        statements.sort();
870        statements.dedup();
871        statements
872    }
873
874    /// Everything the user has explicitly asked to be remembered this session.
875    pub fn pending_explicit_commands(&self) -> Vec<MutationIntent> {
876        self.ledger
877            .usable_candidates()
878            .iter()
879            .filter_map(|c| c.mutation_intent)
880            .collect()
881    }
882
883    async fn refresh_overlay(&self) {
884        let candidates = self.ledger.usable_candidates();
885
886        // Anything the user stated outright this session hides the durable
887        // record it contradicts for the rest of the conversation. Reconciliation
888        // will make that permanent; until then the overlay is the truth.
889        let suppressed: std::collections::HashSet<String> = candidates
890            .iter()
891            .filter(|c| {
892                c.explicitness.is_explicit() && c.mutation_intent != Some(MutationIntent::List)
893            })
894            .map(|c| c.subject_predicate().to_string())
895            .collect();
896        self.retriever.suppress_windows(suppressed);
897
898        let revision = {
899            let mut overlay = self.overlay.write();
900            overlay.rebuild(&self.user, &self.session_id, &candidates, Utc::now());
901            self.overlay_handle.replace(clone_index(overlay.index()));
902            overlay.revision()
903        };
904        self.retriever.invalidate_cache();
905        let _ = self
906            .events
907            .append(None, MemoryEvent::SessionOverlayUpdated { revision })
908            .await;
909    }
910
911    async fn recompile_canonical(&self) -> Result<(), MemoryError> {
912        let records = self.repository.all(&self.user).await?;
913        self.canonical.replace(MemoryIndex::build(
914            records
915                .iter()
916                .filter(|m| m.status == MemoryStatus::Active)
917                .map(IndexedMemory::from_canonical),
918        ));
919
920        // The semantic backend is recompiled too, and for the same reason the
921        // lexical index is: reconciliation has just decided what is true, and a
922        // retriever holding the previous answer is a retriever that has stopped
923        // agreeing with memory.
924        //
925        // It is passed the whole active set rather than a diff — see
926        // `SemanticFallback::reconcile` — and a well-behaved backend embeds only
927        // what it does not already hold, so the cost of a correction is one
928        // embedding rather than one per record.
929        //
930        // A failure here is logged rather than propagated. The lexical index and
931        // the durable record are already correct at this point; refusing to
932        // finish a recompile because a vector store was unreachable would turn a
933        // degraded semantic layer into a failed commit, and the degradation is
934        // the safe direction — `semantic_ranking` drops ids that no longer
935        // resolve, so a stale backend loses facts rather than serving wrong ones.
936        if let Some(semantic) = self.retriever.semantic() {
937            let active: Vec<(crate::core::MemoryId, String)> = records
938                .iter()
939                .filter(|m| m.status == MemoryStatus::Active)
940                .map(|m| (m.id.clone(), crate::retrieval::embedding_text(m)))
941                .collect();
942            // Failure is swallowed on purpose. By this point the durable record
943            // and the lexical index are already correct, so refusing to finish
944            // the recompile because a vector store was unreachable would turn a
945            // degraded semantic layer into a failed commit. The degradation is
946            // also the safe direction: `semantic_ranking` drops ids that no
947            // longer resolve, so a backend left behind loses facts rather than
948            // serving wrong ones.
949            let _ = semantic.reconcile(&active, self.canonical.revision()).await;
950        }
951        let planner = Arc::new(DeterministicPlanner::with_entities(
952            KnownEntities::from_index(&self.canonical.read()),
953        ));
954        *self.planner.write() = planner.clone();
955        if !self
956            .caller_supplied_extractor
957            .load(std::sync::atomic::Ordering::Acquire)
958        {
959            *self.plan_extractor.write() = Arc::new(DeterministicPlanExtractor::new(planner));
960        }
961        Ok(())
962    }
963}
964
965/// Build the observation behind an explicit memory command.
966///
967/// Explicit commands are the one path where the engine trusts a statement
968/// wholesale: the user said it about themselves, on purpose, to be remembered.
969fn explicit_observation(
970    intent: MutationIntent,
971    statement: &str,
972    target: Option<crate::core::CanonicalPredicate>,
973    session_id: SessionId,
974    turn_id: TurnId,
975    now: chrono::DateTime<Utc>,
976) -> crate::core::MemoryObservation {
977    use crate::core::{
978        CanonicalPredicate, EntityRef, Explicitness, MemoryKind, MemoryValue, ObservationId,
979        ProposedPersistence, SensitivityClass, SpeakerAttribution, TemporalScope,
980        TranscriptEvidence,
981    };
982
983    // A correction has to land on the predicate of the fact it corrects.
984    //
985    // Reconciliation matches on `subject|predicate`, and so does the in-session
986    // suppression that hides a durable record the user has just contradicted.
987    // Naming the new fact `preference` regardless of what it is about — which
988    // is what this did — means the window suppressed (`user|preference`) is
989    // never the window the record lives in (`user|beverage_preference`), so the
990    // correction is accepted, reported as effective, and the next recall serves
991    // the old value alongside the new one. `target` is the predicate the corpus
992    // already uses for this fact, resolved before the observation is built.
993    let predicate = match intent {
994        MutationIntent::Forget | MutationIntent::Delete => "memory_removal".to_string(),
995        MutationIntent::List => "memory_listing".to_string(),
996        _ => target
997            .map(|p| p.as_str().to_string())
998            .unwrap_or_else(|| "preference".to_string()),
999    };
1000
1001    crate::core::MemoryObservation {
1002        observation_id: ObservationId::generate(),
1003        session_id,
1004        turn_id,
1005        subject: EntityRef::user(),
1006        predicate: CanonicalPredicate::new(&predicate),
1007        value: MemoryValue::Text(statement.to_string()),
1008        canonical_statement: statement.to_string(),
1009        kind: MemoryKind::Preference,
1010        explicitness: Explicitness::ExplicitCommand,
1011        confidence: 1.0,
1012        persistence: ProposedPersistence::Durable,
1013        temporal_scope: TemporalScope::Persistent,
1014        valid_from: Some(now),
1015        expected_expiry: None,
1016        transcript_evidence: TranscriptEvidence::new(statement),
1017        speaker_attribution: SpeakerAttribution::User,
1018        sensitivity: SensitivityClass::Normal,
1019        mutation_intent: Some(intent),
1020        search_terms: Vec::new(),
1021    }
1022}
1023
1024fn operation_label(intent: MutationIntent) -> &'static str {
1025    match intent {
1026        MutationIntent::Remember => "remember",
1027        MutationIntent::Correct => "correct",
1028        MutationIntent::Forget => "forget",
1029        MutationIntent::Delete => "delete",
1030        MutationIntent::List => "list",
1031    }
1032}
1033
1034/// Copy an index so the overlay and the retriever's handle stay independent.
1035///
1036/// The overlay owns the authoritative projection of the ledger; the handle is
1037/// what the retriever reads without ever blocking on a rebuild.
1038fn clone_index(source: &MemoryIndex) -> MemoryIndex {
1039    MemoryIndex::build(source.documents().cloned())
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045    use crate::core::InMemoryEventLog;
1046
1047    fn engine() -> MemoryEngine {
1048        MemoryEngine::in_memory(UserId::new("usr_1"))
1049    }
1050
1051    /// One active canonical record, for seeding a repository directly.
1052    fn seed_record(id: &str) -> crate::core::CanonicalMemory {
1053        use crate::core::{
1054            CanonicalPredicate, EntityRef, Explicitness, MemoryId, MemoryKind, MemorySource,
1055            MemoryValue, RetrievalMetadata, TemporalMetadata, TemporalScope,
1056        };
1057        crate::core::CanonicalMemory {
1058            id: MemoryId::new(id),
1059            owner: UserId::new("usr_1"),
1060            kind: MemoryKind::Preference,
1061            predicate: CanonicalPredicate::new("dietary_identity"),
1062            status: MemoryStatus::Active,
1063            confidence: 0.9,
1064            subject: EntityRef::named("user"),
1065            value: MemoryValue::Text("pescatarian".into()),
1066            statement: "The user is pescatarian.".into(),
1067            evidence_summary: "stated".into(),
1068            source: MemorySource::from_explicitness(
1069                Explicitness::ExplicitStatement,
1070                SessionId::new("ses_seed"),
1071                TurnId(1),
1072            ),
1073            temporal: TemporalMetadata::created_at(Utc::now()),
1074            retrieval: RetrievalMetadata {
1075                subject: crate::core::normalize_token("user"),
1076                ..Default::default()
1077            },
1078            temporal_scope: TemporalScope::Persistent,
1079            qualifier: None,
1080            evidence: Default::default(),
1081            privacy: Default::default(),
1082            supersedes: Vec::new(),
1083            superseded_by: None,
1084        }
1085    }
1086
1087    /// A backend that records what it was asked to reconcile against.
1088    #[derive(Default)]
1089    struct RecordingSemantic {
1090        seen: parking_lot::Mutex<Vec<Vec<String>>>,
1091    }
1092
1093    #[async_trait::async_trait]
1094    impl SemanticFallback for RecordingSemantic {
1095        async fn search(
1096            &self,
1097            _query: &str,
1098            _limit: usize,
1099        ) -> Result<Vec<crate::core::MemoryId>, MemoryError> {
1100            Ok(Vec::new())
1101        }
1102        async fn reconcile(
1103            &self,
1104            active: &[(crate::core::MemoryId, String)],
1105            _revision: u64,
1106        ) -> Result<(), MemoryError> {
1107            self.seen
1108                .lock()
1109                .push(active.iter().map(|(id, _)| id.to_string()).collect());
1110            Ok(())
1111        }
1112    }
1113
1114    /// `compile_index` must bring the semantic backend along.
1115    ///
1116    /// Both compile paths exist to make retrieval agree with the repository,
1117    /// and only one of them used to reconcile. That left the documented startup
1118    /// sequence — restore a persisted index, then `compile_index` — with a
1119    /// lexical index in step and a semantic one that could be missing every
1120    /// record written since the vectors were last saved, until some later
1121    /// session happened to seal.
1122    #[tokio::test]
1123    async fn compiling_the_index_reconciles_the_semantic_backend() {
1124        let recorder = Arc::new(RecordingSemantic::default());
1125        let engine =
1126            MemoryEngine::in_memory(UserId::new("usr_1")).with_semantic_fallback(recorder.clone());
1127
1128        // Seed the repository directly rather than through a session. That is
1129        // the situation this path exists for — a process starting against a
1130        // corpus it did not just write, restoring a persisted index and
1131        // compiling — and going through a session would let the session's own
1132        // reconcile do the work and prove nothing about this one.
1133        engine
1134            .repository()
1135            .commit(
1136                crate::okf::MemoryTransaction::new(engine.user().clone(), "tx-seed")
1137                    .put(seed_record("mem_seed")),
1138            )
1139            .await
1140            .expect("seed commit");
1141
1142        let before = recorder.seen.lock().len();
1143        engine.compile_index().await.expect("compile");
1144        let seen = recorder.seen.lock();
1145        assert!(
1146            seen.len() > before,
1147            "compile_index did not reconcile the semantic backend at all"
1148        );
1149        assert!(
1150            !seen.last().expect("a reconcile happened").is_empty(),
1151            "compile_index reconciled against an empty corpus: {seen:?}"
1152        );
1153    }
1154
1155    /// A scoped or hinted recall must honour the session's own deadlines.
1156    ///
1157    /// `recall` read the configured budget and `recall_scoped` built a default
1158    /// one, so a deployment that set `immediate_semantic_timeout_ms` to zero to
1159    /// keep semantic work off the tool path still got the default 10 ms
1160    /// whenever the model filled in a scope or a hint — which, now that the
1161    /// memory map tells it what the hint values are, is most of the time.
1162    ///
1163    /// Asserted by whether the backend was *consulted*, not by comparing two
1164    /// budget constructors: the bug was which constructor the call site used,
1165    /// and a test that calls the right one itself cannot see that.
1166    #[tokio::test]
1167    async fn a_zero_semantic_deadline_is_honoured_on_the_scoped_path_too() {
1168        #[derive(Default)]
1169        struct CountingSemantic {
1170            searches: std::sync::atomic::AtomicUsize,
1171        }
1172        #[async_trait::async_trait]
1173        impl SemanticFallback for CountingSemantic {
1174            async fn search(
1175                &self,
1176                _query: &str,
1177                _limit: usize,
1178            ) -> Result<Vec<crate::core::MemoryId>, MemoryError> {
1179                self.searches
1180                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1181                Ok(Vec::new())
1182            }
1183        }
1184
1185        let mut config = MemoryRuntimeConfig::default();
1186        // The deployment's decision: no semantic work on the tool path.
1187        config.retrieval.immediate_semantic_timeout_ms = 0;
1188
1189        let backend = Arc::new(CountingSemantic::default());
1190        let engine = MemoryEngine::new(
1191            UserId::new("usr_1"),
1192            Arc::new(OkfRepository::in_memory()),
1193            Arc::new(InMemoryEventLog::new()),
1194            config,
1195        )
1196        .with_semantic_fallback(backend.clone());
1197        engine
1198            .repository()
1199            .commit(
1200                crate::okf::MemoryTransaction::new(engine.user().clone(), "tx-seed")
1201                    .put(seed_record("mem_seed")),
1202            )
1203            .await
1204            .expect("seed commit");
1205        engine.compile_index().await.expect("compile");
1206
1207        let session = engine.begin_session(SessionId::new("ses_1"));
1208        session.begin_turn(TurnId(1));
1209        // A hinted recall — the shape `recall_context` produces once the model
1210        // can name a filter value.
1211        let _ = session
1212            .recall_scoped(
1213                "what does the user eat",
1214                TurnId(1),
1215                crate::runtime::tools::RecallScope::All,
1216                Some("user".to_string()),
1217                None,
1218            )
1219            .await;
1220
1221        assert_eq!(
1222            backend.searches.load(std::sync::atomic::Ordering::SeqCst),
1223            0,
1224            "the semantic backend was consulted despite a zero deadline — the \
1225             scoped path built a default budget instead of the configured one"
1226        );
1227    }
1228
1229    #[tokio::test]
1230    async fn a_fact_stated_this_session_is_recalled_in_a_later_turn() {
1231        let engine = engine();
1232        let session = engine.begin_session(SessionId::new("ses_1"));
1233
1234        session.begin_turn(TurnId(1));
1235        session
1236            .observe_final_transcript(TurnId(1), "I am pescatarian")
1237            .await
1238            .unwrap();
1239        session.on_turn_complete(TurnId(1)).await.unwrap();
1240
1241        session.begin_turn(TurnId(2));
1242        let snapshot = session
1243            .prepare(
1244                TurnId(2),
1245                "what do you remember about my dietary preferences",
1246            )
1247            .await
1248            .unwrap();
1249        assert!(
1250            snapshot
1251                .facts
1252                .iter()
1253                .any(|f| f.statement.contains("pescatarian")),
1254            "prepared: {:?}",
1255            snapshot.facts
1256        );
1257    }
1258
1259    #[tokio::test]
1260    async fn a_fact_survives_into_the_next_session_after_reconciliation() {
1261        let engine = engine();
1262
1263        let first = engine.begin_session(SessionId::new("ses_1"));
1264        first.begin_turn(TurnId(1));
1265        first
1266            .observe_final_transcript(TurnId(1), "I am pescatarian")
1267            .await
1268            .unwrap();
1269        let report = first.finish().await.unwrap();
1270        assert_eq!(report.creates, 1);
1271
1272        engine.compile_index().await.unwrap();
1273
1274        let second = engine.begin_session(SessionId::new("ses_2"));
1275        second.begin_turn(TurnId(1));
1276        let snapshot = second
1277            .prepare(
1278                TurnId(1),
1279                "what do you remember about my dietary preferences",
1280            )
1281            .await
1282            .unwrap();
1283        assert!(
1284            snapshot
1285                .facts
1286                .iter()
1287                .any(|f| f.statement.contains("pescatarian"))
1288        );
1289    }
1290
1291    #[tokio::test]
1292    async fn a_correction_stated_mid_session_takes_effect_on_the_next_turn() {
1293        let engine = engine();
1294        let session = engine.begin_session(SessionId::new("ses_1"));
1295
1296        session.begin_turn(TurnId(1));
1297        session
1298            .observe_final_transcript(TurnId(1), "I am vegetarian")
1299            .await
1300            .unwrap();
1301        session.on_turn_complete(TurnId(1)).await.unwrap();
1302
1303        session.begin_turn(TurnId(2));
1304        session
1305            .observe_final_transcript(TurnId(2), "actually I am pescatarian")
1306            .await
1307            .unwrap();
1308        session.on_turn_complete(TurnId(2)).await.unwrap();
1309
1310        session.begin_turn(TurnId(3));
1311        let payload = session
1312            .recall("diet vegetarian pescatarian", TurnId(3))
1313            .await;
1314        let rendered = payload.to_string();
1315        assert!(rendered.contains("pescatarian"), "got {rendered}");
1316    }
1317
1318    #[tokio::test]
1319    async fn a_correction_hides_the_durable_fact_it_contradicts_immediately() {
1320        let engine = engine();
1321
1322        // Monday: the user is vegetarian, and it is reconciled and indexed.
1323        let monday = engine.begin_session(SessionId::new("ses_monday"));
1324        monday.begin_turn(TurnId(1));
1325        monday
1326            .observe_final_transcript(TurnId(1), "I am vegetarian")
1327            .await
1328            .unwrap();
1329        monday.finish().await.unwrap();
1330        engine.compile_index().await.unwrap();
1331
1332        // Thursday: the user corrects it. The old fact must stop being
1333        // retrieved now, not after the session reconciles.
1334        let thursday = engine.begin_session(SessionId::new("ses_thursday"));
1335        thursday.begin_turn(TurnId(1));
1336        thursday
1337            .observe_final_transcript(TurnId(1), "actually I am pescatarian")
1338            .await
1339            .unwrap();
1340        thursday.on_turn_complete(TurnId(1)).await.unwrap();
1341
1342        thursday.begin_turn(TurnId(2));
1343        let snapshot = thursday
1344            .prepare(
1345                TurnId(2),
1346                "what do you remember about my dietary preferences",
1347            )
1348            .await
1349            .unwrap();
1350
1351        let statements: Vec<&str> = snapshot
1352            .facts
1353            .iter()
1354            .map(|f| f.statement.as_str())
1355            .collect();
1356        assert!(
1357            statements.iter().any(|s| s.contains("pescatarian")),
1358            "the correction was not recalled: {statements:?}"
1359        );
1360        assert!(
1361            !statements.iter().any(|s| s.contains("vegetarian")),
1362            "the corrected-away fact was still recalled: {statements:?}"
1363        );
1364    }
1365
1366    #[tokio::test]
1367    async fn asking_what_is_remembered_does_not_itself_become_a_memory() {
1368        let engine = engine();
1369        let session = engine.begin_session(SessionId::new("ses_1"));
1370        session.begin_turn(TurnId(1));
1371        session
1372            .observe_final_transcript(TurnId(1), "I am pescatarian")
1373            .await
1374            .unwrap();
1375        session.begin_turn(TurnId(2));
1376        session
1377            .observe_final_transcript(TurnId(2), "what do you remember about me")
1378            .await
1379            .unwrap();
1380
1381        let snapshot = session
1382            .prepare(
1383                TurnId(3),
1384                "what do you remember about my dietary preferences",
1385            )
1386            .await
1387            .unwrap();
1388        assert!(
1389            !snapshot
1390                .facts
1391                .iter()
1392                .any(|f| f.statement.contains("asked what is remembered")),
1393            "a question about memory was recalled as a memory: {:?}",
1394            snapshot.facts
1395        );
1396    }
1397
1398    #[tokio::test]
1399    async fn a_generic_question_gets_no_memory_and_no_search() {
1400        let engine = engine();
1401        let session = engine.begin_session(SessionId::new("ses_1"));
1402        session.begin_turn(TurnId(1));
1403        let snapshot = session
1404            .prepare(TurnId(1), "what is the capital of France")
1405            .await
1406            .unwrap();
1407        assert!(snapshot.is_empty());
1408        assert_eq!(
1409            session.recall("capital of France", TurnId(1)).await["status"],
1410            "not_found"
1411        );
1412    }
1413
1414    #[tokio::test]
1415    async fn the_active_snapshot_does_not_change_mid_turn() {
1416        let engine = engine();
1417        let session = engine.begin_session(SessionId::new("ses_1"));
1418
1419        session.begin_turn(TurnId(1));
1420        session
1421            .observe_final_transcript(TurnId(1), "I am pescatarian")
1422            .await
1423            .unwrap();
1424        session.begin_turn(TurnId(2));
1425        session.prepare(TurnId(2), "what do I eat").await.unwrap();
1426
1427        let during_turn = session.active_snapshot();
1428        // A newer preparation lands while turn 2 is still in flight.
1429        session
1430            .observe_final_transcript(TurnId(3), "I am allergic to nuts")
1431            .await
1432            .unwrap();
1433        session
1434            .prepare(TurnId(3), "what am I allergic to")
1435            .await
1436            .unwrap();
1437
1438        assert_eq!(
1439            session.active_snapshot().snapshot_id,
1440            during_turn.snapshot_id,
1441            "the in-flight turn keeps its frozen snapshot"
1442        );
1443    }
1444
1445    #[tokio::test]
1446    async fn bystander_speech_never_reaches_the_ledger() {
1447        let engine = engine();
1448        let session = engine.begin_session(SessionId::new("ses_1"));
1449        session.begin_turn(TurnId(1));
1450        session
1451            .observe_final_transcript(TurnId(1), "the weather is nice today")
1452            .await
1453            .unwrap();
1454        assert!(session.ledger().is_empty());
1455    }
1456
1457    #[tokio::test]
1458    async fn reconciling_the_same_session_twice_writes_once() {
1459        let engine = engine();
1460        let session = engine.begin_session(SessionId::new("ses_1"));
1461        session.begin_turn(TurnId(1));
1462        session
1463            .observe_final_transcript(TurnId(1), "I am pescatarian")
1464            .await
1465            .unwrap();
1466
1467        session.finish().await.unwrap();
1468        session.finish().await.unwrap();
1469
1470        let stored = engine.repository().all(engine.user()).await.unwrap();
1471        assert_eq!(stored.len(), 1);
1472    }
1473
1474    #[tokio::test]
1475    async fn the_event_log_records_the_turn_before_the_extraction() {
1476        let log = Arc::new(InMemoryEventLog::new());
1477        let engine = MemoryEngine::new(
1478            UserId::new("usr_1"),
1479            Arc::new(OkfRepository::in_memory()),
1480            log.clone(),
1481            MemoryRuntimeConfig::default(),
1482        );
1483        let session = engine.begin_session(SessionId::new("ses_1"));
1484        session
1485            .observe_final_transcript(TurnId(1), "I am pescatarian")
1486            .await
1487            .unwrap();
1488
1489        let entries = log.entries();
1490        assert_eq!(entries[0].payload.label(), "final_transcript_recorded");
1491    }
1492
1493    #[tokio::test]
1494    async fn a_staged_pattern_is_promoted_once_it_spans_sessions_and_days() {
1495        let engine = engine();
1496
1497        // Two sessions, each stating the routine outright.
1498        for (idx, session_id) in ["ses_1", "ses_2"].iter().enumerate() {
1499            let session = engine.begin_session(SessionId::new(*session_id));
1500            session.begin_turn(TurnId(1));
1501            session
1502                .observe_final_transcript(TurnId(1), "I always go to the gym before work")
1503                .await
1504                .unwrap();
1505            session.finish().await.unwrap();
1506            let _ = idx;
1507        }
1508
1509        let stored = engine.repository().all(engine.user()).await.unwrap();
1510        let routine = stored
1511            .iter()
1512            .find(|m| m.predicate.as_str() == "exercise_routine")
1513            .expect("the routine was recorded");
1514        assert_eq!(routine.evidence.distinct_sessions, 2);
1515    }
1516}