1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::core::{
13 CanonicalMemory, CanonicalPredicate, MemoryId, MemoryKind, MemoryStatus, TemporalScope,
14 normalize_token,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Field {
21 Subject,
23 Entities,
25 Aliases,
27 Predicate,
29 Tags,
31 Location,
33 Statement,
35}
36
37impl Field {
38 pub const ALL: [Field; 7] = [
40 Field::Subject,
41 Field::Entities,
42 Field::Aliases,
43 Field::Predicate,
44 Field::Tags,
45 Field::Location,
46 Field::Statement,
47 ];
48
49 pub fn slot(self) -> usize {
51 match self {
52 Field::Subject => 0,
53 Field::Entities => 1,
54 Field::Aliases => 2,
55 Field::Predicate => 3,
56 Field::Tags => 4,
57 Field::Location => 5,
58 Field::Statement => 6,
59 }
60 }
61
62 pub fn weight(self) -> f32 {
64 match self {
65 Field::Subject => 3.0,
66 Field::Entities => 3.0,
67 Field::Aliases => 2.5,
68 Field::Predicate => 2.2,
69 Field::Tags => 2.0,
70 Field::Location => 1.5,
71 Field::Statement => 1.0,
72 }
73 }
74
75 pub fn label(self) -> &'static str {
77 match self {
78 Field::Subject => "subject",
79 Field::Entities => "entities",
80 Field::Aliases => "aliases",
81 Field::Predicate => "predicate",
82 Field::Tags => "tags",
83 Field::Location => "location",
84 Field::Statement => "statement",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum MemoryOrigin {
97 Canonical,
99 SessionOverlay,
101}
102
103#[derive(Debug, Clone)]
105pub struct IndexedMemory {
106 pub id: MemoryId,
108 pub kind: MemoryKind,
110 pub status: MemoryStatus,
112 pub predicate: CanonicalPredicate,
114 pub confidence: f32,
116 pub explicit: bool,
118 pub origin: MemoryOrigin,
120 pub temporal_scope: TemporalScope,
122 pub valid_from: DateTime<Utc>,
124 pub expires_at: Option<DateTime<Utc>>,
126 pub statement: String,
128 pub value: String,
133 pub subject_form: String,
135 pub entity_forms: Vec<String>,
137 pub fields: [Vec<String>; 7],
139}
140
141impl IndexedMemory {
142 pub fn from_canonical(memory: &CanonicalMemory) -> Self {
144 let mut fields: [Vec<String>; 7] = Default::default();
145 fields[Field::Subject.slot()] = tokenize(&memory.retrieval.subject);
146 fields[Field::Entities.slot()] = memory
147 .retrieval
148 .entities
149 .iter()
150 .flat_map(|e| tokenize(e))
151 .collect();
152 fields[Field::Aliases.slot()] = memory
153 .retrieval
154 .aliases
155 .iter()
156 .chain(memory.subject.aliases.iter())
157 .flat_map(|a| tokenize(a))
158 .collect();
159 fields[Field::Predicate.slot()] = tokenize(memory.predicate.as_str());
160 fields[Field::Tags.slot()] = memory
161 .retrieval
162 .tags
163 .iter()
164 .flat_map(|t| tokenize(t))
165 .collect();
166 fields[Field::Location.slot()] = memory
167 .retrieval
168 .location
169 .as_deref()
170 .map(tokenize)
171 .unwrap_or_default();
172 fields[Field::Statement.slot()] = tokenize(&memory.statement);
173
174 let mut entity_forms: Vec<String> = memory
175 .retrieval
176 .entities
177 .iter()
178 .map(|e| normalize_token(e))
179 .collect();
180 entity_forms.extend(memory.subject.surface_forms());
181 entity_forms.retain(|f| !f.is_empty());
182 entity_forms.sort();
183 entity_forms.dedup();
184
185 Self {
186 id: memory.id.clone(),
187 kind: memory.kind,
188 status: memory.status,
189 predicate: memory.predicate.clone(),
190 confidence: memory.confidence,
191 explicit: memory.source.is_explicit(),
192 origin: MemoryOrigin::Canonical,
193 temporal_scope: memory.temporal_scope,
194 valid_from: memory.temporal.valid_from,
195 expires_at: memory.temporal.expires_at,
196 statement: memory.statement.clone(),
197 value: memory.value.display(),
198 subject_form: normalize_token(&memory.subject.display),
199 entity_forms,
200 fields,
201 }
202 }
203
204 pub fn as_session_overlay(mut self) -> Self {
206 self.origin = MemoryOrigin::SessionOverlay;
207 self
208 }
209
210 pub fn field_len(&self, field: Field) -> usize {
212 self.fields[field.slot()].len()
213 }
214
215 pub fn is_retrievable(&self, now: DateTime<Utc>) -> bool {
217 let status_ok = match self.origin {
218 MemoryOrigin::SessionOverlay => self.status != MemoryStatus::Deleted,
220 MemoryOrigin::Canonical => self.status == MemoryStatus::Active,
221 };
222 status_ok && self.expires_at.is_none_or(|e| e > now)
223 }
224}
225
226pub fn tokenize(text: &str) -> Vec<String> {
238 text.split(|c: char| !c.is_alphanumeric())
239 .filter(|t| !t.is_empty())
240 .map(|t| singularize(&t.to_lowercase()))
241 .filter(|t| !is_stop_word(t))
242 .collect()
243}
244
245fn singularize(token: &str) -> String {
247 if token.len() <= 3 {
248 return token.to_string();
249 }
250 if let Some(stem) = token.strip_suffix("ies") {
251 return format!("{stem}y");
252 }
253 for suffix in ["ches", "shes", "sses", "xes", "zes"] {
254 if let Some(stem) = token.strip_suffix("es")
255 && token.ends_with(suffix)
256 {
257 return stem.to_string();
258 }
259 }
260 if token.ends_with('s')
261 && !token.ends_with("ss")
262 && !token.ends_with("us")
263 && !token.ends_with("is")
264 && !token.ends_with("oes")
267 {
268 return token[..token.len() - 1].to_string();
269 }
270 token.to_string()
271}
272
273fn is_stop_word(token: &str) -> bool {
275 const STOP: &[&str] = &[
276 "a", "an", "and", "are", "as", "at", "be", "but", "by", "do", "does", "for", "from", "had",
277 "has", "have", "in", "is", "it", "its", "of", "on", "or", "that", "the", "to", "was",
278 "were", "will", "with",
279 ];
280 STOP.contains(&token)
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::core::{
287 EntityRef, EvidenceCounters, Explicitness, MemorySource, MemoryValue, PrivacyMetadata,
288 RetrievalMetadata, SessionId, TemporalMetadata, TurnId, UserId,
289 };
290
291 fn canonical() -> CanonicalMemory {
292 CanonicalMemory {
293 id: MemoryId::new("mem_1"),
294 owner: UserId::new("usr_1"),
295 kind: MemoryKind::RelationshipPreference,
296 predicate: CanonicalPredicate::new("venue_preference"),
297 status: MemoryStatus::Active,
298 confidence: 0.9,
299 subject: EntityRef::named("Rhea").with_alias("my wife"),
300 value: MemoryValue::Text("quiet restaurants".into()),
301 statement: "Rhea prefers quiet restaurants.".into(),
302 evidence_summary: "stated".into(),
303 source: MemorySource::from_explicitness(
304 Explicitness::ExplicitStatement,
305 SessionId::new("ses_1"),
306 TurnId(3),
307 ),
308 temporal: TemporalMetadata::created_at(Utc::now()),
309 retrieval: RetrievalMetadata {
310 subject: "rhea".into(),
311 tags: vec!["restaurant".into(), "noise".into()],
312 aliases: vec!["wife".into()],
313 entities: vec!["Rhea".into()],
314 location: Some("Bandra".into()),
315 },
316 evidence: EvidenceCounters::first(),
317 privacy: PrivacyMetadata::default(),
318 temporal_scope: TemporalScope::Persistent,
319 supersedes: Vec::new(),
320 superseded_by: None,
321 qualifier: None,
322 }
323 }
324
325 #[test]
326 fn tokenizer_normalizes_and_drops_stop_words() {
327 assert_eq!(
328 tokenize("The user is a Pescatarian!"),
329 vec!["user", "pescatarian"]
330 );
331 assert!(tokenize(" ").is_empty());
332 }
333
334 #[test]
335 fn regular_plurals_fold_onto_their_singular() {
336 assert_eq!(tokenize("restaurants"), tokenize("restaurant"));
337 assert_eq!(tokenize("preferences"), tokenize("preference"));
338 assert_eq!(tokenize("allergies"), vec!["allergy"]);
339 assert_eq!(tokenize("lunches"), vec!["lunch"]);
340 }
341
342 #[test]
343 fn plural_folding_leaves_verbs_that_merely_end_in_s() {
344 assert_eq!(tokenize("does"), vec!["doe"; 0], "does is a stop word");
345 assert_eq!(super::singularize("does"), "does");
346 assert_eq!(super::singularize("goes"), "goes");
347 }
348
349 #[test]
350 fn plural_folding_leaves_names_and_short_words_alone() {
351 assert_eq!(tokenize("Rhea"), vec!["rhea"]);
352 assert_eq!(tokenize("Kushal"), vec!["kushal"]);
353 assert_eq!(tokenize("gas"), vec!["gas"]);
354 assert_eq!(tokenize("this"), vec!["this"]);
355 }
356
357 #[test]
358 fn every_field_is_populated_from_the_record() {
359 let doc = IndexedMemory::from_canonical(&canonical());
360 assert_eq!(doc.fields[Field::Subject.slot()], vec!["rhea"]);
361 assert_eq!(doc.fields[Field::Tags.slot()], vec!["restaurant", "noise"]);
362 assert!(doc.fields[Field::Aliases.slot()].contains(&"wife".to_string()));
363 assert_eq!(doc.fields[Field::Location.slot()], vec!["bandra"]);
364 assert!(doc.fields[Field::Statement.slot()].contains(&"quiet".to_string()));
365 assert!(doc.entity_forms.contains(&"rhea".to_string()));
366 assert!(doc.entity_forms.contains(&"my wife".to_string()));
367 }
368
369 #[test]
370 fn the_value_is_kept_separately_from_the_sentence() {
371 let doc = IndexedMemory::from_canonical(&canonical());
372 assert_eq!(doc.value, "quiet restaurants");
373 assert_eq!(doc.statement, "Rhea prefers quiet restaurants.");
374 }
375
376 #[test]
377 fn subject_and_entity_fields_outweigh_the_statement() {
378 assert!(Field::Subject.weight() > Field::Statement.weight());
379 assert!(Field::Aliases.weight() > Field::Tags.weight());
380 }
381
382 #[test]
383 fn expired_and_superseded_documents_are_not_retrievable() {
384 let now = Utc::now();
385 let mut doc = IndexedMemory::from_canonical(&canonical());
386 assert!(doc.is_retrievable(now));
387
388 doc.expires_at = Some(now - chrono::Duration::hours(1));
389 assert!(!doc.is_retrievable(now));
390
391 let mut superseded = IndexedMemory::from_canonical(&canonical());
392 superseded.status = MemoryStatus::Superseded;
393 assert!(!superseded.is_retrievable(now));
394 }
395
396 #[test]
397 fn overlay_facts_stay_retrievable_while_staged() {
398 let mut doc = IndexedMemory::from_canonical(&canonical()).as_session_overlay();
399 doc.status = MemoryStatus::Staged;
400 assert!(doc.is_retrievable(Utc::now()));
401 }
402}