Skip to main content

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.retriever.filter_snapshot(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.retriever.filter_snapshot(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.ledger.micro_reconcile();
735        self.refresh_overlay().await?;
736
737        let accepted = !matches!(outcome, LedgerOutcome::Rejected(_));
738        Ok(serde_json::json!({
739            "status": if accepted { "accepted" } else { "refused" },
740            "operation": operation_label(intent),
741            "effective_in_session": accepted,
742            "durable_commit": "pending",
743        }))
744    }
745
746    /// The predicate the corpus already uses for the fact a command is about.
747    ///
748    /// An explicit "remember that…" or "correct that…" arrives as a sentence,
749    /// not as a triple, and the fact it concerns already has a name in the
750    /// corpus. Finding that name is what makes the command land on the record
751    /// it means: reconciliation matches on `subject|predicate`, and so does the
752    /// in-session suppression that hides a durable fact the user has just
753    /// contradicted. A command filed under a predicate of its own supersedes
754    /// nothing and hides nothing — it becomes a second, contradicting record.
755    ///
756    /// This is the same trick `known_predicates` plays for the extraction
757    /// model, applied to the path that never asks a model anything: the
758    /// vocabulary comes from the corpus rather than from a constant.
759    ///
760    /// Deliberately conservative. A weak match is worse than no match, because
761    /// hijacking the wrong window suppresses a fact the user never mentioned, so
762    /// the hit has to clear the same score floor the assembler uses before its
763    /// predicate is adopted.
764    fn resolve_target_predicate(
765        &self,
766        intent: MutationIntent,
767        statement: &str,
768    ) -> Option<crate::core::CanonicalPredicate> {
769        use crate::bm25::Query;
770
771        // A deletion names what to remove, not a fact to restate, and it is
772        // routed by its own `memory_removal` predicate.
773        if matches!(
774            intent,
775            MutationIntent::Forget | MutationIntent::Delete | MutationIntent::List
776        ) {
777            return None;
778        }
779
780        let query = Query::new(statement)
781            .with_boost_only(crate::retrieval::non_topical_terms(statement))
782            .with_limit(1);
783        let canonical = self.canonical.read();
784        let hit = canonical.search(&query, Utc::now()).into_iter().next()?;
785        if hit.score < self.config.retrieval.minimum_candidate_score {
786            return None;
787        }
788        canonical.get(&hit.id).map(|doc| doc.predicate.clone())
789    }
790
791    /// The predicate names this user's corpus already uses.
792    ///
793    /// Offered to the extraction model so a correction lands on the predicate
794    /// it is correcting. Reconciliation matches on subject and predicate; a
795    /// model free to name each fact afresh writes `dietary_preference` in one
796    /// session and `dietary_identity` in the next, and "actually I'm
797    /// pescatarian now" becomes a second active record rather than superseding
798    /// the first. This is the entity table's trick applied to predicates: the
799    /// vocabulary comes from the corpus.
800    ///
801    /// Bounded, because it goes in a prompt. Session candidates come first —
802    /// a correction usually chases something said minutes ago.
803    pub fn known_predicates(&self) -> Vec<String> {
804        const LIMIT: usize = 60;
805        let mut out: Vec<String> = Vec::new();
806        let mut push = |predicate: &str| {
807            if out.len() < LIMIT && !predicate.is_empty() && !out.iter().any(|p| p == predicate) {
808                out.push(predicate.to_string());
809            }
810        };
811        for candidate in self.ledger.usable_candidates() {
812            push(candidate.predicate.as_str());
813        }
814        let now = Utc::now();
815        for doc in self.canonical.read().documents() {
816            if doc.is_retrievable(now) {
817                push(doc.predicate.as_str());
818            }
819        }
820        out
821    }
822
823    /// The predicate/value pairs memory can currently assert.
824    ///
825    /// Visible session facts shadow canonical ones. Explicit removals and
826    /// corrections use the same visibility rules as retrieval.
827    pub fn known_values(&self) -> Vec<(crate::core::CanonicalPredicate, serde_json::Value)> {
828        let mut out = Vec::new();
829        for doc in self.visible_documents() {
830            if !out.iter().any(|(predicate, _)| predicate == &doc.predicate) {
831                out.push((doc.predicate, serde_json::Value::String(doc.value)));
832            }
833        }
834        out
835    }
836
837    /// Every statement currently retrievable, canonical and provisional.
838    pub fn known_statements(&self) -> Vec<String> {
839        let mut statements: Vec<_> = self
840            .visible_documents()
841            .into_iter()
842            .map(|doc| doc.statement)
843            .collect();
844        statements.sort();
845        statements.dedup();
846        statements
847    }
848
849    fn visible_documents(&self) -> Vec<IndexedMemory> {
850        let now = Utc::now();
851        // Copy before consulting the retriever, which also reads these indexes.
852        let mut documents: Vec<_> = self.overlay_handle.read().documents().cloned().collect();
853        documents.extend(self.canonical.read().documents().cloned());
854        documents
855            .into_iter()
856            .filter(|doc| {
857                doc.is_retrievable(now) && self.retriever.record_visible(&doc.id, doc.origin)
858            })
859            .collect()
860    }
861
862    /// Everything the user has explicitly asked to be remembered this session.
863    pub fn pending_explicit_commands(&self) -> Vec<MutationIntent> {
864        self.ledger
865            .usable_candidates()
866            .iter()
867            .filter_map(|c| c.mutation_intent)
868            .collect()
869    }
870
871    async fn refresh_overlay(&self) -> Result<(), MemoryError> {
872        let mut candidates = self.ledger.usable_candidates();
873        let removals: Vec<_> = candidates
874            .iter()
875            .filter(|candidate| {
876                matches!(
877                    candidate.mutation_intent,
878                    Some(MutationIntent::Forget | MutationIntent::Delete)
879                )
880            })
881            .filter_map(crate::reconcile::consolidate::deletion_selector)
882            .collect();
883        let mut removed = std::collections::HashSet::new();
884        if !removals.is_empty() {
885            for memory in self.repository.all(&self.user).await? {
886                if removals.iter().any(|selector| selector.matches(&memory)) {
887                    removed.insert(memory.id);
888                }
889            }
890            candidates.retain(|candidate| {
891                let memory = crate::ingestion::provisional_memory(
892                    candidate,
893                    &self.user,
894                    &self.session_id,
895                    Utc::now(),
896                );
897                let matches = removals.iter().any(|selector| selector.matches(&memory));
898                if matches {
899                    removed.insert(memory.id);
900                }
901                !matches
902            });
903        }
904        self.retriever.suppress_records(removed);
905
906        // Anything the user stated outright this session hides the durable
907        // record it contradicts for the rest of the conversation. Reconciliation
908        // will make that permanent; until then the overlay is the truth.
909        let suppressed: std::collections::HashSet<String> = candidates
910            .iter()
911            .filter(|c| {
912                c.explicitness.is_explicit() && c.mutation_intent != Some(MutationIntent::List)
913            })
914            .map(|c| c.subject_predicate().to_string())
915            .collect();
916        self.retriever.suppress_windows(suppressed);
917
918        let revision = {
919            let mut overlay = self.overlay.write();
920            overlay.rebuild(&self.user, &self.session_id, &candidates, Utc::now());
921            self.overlay_handle.replace(clone_index(overlay.index()));
922            overlay.revision()
923        };
924        self.retriever.invalidate_cache();
925        let _ = self
926            .events
927            .append(None, MemoryEvent::SessionOverlayUpdated { revision })
928            .await;
929        Ok(())
930    }
931
932    async fn recompile_canonical(&self) -> Result<(), MemoryError> {
933        let records = self.repository.all(&self.user).await?;
934        self.canonical.replace(MemoryIndex::build(
935            records
936                .iter()
937                .filter(|m| m.status == MemoryStatus::Active)
938                .map(IndexedMemory::from_canonical),
939        ));
940
941        // The semantic backend is recompiled too, and for the same reason the
942        // lexical index is: reconciliation has just decided what is true, and a
943        // retriever holding the previous answer is a retriever that has stopped
944        // agreeing with memory.
945        //
946        // It is passed the whole active set rather than a diff — see
947        // `SemanticFallback::reconcile` — and a well-behaved backend embeds only
948        // what it does not already hold, so the cost of a correction is one
949        // embedding rather than one per record.
950        //
951        // A failure here is logged rather than propagated. The lexical index and
952        // the durable record are already correct at this point; refusing to
953        // finish a recompile because a vector store was unreachable would turn a
954        // degraded semantic layer into a failed commit, and the degradation is
955        // the safe direction — `semantic_ranking` drops ids that no longer
956        // resolve, so a stale backend loses facts rather than serving wrong ones.
957        if let Some(semantic) = self.retriever.semantic() {
958            let active: Vec<(crate::core::MemoryId, String)> = records
959                .iter()
960                .filter(|m| m.status == MemoryStatus::Active)
961                .map(|m| (m.id.clone(), crate::retrieval::embedding_text(m)))
962                .collect();
963            // Failure is swallowed on purpose. By this point the durable record
964            // and the lexical index are already correct, so refusing to finish
965            // the recompile because a vector store was unreachable would turn a
966            // degraded semantic layer into a failed commit. The degradation is
967            // also the safe direction: `semantic_ranking` drops ids that no
968            // longer resolve, so a backend left behind loses facts rather than
969            // serving wrong ones.
970            let _ = semantic.reconcile(&active, self.canonical.revision()).await;
971        }
972        let planner = Arc::new(DeterministicPlanner::with_entities(
973            KnownEntities::from_index(&self.canonical.read()),
974        ));
975        *self.planner.write() = planner.clone();
976        if !self
977            .caller_supplied_extractor
978            .load(std::sync::atomic::Ordering::Acquire)
979        {
980            *self.plan_extractor.write() = Arc::new(DeterministicPlanExtractor::new(planner));
981        }
982        Ok(())
983    }
984}
985
986/// Build the observation behind an explicit memory command.
987///
988/// Explicit commands are the one path where the engine trusts a statement
989/// wholesale: the user said it about themselves, on purpose, to be remembered.
990fn explicit_observation(
991    intent: MutationIntent,
992    statement: &str,
993    target: Option<crate::core::CanonicalPredicate>,
994    session_id: SessionId,
995    turn_id: TurnId,
996    now: chrono::DateTime<Utc>,
997) -> crate::core::MemoryObservation {
998    use crate::core::{
999        CanonicalPredicate, EntityRef, Explicitness, MemoryKind, MemoryValue, ObservationId,
1000        ProposedPersistence, SensitivityClass, SpeakerAttribution, TemporalScope,
1001        TranscriptEvidence,
1002    };
1003
1004    // A correction has to land on the predicate of the fact it corrects.
1005    //
1006    // Reconciliation matches on `subject|predicate`, and so does the in-session
1007    // suppression that hides a durable record the user has just contradicted.
1008    // Naming the new fact `preference` regardless of what it is about — which
1009    // is what this did — means the window suppressed (`user|preference`) is
1010    // never the window the record lives in (`user|beverage_preference`), so the
1011    // correction is accepted, reported as effective, and the next recall serves
1012    // the old value alongside the new one. `target` is the predicate the corpus
1013    // already uses for this fact, resolved before the observation is built.
1014    let predicate = match intent {
1015        MutationIntent::Forget | MutationIntent::Delete => "memory_removal".to_string(),
1016        MutationIntent::List => "memory_listing".to_string(),
1017        _ => target
1018            .map(|p| p.as_str().to_string())
1019            .unwrap_or_else(|| "preference".to_string()),
1020    };
1021
1022    crate::core::MemoryObservation {
1023        observation_id: ObservationId::generate(),
1024        session_id,
1025        turn_id,
1026        subject: EntityRef::user(),
1027        predicate: CanonicalPredicate::new(&predicate),
1028        value: MemoryValue::Text(statement.to_string()),
1029        canonical_statement: statement.to_string(),
1030        kind: MemoryKind::Preference,
1031        explicitness: Explicitness::ExplicitCommand,
1032        confidence: 1.0,
1033        persistence: ProposedPersistence::Durable,
1034        temporal_scope: TemporalScope::Persistent,
1035        valid_from: Some(now),
1036        expected_expiry: None,
1037        transcript_evidence: TranscriptEvidence::new(statement),
1038        speaker_attribution: SpeakerAttribution::User,
1039        sensitivity: SensitivityClass::Normal,
1040        mutation_intent: Some(intent),
1041        search_terms: Vec::new(),
1042    }
1043}
1044
1045fn operation_label(intent: MutationIntent) -> &'static str {
1046    match intent {
1047        MutationIntent::Remember => "remember",
1048        MutationIntent::Correct => "correct",
1049        MutationIntent::Forget => "forget",
1050        MutationIntent::Delete => "delete",
1051        MutationIntent::List => "list",
1052    }
1053}
1054
1055/// Copy an index so the overlay and the retriever's handle stay independent.
1056///
1057/// The overlay owns the authoritative projection of the ledger; the handle is
1058/// what the retriever reads without ever blocking on a rebuild.
1059fn clone_index(source: &MemoryIndex) -> MemoryIndex {
1060    MemoryIndex::build(source.documents().cloned())
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066    use crate::core::InMemoryEventLog;
1067
1068    fn engine() -> MemoryEngine {
1069        MemoryEngine::in_memory(UserId::new("usr_1"))
1070    }
1071
1072    /// One active canonical record, for seeding a repository directly.
1073    fn seed_record(id: &str) -> crate::core::CanonicalMemory {
1074        use crate::core::{
1075            CanonicalPredicate, EntityRef, Explicitness, MemoryId, MemoryKind, MemorySource,
1076            MemoryValue, RetrievalMetadata, TemporalMetadata, TemporalScope,
1077        };
1078        crate::core::CanonicalMemory {
1079            id: MemoryId::new(id),
1080            owner: UserId::new("usr_1"),
1081            kind: MemoryKind::Preference,
1082            predicate: CanonicalPredicate::new("dietary_identity"),
1083            status: MemoryStatus::Active,
1084            confidence: 0.9,
1085            subject: EntityRef::named("user"),
1086            value: MemoryValue::Text("pescatarian".into()),
1087            statement: "The user is pescatarian.".into(),
1088            evidence_summary: "stated".into(),
1089            source: MemorySource::from_explicitness(
1090                Explicitness::ExplicitStatement,
1091                SessionId::new("ses_seed"),
1092                TurnId(1),
1093            ),
1094            temporal: TemporalMetadata::created_at(Utc::now()),
1095            retrieval: RetrievalMetadata {
1096                subject: crate::core::normalize_token("user"),
1097                ..Default::default()
1098            },
1099            temporal_scope: TemporalScope::Persistent,
1100            qualifier: None,
1101            evidence: Default::default(),
1102            privacy: Default::default(),
1103            supersedes: Vec::new(),
1104            superseded_by: None,
1105        }
1106    }
1107
1108    /// A backend that records what it was asked to reconcile against.
1109    #[derive(Default)]
1110    struct RecordingSemantic {
1111        seen: parking_lot::Mutex<Vec<Vec<String>>>,
1112    }
1113
1114    #[async_trait::async_trait]
1115    impl SemanticFallback for RecordingSemantic {
1116        async fn search(
1117            &self,
1118            _query: &str,
1119            _limit: usize,
1120        ) -> Result<Vec<crate::core::MemoryId>, MemoryError> {
1121            Ok(Vec::new())
1122        }
1123        async fn reconcile(
1124            &self,
1125            active: &[(crate::core::MemoryId, String)],
1126            _revision: u64,
1127        ) -> Result<(), MemoryError> {
1128            self.seen
1129                .lock()
1130                .push(active.iter().map(|(id, _)| id.to_string()).collect());
1131            Ok(())
1132        }
1133    }
1134
1135    /// `compile_index` must bring the semantic backend along.
1136    ///
1137    /// Both compile paths exist to make retrieval agree with the repository,
1138    /// and only one of them used to reconcile. That left the documented startup
1139    /// sequence — restore a persisted index, then `compile_index` — with a
1140    /// lexical index in step and a semantic one that could be missing every
1141    /// record written since the vectors were last saved, until some later
1142    /// session happened to seal.
1143    #[tokio::test]
1144    async fn compiling_the_index_reconciles_the_semantic_backend() {
1145        let recorder = Arc::new(RecordingSemantic::default());
1146        let engine =
1147            MemoryEngine::in_memory(UserId::new("usr_1")).with_semantic_fallback(recorder.clone());
1148
1149        // Seed the repository directly rather than through a session. That is
1150        // the situation this path exists for — a process starting against a
1151        // corpus it did not just write, restoring a persisted index and
1152        // compiling — and going through a session would let the session's own
1153        // reconcile do the work and prove nothing about this one.
1154        engine
1155            .repository()
1156            .commit(
1157                crate::okf::MemoryTransaction::new(engine.user().clone(), "tx-seed")
1158                    .put(seed_record("mem_seed")),
1159            )
1160            .await
1161            .expect("seed commit");
1162
1163        let before = recorder.seen.lock().len();
1164        engine.compile_index().await.expect("compile");
1165        let seen = recorder.seen.lock();
1166        assert!(
1167            seen.len() > before,
1168            "compile_index did not reconcile the semantic backend at all"
1169        );
1170        assert!(
1171            !seen.last().expect("a reconcile happened").is_empty(),
1172            "compile_index reconciled against an empty corpus: {seen:?}"
1173        );
1174    }
1175
1176    /// A scoped or hinted recall must honour the session's own deadlines.
1177    ///
1178    /// `recall` read the configured budget and `recall_scoped` built a default
1179    /// one, so a deployment that set `immediate_semantic_timeout_ms` to zero to
1180    /// keep semantic work off the tool path still got the default 10 ms
1181    /// whenever the model filled in a scope or a hint — which, now that the
1182    /// memory map tells it what the hint values are, is most of the time.
1183    ///
1184    /// Asserted by whether the backend was *consulted*, not by comparing two
1185    /// budget constructors: the bug was which constructor the call site used,
1186    /// and a test that calls the right one itself cannot see that.
1187    #[tokio::test]
1188    async fn a_zero_semantic_deadline_is_honoured_on_the_scoped_path_too() {
1189        #[derive(Default)]
1190        struct CountingSemantic {
1191            searches: std::sync::atomic::AtomicUsize,
1192        }
1193        #[async_trait::async_trait]
1194        impl SemanticFallback for CountingSemantic {
1195            async fn search(
1196                &self,
1197                _query: &str,
1198                _limit: usize,
1199            ) -> Result<Vec<crate::core::MemoryId>, MemoryError> {
1200                self.searches
1201                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1202                Ok(Vec::new())
1203            }
1204        }
1205
1206        let mut config = MemoryRuntimeConfig::default();
1207        // The deployment's decision: no semantic work on the tool path.
1208        config.retrieval.immediate_semantic_timeout_ms = 0;
1209
1210        let backend = Arc::new(CountingSemantic::default());
1211        let engine = MemoryEngine::new(
1212            UserId::new("usr_1"),
1213            Arc::new(OkfRepository::in_memory()),
1214            Arc::new(InMemoryEventLog::new()),
1215            config,
1216        )
1217        .with_semantic_fallback(backend.clone());
1218        engine
1219            .repository()
1220            .commit(
1221                crate::okf::MemoryTransaction::new(engine.user().clone(), "tx-seed")
1222                    .put(seed_record("mem_seed")),
1223            )
1224            .await
1225            .expect("seed commit");
1226        engine.compile_index().await.expect("compile");
1227
1228        let session = engine.begin_session(SessionId::new("ses_1"));
1229        session.begin_turn(TurnId(1));
1230        // A hinted recall — the shape `recall_context` produces once the model
1231        // can name a filter value.
1232        let _ = session
1233            .recall_scoped(
1234                "what does the user eat",
1235                TurnId(1),
1236                crate::runtime::tools::RecallScope::All,
1237                Some("user".to_string()),
1238                None,
1239            )
1240            .await;
1241
1242        assert_eq!(
1243            backend.searches.load(std::sync::atomic::Ordering::SeqCst),
1244            0,
1245            "the semantic backend was consulted despite a zero deadline — the \
1246             scoped path built a default budget instead of the configured one"
1247        );
1248    }
1249
1250    #[tokio::test]
1251    async fn a_fact_stated_this_session_is_recalled_in_a_later_turn() {
1252        let engine = engine();
1253        let session = engine.begin_session(SessionId::new("ses_1"));
1254
1255        session.begin_turn(TurnId(1));
1256        session
1257            .observe_final_transcript(TurnId(1), "I am pescatarian")
1258            .await
1259            .unwrap();
1260        session.on_turn_complete(TurnId(1)).await.unwrap();
1261
1262        session.begin_turn(TurnId(2));
1263        let snapshot = session
1264            .prepare(
1265                TurnId(2),
1266                "what do you remember about my dietary preferences",
1267            )
1268            .await
1269            .unwrap();
1270        assert!(
1271            snapshot
1272                .facts
1273                .iter()
1274                .any(|f| f.statement.contains("pescatarian")),
1275            "prepared: {:?}",
1276            snapshot.facts
1277        );
1278    }
1279
1280    #[tokio::test]
1281    async fn a_fact_survives_into_the_next_session_after_reconciliation() {
1282        let engine = engine();
1283
1284        let first = engine.begin_session(SessionId::new("ses_1"));
1285        first.begin_turn(TurnId(1));
1286        first
1287            .observe_final_transcript(TurnId(1), "I am pescatarian")
1288            .await
1289            .unwrap();
1290        let report = first.finish().await.unwrap();
1291        assert_eq!(report.creates, 1);
1292
1293        engine.compile_index().await.unwrap();
1294
1295        let second = engine.begin_session(SessionId::new("ses_2"));
1296        second.begin_turn(TurnId(1));
1297        let snapshot = second
1298            .prepare(
1299                TurnId(1),
1300                "what do you remember about my dietary preferences",
1301            )
1302            .await
1303            .unwrap();
1304        assert!(
1305            snapshot
1306                .facts
1307                .iter()
1308                .any(|f| f.statement.contains("pescatarian"))
1309        );
1310    }
1311
1312    #[tokio::test]
1313    async fn a_correction_stated_mid_session_takes_effect_on_the_next_turn() {
1314        let engine = engine();
1315        let session = engine.begin_session(SessionId::new("ses_1"));
1316
1317        session.begin_turn(TurnId(1));
1318        session
1319            .observe_final_transcript(TurnId(1), "I am vegetarian")
1320            .await
1321            .unwrap();
1322        session.on_turn_complete(TurnId(1)).await.unwrap();
1323
1324        session.begin_turn(TurnId(2));
1325        session
1326            .observe_final_transcript(TurnId(2), "actually I am pescatarian")
1327            .await
1328            .unwrap();
1329        session.on_turn_complete(TurnId(2)).await.unwrap();
1330
1331        session.begin_turn(TurnId(3));
1332        let payload = session
1333            .recall("diet vegetarian pescatarian", TurnId(3))
1334            .await;
1335        let rendered = payload.to_string();
1336        assert!(rendered.contains("pescatarian"), "got {rendered}");
1337    }
1338
1339    #[tokio::test]
1340    async fn a_correction_hides_the_durable_fact_it_contradicts_immediately() {
1341        let engine = engine();
1342
1343        // Monday: the user is vegetarian, and it is reconciled and indexed.
1344        let monday = engine.begin_session(SessionId::new("ses_monday"));
1345        monday.begin_turn(TurnId(1));
1346        monday
1347            .observe_final_transcript(TurnId(1), "I am vegetarian")
1348            .await
1349            .unwrap();
1350        monday.finish().await.unwrap();
1351        engine.compile_index().await.unwrap();
1352
1353        // Thursday: the user corrects it. The old fact must stop being
1354        // retrieved now, not after the session reconciles.
1355        let thursday = engine.begin_session(SessionId::new("ses_thursday"));
1356        thursday.begin_turn(TurnId(1));
1357        thursday
1358            .observe_final_transcript(TurnId(1), "actually I am pescatarian")
1359            .await
1360            .unwrap();
1361        thursday.on_turn_complete(TurnId(1)).await.unwrap();
1362
1363        thursday.begin_turn(TurnId(2));
1364        let snapshot = thursday
1365            .prepare(
1366                TurnId(2),
1367                "what do you remember about my dietary preferences",
1368            )
1369            .await
1370            .unwrap();
1371
1372        let statements: Vec<&str> = snapshot
1373            .facts
1374            .iter()
1375            .map(|f| f.statement.as_str())
1376            .collect();
1377        assert!(
1378            statements.iter().any(|s| s.contains("pescatarian")),
1379            "the correction was not recalled: {statements:?}"
1380        );
1381        assert!(
1382            !statements.iter().any(|s| s.contains("vegetarian")),
1383            "the corrected-away fact was still recalled: {statements:?}"
1384        );
1385    }
1386
1387    #[tokio::test]
1388    async fn asking_what_is_remembered_does_not_itself_become_a_memory() {
1389        let engine = engine();
1390        let session = engine.begin_session(SessionId::new("ses_1"));
1391        session.begin_turn(TurnId(1));
1392        session
1393            .observe_final_transcript(TurnId(1), "I am pescatarian")
1394            .await
1395            .unwrap();
1396        session.begin_turn(TurnId(2));
1397        session
1398            .observe_final_transcript(TurnId(2), "what do you remember about me")
1399            .await
1400            .unwrap();
1401
1402        let snapshot = session
1403            .prepare(
1404                TurnId(3),
1405                "what do you remember about my dietary preferences",
1406            )
1407            .await
1408            .unwrap();
1409        assert!(
1410            !snapshot
1411                .facts
1412                .iter()
1413                .any(|f| f.statement.contains("asked what is remembered")),
1414            "a question about memory was recalled as a memory: {:?}",
1415            snapshot.facts
1416        );
1417    }
1418
1419    #[tokio::test]
1420    async fn a_generic_question_gets_no_memory_and_no_search() {
1421        let engine = engine();
1422        let session = engine.begin_session(SessionId::new("ses_1"));
1423        session.begin_turn(TurnId(1));
1424        let snapshot = session
1425            .prepare(TurnId(1), "what is the capital of France")
1426            .await
1427            .unwrap();
1428        assert!(snapshot.is_empty());
1429        assert_eq!(
1430            session.recall("capital of France", TurnId(1)).await["status"],
1431            "not_found"
1432        );
1433    }
1434
1435    #[tokio::test]
1436    async fn the_active_snapshot_does_not_change_mid_turn() {
1437        let engine = engine();
1438        let session = engine.begin_session(SessionId::new("ses_1"));
1439
1440        session.begin_turn(TurnId(1));
1441        session
1442            .observe_final_transcript(TurnId(1), "I am pescatarian")
1443            .await
1444            .unwrap();
1445        session.begin_turn(TurnId(2));
1446        session.prepare(TurnId(2), "what do I eat").await.unwrap();
1447
1448        let during_turn = session.active_snapshot();
1449        // A newer preparation lands while turn 2 is still in flight.
1450        session
1451            .observe_final_transcript(TurnId(3), "I am allergic to nuts")
1452            .await
1453            .unwrap();
1454        session
1455            .prepare(TurnId(3), "what am I allergic to")
1456            .await
1457            .unwrap();
1458
1459        assert_eq!(
1460            session.active_snapshot().snapshot_id,
1461            during_turn.snapshot_id,
1462            "the in-flight turn keeps its frozen snapshot"
1463        );
1464    }
1465
1466    #[tokio::test]
1467    async fn bystander_speech_never_reaches_the_ledger() {
1468        let engine = engine();
1469        let session = engine.begin_session(SessionId::new("ses_1"));
1470        session.begin_turn(TurnId(1));
1471        session
1472            .observe_final_transcript(TurnId(1), "the weather is nice today")
1473            .await
1474            .unwrap();
1475        assert!(session.ledger().is_empty());
1476    }
1477
1478    #[tokio::test]
1479    async fn reconciling_the_same_session_twice_writes_once() {
1480        let engine = engine();
1481        let session = engine.begin_session(SessionId::new("ses_1"));
1482        session.begin_turn(TurnId(1));
1483        session
1484            .observe_final_transcript(TurnId(1), "I am pescatarian")
1485            .await
1486            .unwrap();
1487
1488        session.finish().await.unwrap();
1489        session.finish().await.unwrap();
1490
1491        let stored = engine.repository().all(engine.user()).await.unwrap();
1492        assert_eq!(stored.len(), 1);
1493    }
1494
1495    #[tokio::test]
1496    async fn forget_hides_canonical_slots_and_frozen_recall_before_reconciliation() {
1497        let engine = engine();
1498        let first = engine.begin_session(SessionId::new("prior"));
1499        first
1500            .observe_final_transcript(TurnId(1), "I am pescatarian")
1501            .await
1502            .unwrap();
1503        first.finish().await.unwrap();
1504        let session = engine.begin_session(SessionId::new("forget"));
1505        session
1506            .prepare(TurnId(1), "dietary preference pescatarian")
1507            .await
1508            .unwrap();
1509        session.begin_turn(TurnId(1));
1510        assert!(!session.active_snapshot().is_empty());
1511        session
1512            .apply_explicit_command(MutationIntent::Forget, "pescatarian", TurnId(2))
1513            .await
1514            .unwrap();
1515        assert!(session.known_values().is_empty());
1516        assert!(session.known_statements().is_empty());
1517        assert!(session.active_snapshot().is_empty());
1518        assert!(session.prepared_snapshot().is_empty());
1519        assert_eq!(
1520            session
1521                .recall("dietary preference pescatarian", TurnId(2))
1522                .await["status"],
1523            "not_found"
1524        );
1525        session.finish().await.unwrap();
1526        assert!(
1527            engine
1528                .begin_session(SessionId::new("after"))
1529                .known_values()
1530                .is_empty()
1531        );
1532    }
1533
1534    #[tokio::test]
1535    async fn forgetting_a_new_session_fact_does_not_recreate_it_at_finish() {
1536        let engine = engine();
1537        let session = engine.begin_session(SessionId::new("same-session"));
1538        session
1539            .observe_final_transcript(TurnId(1), "I am pescatarian")
1540            .await
1541            .unwrap();
1542        session
1543            .apply_explicit_command(MutationIntent::Forget, "pescatarian", TurnId(2))
1544            .await
1545            .unwrap();
1546        assert!(session.known_values().is_empty());
1547        session.finish().await.unwrap();
1548        assert!(
1549            engine
1550                .repository()
1551                .all(engine.user())
1552                .await
1553                .unwrap()
1554                .is_empty()
1555        );
1556    }
1557
1558    #[tokio::test]
1559    async fn successive_removals_keep_prior_targets_and_preserve_unrelated_words() {
1560        let engine = engine();
1561        let session = engine.begin_session(SessionId::new("removals"));
1562        for (turn, intent, statement) in [
1563            (1, MutationIntent::Remember, "Enjoys art"),
1564            (2, MutationIntent::Remember, "Owns a shopping cart"),
1565            (3, MutationIntent::Forget, "art"),
1566            (4, MutationIntent::Forget, "unrelated"),
1567        ] {
1568            session
1569                .apply_explicit_command(intent, statement, TurnId(turn))
1570                .await
1571                .unwrap();
1572        }
1573        assert_eq!(session.known_statements(), vec!["Owns a shopping cart"]);
1574        session.finish().await.unwrap();
1575        let remaining = engine.repository().all(engine.user()).await.unwrap();
1576        assert_eq!(remaining.len(), 1);
1577        assert_eq!(remaining[0].statement, "Owns a shopping cart");
1578    }
1579
1580    #[tokio::test]
1581    async fn the_event_log_records_the_turn_before_the_extraction() {
1582        let log = Arc::new(InMemoryEventLog::new());
1583        let engine = MemoryEngine::new(
1584            UserId::new("usr_1"),
1585            Arc::new(OkfRepository::in_memory()),
1586            log.clone(),
1587            MemoryRuntimeConfig::default(),
1588        );
1589        let session = engine.begin_session(SessionId::new("ses_1"));
1590        session
1591            .observe_final_transcript(TurnId(1), "I am pescatarian")
1592            .await
1593            .unwrap();
1594
1595        let entries = log.entries();
1596        assert_eq!(entries[0].payload.label(), "final_transcript_recorded");
1597    }
1598
1599    #[tokio::test]
1600    async fn a_staged_pattern_is_promoted_once_it_spans_sessions_and_days() {
1601        let engine = engine();
1602
1603        // Two sessions, each stating the routine outright.
1604        for (idx, session_id) in ["ses_1", "ses_2"].iter().enumerate() {
1605            let session = engine.begin_session(SessionId::new(*session_id));
1606            session.begin_turn(TurnId(1));
1607            session
1608                .observe_final_transcript(TurnId(1), "I always go to the gym before work")
1609                .await
1610                .unwrap();
1611            session.finish().await.unwrap();
1612            let _ = idx;
1613        }
1614
1615        let stored = engine.repository().all(engine.user()).await.unwrap();
1616        let routine = stored
1617            .iter()
1618            .find(|m| m.predicate.as_str() == "exercise_routine")
1619            .expect("the routine was recorded");
1620        assert_eq!(routine.evidence.distinct_sessions, 2);
1621    }
1622}