1use 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
37pub const PLAN_EXTRACTION_TIMEOUT_MS: u64 = 4_000;
40
41pub 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 caller_supplied_extractor: Arc<std::sync::atomic::AtomicBool>,
52 observation_extractor: Arc<dyn MemoryObservationExtractor>,
53 semantic: Option<Arc<dyn SemanticFallback>>,
55}
56
57impl MemoryEngine {
58 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 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 pub fn with_semantic_fallback(mut self, fallback: Arc<dyn SemanticFallback>) -> Self {
107 self.semantic = Some(fallback);
108 self
109 }
110
111 pub fn with_plan_extractor(self, extractor: Arc<dyn RetrievalPlanExtractor>) -> Self {
117 *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 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 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 pub fn user(&self) -> &UserId {
163 &self.user
164 }
165
166 pub fn repository(&self) -> &Arc<dyn MemoryRepository> {
168 &self.repository
169 }
170
171 pub fn events(&self) -> &Arc<dyn MemoryEventLog> {
173 &self.events
174 }
175
176 pub fn config(&self) -> &MemoryRuntimeConfig {
178 &self.config
179 }
180
181 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 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 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 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
302pub 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 current_turn: RwLock<TurnId>,
325 prepared: RwLock<PreparedMemorySnapshot>,
326 active: RwLock<PreparedMemorySnapshot>,
327 cadence: RwLock<CadenceTracker>,
328 events: SessionEventWriter,
329 canonical: Arc<IndexHandle>,
330 memory_map: RwLock<(u64, String)>,
337}
338
339impl MemorySession {
340 pub fn session_id(&self) -> &SessionId {
342 &self.session_id
343 }
344
345 pub fn ledger(&self) -> &Arc<InMemorySessionLedger> {
347 &self.ledger
348 }
349
350 pub fn retriever(&self) -> &Arc<LocalMemoryRetriever> {
352 &self.retriever
353 }
354
355 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 pub fn config(&self) -> &MemoryRuntimeConfig {
385 &self.config
386 }
387
388 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 pub fn current_turn(&self) -> TurnId {
403 *self.current_turn.read()
404 }
405
406 pub fn active_snapshot(&self) -> PreparedMemorySnapshot {
408 self.retriever.filter_snapshot(self.active.read().clone())
409 }
410
411 pub fn prepared_snapshot(&self) -> PreparedMemorySnapshot {
413 self.retriever.filter_snapshot(self.prepared.read().clone())
414 }
415
416 pub fn generation(&self) -> &GenerationGuard {
418 &self.generation
419 }
420
421 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 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 Err(_) if !active.is_empty() => active.to_tool_payload(),
491 Err(_) => PreparedMemorySnapshot::empty(turn_id).to_tool_payload(),
492 }
493 }
494
495 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 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 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 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 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 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 pub fn is_idle(&self) -> bool {
660 self.cadence.read().is_idle(Utc::now())
661 }
662
663 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 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 fn resolve_target_predicate(
765 &self,
766 intent: MutationIntent,
767 statement: &str,
768 ) -> Option<crate::core::CanonicalPredicate> {
769 use crate::bm25::Query;
770
771 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 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 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 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 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 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 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 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 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
986fn 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 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
1055fn 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 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 #[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 #[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 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 #[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 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 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 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 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 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 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}