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.active.read().clone()
409 }
410
411 pub fn prepared_snapshot(&self) -> PreparedMemorySnapshot {
413 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.refresh_overlay().await;
735
736 let accepted = !matches!(outcome, LedgerOutcome::Rejected(_));
737 Ok(serde_json::json!({
738 "status": if accepted { "accepted" } else { "refused" },
739 "operation": operation_label(intent),
740 "effective_in_session": accepted,
741 "durable_commit": "pending",
742 }))
743 }
744
745 fn resolve_target_predicate(
764 &self,
765 intent: MutationIntent,
766 statement: &str,
767 ) -> Option<crate::core::CanonicalPredicate> {
768 use crate::bm25::Query;
769
770 if matches!(
773 intent,
774 MutationIntent::Forget | MutationIntent::Delete | MutationIntent::List
775 ) {
776 return None;
777 }
778
779 let query = Query::new(statement)
780 .with_boost_only(crate::retrieval::non_topical_terms(statement))
781 .with_limit(1);
782 let canonical = self.canonical.read();
783 let hit = canonical.search(&query, Utc::now()).into_iter().next()?;
784 if hit.score < self.config.retrieval.minimum_candidate_score {
785 return None;
786 }
787 canonical.get(&hit.id).map(|doc| doc.predicate.clone())
788 }
789
790 pub fn known_predicates(&self) -> Vec<String> {
803 const LIMIT: usize = 60;
804 let mut out: Vec<String> = Vec::new();
805 let mut push = |predicate: &str| {
806 if out.len() < LIMIT && !predicate.is_empty() && !out.iter().any(|p| p == predicate) {
807 out.push(predicate.to_string());
808 }
809 };
810 for candidate in self.ledger.usable_candidates() {
811 push(candidate.predicate.as_str());
812 }
813 let now = Utc::now();
814 for doc in self.canonical.read().documents() {
815 if doc.is_retrievable(now) {
816 push(doc.predicate.as_str());
817 }
818 }
819 out
820 }
821
822 pub fn known_values(&self) -> Vec<(crate::core::CanonicalPredicate, serde_json::Value)> {
827 let mut out: Vec<(crate::core::CanonicalPredicate, serde_json::Value)> = Vec::new();
828 let mut push = |predicate: crate::core::CanonicalPredicate, value: serde_json::Value| {
829 if !out.iter().any(|(p, _)| p == &predicate) {
830 out.push((predicate, value));
831 }
832 };
833
834 for candidate in self.ledger.usable_candidates() {
835 if candidate.mutation_intent == Some(MutationIntent::List) {
836 continue;
837 }
838 push(
839 candidate.predicate.clone(),
840 serde_json::Value::String(candidate.value.display()),
841 );
842 }
843 let now = Utc::now();
844 for doc in self.canonical.read().documents() {
845 if doc.is_retrievable(now) {
846 push(
847 doc.predicate.clone(),
848 serde_json::Value::String(doc.value.clone()),
849 );
850 }
851 }
852 out
853 }
854
855 pub fn known_statements(&self) -> Vec<String> {
857 let mut statements: Vec<String> = self
858 .canonical
859 .read()
860 .documents()
861 .map(|d| d.statement.clone())
862 .collect();
863 statements.extend(
864 self.ledger
865 .usable_candidates()
866 .iter()
867 .map(|c| c.canonical_statement.clone()),
868 );
869 statements.sort();
870 statements.dedup();
871 statements
872 }
873
874 pub fn pending_explicit_commands(&self) -> Vec<MutationIntent> {
876 self.ledger
877 .usable_candidates()
878 .iter()
879 .filter_map(|c| c.mutation_intent)
880 .collect()
881 }
882
883 async fn refresh_overlay(&self) {
884 let candidates = self.ledger.usable_candidates();
885
886 let suppressed: std::collections::HashSet<String> = candidates
890 .iter()
891 .filter(|c| {
892 c.explicitness.is_explicit() && c.mutation_intent != Some(MutationIntent::List)
893 })
894 .map(|c| c.subject_predicate().to_string())
895 .collect();
896 self.retriever.suppress_windows(suppressed);
897
898 let revision = {
899 let mut overlay = self.overlay.write();
900 overlay.rebuild(&self.user, &self.session_id, &candidates, Utc::now());
901 self.overlay_handle.replace(clone_index(overlay.index()));
902 overlay.revision()
903 };
904 self.retriever.invalidate_cache();
905 let _ = self
906 .events
907 .append(None, MemoryEvent::SessionOverlayUpdated { revision })
908 .await;
909 }
910
911 async fn recompile_canonical(&self) -> Result<(), MemoryError> {
912 let records = self.repository.all(&self.user).await?;
913 self.canonical.replace(MemoryIndex::build(
914 records
915 .iter()
916 .filter(|m| m.status == MemoryStatus::Active)
917 .map(IndexedMemory::from_canonical),
918 ));
919
920 if let Some(semantic) = self.retriever.semantic() {
937 let active: Vec<(crate::core::MemoryId, String)> = records
938 .iter()
939 .filter(|m| m.status == MemoryStatus::Active)
940 .map(|m| (m.id.clone(), crate::retrieval::embedding_text(m)))
941 .collect();
942 let _ = semantic.reconcile(&active, self.canonical.revision()).await;
950 }
951 let planner = Arc::new(DeterministicPlanner::with_entities(
952 KnownEntities::from_index(&self.canonical.read()),
953 ));
954 *self.planner.write() = planner.clone();
955 if !self
956 .caller_supplied_extractor
957 .load(std::sync::atomic::Ordering::Acquire)
958 {
959 *self.plan_extractor.write() = Arc::new(DeterministicPlanExtractor::new(planner));
960 }
961 Ok(())
962 }
963}
964
965fn explicit_observation(
970 intent: MutationIntent,
971 statement: &str,
972 target: Option<crate::core::CanonicalPredicate>,
973 session_id: SessionId,
974 turn_id: TurnId,
975 now: chrono::DateTime<Utc>,
976) -> crate::core::MemoryObservation {
977 use crate::core::{
978 CanonicalPredicate, EntityRef, Explicitness, MemoryKind, MemoryValue, ObservationId,
979 ProposedPersistence, SensitivityClass, SpeakerAttribution, TemporalScope,
980 TranscriptEvidence,
981 };
982
983 let predicate = match intent {
994 MutationIntent::Forget | MutationIntent::Delete => "memory_removal".to_string(),
995 MutationIntent::List => "memory_listing".to_string(),
996 _ => target
997 .map(|p| p.as_str().to_string())
998 .unwrap_or_else(|| "preference".to_string()),
999 };
1000
1001 crate::core::MemoryObservation {
1002 observation_id: ObservationId::generate(),
1003 session_id,
1004 turn_id,
1005 subject: EntityRef::user(),
1006 predicate: CanonicalPredicate::new(&predicate),
1007 value: MemoryValue::Text(statement.to_string()),
1008 canonical_statement: statement.to_string(),
1009 kind: MemoryKind::Preference,
1010 explicitness: Explicitness::ExplicitCommand,
1011 confidence: 1.0,
1012 persistence: ProposedPersistence::Durable,
1013 temporal_scope: TemporalScope::Persistent,
1014 valid_from: Some(now),
1015 expected_expiry: None,
1016 transcript_evidence: TranscriptEvidence::new(statement),
1017 speaker_attribution: SpeakerAttribution::User,
1018 sensitivity: SensitivityClass::Normal,
1019 mutation_intent: Some(intent),
1020 search_terms: Vec::new(),
1021 }
1022}
1023
1024fn operation_label(intent: MutationIntent) -> &'static str {
1025 match intent {
1026 MutationIntent::Remember => "remember",
1027 MutationIntent::Correct => "correct",
1028 MutationIntent::Forget => "forget",
1029 MutationIntent::Delete => "delete",
1030 MutationIntent::List => "list",
1031 }
1032}
1033
1034fn clone_index(source: &MemoryIndex) -> MemoryIndex {
1039 MemoryIndex::build(source.documents().cloned())
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::*;
1045 use crate::core::InMemoryEventLog;
1046
1047 fn engine() -> MemoryEngine {
1048 MemoryEngine::in_memory(UserId::new("usr_1"))
1049 }
1050
1051 fn seed_record(id: &str) -> crate::core::CanonicalMemory {
1053 use crate::core::{
1054 CanonicalPredicate, EntityRef, Explicitness, MemoryId, MemoryKind, MemorySource,
1055 MemoryValue, RetrievalMetadata, TemporalMetadata, TemporalScope,
1056 };
1057 crate::core::CanonicalMemory {
1058 id: MemoryId::new(id),
1059 owner: UserId::new("usr_1"),
1060 kind: MemoryKind::Preference,
1061 predicate: CanonicalPredicate::new("dietary_identity"),
1062 status: MemoryStatus::Active,
1063 confidence: 0.9,
1064 subject: EntityRef::named("user"),
1065 value: MemoryValue::Text("pescatarian".into()),
1066 statement: "The user is pescatarian.".into(),
1067 evidence_summary: "stated".into(),
1068 source: MemorySource::from_explicitness(
1069 Explicitness::ExplicitStatement,
1070 SessionId::new("ses_seed"),
1071 TurnId(1),
1072 ),
1073 temporal: TemporalMetadata::created_at(Utc::now()),
1074 retrieval: RetrievalMetadata {
1075 subject: crate::core::normalize_token("user"),
1076 ..Default::default()
1077 },
1078 temporal_scope: TemporalScope::Persistent,
1079 qualifier: None,
1080 evidence: Default::default(),
1081 privacy: Default::default(),
1082 supersedes: Vec::new(),
1083 superseded_by: None,
1084 }
1085 }
1086
1087 #[derive(Default)]
1089 struct RecordingSemantic {
1090 seen: parking_lot::Mutex<Vec<Vec<String>>>,
1091 }
1092
1093 #[async_trait::async_trait]
1094 impl SemanticFallback for RecordingSemantic {
1095 async fn search(
1096 &self,
1097 _query: &str,
1098 _limit: usize,
1099 ) -> Result<Vec<crate::core::MemoryId>, MemoryError> {
1100 Ok(Vec::new())
1101 }
1102 async fn reconcile(
1103 &self,
1104 active: &[(crate::core::MemoryId, String)],
1105 _revision: u64,
1106 ) -> Result<(), MemoryError> {
1107 self.seen
1108 .lock()
1109 .push(active.iter().map(|(id, _)| id.to_string()).collect());
1110 Ok(())
1111 }
1112 }
1113
1114 #[tokio::test]
1123 async fn compiling_the_index_reconciles_the_semantic_backend() {
1124 let recorder = Arc::new(RecordingSemantic::default());
1125 let engine =
1126 MemoryEngine::in_memory(UserId::new("usr_1")).with_semantic_fallback(recorder.clone());
1127
1128 engine
1134 .repository()
1135 .commit(
1136 crate::okf::MemoryTransaction::new(engine.user().clone(), "tx-seed")
1137 .put(seed_record("mem_seed")),
1138 )
1139 .await
1140 .expect("seed commit");
1141
1142 let before = recorder.seen.lock().len();
1143 engine.compile_index().await.expect("compile");
1144 let seen = recorder.seen.lock();
1145 assert!(
1146 seen.len() > before,
1147 "compile_index did not reconcile the semantic backend at all"
1148 );
1149 assert!(
1150 !seen.last().expect("a reconcile happened").is_empty(),
1151 "compile_index reconciled against an empty corpus: {seen:?}"
1152 );
1153 }
1154
1155 #[tokio::test]
1167 async fn a_zero_semantic_deadline_is_honoured_on_the_scoped_path_too() {
1168 #[derive(Default)]
1169 struct CountingSemantic {
1170 searches: std::sync::atomic::AtomicUsize,
1171 }
1172 #[async_trait::async_trait]
1173 impl SemanticFallback for CountingSemantic {
1174 async fn search(
1175 &self,
1176 _query: &str,
1177 _limit: usize,
1178 ) -> Result<Vec<crate::core::MemoryId>, MemoryError> {
1179 self.searches
1180 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1181 Ok(Vec::new())
1182 }
1183 }
1184
1185 let mut config = MemoryRuntimeConfig::default();
1186 config.retrieval.immediate_semantic_timeout_ms = 0;
1188
1189 let backend = Arc::new(CountingSemantic::default());
1190 let engine = MemoryEngine::new(
1191 UserId::new("usr_1"),
1192 Arc::new(OkfRepository::in_memory()),
1193 Arc::new(InMemoryEventLog::new()),
1194 config,
1195 )
1196 .with_semantic_fallback(backend.clone());
1197 engine
1198 .repository()
1199 .commit(
1200 crate::okf::MemoryTransaction::new(engine.user().clone(), "tx-seed")
1201 .put(seed_record("mem_seed")),
1202 )
1203 .await
1204 .expect("seed commit");
1205 engine.compile_index().await.expect("compile");
1206
1207 let session = engine.begin_session(SessionId::new("ses_1"));
1208 session.begin_turn(TurnId(1));
1209 let _ = session
1212 .recall_scoped(
1213 "what does the user eat",
1214 TurnId(1),
1215 crate::runtime::tools::RecallScope::All,
1216 Some("user".to_string()),
1217 None,
1218 )
1219 .await;
1220
1221 assert_eq!(
1222 backend.searches.load(std::sync::atomic::Ordering::SeqCst),
1223 0,
1224 "the semantic backend was consulted despite a zero deadline — the \
1225 scoped path built a default budget instead of the configured one"
1226 );
1227 }
1228
1229 #[tokio::test]
1230 async fn a_fact_stated_this_session_is_recalled_in_a_later_turn() {
1231 let engine = engine();
1232 let session = engine.begin_session(SessionId::new("ses_1"));
1233
1234 session.begin_turn(TurnId(1));
1235 session
1236 .observe_final_transcript(TurnId(1), "I am pescatarian")
1237 .await
1238 .unwrap();
1239 session.on_turn_complete(TurnId(1)).await.unwrap();
1240
1241 session.begin_turn(TurnId(2));
1242 let snapshot = session
1243 .prepare(
1244 TurnId(2),
1245 "what do you remember about my dietary preferences",
1246 )
1247 .await
1248 .unwrap();
1249 assert!(
1250 snapshot
1251 .facts
1252 .iter()
1253 .any(|f| f.statement.contains("pescatarian")),
1254 "prepared: {:?}",
1255 snapshot.facts
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn a_fact_survives_into_the_next_session_after_reconciliation() {
1261 let engine = engine();
1262
1263 let first = engine.begin_session(SessionId::new("ses_1"));
1264 first.begin_turn(TurnId(1));
1265 first
1266 .observe_final_transcript(TurnId(1), "I am pescatarian")
1267 .await
1268 .unwrap();
1269 let report = first.finish().await.unwrap();
1270 assert_eq!(report.creates, 1);
1271
1272 engine.compile_index().await.unwrap();
1273
1274 let second = engine.begin_session(SessionId::new("ses_2"));
1275 second.begin_turn(TurnId(1));
1276 let snapshot = second
1277 .prepare(
1278 TurnId(1),
1279 "what do you remember about my dietary preferences",
1280 )
1281 .await
1282 .unwrap();
1283 assert!(
1284 snapshot
1285 .facts
1286 .iter()
1287 .any(|f| f.statement.contains("pescatarian"))
1288 );
1289 }
1290
1291 #[tokio::test]
1292 async fn a_correction_stated_mid_session_takes_effect_on_the_next_turn() {
1293 let engine = engine();
1294 let session = engine.begin_session(SessionId::new("ses_1"));
1295
1296 session.begin_turn(TurnId(1));
1297 session
1298 .observe_final_transcript(TurnId(1), "I am vegetarian")
1299 .await
1300 .unwrap();
1301 session.on_turn_complete(TurnId(1)).await.unwrap();
1302
1303 session.begin_turn(TurnId(2));
1304 session
1305 .observe_final_transcript(TurnId(2), "actually I am pescatarian")
1306 .await
1307 .unwrap();
1308 session.on_turn_complete(TurnId(2)).await.unwrap();
1309
1310 session.begin_turn(TurnId(3));
1311 let payload = session
1312 .recall("diet vegetarian pescatarian", TurnId(3))
1313 .await;
1314 let rendered = payload.to_string();
1315 assert!(rendered.contains("pescatarian"), "got {rendered}");
1316 }
1317
1318 #[tokio::test]
1319 async fn a_correction_hides_the_durable_fact_it_contradicts_immediately() {
1320 let engine = engine();
1321
1322 let monday = engine.begin_session(SessionId::new("ses_monday"));
1324 monday.begin_turn(TurnId(1));
1325 monday
1326 .observe_final_transcript(TurnId(1), "I am vegetarian")
1327 .await
1328 .unwrap();
1329 monday.finish().await.unwrap();
1330 engine.compile_index().await.unwrap();
1331
1332 let thursday = engine.begin_session(SessionId::new("ses_thursday"));
1335 thursday.begin_turn(TurnId(1));
1336 thursday
1337 .observe_final_transcript(TurnId(1), "actually I am pescatarian")
1338 .await
1339 .unwrap();
1340 thursday.on_turn_complete(TurnId(1)).await.unwrap();
1341
1342 thursday.begin_turn(TurnId(2));
1343 let snapshot = thursday
1344 .prepare(
1345 TurnId(2),
1346 "what do you remember about my dietary preferences",
1347 )
1348 .await
1349 .unwrap();
1350
1351 let statements: Vec<&str> = snapshot
1352 .facts
1353 .iter()
1354 .map(|f| f.statement.as_str())
1355 .collect();
1356 assert!(
1357 statements.iter().any(|s| s.contains("pescatarian")),
1358 "the correction was not recalled: {statements:?}"
1359 );
1360 assert!(
1361 !statements.iter().any(|s| s.contains("vegetarian")),
1362 "the corrected-away fact was still recalled: {statements:?}"
1363 );
1364 }
1365
1366 #[tokio::test]
1367 async fn asking_what_is_remembered_does_not_itself_become_a_memory() {
1368 let engine = engine();
1369 let session = engine.begin_session(SessionId::new("ses_1"));
1370 session.begin_turn(TurnId(1));
1371 session
1372 .observe_final_transcript(TurnId(1), "I am pescatarian")
1373 .await
1374 .unwrap();
1375 session.begin_turn(TurnId(2));
1376 session
1377 .observe_final_transcript(TurnId(2), "what do you remember about me")
1378 .await
1379 .unwrap();
1380
1381 let snapshot = session
1382 .prepare(
1383 TurnId(3),
1384 "what do you remember about my dietary preferences",
1385 )
1386 .await
1387 .unwrap();
1388 assert!(
1389 !snapshot
1390 .facts
1391 .iter()
1392 .any(|f| f.statement.contains("asked what is remembered")),
1393 "a question about memory was recalled as a memory: {:?}",
1394 snapshot.facts
1395 );
1396 }
1397
1398 #[tokio::test]
1399 async fn a_generic_question_gets_no_memory_and_no_search() {
1400 let engine = engine();
1401 let session = engine.begin_session(SessionId::new("ses_1"));
1402 session.begin_turn(TurnId(1));
1403 let snapshot = session
1404 .prepare(TurnId(1), "what is the capital of France")
1405 .await
1406 .unwrap();
1407 assert!(snapshot.is_empty());
1408 assert_eq!(
1409 session.recall("capital of France", TurnId(1)).await["status"],
1410 "not_found"
1411 );
1412 }
1413
1414 #[tokio::test]
1415 async fn the_active_snapshot_does_not_change_mid_turn() {
1416 let engine = engine();
1417 let session = engine.begin_session(SessionId::new("ses_1"));
1418
1419 session.begin_turn(TurnId(1));
1420 session
1421 .observe_final_transcript(TurnId(1), "I am pescatarian")
1422 .await
1423 .unwrap();
1424 session.begin_turn(TurnId(2));
1425 session.prepare(TurnId(2), "what do I eat").await.unwrap();
1426
1427 let during_turn = session.active_snapshot();
1428 session
1430 .observe_final_transcript(TurnId(3), "I am allergic to nuts")
1431 .await
1432 .unwrap();
1433 session
1434 .prepare(TurnId(3), "what am I allergic to")
1435 .await
1436 .unwrap();
1437
1438 assert_eq!(
1439 session.active_snapshot().snapshot_id,
1440 during_turn.snapshot_id,
1441 "the in-flight turn keeps its frozen snapshot"
1442 );
1443 }
1444
1445 #[tokio::test]
1446 async fn bystander_speech_never_reaches_the_ledger() {
1447 let engine = engine();
1448 let session = engine.begin_session(SessionId::new("ses_1"));
1449 session.begin_turn(TurnId(1));
1450 session
1451 .observe_final_transcript(TurnId(1), "the weather is nice today")
1452 .await
1453 .unwrap();
1454 assert!(session.ledger().is_empty());
1455 }
1456
1457 #[tokio::test]
1458 async fn reconciling_the_same_session_twice_writes_once() {
1459 let engine = engine();
1460 let session = engine.begin_session(SessionId::new("ses_1"));
1461 session.begin_turn(TurnId(1));
1462 session
1463 .observe_final_transcript(TurnId(1), "I am pescatarian")
1464 .await
1465 .unwrap();
1466
1467 session.finish().await.unwrap();
1468 session.finish().await.unwrap();
1469
1470 let stored = engine.repository().all(engine.user()).await.unwrap();
1471 assert_eq!(stored.len(), 1);
1472 }
1473
1474 #[tokio::test]
1475 async fn the_event_log_records_the_turn_before_the_extraction() {
1476 let log = Arc::new(InMemoryEventLog::new());
1477 let engine = MemoryEngine::new(
1478 UserId::new("usr_1"),
1479 Arc::new(OkfRepository::in_memory()),
1480 log.clone(),
1481 MemoryRuntimeConfig::default(),
1482 );
1483 let session = engine.begin_session(SessionId::new("ses_1"));
1484 session
1485 .observe_final_transcript(TurnId(1), "I am pescatarian")
1486 .await
1487 .unwrap();
1488
1489 let entries = log.entries();
1490 assert_eq!(entries[0].payload.label(), "final_transcript_recorded");
1491 }
1492
1493 #[tokio::test]
1494 async fn a_staged_pattern_is_promoted_once_it_spans_sessions_and_days() {
1495 let engine = engine();
1496
1497 for (idx, session_id) in ["ses_1", "ses_2"].iter().enumerate() {
1499 let session = engine.begin_session(SessionId::new(*session_id));
1500 session.begin_turn(TurnId(1));
1501 session
1502 .observe_final_transcript(TurnId(1), "I always go to the gym before work")
1503 .await
1504 .unwrap();
1505 session.finish().await.unwrap();
1506 let _ = idx;
1507 }
1508
1509 let stored = engine.repository().all(engine.user()).await.unwrap();
1510 let routine = stored
1511 .iter()
1512 .find(|m| m.predicate.as_str() == "exercise_routine")
1513 .expect("the routine was recorded");
1514 assert_eq!(routine.evidence.distinct_sessions, 2);
1515 }
1516}