xz_memory_engine/layered/
fact.rs1use async_trait::async_trait;
4use chrono::Utc;
5use uuid::Uuid;
6use xz_memory_core::{Entry, EntryStore, IndexSearcher, SearchOptions, StoreError};
7
8use super::traits::FactMemory;
9
10impl<S: EntryStore, I: IndexSearcher> super::default::LayeredMemory<S, I> {
11 fn facts_partition() -> String {
12 "facts".to_string()
13 }
14}
15
16fn pack_body(tags: &[String], fact_text: &str) -> String {
17 let tags_json = serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string());
18 format!("{}\n{}", tags_json, fact_text)
19}
20
21fn unpack_body(body: &str) -> (String, Vec<String>) {
22 if let Some((tags_json, fact_text)) = body.split_once('\n') {
23 let tags: Vec<String> = serde_json::from_str(tags_json).unwrap_or_default();
24 (fact_text.to_string(), tags)
25 } else {
26 (body.to_string(), vec![])
27 }
28}
29
30#[async_trait]
31impl<S: EntryStore, I: IndexSearcher> FactMemory for super::default::LayeredMemory<S, I> {
32 async fn remember(&self, fact: &str, tags: &[String]) -> Result<String, StoreError> {
33 let id = Uuid::new_v4().to_string();
34 let entry = Entry {
35 id: id.clone(),
36 partition: Self::facts_partition(),
37 body: pack_body(tags, fact),
38 recorded_at: Utc::now().timestamp_millis() as u64,
39 };
40 self.store.append(entry).await?;
41 Ok(id)
42 }
43
44 async fn recall(
45 &self,
46 query: &str,
47 limit: usize,
48 ) -> Result<Vec<(String, f32, Vec<String>)>, StoreError> {
49 let opts = SearchOptions { limit, min_relevance: None };
50 let partition = Self::facts_partition();
51 let results = self.searcher.search(&[partition], query, &opts).await?;
52 Ok(results
53 .into_iter()
54 .map(|se| {
55 let (text, tags) = unpack_body(&se.entry.body);
56 (text, se.relevance, tags)
57 })
58 .collect())
59 }
60
61 async fn forget(&self, id: &str) -> Result<(), StoreError> {
62 let _ = self.store.delete(id).await;
63 Ok(())
64 }
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70 use std::sync::Arc;
71 use xz_memory_core::ScoredEntry;
72
73 use crate::backends::InMemoryEntryStore;
74
75 struct MockSearcher {
76 fact: String,
77 }
78
79 #[async_trait]
80 impl IndexSearcher for MockSearcher {
81 async fn search(
82 &self,
83 _partitions: &[String],
84 _query: &str,
85 _opts: &SearchOptions,
86 ) -> Result<Vec<ScoredEntry>, StoreError> {
87 Ok(vec![ScoredEntry {
88 entry: Entry {
89 id: "fact-1".into(),
90 partition: "facts".into(),
91 body: self.fact.clone(),
92 recorded_at: 1000,
93 },
94 relevance: 0.95,
95 }])
96 }
97 }
98
99 fn setup() -> Arc<super::super::default::LayeredMemory<InMemoryEntryStore, MockSearcher>> {
100 let fact = pack_body(&["geography".to_string()], "Tokyo is the capital of Japan");
101 Arc::new(super::super::default::LayeredMemory::new(
102 Arc::new(InMemoryEntryStore::new()),
103 Arc::new(MockSearcher { fact }),
104 ))
105 }
106
107 #[tokio::test]
108 async fn test_remember_and_recall() {
109 let memory = setup();
110
111 let id =
112 memory.remember("Tokyo is the capital of Japan", &["geography".into()]).await.unwrap();
113 assert!(!id.is_empty());
114
115 let results = memory.recall("capital", 5).await.unwrap();
116 assert_eq!(results.len(), 1);
117 assert_eq!(results[0].0, "Tokyo is the capital of Japan");
118 assert!((results[0].1 - 0.95).abs() < 0.01);
119 assert_eq!(results[0].2, vec!["geography"]);
120 }
121
122 #[tokio::test]
123 async fn test_remember_without_tags() {
124 let memory = setup();
125
126 let id = memory.remember("Some fact", &[]).await.unwrap();
127 assert!(!id.is_empty());
128 }
129
130 #[tokio::test]
131 async fn test_forget() {
132 let store = Arc::new(InMemoryEntryStore::new());
133 let searcher = Arc::new(MockSearcher { fact: pack_body(&[], "test") });
134 let memory = super::super::default::LayeredMemory::new(store.clone(), searcher);
135
136 let id = memory.remember("test fact", &[]).await.unwrap();
137 memory.forget(&id).await.unwrap();
138
139 let remaining = store
140 .query(
141 "facts",
142 &xz_memory_core::TimeRange { start: None, end: None },
143 &xz_memory_core::QueryOptions {
144 limit: usize::MAX,
145 sort: xz_memory_core::SortOrder::Ascending,
146 },
147 )
148 .await
149 .unwrap();
150 assert!(remaining.iter().all(|e| e.id != id));
151 }
152
153 #[tokio::test]
154 async fn test_forget_nonexistent_is_noop() {
155 let memory = setup();
156 let result = memory.forget("nonexistent-id").await;
157 assert!(result.is_ok());
158 }
159
160 #[tokio::test]
161 async fn test_pack_unpack_roundtrip() {
162 let tags = vec!["t1".to_string(), "t2".to_string()];
163 let fact = "Hello world";
164 let body = pack_body(&tags, fact);
165 let (text, parsed_tags) = unpack_body(&body);
166 assert_eq!(text, fact);
167 assert_eq!(parsed_tags, tags);
168 }
169
170 #[tokio::test]
171 async fn test_unpack_no_tags() {
172 let (text, tags) = unpack_body("plain fact with no newlines");
173 assert_eq!(text, "plain fact with no newlines");
174 assert!(tags.is_empty());
175 }
176}