1use std::collections::{HashMap, HashSet};
4use std::future::Future;
5use std::pin::Pin;
6
7use parking_lot::RwLock;
8
9use chrono::Utc;
10
11use crate::auth::TenantScope;
12use crate::error::Error;
13
14use super::bm25;
15use super::hybrid;
16use super::scoring::{STRENGTH_DECAY_RATE, ScoringWeights, composite_score, effective_strength};
17use super::{Memory, MemoryEntry, MemoryQuery};
18
19pub const IN_MEMORY_STORE_DEFAULT_CAP: usize = 100_000;
26
27#[derive(Debug, Clone, Default)]
32struct EntryTokens {
33 lower_content: String,
37 content_words: Vec<String>,
41 lower_keywords: Vec<String>,
44}
45
46fn build_entry_tokens(entry: &MemoryEntry) -> EntryTokens {
47 let lower_content = entry.content.to_lowercase();
48 let content_words: Vec<String> = lower_content.split_whitespace().map(String::from).collect();
49 let lower_keywords: Vec<String> = entry.keywords.iter().map(|k| k.to_lowercase()).collect();
50 EntryTokens {
51 lower_content,
52 content_words,
53 lower_keywords,
54 }
55}
56
57fn index_entry(
61 inverted: &mut HashMap<String, HashSet<String>>,
62 entry_id: &str,
63 tokens: &EntryTokens,
64) {
65 let mut seen: HashSet<&str> = HashSet::with_capacity(tokens.content_words.len());
66 for word in tokens
67 .content_words
68 .iter()
69 .chain(tokens.lower_keywords.iter())
70 {
71 if seen.insert(word.as_str()) {
72 inverted
73 .entry(word.clone())
74 .or_default()
75 .insert(entry_id.to_string());
76 }
77 }
78}
79
80fn deindex_entry(
85 inverted: &mut HashMap<String, HashSet<String>>,
86 entry_id: &str,
87 tokens: &EntryTokens,
88) {
89 let mut seen: HashSet<&str> = HashSet::with_capacity(tokens.content_words.len());
90 for word in tokens
91 .content_words
92 .iter()
93 .chain(tokens.lower_keywords.iter())
94 {
95 if !seen.insert(word.as_str()) {
96 continue;
97 }
98 if let Some(bucket) = inverted.get_mut(word) {
99 bucket.remove(entry_id);
100 if bucket.is_empty() {
101 inverted.remove(word);
102 }
103 }
104 }
105}
106
107pub struct InMemoryStore {
129 entries: RwLock<HashMap<String, MemoryEntry>>,
130 tokens: RwLock<HashMap<String, EntryTokens>>,
131 inverted: RwLock<HashMap<String, HashSet<String>>>,
136 scoring_weights: ScoringWeights,
137 max_entries: usize,
138}
139
140impl InMemoryStore {
141 pub fn new() -> Self {
143 Self {
144 entries: RwLock::new(HashMap::new()),
145 tokens: RwLock::new(HashMap::new()),
146 inverted: RwLock::new(HashMap::new()),
147 scoring_weights: ScoringWeights::default(),
148 max_entries: IN_MEMORY_STORE_DEFAULT_CAP,
149 }
150 }
151
152 pub fn with_scoring_weights(mut self, weights: ScoringWeights) -> Self {
154 self.scoring_weights = weights;
155 self
156 }
157
158 pub fn with_max_entries(mut self, max_entries: usize) -> Self {
160 self.max_entries = max_entries;
161 self
162 }
163
164 pub fn get(&self, id: &str) -> Option<MemoryEntry> {
166 self.entries.read().get(id).cloned()
167 }
168}
169
170impl Default for InMemoryStore {
171 fn default() -> Self {
172 Self::new()
173 }
174}
175
176impl Memory for InMemoryStore {
177 fn store(
178 &self,
179 scope: &TenantScope,
180 mut entry: MemoryEntry,
181 ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
182 entry.author_tenant_id = Some(scope.tenant_id.clone());
184 entry.author_user_id = scope.user_id.clone();
185 Box::pin(async move {
186 let mut entries = self.entries.write();
187 let mut tokens = self.tokens.write();
188 let mut inverted = self.inverted.write();
189 if !entries.contains_key(&entry.id) && entries.len() >= self.max_entries {
196 let now = Utc::now();
197 if let Some(victim_id) = entries
198 .values()
199 .min_by(|a, b| {
200 let ea = effective_strength(
201 a.strength,
202 a.last_accessed,
203 now,
204 STRENGTH_DECAY_RATE,
205 );
206 let eb = effective_strength(
207 b.strength,
208 b.last_accessed,
209 now,
210 STRENGTH_DECAY_RATE,
211 );
212 ea.partial_cmp(&eb).unwrap_or(std::cmp::Ordering::Equal)
213 })
214 .map(|e| e.id.clone())
215 {
216 entries.remove(&victim_id);
217 if let Some(victim_tokens) = tokens.remove(&victim_id) {
218 deindex_entry(&mut inverted, &victim_id, &victim_tokens);
219 }
220 tracing::warn!(
221 evicted = %victim_id,
222 cap = self.max_entries,
223 "InMemoryStore at cap; evicted weakest entry (F-MEM-3)"
224 );
225 }
226 }
227 let entry_tokens = build_entry_tokens(&entry);
230 let id = entry.id.clone();
231 if let Some(old_tokens) = tokens.get(&id) {
234 deindex_entry(&mut inverted, &id, old_tokens);
235 }
236 index_entry(&mut inverted, &id, &entry_tokens);
237 entries.insert(id.clone(), entry);
238 tokens.insert(id, entry_tokens);
239 Ok(())
240 })
241 }
242
243 fn recall(
244 &self,
245 scope: &TenantScope,
246 query: MemoryQuery,
247 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryEntry>, Error>> + Send + '_>> {
248 let tenant_id = scope.tenant_id.clone();
249 Box::pin(async move {
250 let mut entries = self.entries.write();
255 let tokens_cache = self.tokens.read();
260 let inverted = self.inverted.read();
264
265 let now = Utc::now();
266 let query_tokens: Vec<String> = query
267 .text
268 .as_deref()
269 .map(|t| {
270 let mut seen = std::collections::HashSet::new();
271 t.to_lowercase()
272 .split_whitespace()
273 .filter(|tok| seen.insert(tok.to_string()))
274 .map(String::from)
275 .collect()
276 })
277 .unwrap_or_default();
278
279 let exact_word_candidate_ids: Option<Vec<String>> =
294 if query.exact_words && !query_tokens.is_empty() {
295 let mut ids: HashSet<String> = HashSet::new();
296 for token in &query_tokens {
297 if let Some(bucket) = inverted.get(token.as_str()) {
298 ids.extend(bucket.iter().cloned());
299 }
300 }
301 Some(ids.into_iter().collect())
302 } else {
303 None
304 };
305
306 let filter_logic = |e: &MemoryEntry| -> bool {
318 if e.author_tenant_id.as_deref() != Some(tenant_id.as_str()) {
320 return false;
321 }
322 if let Some(ref agent) = query.agent {
323 if e.agent != *agent {
324 return false;
325 }
326 } else if let Some(ref prefix) = query.agent_prefix
327 && !e.agent.starts_with(prefix.as_str())
328 {
329 return false;
330 }
331 if let Some(ref cat) = query.category
332 && e.category != *cat
333 {
334 return false;
335 }
336 if !query.tags.is_empty() && !query.tags.iter().any(|t| e.tags.contains(t)) {
337 return false;
338 }
339 if let Some(ref mt) = query.memory_type
340 && e.memory_type != *mt
341 {
342 return false;
343 }
344 if let Some(max_conf) = query.max_confidentiality
345 && e.confidentiality > max_conf
346 {
347 return false;
348 }
349 if let Some(min_s) = query.min_strength {
350 let eff =
351 effective_strength(e.strength, e.last_accessed, now, STRENGTH_DECAY_RATE);
352 if eff < min_s {
353 return false;
354 }
355 }
356 true
357 };
358
359 let candidates: Vec<(&MemoryEntry, &EntryTokens)> = match exact_word_candidate_ids {
360 Some(ids) => ids
361 .iter()
362 .filter_map(|id| {
363 let e = entries.get(id)?;
364 if !filter_logic(e) {
365 return None;
366 }
367 let tokens = tokens_cache.get(id)?;
368 Some((e, tokens))
369 })
370 .collect(),
371 None => entries
372 .values()
373 .filter_map(|e| {
374 if !filter_logic(e) {
375 return None;
376 }
377 let tokens = tokens_cache.get(&e.id)?;
382 if !query_tokens.is_empty() {
385 let has_match = query_tokens.iter().any(|token| {
386 tokens.lower_content.contains(token.as_str())
387 || tokens
388 .lower_keywords
389 .iter()
390 .any(|k| k.contains(token.as_str()))
391 });
392 if !has_match {
393 return None;
394 }
395 }
396 Some((e, tokens))
397 })
398 .collect(),
399 };
400
401 let avgdl = if candidates.is_empty() {
404 1.0
405 } else {
406 let total_words: usize =
407 candidates.iter().map(|(_, t)| t.content_words.len()).sum();
408 (total_words as f64 / candidates.len() as f64).max(1.0)
409 };
410
411 let bm25_map: HashMap<&str, f64> = candidates
415 .iter()
416 .map(|(e, t)| {
417 let score = bm25::bm25_score_pre(
418 &t.content_words,
419 &t.lower_keywords,
420 &query_tokens,
421 avgdl,
422 bm25::DEFAULT_K1,
423 bm25::DEFAULT_B,
424 );
425 (e.id.as_str(), score)
426 })
427 .collect();
428
429 let relevance_map: HashMap<&str, f64> = if let Some(ref q_emb) = query.query_embedding {
432 let mut bm25_ranked: Vec<(&str, f64)> =
434 bm25_map.iter().map(|(id, &s)| (*id, s)).collect();
435 bm25_ranked
436 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
437
438 let mut vector_ranked: Vec<(&str, f64)> = candidates
440 .iter()
441 .filter_map(|(e, _)| {
442 e.embedding
443 .as_ref()
444 .map(|emb| (e.id.as_str(), hybrid::cosine_similarity(emb, q_emb)))
445 })
446 .collect();
447 vector_ranked
448 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
449
450 if vector_ranked.is_empty() {
451 let max_bm25 = bm25_map
453 .values()
454 .copied()
455 .fold(f64::NEG_INFINITY, f64::max)
456 .max(1.0);
457 bm25_map
458 .iter()
459 .map(|(id, &s)| (*id, s / max_bm25))
460 .collect()
461 } else {
462 let fused = hybrid::rrf_fuse(&bm25_ranked, &vector_ranked, 50);
463 let max_fused = fused
464 .iter()
465 .map(|(_, s)| *s)
466 .fold(f64::NEG_INFINITY, f64::max)
467 .max(f64::EPSILON);
468 let mut out: HashMap<&str, f64> = HashMap::with_capacity(fused.len());
473 for (id_owned, score) in &fused {
474 if let Some((k, _)) = bm25_map.get_key_value(id_owned.as_str()) {
475 out.insert(k, score / max_fused);
476 }
477 }
478 out
479 }
480 } else {
481 let max_bm25 = bm25_map
483 .values()
484 .copied()
485 .fold(f64::NEG_INFINITY, f64::max)
486 .max(1.0);
487 bm25_map
488 .iter()
489 .map(|(id, &s)| (*id, s / max_bm25))
490 .collect()
491 };
492
493 let mut scored: Vec<(&MemoryEntry, f64)> = candidates
498 .iter()
499 .map(|(e, _)| {
500 let relevance = relevance_map.get(e.id.as_str()).copied().unwrap_or(0.0);
501 let eff =
502 effective_strength(e.strength, e.last_accessed, now, STRENGTH_DECAY_RATE);
503 let score = composite_score(
504 &self.scoring_weights,
505 e.created_at,
506 now,
507 e.importance,
508 relevance,
509 eff,
510 );
511 (*e, score)
512 })
513 .collect();
514 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
515
516 if query.limit > 0 && scored.len() > query.limit {
517 scored.truncate(query.limit);
518 }
519
520 let top_ids: std::collections::HashSet<String> =
523 scored.iter().map(|(e, _)| e.id.clone()).collect();
524 let mut to_expand = Vec::new();
525 let mut seen_expanded = std::collections::HashSet::new();
526 for (entry, _) in &scored {
527 for related_id in &entry.related_ids {
528 if !top_ids.contains(related_id) && seen_expanded.insert(related_id.clone()) {
529 to_expand.push(related_id.clone());
530 }
531 }
532 }
533
534 let max_bm25 = bm25_map
540 .values()
541 .copied()
542 .fold(f64::NEG_INFINITY, f64::max)
543 .max(1.0);
544 let min_s = query.min_strength.unwrap_or(0.0);
545 let mut expanded_added = 0usize;
546 for related_id in &to_expand {
547 if let Some(related) = entries.get(related_id) {
548 if related.author_tenant_id.as_deref() != Some(tenant_id.as_str()) {
558 continue;
559 }
560 if let Some(max_conf) = query.max_confidentiality
561 && related.confidentiality > max_conf
562 {
563 continue;
564 }
565 let eff = effective_strength(
566 related.strength,
567 related.last_accessed,
568 now,
569 STRENGTH_DECAY_RATE,
570 );
571 if eff < min_s {
572 continue;
573 }
574 let Some(related_tokens) = tokens_cache.get(related_id) else {
579 continue;
580 };
581 let relevance = bm25::bm25_score_pre(
582 &related_tokens.content_words,
583 &related_tokens.lower_keywords,
584 &query_tokens,
585 avgdl,
586 bm25::DEFAULT_K1,
587 bm25::DEFAULT_B,
588 );
589 let normalised = relevance / max_bm25;
590 let score = composite_score(
591 &self.scoring_weights,
592 related.created_at,
593 now,
594 related.importance,
595 normalised,
596 eff,
597 );
598 scored.push((related, score));
599 expanded_added += 1;
600 }
601 }
602
603 if expanded_added > 0 {
604 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
605 if query.limit > 0 && scored.len() > query.limit {
606 scored.truncate(query.limit);
607 }
608 }
609
610 let top_ids_final: Vec<String> = scored.iter().map(|(e, _)| e.id.clone()).collect();
615 drop(scored);
616 drop(candidates);
617 drop(tokens_cache);
618 drop(inverted);
619
620 let reinforce = query.reinforce;
625 let mut results: Vec<MemoryEntry> = Vec::with_capacity(top_ids_final.len());
626 for id in top_ids_final {
627 if let Some(e) = entries.get_mut(&id) {
628 e.access_count += 1;
629 e.last_accessed = now;
630 if reinforce {
631 e.strength = (e.strength + 0.2).min(1.0);
633 }
634 results.push(e.clone());
635 }
636 }
637
638 Ok(results)
639 })
640 }
641
642 fn update(
643 &self,
644 scope: &TenantScope,
645 id: &str,
646 content: String,
647 ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
648 let id = id.to_string();
649 let tenant_id = scope.tenant_id.clone();
650 Box::pin(async move {
651 let mut entries = self.entries.write();
652 let mut tokens = self.tokens.write();
653 let mut inverted = self.inverted.write();
654 match entries.get_mut(&id) {
655 Some(entry) if entry.author_tenant_id.as_deref() == Some(tenant_id.as_str()) => {
656 entry.content = content;
657 entry.last_accessed = Utc::now();
658 let new_tokens = build_entry_tokens(entry);
661 if let Some(old_tokens) = tokens.get(&id) {
662 deindex_entry(&mut inverted, &id, old_tokens);
663 }
664 index_entry(&mut inverted, &id, &new_tokens);
665 tokens.insert(id.clone(), new_tokens);
666 Ok(())
667 }
668 Some(_) => {
669 Err(Error::Memory(format!("memory not found: {id}")))
671 }
672 None => Err(Error::Memory(format!("memory not found: {id}"))),
673 }
674 })
675 }
676
677 fn forget(
678 &self,
679 scope: &TenantScope,
680 id: &str,
681 ) -> Pin<Box<dyn Future<Output = Result<bool, Error>> + Send + '_>> {
682 let id = id.to_string();
683 let tenant_id = scope.tenant_id.clone();
684 Box::pin(async move {
685 let mut entries = self.entries.write();
686 let mut tokens = self.tokens.write();
687 let mut inverted = self.inverted.write();
688 let belongs = entries
692 .get(&id)
693 .map(|e| e.author_tenant_id.as_deref() == Some(tenant_id.as_str()))
694 .unwrap_or(false);
695 if belongs {
696 let removed = entries.remove(&id).is_some();
697 if let Some(old_tokens) = tokens.remove(&id) {
698 deindex_entry(&mut inverted, &id, &old_tokens);
699 }
700 Ok(removed)
701 } else {
702 Ok(false)
703 }
704 })
705 }
706
707 fn add_link(
708 &self,
709 scope: &TenantScope,
710 id: &str,
711 related_id: &str,
712 ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
713 let id = id.to_string();
714 let related_id = related_id.to_string();
715 let tenant_id = scope.tenant_id.clone();
716 Box::pin(async move {
717 let mut entries = self.entries.write();
718
719 let id_ok = entries
721 .get(&id)
722 .map(|e| e.author_tenant_id.as_deref() == Some(tenant_id.as_str()))
723 .unwrap_or(false);
724 let rel_ok = entries
725 .get(&related_id)
726 .map(|e| e.author_tenant_id.as_deref() == Some(tenant_id.as_str()))
727 .unwrap_or(false);
728
729 if id_ok
730 && let Some(entry) = entries.get_mut(&id)
731 && !entry.related_ids.contains(&related_id)
732 {
733 entry.related_ids.push(related_id.clone());
734 }
735 if rel_ok
736 && let Some(entry) = entries.get_mut(&related_id)
737 && !entry.related_ids.contains(&id)
738 {
739 entry.related_ids.push(id);
740 }
741 Ok(())
742 })
743 }
744
745 fn prune(
746 &self,
747 scope: &TenantScope,
748 min_strength: f64,
749 min_age: chrono::Duration,
750 agent_prefix: Option<&str>,
751 ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
752 let owned_prefix = agent_prefix.map(String::from);
753 let tenant_id = scope.tenant_id.clone();
754 Box::pin(async move {
755 let mut entries = self.entries.write();
756
757 let now = Utc::now();
758 let to_remove: Vec<String> = entries
759 .values()
760 .filter(|e| {
761 if e.author_tenant_id.as_deref() != Some(tenant_id.as_str()) {
763 return false;
764 }
765 if let Some(ref prefix) = owned_prefix {
773 let p = prefix.as_str();
774 let agent = e.agent.as_str();
775 let separator_match = agent.len() > p.len()
776 && agent.starts_with(p)
777 && agent.as_bytes()[p.len()] == b':';
778 if agent != p && !separator_match {
779 return false;
780 }
781 }
782 let eff =
783 effective_strength(e.strength, e.last_accessed, now, STRENGTH_DECAY_RATE);
784 eff < min_strength && now.signed_duration_since(e.created_at) > min_age
785 })
786 .map(|e| e.id.clone())
787 .collect();
788
789 let count = to_remove.len();
790 let mut tokens = self.tokens.write();
791 let mut inverted = self.inverted.write();
792 for id in to_remove {
793 entries.remove(&id);
794 if let Some(old_tokens) = tokens.remove(&id) {
795 deindex_entry(&mut inverted, &id, &old_tokens);
796 }
797 }
798 Ok(count)
799 })
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806 use chrono::Utc;
807
808 use super::super::{Confidentiality, MemoryType};
809
810 fn test_scope() -> TenantScope {
811 TenantScope::default()
812 }
813
814 fn make_entry(id: &str, agent: &str, content: &str, category: &str) -> MemoryEntry {
815 MemoryEntry {
816 id: id.into(),
817 agent: agent.into(),
818 content: content.into(),
819 category: category.into(),
820 tags: vec![],
821 created_at: Utc::now(),
822 last_accessed: Utc::now(),
823 access_count: 0,
824 importance: 5,
825 memory_type: MemoryType::default(),
826 keywords: vec![],
827 summary: None,
828 strength: 1.0,
829 related_ids: vec![],
830 source_ids: vec![],
831 embedding: None,
832 confidentiality: Confidentiality::default(),
833 author_user_id: None,
834 author_tenant_id: None,
835 }
836 }
837
838 fn make_entry_with_tags(
839 id: &str,
840 agent: &str,
841 content: &str,
842 category: &str,
843 tags: Vec<String>,
844 ) -> MemoryEntry {
845 MemoryEntry {
846 id: id.into(),
847 agent: agent.into(),
848 content: content.into(),
849 category: category.into(),
850 tags,
851 created_at: Utc::now(),
852 last_accessed: Utc::now(),
853 access_count: 0,
854 importance: 5,
855 memory_type: MemoryType::default(),
856 keywords: vec![],
857 summary: None,
858 strength: 1.0,
859 related_ids: vec![],
860 source_ids: vec![],
861 embedding: None,
862 confidentiality: Confidentiality::default(),
863 author_user_id: None,
864 author_tenant_id: None,
865 }
866 }
867
868 #[tokio::test]
869 async fn store_and_recall() {
870 let store = InMemoryStore::new();
871 let entry = make_entry("m1", "agent1", "Rust is fast", "fact");
872 store.store(&test_scope(), entry).await.unwrap();
873
874 let results = store
875 .recall(
876 &test_scope(),
877 MemoryQuery {
878 limit: 10,
879 ..Default::default()
880 },
881 )
882 .await
883 .unwrap();
884 assert_eq!(results.len(), 1);
885 assert_eq!(results[0].content, "Rust is fast");
886 }
887
888 #[tokio::test]
889 async fn graph_expansion_does_not_leak_cross_tenant_related_entries() {
890 let store = InMemoryStore::new();
896 let t1 = TenantScope::new("tenant-1");
897 let t2 = TenantScope::new("tenant-2");
898
899 store
900 .store(
901 &t2,
902 make_entry("secret", "a", "tenant two private data", "fact"),
903 )
904 .await
905 .unwrap();
906
907 let mut bait = make_entry("bait", "a", "rust is fast", "fact");
908 bait.related_ids = vec!["secret".to_string()];
909 store.store(&t1, bait).await.unwrap();
910
911 let results = store
912 .recall(
913 &t1,
914 MemoryQuery {
915 text: Some("rust".into()),
916 limit: 10,
917 ..Default::default()
918 },
919 )
920 .await
921 .unwrap();
922
923 assert!(
924 results.iter().any(|e| e.id == "bait"),
925 "own entry should be recalled"
926 );
927 assert!(
928 !results.iter().any(|e| e.id == "secret"),
929 "cross-tenant related entry leaked via graph expansion: {:?}",
930 results.iter().map(|e| &e.id).collect::<Vec<_>>()
931 );
932 }
933
934 #[tokio::test]
935 async fn graph_expansion_preserves_cross_category_within_tenant() {
936 let store = InMemoryStore::new();
942 let scope = test_scope();
943
944 store
945 .store(
946 &scope,
947 make_entry("related", "a", "rust ownership notes", "design"),
948 )
949 .await
950 .unwrap();
951 let mut bait = make_entry("bait", "a", "rust borrow checker", "fact");
952 bait.related_ids = vec!["related".to_string()];
953 store.store(&scope, bait).await.unwrap();
954
955 let results = store
956 .recall(
957 &scope,
958 MemoryQuery {
959 text: Some("rust".into()),
960 category: Some("fact".into()),
961 limit: 10,
962 ..Default::default()
963 },
964 )
965 .await
966 .unwrap();
967
968 assert!(results.iter().any(|e| e.id == "bait"));
969 assert!(
970 results.iter().any(|e| e.id == "related"),
971 "same-tenant related entry in a different category must still be \
972 surfaced via graph expansion: {:?}",
973 results
974 .iter()
975 .map(|e| (&e.id, &e.category))
976 .collect::<Vec<_>>()
977 );
978 }
979
980 #[tokio::test]
981 async fn recall_by_text() {
982 let store = InMemoryStore::new();
983 store
984 .store(&test_scope(), make_entry("m1", "a", "Rust is fast", "fact"))
985 .await
986 .unwrap();
987 store
988 .store(
989 &test_scope(),
990 make_entry("m2", "a", "Python is slow", "fact"),
991 )
992 .await
993 .unwrap();
994
995 let results = store
996 .recall(
997 &test_scope(),
998 MemoryQuery {
999 text: Some("rust".into()),
1000 limit: 10,
1001 ..Default::default()
1002 },
1003 )
1004 .await
1005 .unwrap();
1006 assert_eq!(results.len(), 1);
1007 assert_eq!(results[0].id, "m1");
1008 }
1009
1010 #[tokio::test]
1011 async fn recall_by_category() {
1012 let store = InMemoryStore::new();
1013 store
1014 .store(
1015 &test_scope(),
1016 make_entry("m1", "a", "remember this", "fact"),
1017 )
1018 .await
1019 .unwrap();
1020 store
1021 .store(
1022 &test_scope(),
1023 make_entry("m2", "a", "I saw something", "observation"),
1024 )
1025 .await
1026 .unwrap();
1027
1028 let results = store
1029 .recall(
1030 &test_scope(),
1031 MemoryQuery {
1032 category: Some("observation".into()),
1033 limit: 10,
1034 ..Default::default()
1035 },
1036 )
1037 .await
1038 .unwrap();
1039 assert_eq!(results.len(), 1);
1040 assert_eq!(results[0].id, "m2");
1041 }
1042
1043 #[tokio::test]
1044 async fn recall_by_tags() {
1045 let store = InMemoryStore::new();
1046 store
1047 .store(
1048 &test_scope(),
1049 make_entry_with_tags(
1050 "m1",
1051 "a",
1052 "Rust memory safety",
1053 "fact",
1054 vec!["rust".into(), "safety".into()],
1055 ),
1056 )
1057 .await
1058 .unwrap();
1059 store
1060 .store(
1061 &test_scope(),
1062 make_entry_with_tags(
1063 "m2",
1064 "a",
1065 "Go is garbage collected",
1066 "fact",
1067 vec!["go".into()],
1068 ),
1069 )
1070 .await
1071 .unwrap();
1072
1073 let results = store
1074 .recall(
1075 &test_scope(),
1076 MemoryQuery {
1077 tags: vec!["rust".into()],
1078 limit: 10,
1079 ..Default::default()
1080 },
1081 )
1082 .await
1083 .unwrap();
1084 assert_eq!(results.len(), 1);
1085 assert_eq!(results[0].id, "m1");
1086 }
1087
1088 #[tokio::test]
1089 async fn recall_by_agent() {
1090 let store = InMemoryStore::new();
1091 store
1092 .store(
1093 &test_scope(),
1094 make_entry("m1", "researcher", "data point", "fact"),
1095 )
1096 .await
1097 .unwrap();
1098 store
1099 .store(
1100 &test_scope(),
1101 make_entry("m2", "coder", "code snippet", "procedure"),
1102 )
1103 .await
1104 .unwrap();
1105
1106 let results = store
1107 .recall(
1108 &test_scope(),
1109 MemoryQuery {
1110 agent: Some("researcher".into()),
1111 limit: 10,
1112 ..Default::default()
1113 },
1114 )
1115 .await
1116 .unwrap();
1117 assert_eq!(results.len(), 1);
1118 assert_eq!(results[0].id, "m1");
1119 }
1120
1121 #[tokio::test]
1122 async fn recall_limit() {
1123 let store = InMemoryStore::new();
1124 for i in 0..10 {
1125 store
1126 .store(
1127 &test_scope(),
1128 make_entry(&format!("m{i}"), "a", &format!("entry {i}"), "fact"),
1129 )
1130 .await
1131 .unwrap();
1132 }
1133
1134 let results = store
1135 .recall(
1136 &test_scope(),
1137 MemoryQuery {
1138 limit: 3,
1139 ..Default::default()
1140 },
1141 )
1142 .await
1143 .unwrap();
1144 assert_eq!(results.len(), 3);
1145 }
1146
1147 #[tokio::test]
1148 async fn update_existing() {
1149 let store = InMemoryStore::new();
1150 store
1151 .store(&test_scope(), make_entry("m1", "a", "original", "fact"))
1152 .await
1153 .unwrap();
1154
1155 store
1156 .update(&test_scope(), "m1", "updated content".into())
1157 .await
1158 .unwrap();
1159
1160 let results = store
1161 .recall(
1162 &test_scope(),
1163 MemoryQuery {
1164 limit: 10,
1165 ..Default::default()
1166 },
1167 )
1168 .await
1169 .unwrap();
1170 assert_eq!(results[0].content, "updated content");
1171 }
1172
1173 #[tokio::test]
1174 async fn update_nonexistent() {
1175 let store = InMemoryStore::new();
1176 let err = store
1177 .update(&test_scope(), "missing", "content".into())
1178 .await
1179 .unwrap_err();
1180 assert!(err.to_string().contains("not found"));
1181 }
1182
1183 #[tokio::test]
1184 async fn forget_existing() {
1185 let store = InMemoryStore::new();
1186 store
1187 .store(&test_scope(), make_entry("m1", "a", "to delete", "fact"))
1188 .await
1189 .unwrap();
1190
1191 assert!(store.forget(&test_scope(), "m1").await.unwrap());
1192
1193 let results = store
1194 .recall(
1195 &test_scope(),
1196 MemoryQuery {
1197 limit: 10,
1198 ..Default::default()
1199 },
1200 )
1201 .await
1202 .unwrap();
1203 assert!(results.is_empty());
1204 }
1205
1206 #[tokio::test]
1207 async fn forget_nonexistent() {
1208 let store = InMemoryStore::new();
1209 assert!(!store.forget(&test_scope(), "missing").await.unwrap());
1210 }
1211
1212 #[test]
1213 fn is_send_sync() {
1214 fn assert_send_sync<T: Send + Sync>() {}
1215 assert_send_sync::<InMemoryStore>();
1216 }
1217
1218 #[tokio::test]
1219 async fn get_returns_entry_by_id_or_none() {
1220 let store = InMemoryStore::new();
1221 let scope = test_scope();
1222 let entry = make_entry("e1", "test", "hello world", "fact");
1223 store.store(&scope, entry).await.unwrap();
1224
1225 let got = store.get("e1").expect("entry e1 should exist");
1226 assert_eq!(got.content, "hello world");
1227 assert!(store.get("missing").is_none());
1228 }
1229
1230 #[tokio::test]
1231 async fn recall_sorts_by_composite_score() {
1232 let store = InMemoryStore::new();
1233
1234 let mut high_imp = make_entry("m1", "a", "high importance", "fact");
1236 high_imp.importance = 10;
1237 high_imp.created_at = Utc::now() - chrono::Duration::hours(48);
1238 store.store(&test_scope(), high_imp).await.unwrap();
1239
1240 let mut low_imp = make_entry("m2", "a", "low importance", "fact");
1242 low_imp.importance = 1;
1243 low_imp.created_at = Utc::now();
1244 store.store(&test_scope(), low_imp).await.unwrap();
1245
1246 let results = store
1247 .recall(
1248 &test_scope(),
1249 MemoryQuery {
1250 limit: 10,
1251 ..Default::default()
1252 },
1253 )
1254 .await
1255 .unwrap();
1256
1257 assert_eq!(results.len(), 2);
1258 assert_eq!(results[0].id, "m1");
1263 assert_eq!(results[1].id, "m2");
1264 }
1265
1266 #[tokio::test]
1267 async fn recall_recent_high_importance_first() {
1268 let store = InMemoryStore::new();
1269
1270 let mut old_low = make_entry("m1", "a", "old low", "fact");
1272 old_low.importance = 1;
1273 old_low.created_at = Utc::now() - chrono::Duration::hours(1000);
1274 store.store(&test_scope(), old_low).await.unwrap();
1275
1276 let mut recent_high = make_entry("m2", "a", "recent high", "fact");
1278 recent_high.importance = 10;
1279 recent_high.created_at = Utc::now();
1280 store.store(&test_scope(), recent_high).await.unwrap();
1281
1282 let results = store
1283 .recall(
1284 &test_scope(),
1285 MemoryQuery {
1286 limit: 10,
1287 ..Default::default()
1288 },
1289 )
1290 .await
1291 .unwrap();
1292
1293 assert_eq!(results[0].id, "m2");
1294 }
1295
1296 #[tokio::test]
1297 async fn recall_with_custom_weights() {
1298 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
1300 alpha: 0.0,
1301 beta: 1.0,
1302 gamma: 0.0,
1303 delta: 0.0,
1304 decay_rate: 0.01,
1305 });
1306
1307 let mut low = make_entry("m1", "a", "recent but low", "fact");
1308 low.importance = 1;
1309 low.created_at = Utc::now();
1310 store.store(&test_scope(), low).await.unwrap();
1311
1312 let mut high = make_entry("m2", "a", "old but high", "fact");
1313 high.importance = 10;
1314 high.created_at = Utc::now() - chrono::Duration::hours(1000);
1315 store.store(&test_scope(), high).await.unwrap();
1316
1317 let results = store
1318 .recall(
1319 &test_scope(),
1320 MemoryQuery {
1321 limit: 10,
1322 ..Default::default()
1323 },
1324 )
1325 .await
1326 .unwrap();
1327
1328 assert_eq!(results[0].id, "m2");
1330 }
1331
1332 #[tokio::test]
1333 async fn recall_text_query_affects_relevance() {
1334 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
1336 alpha: 0.0,
1337 beta: 0.0,
1338 gamma: 1.0,
1339 delta: 0.0,
1340 decay_rate: 0.01,
1341 });
1342
1343 let mut e1 = make_entry("m1", "a", "Rust is fast", "fact");
1344 e1.importance = 5;
1345 store.store(&test_scope(), e1).await.unwrap();
1346
1347 let results = store
1349 .recall(
1350 &test_scope(),
1351 MemoryQuery {
1352 text: Some("Rust".into()),
1353 limit: 10,
1354 ..Default::default()
1355 },
1356 )
1357 .await
1358 .unwrap();
1359
1360 assert_eq!(results.len(), 1);
1361 assert_eq!(results[0].id, "m1");
1362 }
1363
1364 #[tokio::test]
1365 async fn recall_limit_zero_returns_all() {
1366 let store = InMemoryStore::new();
1367 for i in 0..5 {
1368 store
1369 .store(
1370 &test_scope(),
1371 make_entry(&format!("m{i}"), "a", &format!("entry {i}"), "fact"),
1372 )
1373 .await
1374 .unwrap();
1375 }
1376
1377 let results = store
1379 .recall(
1380 &test_scope(),
1381 MemoryQuery {
1382 limit: 0,
1383 ..Default::default()
1384 },
1385 )
1386 .await
1387 .unwrap();
1388 assert_eq!(results.len(), 5);
1389 }
1390
1391 #[tokio::test]
1392 async fn recall_deduplicates_query_tokens() {
1393 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
1396 alpha: 0.0,
1397 beta: 0.0,
1398 gamma: 1.0,
1399 delta: 0.0,
1400 decay_rate: 0.01,
1401 });
1402
1403 store
1404 .store(&test_scope(), make_entry("m1", "a", "Rust is fast", "fact"))
1405 .await
1406 .unwrap();
1407 store
1408 .store(
1409 &test_scope(),
1410 make_entry("m2", "a", "Python is slow", "fact"),
1411 )
1412 .await
1413 .unwrap();
1414
1415 let results = store
1417 .recall(
1418 &test_scope(),
1419 MemoryQuery {
1420 text: Some("rust rust rust".into()),
1421 limit: 10,
1422 ..Default::default()
1423 },
1424 )
1425 .await
1426 .unwrap();
1427
1428 assert_eq!(results.len(), 1);
1430 assert_eq!(results[0].id, "m1");
1431 }
1432
1433 #[tokio::test]
1434 async fn relevance_score_differentiates_results() {
1435 let store = InMemoryStore::new();
1439
1440 let mut entry_partial =
1441 make_entry("m1", "agent1", "Rust is popular in the industry", "fact");
1442 entry_partial.importance = 5;
1443
1444 let mut entry_full =
1445 make_entry("m2", "agent1", "Rust is fast and safe for systems", "fact");
1446 entry_full.importance = 5;
1447
1448 store.store(&test_scope(), entry_partial).await.unwrap();
1450 store.store(&test_scope(), entry_full).await.unwrap();
1451
1452 let results = store
1453 .recall(
1454 &test_scope(),
1455 MemoryQuery {
1456 text: Some("Rust fast".into()),
1457 limit: 10,
1458 ..Default::default()
1459 },
1460 )
1461 .await
1462 .unwrap();
1463
1464 assert_eq!(results.len(), 2);
1466 assert_eq!(
1468 results[0].id, "m2",
1469 "entry matching more query tokens should rank first"
1470 );
1471 }
1472
1473 #[tokio::test]
1476 async fn recall_filters_by_memory_type() {
1477 let store = InMemoryStore::new();
1478
1479 let mut episodic = make_entry("m1", "a", "episodic fact", "fact");
1480 episodic.memory_type = MemoryType::Episodic;
1481 store.store(&test_scope(), episodic).await.unwrap();
1482
1483 let mut semantic = make_entry("m2", "a", "semantic knowledge", "fact");
1484 semantic.memory_type = MemoryType::Semantic;
1485 store.store(&test_scope(), semantic).await.unwrap();
1486
1487 let mut reflection = make_entry("m3", "a", "reflection insight", "fact");
1488 reflection.memory_type = MemoryType::Reflection;
1489 store.store(&test_scope(), reflection).await.unwrap();
1490
1491 let results = store
1493 .recall(
1494 &test_scope(),
1495 MemoryQuery {
1496 memory_type: Some(MemoryType::Semantic),
1497 limit: 10,
1498 ..Default::default()
1499 },
1500 )
1501 .await
1502 .unwrap();
1503 assert_eq!(results.len(), 1);
1504 assert_eq!(results[0].id, "m2");
1505
1506 let results = store
1508 .recall(
1509 &test_scope(),
1510 MemoryQuery {
1511 memory_type: Some(MemoryType::Reflection),
1512 limit: 10,
1513 ..Default::default()
1514 },
1515 )
1516 .await
1517 .unwrap();
1518 assert_eq!(results.len(), 1);
1519 assert_eq!(results[0].id, "m3");
1520
1521 let results = store
1523 .recall(
1524 &test_scope(),
1525 MemoryQuery {
1526 limit: 10,
1527 ..Default::default()
1528 },
1529 )
1530 .await
1531 .unwrap();
1532 assert_eq!(results.len(), 3);
1533 }
1534
1535 #[tokio::test]
1536 async fn recall_filters_by_min_strength() {
1537 let store = InMemoryStore::new();
1538
1539 let mut strong = make_entry("m1", "a", "strong memory", "fact");
1540 strong.strength = 0.9;
1541 store.store(&test_scope(), strong).await.unwrap();
1542
1543 let mut weak = make_entry("m2", "a", "weak memory", "fact");
1544 weak.strength = 0.05;
1545 store.store(&test_scope(), weak).await.unwrap();
1546
1547 let results = store
1549 .recall(
1550 &test_scope(),
1551 MemoryQuery {
1552 min_strength: Some(0.5),
1553 limit: 10,
1554 ..Default::default()
1555 },
1556 )
1557 .await
1558 .unwrap();
1559 assert_eq!(results.len(), 1);
1560 assert_eq!(results[0].id, "m1");
1561 }
1562
1563 #[tokio::test]
1564 async fn strength_reinforced_on_access() {
1565 let store = InMemoryStore::new();
1566
1567 let mut entry = make_entry("m1", "a", "test", "fact");
1568 entry.strength = 0.5;
1569 store.store(&test_scope(), entry).await.unwrap();
1570
1571 let results = store
1573 .recall(
1574 &test_scope(),
1575 MemoryQuery {
1576 limit: 10,
1577 ..Default::default()
1578 },
1579 )
1580 .await
1581 .unwrap();
1582 assert!((results[0].strength - 0.7).abs() < f64::EPSILON);
1583
1584 let results = store
1586 .recall(
1587 &test_scope(),
1588 MemoryQuery {
1589 limit: 10,
1590 ..Default::default()
1591 },
1592 )
1593 .await
1594 .unwrap();
1595 assert!((results[0].strength - 0.9).abs() < f64::EPSILON);
1596 }
1597
1598 #[tokio::test]
1599 async fn store_preserves_caller_supplied_strength() {
1600 let store = InMemoryStore::new();
1604
1605 let mut weak = make_entry("m1", "a", "test", "fact");
1606 weak.strength = 0.05;
1607 store.store(&test_scope(), weak).await.unwrap();
1608
1609 let results = store
1610 .recall(
1611 &test_scope(),
1612 MemoryQuery {
1613 limit: 10,
1614 reinforce: false,
1615 ..Default::default()
1616 },
1617 )
1618 .await
1619 .unwrap();
1620 assert!(
1621 (results[0].strength - 0.05).abs() < f64::EPSILON,
1622 "store must preserve caller strength; recall(reinforce=false) must not mutate it (got {})",
1623 results[0].strength
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn recall_with_reinforce_false_is_idempotent_for_strength() {
1629 let store = InMemoryStore::new();
1630
1631 let mut entry = make_entry("m1", "a", "test", "fact");
1632 entry.strength = 0.4;
1633 store.store(&test_scope(), entry).await.unwrap();
1634
1635 for _ in 0..5 {
1636 let results = store
1637 .recall(
1638 &test_scope(),
1639 MemoryQuery {
1640 limit: 10,
1641 reinforce: false,
1642 ..Default::default()
1643 },
1644 )
1645 .await
1646 .unwrap();
1647 assert!((results[0].strength - 0.4).abs() < f64::EPSILON);
1648 }
1649
1650 let results = store
1652 .recall(
1653 &test_scope(),
1654 MemoryQuery {
1655 limit: 10,
1656 reinforce: false,
1657 ..Default::default()
1658 },
1659 )
1660 .await
1661 .unwrap();
1662 assert!(results[0].access_count >= 5);
1663 }
1664
1665 #[tokio::test]
1666 async fn strength_capped_at_one() {
1667 let store = InMemoryStore::new();
1668
1669 let mut entry = make_entry("m1", "a", "test", "fact");
1670 entry.strength = 0.95;
1671 store.store(&test_scope(), entry).await.unwrap();
1672
1673 let results = store
1675 .recall(
1676 &test_scope(),
1677 MemoryQuery {
1678 limit: 10,
1679 ..Default::default()
1680 },
1681 )
1682 .await
1683 .unwrap();
1684 assert!((results[0].strength - 1.0).abs() < f64::EPSILON);
1685 }
1686
1687 #[tokio::test]
1688 async fn keywords_searched_during_recall() {
1689 let store = InMemoryStore::new();
1690
1691 let mut entry = make_entry("m1", "a", "Rust is great", "fact");
1693 entry.keywords = vec!["performance".into(), "speed".into()];
1694 store.store(&test_scope(), entry).await.unwrap();
1695
1696 let results = store
1697 .recall(
1698 &test_scope(),
1699 MemoryQuery {
1700 text: Some("performance".into()),
1701 limit: 10,
1702 ..Default::default()
1703 },
1704 )
1705 .await
1706 .unwrap();
1707 assert_eq!(results.len(), 1);
1708 assert_eq!(results[0].id, "m1");
1709 }
1710
1711 #[tokio::test]
1712 async fn add_link_bidirectional() {
1713 let store = InMemoryStore::new();
1714 store
1715 .store(&test_scope(), make_entry("m1", "a", "first", "fact"))
1716 .await
1717 .unwrap();
1718 store
1719 .store(&test_scope(), make_entry("m2", "a", "second", "fact"))
1720 .await
1721 .unwrap();
1722
1723 store.add_link(&test_scope(), "m1", "m2").await.unwrap();
1724
1725 let results = store
1726 .recall(
1727 &test_scope(),
1728 MemoryQuery {
1729 limit: 10,
1730 ..Default::default()
1731 },
1732 )
1733 .await
1734 .unwrap();
1735 let m1 = results.iter().find(|e| e.id == "m1").unwrap();
1736 let m2 = results.iter().find(|e| e.id == "m2").unwrap();
1737
1738 assert!(m1.related_ids.contains(&"m2".to_string()));
1739 assert!(m2.related_ids.contains(&"m1".to_string()));
1740 }
1741
1742 #[tokio::test]
1743 async fn add_link_idempotent() {
1744 let store = InMemoryStore::new();
1745 store
1746 .store(&test_scope(), make_entry("m1", "a", "first", "fact"))
1747 .await
1748 .unwrap();
1749 store
1750 .store(&test_scope(), make_entry("m2", "a", "second", "fact"))
1751 .await
1752 .unwrap();
1753
1754 store.add_link(&test_scope(), "m1", "m2").await.unwrap();
1756 store.add_link(&test_scope(), "m1", "m2").await.unwrap();
1757
1758 let results = store
1759 .recall(
1760 &test_scope(),
1761 MemoryQuery {
1762 limit: 10,
1763 ..Default::default()
1764 },
1765 )
1766 .await
1767 .unwrap();
1768 let m1 = results.iter().find(|e| e.id == "m1").unwrap();
1769 assert_eq!(
1770 m1.related_ids.iter().filter(|id| *id == "m2").count(),
1771 1,
1772 "should not have duplicate links"
1773 );
1774 }
1775
1776 #[tokio::test]
1777 async fn prune_removes_below_threshold() {
1778 let store = InMemoryStore::new();
1779
1780 let mut strong = make_entry("m1", "a", "strong", "fact");
1781 strong.strength = 0.8;
1782 strong.created_at = Utc::now() - chrono::Duration::hours(48);
1783 store.store(&test_scope(), strong).await.unwrap();
1784
1785 let mut weak = make_entry("m2", "a", "weak", "fact");
1786 weak.strength = 0.05;
1787 weak.created_at = Utc::now() - chrono::Duration::hours(48);
1788 store.store(&test_scope(), weak).await.unwrap();
1789
1790 let pruned = store
1791 .prune(&test_scope(), 0.1, chrono::Duration::hours(1), None)
1792 .await
1793 .unwrap();
1794 assert_eq!(pruned, 1);
1795
1796 let results = store
1797 .recall(
1798 &test_scope(),
1799 MemoryQuery {
1800 limit: 10,
1801 ..Default::default()
1802 },
1803 )
1804 .await
1805 .unwrap();
1806 assert_eq!(results.len(), 1);
1807 assert_eq!(results[0].id, "m1");
1808 }
1809
1810 #[tokio::test]
1811 async fn prune_respects_min_age() {
1812 let store = InMemoryStore::new();
1813
1814 let mut weak_recent = make_entry("m1", "a", "weak recent", "fact");
1816 weak_recent.strength = 0.01;
1817 weak_recent.created_at = Utc::now(); store.store(&test_scope(), weak_recent).await.unwrap();
1819
1820 let pruned = store
1821 .prune(&test_scope(), 0.1, chrono::Duration::hours(24), None)
1822 .await
1823 .unwrap();
1824 assert_eq!(pruned, 0, "recent entry should not be pruned");
1825 }
1826
1827 #[tokio::test]
1828 async fn prune_uses_effective_strength_with_decay() {
1829 let store = InMemoryStore::new();
1830
1831 let mut old_accessed = make_entry("m1", "a", "old accessed", "fact");
1834 old_accessed.strength = 0.5;
1835 old_accessed.created_at = Utc::now() - chrono::Duration::hours(30 * 24);
1836 old_accessed.last_accessed = Utc::now() - chrono::Duration::hours(30 * 24);
1837 store.store(&test_scope(), old_accessed).await.unwrap();
1838
1839 let mut recently_accessed = make_entry("m2", "a", "recently accessed", "fact");
1841 recently_accessed.strength = 0.5;
1842 recently_accessed.created_at = Utc::now() - chrono::Duration::hours(30 * 24);
1843 recently_accessed.last_accessed = Utc::now();
1844 store.store(&test_scope(), recently_accessed).await.unwrap();
1845
1846 let pruned = store
1850 .prune(&test_scope(), 0.1, chrono::Duration::hours(24), None)
1851 .await
1852 .unwrap();
1853 assert_eq!(pruned, 1, "old unaccessed entry should be pruned");
1854
1855 let results = store
1856 .recall(
1857 &test_scope(),
1858 MemoryQuery {
1859 limit: 10,
1860 ..Default::default()
1861 },
1862 )
1863 .await
1864 .unwrap();
1865 assert_eq!(results.len(), 1);
1866 assert_eq!(results[0].id, "m2");
1867 }
1868
1869 #[tokio::test]
1870 async fn prune_with_agent_prefix_only_removes_matching_agent() {
1871 let store = InMemoryStore::new();
1872
1873 let mut weak_a = make_entry("m1", "agent_a", "weak from A", "fact");
1875 weak_a.strength = 0.01;
1876 weak_a.created_at = Utc::now() - chrono::Duration::hours(48);
1877 weak_a.last_accessed = Utc::now() - chrono::Duration::hours(48);
1878 store.store(&test_scope(), weak_a).await.unwrap();
1879
1880 let mut weak_b = make_entry("m2", "agent_b", "weak from B", "fact");
1881 weak_b.strength = 0.01;
1882 weak_b.created_at = Utc::now() - chrono::Duration::hours(48);
1883 weak_b.last_accessed = Utc::now() - chrono::Duration::hours(48);
1884 store.store(&test_scope(), weak_b).await.unwrap();
1885
1886 let pruned = store
1888 .prune(
1889 &test_scope(),
1890 0.1,
1891 chrono::Duration::hours(1),
1892 Some("agent_a"),
1893 )
1894 .await
1895 .unwrap();
1896 assert_eq!(pruned, 1, "should only prune agent_a's entry");
1897
1898 let results = store
1899 .recall(
1900 &test_scope(),
1901 MemoryQuery {
1902 limit: 10,
1903 ..Default::default()
1904 },
1905 )
1906 .await
1907 .unwrap();
1908 assert_eq!(results.len(), 1);
1909 assert_eq!(results[0].id, "m2");
1910 assert_eq!(results[0].agent, "agent_b");
1911 }
1912
1913 #[tokio::test]
1919 async fn prune_does_not_match_overlapping_agent_prefix() {
1920 let store = InMemoryStore::new();
1921
1922 let mut weak_alice = make_entry("ma", "user:alice", "weak alice", "fact");
1924 weak_alice.strength = 0.01;
1925 weak_alice.created_at = Utc::now() - chrono::Duration::hours(48);
1926 weak_alice.last_accessed = Utc::now() - chrono::Duration::hours(48);
1927 store.store(&test_scope(), weak_alice).await.unwrap();
1928
1929 let mut weak_alice2 = make_entry("m2", "user:alice2", "weak alice2", "fact");
1931 weak_alice2.strength = 0.01;
1932 weak_alice2.created_at = Utc::now() - chrono::Duration::hours(48);
1933 weak_alice2.last_accessed = Utc::now() - chrono::Duration::hours(48);
1934 store.store(&test_scope(), weak_alice2).await.unwrap();
1935
1936 let mut weak_subagent = make_entry("ms", "user:alice:tool", "weak sub", "fact");
1938 weak_subagent.strength = 0.01;
1939 weak_subagent.created_at = Utc::now() - chrono::Duration::hours(48);
1940 weak_subagent.last_accessed = Utc::now() - chrono::Duration::hours(48);
1941 store.store(&test_scope(), weak_subagent).await.unwrap();
1942
1943 let pruned = store
1944 .prune(
1945 &test_scope(),
1946 0.1,
1947 chrono::Duration::hours(1),
1948 Some("user:alice"),
1949 )
1950 .await
1951 .unwrap();
1952
1953 assert_eq!(pruned, 2, "must prune alice and user:alice:tool only");
1955
1956 let results = store
1957 .recall(
1958 &test_scope(),
1959 MemoryQuery {
1960 limit: 10,
1961 ..Default::default()
1962 },
1963 )
1964 .await
1965 .unwrap();
1966 let agents: std::collections::HashSet<&str> =
1967 results.iter().map(|e| e.agent.as_str()).collect();
1968 assert!(
1969 agents.contains("user:alice2"),
1970 "alice2 must survive (overlapping prefix bypass): got {agents:?}"
1971 );
1972 }
1973
1974 #[tokio::test]
1975 async fn prune_none_prefix_removes_all_matching() {
1976 let store = InMemoryStore::new();
1977
1978 let mut weak_a = make_entry("m1", "agent_a", "weak from A", "fact");
1979 weak_a.strength = 0.01;
1980 weak_a.created_at = Utc::now() - chrono::Duration::hours(48);
1981 weak_a.last_accessed = Utc::now() - chrono::Duration::hours(48);
1982 store.store(&test_scope(), weak_a).await.unwrap();
1983
1984 let mut weak_b = make_entry("m2", "agent_b", "weak from B", "fact");
1985 weak_b.strength = 0.01;
1986 weak_b.created_at = Utc::now() - chrono::Duration::hours(48);
1987 weak_b.last_accessed = Utc::now() - chrono::Duration::hours(48);
1988 store.store(&test_scope(), weak_b).await.unwrap();
1989
1990 let pruned = store
1992 .prune(&test_scope(), 0.1, chrono::Duration::hours(1), None)
1993 .await
1994 .unwrap();
1995 assert_eq!(pruned, 2, "should prune all weak entries");
1996 }
1997
1998 #[tokio::test]
1999 async fn recall_bm25_ranks_better_than_naive_keyword() {
2000 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2003 alpha: 0.0,
2004 beta: 0.0,
2005 gamma: 1.0,
2006 delta: 0.0,
2007 decay_rate: 0.01,
2008 });
2009
2010 let e1 = make_entry("m1", "a", "Rust is a programming language", "fact");
2012 store.store(&test_scope(), e1).await.unwrap();
2013
2014 let e2 = make_entry(
2016 "m2",
2017 "a",
2018 "Rust has excellent performance and speed",
2019 "fact",
2020 );
2021 store.store(&test_scope(), e2).await.unwrap();
2022
2023 let results = store
2024 .recall(
2025 &test_scope(),
2026 MemoryQuery {
2027 text: Some("Rust performance".into()),
2028 limit: 10,
2029 ..Default::default()
2030 },
2031 )
2032 .await
2033 .unwrap();
2034
2035 assert_eq!(results.len(), 2);
2036 assert_eq!(
2037 results[0].id, "m2",
2038 "BM25 should rank entry matching more query terms first"
2039 );
2040 }
2041
2042 #[tokio::test]
2043 async fn recall_bm25_keyword_field_boosts_ranking() {
2044 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2046 alpha: 0.0,
2047 beta: 0.0,
2048 gamma: 1.0,
2049 delta: 0.0,
2050 decay_rate: 0.01,
2051 });
2052
2053 let e1 = make_entry("m1", "a", "optimization techniques for databases", "fact");
2055 store.store(&test_scope(), e1).await.unwrap();
2056
2057 let mut e2 = make_entry("m2", "a", "optimization techniques for systems", "fact");
2059 e2.keywords = vec!["optimization".into(), "databases".into()];
2060 store.store(&test_scope(), e2).await.unwrap();
2061
2062 let results = store
2063 .recall(
2064 &test_scope(),
2065 MemoryQuery {
2066 text: Some("optimization databases".into()),
2067 limit: 10,
2068 ..Default::default()
2069 },
2070 )
2071 .await
2072 .unwrap();
2073
2074 assert_eq!(results.len(), 2);
2075 assert_eq!(
2076 results[0].id, "m2",
2077 "entry with keyword match should rank higher"
2078 );
2079 }
2080
2081 #[tokio::test]
2082 async fn strength_affects_ranking() {
2083 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2085 alpha: 0.0,
2086 beta: 0.0,
2087 gamma: 0.0,
2088 delta: 1.0,
2089 decay_rate: 0.01,
2090 });
2091
2092 let mut weak = make_entry("m1", "a", "weak entry", "fact");
2093 weak.strength = 0.2;
2094 store.store(&test_scope(), weak).await.unwrap();
2095
2096 let mut strong = make_entry("m2", "a", "strong entry", "fact");
2097 strong.strength = 0.9;
2098 store.store(&test_scope(), strong).await.unwrap();
2099
2100 let results = store
2101 .recall(
2102 &test_scope(),
2103 MemoryQuery {
2104 limit: 10,
2105 ..Default::default()
2106 },
2107 )
2108 .await
2109 .unwrap();
2110
2111 assert_eq!(results.len(), 2);
2112 assert_eq!(
2113 results[0].id, "m2",
2114 "stronger entry should rank first when delta=1.0"
2115 );
2116 }
2117
2118 #[tokio::test]
2119 async fn hybrid_recall_cosine_boosts_semantic_match() {
2120 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2123 alpha: 0.0,
2124 beta: 0.0,
2125 gamma: 1.0,
2126 delta: 0.0,
2127 decay_rate: 0.01,
2128 });
2129
2130 let e1 = make_entry("m1", "a", "Rust is fast", "fact");
2132 store.store(&test_scope(), e1).await.unwrap();
2133
2134 let mut e2 = make_entry(
2136 "m2",
2137 "a",
2138 "Systems programming language with safety",
2139 "fact",
2140 );
2141 e2.embedding = Some(vec![0.9, 0.1, 0.0]);
2142 store.store(&test_scope(), e2).await.unwrap();
2143
2144 let results = store
2146 .recall(
2147 &test_scope(),
2148 MemoryQuery {
2149 text: Some("rust".into()),
2150 query_embedding: Some(vec![0.9, 0.1, 0.0]),
2151 limit: 10,
2152 ..Default::default()
2153 },
2154 )
2155 .await
2156 .unwrap();
2157
2158 assert_eq!(results.len(), 1);
2162 assert_eq!(results[0].id, "m1");
2163 }
2164
2165 #[tokio::test]
2166 async fn hybrid_recall_fuses_bm25_and_vector() {
2167 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2171 alpha: 0.0,
2172 beta: 0.0,
2173 gamma: 1.0,
2174 delta: 0.0,
2175 decay_rate: 0.01,
2176 });
2177
2178 let mut e1 = make_entry("m1", "a", "Rust is fast and fast", "fact");
2180 e1.embedding = Some(vec![0.0, 0.0, 1.0]); store.store(&test_scope(), e1).await.unwrap();
2182
2183 let mut e2 = make_entry("m2", "a", "Rust has zero-cost abstractions", "fact");
2185 e2.embedding = Some(vec![0.95, 0.05, 0.0]); store.store(&test_scope(), e2).await.unwrap();
2187
2188 let mut e3 = make_entry("m3", "a", "Rust is a programming language", "fact");
2190 e3.embedding = Some(vec![0.5, 0.5, 0.0]); store.store(&test_scope(), e3).await.unwrap();
2192
2193 let results = store
2197 .recall(
2198 &test_scope(),
2199 MemoryQuery {
2200 text: Some("rust fast".into()),
2201 query_embedding: Some(vec![0.95, 0.05, 0.0]),
2202 limit: 10,
2203 ..Default::default()
2204 },
2205 )
2206 .await
2207 .unwrap();
2208
2209 assert_eq!(results.len(), 3);
2210 assert_eq!(
2215 results[0].id, "m2",
2216 "entry with highest cosine similarity should rank first in hybrid mode"
2217 );
2218 }
2219
2220 #[tokio::test]
2221 async fn hybrid_recall_bm25_fallback_when_no_embeddings() {
2222 let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2225 alpha: 0.0,
2226 beta: 0.0,
2227 gamma: 1.0,
2228 delta: 0.0,
2229 decay_rate: 0.01,
2230 });
2231
2232 let e1 = make_entry("m1", "a", "Rust programming language", "fact");
2233 store.store(&test_scope(), e1).await.unwrap();
2234
2235 let e2 = make_entry("m2", "a", "Rust performance and speed", "fact");
2236 store.store(&test_scope(), e2).await.unwrap();
2237
2238 let results = store
2239 .recall(
2240 &test_scope(),
2241 MemoryQuery {
2242 text: Some("Rust performance".into()),
2243 query_embedding: Some(vec![0.5, 0.5, 0.0]),
2244 limit: 10,
2245 ..Default::default()
2246 },
2247 )
2248 .await
2249 .unwrap();
2250
2251 assert_eq!(results.len(), 2);
2252 assert_eq!(results[0].id, "m2");
2254 }
2255
2256 #[tokio::test]
2257 async fn recall_follows_related_ids_one_hop() {
2258 let store = InMemoryStore::new();
2261
2262 let mut m1 = make_entry("m1", "a", "Rust is fast", "fact");
2263 m1.related_ids = vec!["m2".into()];
2264 store.store(&test_scope(), m1).await.unwrap();
2265
2266 let mut m2 = make_entry("m2", "a", "Memory safety guarantees", "fact");
2267 m2.related_ids = vec!["m1".into()];
2268 store.store(&test_scope(), m2).await.unwrap();
2269
2270 let results = store
2271 .recall(
2272 &test_scope(),
2273 MemoryQuery {
2274 text: Some("rust".into()),
2275 limit: 10,
2276 ..Default::default()
2277 },
2278 )
2279 .await
2280 .unwrap();
2281
2282 assert_eq!(results.len(), 2);
2284 let ids: Vec<&str> = results.iter().map(|e| e.id.as_str()).collect();
2285 assert!(ids.contains(&"m1"), "direct match should be in results");
2286 assert!(
2287 ids.contains(&"m2"),
2288 "linked entry should be surfaced via graph expansion"
2289 );
2290 }
2291
2292 #[tokio::test]
2293 async fn recall_graph_expansion_respects_strength_threshold() {
2294 let store = InMemoryStore::new();
2295
2296 let mut m1 = make_entry("m1", "a", "Rust is fast", "fact");
2297 m1.related_ids = vec!["m2".into()];
2298 store.store(&test_scope(), m1).await.unwrap();
2299
2300 let mut m2 = make_entry("m2", "a", "Weak linked memory", "fact");
2302 m2.related_ids = vec!["m1".into()];
2303 m2.strength = 0.01;
2304 m2.last_accessed = Utc::now() - chrono::Duration::hours(720); store.store(&test_scope(), m2).await.unwrap();
2306
2307 let results = store
2308 .recall(
2309 &test_scope(),
2310 MemoryQuery {
2311 text: Some("rust".into()),
2312 min_strength: Some(0.1),
2313 limit: 10,
2314 ..Default::default()
2315 },
2316 )
2317 .await
2318 .unwrap();
2319
2320 assert_eq!(results.len(), 1);
2322 assert_eq!(results[0].id, "m1");
2323 }
2324
2325 #[tokio::test]
2326 async fn recall_graph_expansion_does_not_duplicate() {
2327 let store = InMemoryStore::new();
2328
2329 let mut m1 = make_entry("m1", "a", "Rust is fast", "fact");
2331 m1.related_ids = vec!["m2".into()];
2332 store.store(&test_scope(), m1).await.unwrap();
2333
2334 let mut m2 = make_entry("m2", "a", "Rust is safe", "fact");
2335 m2.related_ids = vec!["m1".into()];
2336 store.store(&test_scope(), m2).await.unwrap();
2337
2338 let results = store
2339 .recall(
2340 &test_scope(),
2341 MemoryQuery {
2342 text: Some("rust".into()),
2343 limit: 10,
2344 ..Default::default()
2345 },
2346 )
2347 .await
2348 .unwrap();
2349
2350 assert_eq!(results.len(), 2);
2352 let ids: Vec<&str> = results.iter().map(|e| e.id.as_str()).collect();
2353 assert_eq!(
2354 ids.iter().filter(|&&id| id == "m1").count(),
2355 1,
2356 "m1 should appear exactly once"
2357 );
2358 assert_eq!(
2359 ids.iter().filter(|&&id| id == "m2").count(),
2360 1,
2361 "m2 should appear exactly once"
2362 );
2363 }
2364
2365 #[tokio::test]
2366 async fn recall_agent_prefix_matches_sub_namespaces() {
2367 let store = InMemoryStore::new();
2368 store
2370 .store(
2371 &test_scope(),
2372 make_entry("m1", "tg:123:assistant", "likes Rust", "fact"),
2373 )
2374 .await
2375 .unwrap();
2376 store
2377 .store(
2378 &test_scope(),
2379 make_entry("m2", "tg:123:researcher", "loves coffee", "fact"),
2380 )
2381 .await
2382 .unwrap();
2383 store
2385 .store(
2386 &test_scope(),
2387 make_entry("m3", "tg:456:assistant", "prefers Python", "fact"),
2388 )
2389 .await
2390 .unwrap();
2391
2392 let results = store
2393 .recall(
2394 &test_scope(),
2395 MemoryQuery {
2396 agent_prefix: Some("tg:123".into()),
2397 limit: 10,
2398 ..Default::default()
2399 },
2400 )
2401 .await
2402 .unwrap();
2403
2404 assert_eq!(results.len(), 2);
2405 let ids: Vec<&str> = results.iter().map(|e| e.id.as_str()).collect();
2406 assert!(ids.contains(&"m1"));
2407 assert!(ids.contains(&"m2"));
2408 }
2409
2410 #[tokio::test]
2411 async fn recall_agent_exact_takes_precedence_over_prefix() {
2412 let store = InMemoryStore::new();
2413 store
2414 .store(
2415 &test_scope(),
2416 make_entry("m1", "tg:123:assistant", "from assistant", "fact"),
2417 )
2418 .await
2419 .unwrap();
2420 store
2421 .store(
2422 &test_scope(),
2423 make_entry("m2", "tg:123:researcher", "from researcher", "fact"),
2424 )
2425 .await
2426 .unwrap();
2427
2428 let results = store
2430 .recall(
2431 &test_scope(),
2432 MemoryQuery {
2433 agent: Some("tg:123:assistant".into()),
2434 agent_prefix: Some("tg:123".into()), limit: 10,
2436 ..Default::default()
2437 },
2438 )
2439 .await
2440 .unwrap();
2441
2442 assert_eq!(results.len(), 1);
2443 assert_eq!(results[0].id, "m1");
2444 }
2445
2446 #[tokio::test]
2447 async fn recall_filters_by_max_confidentiality() {
2448 use super::super::Confidentiality;
2449 let store = InMemoryStore::new();
2450
2451 let mut public = make_entry("m1", "a", "public fact", "fact");
2452 public.confidentiality = Confidentiality::Public;
2453 store.store(&test_scope(), public).await.unwrap();
2454
2455 let mut internal = make_entry("m2", "a", "internal note", "fact");
2456 internal.confidentiality = Confidentiality::Internal;
2457 store.store(&test_scope(), internal).await.unwrap();
2458
2459 let mut confidential = make_entry("m3", "a", "private expense", "fact");
2460 confidential.confidentiality = Confidentiality::Confidential;
2461 store.store(&test_scope(), confidential).await.unwrap();
2462
2463 let mut restricted = make_entry("m4", "a", "api key", "fact");
2464 restricted.confidentiality = Confidentiality::Restricted;
2465 store.store(&test_scope(), restricted).await.unwrap();
2466
2467 let results = store
2469 .recall(
2470 &test_scope(),
2471 MemoryQuery {
2472 max_confidentiality: Some(Confidentiality::Public),
2473 ..Default::default()
2474 },
2475 )
2476 .await
2477 .unwrap();
2478 assert_eq!(results.len(), 1);
2479 assert_eq!(results[0].id, "m1");
2480
2481 let results = store
2483 .recall(
2484 &test_scope(),
2485 MemoryQuery {
2486 max_confidentiality: None,
2487 ..Default::default()
2488 },
2489 )
2490 .await
2491 .unwrap();
2492 assert_eq!(results.len(), 4);
2493
2494 let results = store
2496 .recall(
2497 &test_scope(),
2498 MemoryQuery {
2499 max_confidentiality: Some(Confidentiality::Confidential),
2500 ..Default::default()
2501 },
2502 )
2503 .await
2504 .unwrap();
2505 assert_eq!(results.len(), 3);
2506 assert!(results.iter().all(|e| e.id != "m4"));
2507 }
2508
2509 #[tokio::test]
2510 async fn graph_expansion_respects_max_confidentiality() {
2511 use super::super::Confidentiality;
2512 let store = InMemoryStore::new();
2513
2514 let mut public = make_entry("m1", "a", "project update", "fact");
2516 public.confidentiality = Confidentiality::Public;
2517 public.related_ids = vec!["m2".into()];
2518 public.keywords = vec!["project".into()];
2519 store.store(&test_scope(), public).await.unwrap();
2520
2521 let mut confidential = make_entry("m2", "a", "private expense data", "fact");
2522 confidential.confidentiality = Confidentiality::Confidential;
2523 confidential.keywords = vec!["expense".into()];
2524 store.store(&test_scope(), confidential).await.unwrap();
2525
2526 let results = store
2528 .recall(
2529 &test_scope(),
2530 MemoryQuery {
2531 text: Some("project".into()),
2532 max_confidentiality: Some(Confidentiality::Public),
2533 ..Default::default()
2534 },
2535 )
2536 .await
2537 .unwrap();
2538
2539 assert!(
2540 results.iter().all(|e| e.id != "m2"),
2541 "graph expansion should not include Confidential entries when capped at Public"
2542 );
2543 assert!(results.iter().any(|e| e.id == "m1"));
2544 }
2545
2546 #[tokio::test]
2549 async fn recall_does_not_leak_across_tenants() {
2550 let store = InMemoryStore::new();
2551 let acme = TenantScope::new("acme");
2552 let globex = TenantScope::new("globex");
2553
2554 store
2555 .store(&acme, make_entry("a1", "agent", "acme-secret", "fact"))
2556 .await
2557 .unwrap();
2558 store
2559 .store(&globex, make_entry("g1", "agent", "globex-secret", "fact"))
2560 .await
2561 .unwrap();
2562
2563 let acme_results = store
2564 .recall(
2565 &acme,
2566 MemoryQuery {
2567 agent: Some("agent".into()),
2568 ..Default::default()
2569 },
2570 )
2571 .await
2572 .unwrap();
2573 assert_eq!(acme_results.len(), 1);
2574 assert_eq!(acme_results[0].id, "a1");
2575
2576 let globex_results = store
2577 .recall(
2578 &globex,
2579 MemoryQuery {
2580 agent: Some("agent".into()),
2581 ..Default::default()
2582 },
2583 )
2584 .await
2585 .unwrap();
2586 assert_eq!(globex_results.len(), 1);
2587 assert_eq!(globex_results[0].id, "g1");
2588 }
2589
2590 #[tokio::test]
2591 async fn forget_does_not_delete_other_tenant() {
2592 let store = InMemoryStore::new();
2593 let acme = TenantScope::new("acme");
2594 let globex = TenantScope::new("globex");
2595
2596 store
2597 .store(&acme, make_entry("a1", "agent", "x", "fact"))
2598 .await
2599 .unwrap();
2600 store
2601 .store(&globex, make_entry("g1", "agent", "y", "fact"))
2602 .await
2603 .unwrap();
2604
2605 let removed = store.forget(&globex, "a1").await.unwrap();
2607 assert!(!removed);
2608
2609 let acme_results = store
2610 .recall(
2611 &acme,
2612 MemoryQuery {
2613 agent: Some("agent".into()),
2614 ..Default::default()
2615 },
2616 )
2617 .await
2618 .unwrap();
2619 assert_eq!(acme_results.len(), 1);
2620 }
2621
2622 #[tokio::test]
2623 async fn update_does_not_modify_other_tenant() {
2624 let store = InMemoryStore::new();
2625 let acme = TenantScope::new("acme");
2626 let globex = TenantScope::new("globex");
2627
2628 store
2629 .store(&acme, make_entry("a1", "agent", "original", "fact"))
2630 .await
2631 .unwrap();
2632
2633 let err = store
2635 .update(&globex, "a1", "tampered".into())
2636 .await
2637 .unwrap_err();
2638 assert!(err.to_string().contains("memory not found"), "got: {err}");
2639
2640 let results = store
2642 .recall(
2643 &acme,
2644 MemoryQuery {
2645 agent: Some("agent".into()),
2646 ..Default::default()
2647 },
2648 )
2649 .await
2650 .unwrap();
2651 assert_eq!(results.len(), 1);
2652 assert_eq!(results[0].content, "original");
2653 }
2654
2655 #[tokio::test]
2656 async fn store_under_scope_populates_author_tenant_id() {
2657 let store = InMemoryStore::new();
2658 let scope = TenantScope::new("acme").with_user("u-42");
2659 store
2660 .store(&scope, make_entry("s1", "agent", "x", "fact"))
2661 .await
2662 .unwrap();
2663
2664 let results = store
2665 .recall(
2666 &scope,
2667 MemoryQuery {
2668 agent: Some("agent".into()),
2669 ..Default::default()
2670 },
2671 )
2672 .await
2673 .unwrap();
2674 assert_eq!(results.len(), 1);
2675 assert_eq!(results[0].author_tenant_id.as_deref(), Some("acme"));
2676 assert_eq!(results[0].author_user_id.as_deref(), Some("u-42"));
2677 }
2678}