1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::core::{
11 CanonicalMemory, CanonicalPredicate, DiscardReason, EntityRef, EvidenceCounters, Explicitness,
12 FactFingerprint, MemoryId, MemoryKind, MemorySource, MemoryStatus, MemoryValue, MutationIntent,
13 PrivacyMetadata, ProposedPersistence, RetrievalMetadata, SensitivityClass, SessionId,
14 TemporalMetadata, TemporalScope, TurnId, UserId, normalize_token,
15};
16
17#[derive(Debug, Clone, PartialEq)]
19pub struct ProposedMemory {
20 pub fingerprint: FactFingerprint,
22 pub subject: EntityRef,
24 pub predicate: CanonicalPredicate,
26 pub value: MemoryValue,
28 pub statement: String,
30 pub evidence_summary: String,
32 pub kind: MemoryKind,
34 pub temporal_scope: TemporalScope,
36 pub explicitness: Explicitness,
38 pub confidence: f32,
40 pub evidence: EvidenceCounters,
42 pub persistence: ProposedPersistence,
44 pub expected_expiry: Option<DateTime<Utc>>,
46 pub mutation_intent: Option<MutationIntent>,
48 pub sensitivity: SensitivityClass,
50 pub qualifier: Option<String>,
52 pub session_id: SessionId,
54 pub turn_id: TurnId,
56 pub tags: Vec<String>,
58}
59
60impl ProposedMemory {
61 pub fn into_canonical(
63 self,
64 owner: &UserId,
65 id: MemoryId,
66 status: MemoryStatus,
67 now: DateTime<Utc>,
68 ) -> CanonicalMemory {
69 let mut temporal = TemporalMetadata::created_at(now);
70 temporal.expires_at =
71 crate::core::resolve_expiry(self.kind, self.temporal_scope, self.expected_expiry, now);
72
73 CanonicalMemory {
74 id,
75 owner: owner.clone(),
76 kind: self.kind,
77 predicate: self.predicate.clone(),
78 status,
79 confidence: self.confidence,
80 subject: self.subject.clone(),
81 value: self.value,
82 statement: self.statement,
83 evidence_summary: self.evidence_summary,
84 source: MemorySource::from_explicitness(
85 self.explicitness,
86 self.session_id,
87 self.turn_id,
88 ),
89 temporal,
90 retrieval: RetrievalMetadata {
91 subject: normalize_token(&self.subject.display),
92 tags: self.tags,
93 aliases: Vec::new(),
94 entities: self.subject.surface_forms(),
95 location: None,
96 },
97 evidence: self.evidence,
98 privacy: PrivacyMetadata {
99 deletable: true,
100 exportable: true,
101 sensitivity: self.sensitivity,
102 },
103 temporal_scope: self.temporal_scope,
104 supersedes: Vec::new(),
105 superseded_by: None,
106 qualifier: self.qualifier,
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq)]
113pub enum MemorySelector {
114 ById(MemoryId),
116 BySubjectPredicate(String),
118 ByTopic(String),
120}
121
122impl MemorySelector {
123 pub fn matches(&self, memory: &CanonicalMemory) -> bool {
125 match self {
126 Self::ById(id) => &memory.id == id,
127 Self::BySubjectPredicate(prefix) => {
128 memory.fingerprint().subject_predicate() == prefix.as_str()
129 }
130 Self::ByTopic(topic) => {
131 let needle = normalize_token(topic);
136 !needle.is_empty()
137 && (contains_word_sequence(&normalize_token(&memory.statement), &needle)
138 || memory
139 .retrieval
140 .tags
141 .iter()
142 .any(|t| normalize_token(t) == needle))
143 }
144 }
145 }
146}
147
148fn contains_word_sequence(haystack: &str, needle: &str) -> bool {
152 let hay: Vec<&str> = haystack.split_whitespace().collect();
153 let ned: Vec<&str> = needle.split_whitespace().collect();
154 if ned.is_empty() || ned.len() > hay.len() {
155 return false;
156 }
157 hay.windows(ned.len()).any(|w| w == ned.as_slice())
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum ResolutionKind {
164 Create,
166 Reinforce,
168 Refine,
170 Supersede,
172 Coexist,
174 Stage,
176 Delete,
178 Discard,
180}
181
182impl ResolutionKind {
183 pub fn label(self) -> &'static str {
185 match self {
186 Self::Create => "create",
187 Self::Reinforce => "reinforce",
188 Self::Refine => "refine",
189 Self::Supersede => "supersede",
190 Self::Coexist => "coexist",
191 Self::Stage => "stage",
192 Self::Delete => "delete",
193 Self::Discard => "discard",
194 }
195 }
196
197 pub fn is_write(self) -> bool {
199 !matches!(self, Self::Discard)
200 }
201}
202
203#[derive(Debug, Clone, PartialEq)]
205pub struct ResolvedMutation {
206 pub kind: ResolutionKind,
208 pub fingerprint: FactFingerprint,
210 pub writes: Vec<CanonicalMemory>,
212 pub deletes: Vec<MemoryId>,
214 pub discard_reason: Option<DiscardReason>,
216}
217
218impl ResolvedMutation {
219 pub fn discard(fingerprint: FactFingerprint, reason: DiscardReason) -> Self {
221 Self {
222 kind: ResolutionKind::Discard,
223 fingerprint,
224 writes: Vec::new(),
225 deletes: Vec::new(),
226 discard_reason: Some(reason),
227 }
228 }
229
230 pub fn write(
232 kind: ResolutionKind,
233 fingerprint: FactFingerprint,
234 memory: CanonicalMemory,
235 ) -> Self {
236 Self {
237 kind,
238 fingerprint,
239 writes: vec![memory],
240 deletes: Vec::new(),
241 discard_reason: None,
242 }
243 }
244
245 pub fn is_empty(&self) -> bool {
247 self.writes.is_empty() && self.deletes.is_empty()
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn memory(id: &str, statement: &str, tags: &[&str]) -> CanonicalMemory {
256 let now = Utc::now();
257 CanonicalMemory {
258 id: MemoryId::new(id),
259 owner: UserId::new("usr_1"),
260 kind: MemoryKind::Preference,
261 predicate: CanonicalPredicate::new("dietary_identity"),
262 status: MemoryStatus::Active,
263 confidence: 0.9,
264 subject: EntityRef::user(),
265 value: MemoryValue::Text(statement.into()),
266 statement: statement.into(),
267 evidence_summary: "stated".into(),
268 source: MemorySource::from_explicitness(
269 Explicitness::ExplicitStatement,
270 SessionId::new("ses_1"),
271 TurnId(1),
272 ),
273 temporal: TemporalMetadata::created_at(now),
274 retrieval: RetrievalMetadata {
275 subject: "user".into(),
276 tags: tags.iter().map(|t| (*t).to_string()).collect(),
277 ..Default::default()
278 },
279 evidence: EvidenceCounters::first(),
280 privacy: PrivacyMetadata::default(),
281 temporal_scope: TemporalScope::Persistent,
282 supersedes: Vec::new(),
283 superseded_by: None,
284 qualifier: None,
285 }
286 }
287
288 #[test]
289 fn selectors_target_what_they_name_and_nothing_else() {
290 let record = memory("mem_a", "The user is pescatarian.", &["diet"]);
291
292 assert!(MemorySelector::ById(MemoryId::new("mem_a")).matches(&record));
293 assert!(!MemorySelector::ById(MemoryId::new("mem_b")).matches(&record));
294
295 assert!(
296 MemorySelector::BySubjectPredicate("user|dietary_identity".into()).matches(&record)
297 );
298 assert!(!MemorySelector::BySubjectPredicate("user|coffee_order".into()).matches(&record));
299
300 assert!(MemorySelector::ByTopic("pescatarian".into()).matches(&record));
301 assert!(MemorySelector::ByTopic("diet".into()).matches(&record));
302 assert!(!MemorySelector::ByTopic("cycling".into()).matches(&record));
303 }
304
305 #[test]
306 fn a_topic_matches_whole_words_only() {
307 let cart = memory("mem_cart", "The user left items in the cart.", &[]);
309 assert!(!MemorySelector::ByTopic("art".into()).matches(&cart));
310 assert!(MemorySelector::ByTopic("cart".into()).matches(&cart));
311
312 let dinner = memory("mem_d", "The user enjoyed the quiet dinner in Bandra.", &[]);
314 assert!(MemorySelector::ByTopic("quiet dinner".into()).matches(&dinner));
315 assert!(!MemorySelector::ByTopic("dinner quiet".into()).matches(&dinner));
316 }
317
318 #[test]
319 fn an_empty_topic_selector_matches_nothing() {
320 let record = memory("mem_a", "The user is pescatarian.", &[]);
321 assert!(!MemorySelector::ByTopic(" ".into()).matches(&record));
322 }
323
324 #[test]
325 fn every_resolution_but_discard_writes_something() {
326 for kind in [
327 ResolutionKind::Create,
328 ResolutionKind::Reinforce,
329 ResolutionKind::Refine,
330 ResolutionKind::Supersede,
331 ResolutionKind::Coexist,
332 ResolutionKind::Stage,
333 ResolutionKind::Delete,
334 ] {
335 assert!(kind.is_write(), "{} should write", kind.label());
336 }
337 assert!(!ResolutionKind::Discard.is_write());
338 }
339}