1use chrono::{DateTime, Duration, Utc};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13use super::ids::{EntityId, MemoryId, ObservationId, SessionId, TurnId, UserId};
14
15#[derive(
20 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
21)]
22#[serde(rename_all = "snake_case")]
23pub enum MemoryKind {
24 Identity,
26 Preference,
28 Relationship,
30 RelationshipPreference,
32 Routine,
34 Commitment,
36 Project,
38 Episodic,
40 CommunicationStyle,
42 LocationPreference,
44 StagedPattern,
46}
47
48impl MemoryKind {
49 pub fn is_episodic(self) -> bool {
51 matches!(self, Self::Episodic | Self::Commitment)
52 }
53
54 pub fn scope_label(self) -> &'static str {
56 match self {
57 Self::Identity => "profile",
58 Self::Preference | Self::LocationPreference => "preferences",
59 Self::Relationship | Self::RelationshipPreference => "relationships",
60 Self::Routine => "routines",
61 Self::Commitment => "commitments",
62 Self::Project => "projects",
63 Self::Episodic => "episodes",
64 Self::CommunicationStyle => "communication",
65 Self::StagedPattern => "staged",
66 }
67 }
68}
69
70impl fmt::Display for MemoryKind {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 let raw = serde_json::to_value(self).map_err(|_| fmt::Error)?;
73 f.write_str(raw.as_str().unwrap_or("unknown"))
74 }
75}
76
77#[derive(
83 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
84)]
85#[serde(rename_all = "snake_case")]
86pub enum Explicitness {
87 WeakInference,
89 StrongImplication,
91 ExplicitStatement,
93 ExplicitCommand,
95}
96
97impl Explicitness {
98 pub fn confidence_ceiling(self, distinct_evidence: u32) -> f32 {
103 match self {
104 Self::ExplicitCommand => 1.0,
105 Self::ExplicitStatement => 0.95,
106 Self::StrongImplication => {
107 if distinct_evidence > 1 {
108 0.85
109 } else {
110 0.70
111 }
112 }
113 Self::WeakInference => {
114 if distinct_evidence > 1 {
115 0.75
116 } else {
117 0.55
118 }
119 }
120 }
121 }
122
123 pub fn is_explicit(self) -> bool {
125 matches!(self, Self::ExplicitStatement | Self::ExplicitCommand)
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
131#[serde(rename_all = "snake_case")]
132pub enum MemoryStatus {
133 #[default]
135 Active,
136 Staged,
138 Superseded,
140 Expired,
142 Deleted,
144}
145
146impl MemoryStatus {
147 pub fn is_retrievable(self) -> bool {
149 matches!(self, Self::Active)
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
155#[serde(rename_all = "snake_case")]
156pub enum TemporalScope {
157 #[default]
159 Persistent,
160 RecentHistory,
162 Momentary,
164 Scheduled,
166}
167
168#[derive(
173 Debug,
174 Clone,
175 Copy,
176 PartialEq,
177 Eq,
178 Hash,
179 PartialOrd,
180 Ord,
181 Serialize,
182 Deserialize,
183 JsonSchema,
184 Default,
185)]
186#[serde(rename_all = "snake_case")]
187pub enum SensitivityClass {
188 #[default]
190 Normal,
191 Sensitive,
193 Restricted,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
202#[serde(rename_all = "snake_case")]
203pub enum SpeakerAttribution {
204 User,
206 Bystander,
208 Assistant,
210 #[default]
212 Unknown,
213}
214
215impl SpeakerAttribution {
216 pub fn may_be_stored(self) -> bool {
218 matches!(self, Self::User)
219 }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
224#[serde(rename_all = "snake_case")]
225pub enum ProposedPersistence {
226 Durable,
228 Episodic,
230 SessionOnly,
232 Staged,
234 Discard,
236}
237
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
240#[serde(rename_all = "snake_case", tag = "type", content = "value")]
241pub enum MemoryValue {
242 Text(String),
244 Bool(bool),
246 Number(f64),
248 List(Vec<String>),
250}
251
252impl MemoryValue {
253 pub fn normalized(&self) -> String {
255 match self {
256 Self::Text(t) => normalize_token(t),
257 Self::Bool(b) => b.to_string(),
258 Self::Number(n) => format!("{n}"),
259 Self::List(items) => {
260 let mut normalized: Vec<String> =
261 items.iter().map(|i| normalize_token(i)).collect();
262 normalized.sort();
263 normalized.join(",")
264 }
265 }
266 }
267
268 pub fn display(&self) -> String {
270 match self {
271 Self::Text(t) => t.clone(),
272 Self::Bool(b) => b.to_string(),
273 Self::Number(n) => format!("{n}"),
274 Self::List(items) => items.join(", "),
275 }
276 }
277}
278
279impl From<&str> for MemoryValue {
280 fn from(value: &str) -> Self {
281 Self::Text(value.to_string())
282 }
283}
284
285#[derive(
290 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
291)]
292#[serde(transparent)]
293pub struct CanonicalPredicate(String);
294
295impl CanonicalPredicate {
296 pub fn new(raw: impl AsRef<str>) -> Self {
298 let normalized: String = raw
299 .as_ref()
300 .trim()
301 .chars()
302 .map(|c| {
303 if c.is_ascii_alphanumeric() {
304 c.to_ascii_lowercase()
305 } else {
306 '_'
307 }
308 })
309 .collect();
310 let collapsed = normalized
311 .split('_')
312 .filter(|s| !s.is_empty())
313 .collect::<Vec<_>>()
314 .join("_");
315 Self(collapsed)
316 }
317
318 pub fn as_str(&self) -> &str {
320 &self.0
321 }
322}
323
324impl fmt::Display for CanonicalPredicate {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 f.write_str(&self.0)
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
332pub struct EntityRef {
333 pub id: EntityId,
335 pub display: String,
337 #[serde(default)]
339 pub aliases: Vec<String>,
340}
341
342impl EntityRef {
343 pub fn user() -> Self {
345 Self {
346 id: EntityId::new("user"),
347 display: "user".to_string(),
348 aliases: vec!["I".into(), "me".into(), "my".into()],
349 }
350 }
351
352 pub fn named(display: impl Into<String>) -> Self {
354 let display = display.into();
355 Self {
356 id: EntityId::new(normalize_token(&display)),
357 display,
358 aliases: Vec::new(),
359 }
360 }
361
362 pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
364 self.aliases.push(alias.into());
365 self
366 }
367
368 pub fn surface_forms(&self) -> Vec<String> {
370 let mut forms = vec![normalize_token(&self.display)];
371 forms.extend(self.aliases.iter().map(|a| normalize_token(a)));
372 forms.retain(|f| !f.is_empty());
373 forms.sort();
374 forms.dedup();
375 forms
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
385#[serde(transparent)]
386pub struct FactFingerprint(String);
387
388impl FactFingerprint {
389 pub fn new(
391 subject: &EntityRef,
392 predicate: &CanonicalPredicate,
393 value: &MemoryValue,
394 scope: TemporalScope,
395 ) -> Self {
396 let scope_part = match scope {
397 TemporalScope::Persistent => "",
398 TemporalScope::RecentHistory => "|recent",
399 TemporalScope::Momentary => "|momentary",
400 TemporalScope::Scheduled => "|scheduled",
401 };
402 Self(format!(
403 "{}|{}|{}{}",
404 normalize_token(&subject.display),
405 predicate.as_str(),
406 value.normalized(),
407 scope_part
408 ))
409 }
410
411 pub fn as_str(&self) -> &str {
413 &self.0
414 }
415
416 pub fn subject(&self) -> &str {
418 self.0.split('|').next().unwrap_or(&self.0)
419 }
420
421 pub fn subject_predicate(&self) -> &str {
423 let mut parts = self.0.match_indices('|');
424 match (parts.next(), parts.next()) {
425 (Some(_), Some((second, _))) => &self.0[..second],
426 _ => &self.0,
427 }
428 }
429}
430
431impl fmt::Display for FactFingerprint {
432 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433 f.write_str(&self.0)
434 }
435}
436
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
439pub struct MemorySource {
440 pub source_type: String,
442 pub session_id: Option<SessionId>,
444 pub turn_id: Option<TurnId>,
446}
447
448impl MemorySource {
449 pub fn from_explicitness(
451 explicitness: Explicitness,
452 session_id: SessionId,
453 turn_id: TurnId,
454 ) -> Self {
455 let source_type = match explicitness {
456 Explicitness::ExplicitCommand => "explicit_user_command",
457 Explicitness::ExplicitStatement => "explicit_user_statement",
458 Explicitness::StrongImplication => "strong_implication",
459 Explicitness::WeakInference => "weak_inference",
460 };
461 Self {
462 source_type: source_type.to_string(),
463 session_id: Some(session_id),
464 turn_id: Some(turn_id),
465 }
466 }
467
468 pub fn is_explicit(&self) -> bool {
470 self.source_type.starts_with("explicit_")
471 }
472}
473
474#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
476pub struct TemporalMetadata {
477 pub created_at: DateTime<Utc>,
479 pub updated_at: DateTime<Utc>,
481 pub last_confirmed_at: DateTime<Utc>,
483 pub valid_from: DateTime<Utc>,
485 #[serde(default)]
487 pub valid_to: Option<DateTime<Utc>>,
488 #[serde(default)]
490 pub expires_at: Option<DateTime<Utc>>,
491}
492
493impl TemporalMetadata {
494 pub fn created_at(now: DateTime<Utc>) -> Self {
496 Self {
497 created_at: now,
498 updated_at: now,
499 last_confirmed_at: now,
500 valid_from: now,
501 valid_to: None,
502 expires_at: None,
503 }
504 }
505
506 pub fn with_ttl(mut self, ttl: Duration) -> Self {
508 self.expires_at = Some(self.valid_from + ttl);
509 self
510 }
511
512 pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
514 self.expires_at.is_some_and(|e| e <= now) || self.valid_to.is_some_and(|v| v <= now)
515 }
516
517 pub fn days_since_confirmed(&self, now: DateTime<Utc>) -> i64 {
519 (now - self.last_confirmed_at).num_days().max(0)
520 }
521}
522
523#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
525pub struct RetrievalMetadata {
526 pub subject: String,
528 #[serde(default)]
530 pub tags: Vec<String>,
531 #[serde(default)]
533 pub aliases: Vec<String>,
534 #[serde(default)]
536 pub entities: Vec<String>,
537 #[serde(default)]
539 pub location: Option<String>,
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
544pub struct EvidenceCounters {
545 pub count: u32,
547 pub distinct_sessions: u32,
549 pub distinct_days: u32,
551}
552
553impl EvidenceCounters {
554 pub fn first() -> Self {
556 Self {
557 count: 1,
558 distinct_sessions: 1,
559 distinct_days: 1,
560 }
561 }
562}
563
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
566pub struct PrivacyMetadata {
567 pub deletable: bool,
569 pub exportable: bool,
571 pub sensitivity: SensitivityClass,
573}
574
575impl Default for PrivacyMetadata {
576 fn default() -> Self {
577 Self {
578 deletable: true,
579 exportable: true,
580 sensitivity: SensitivityClass::Normal,
581 }
582 }
583}
584
585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
587pub struct CanonicalMemory {
588 pub id: MemoryId,
590 pub owner: UserId,
592 pub kind: MemoryKind,
594 pub predicate: CanonicalPredicate,
596 pub status: MemoryStatus,
598 pub confidence: f32,
600 pub subject: EntityRef,
602 pub value: MemoryValue,
604 pub statement: String,
606 pub evidence_summary: String,
608 pub source: MemorySource,
610 pub temporal: TemporalMetadata,
612 pub retrieval: RetrievalMetadata,
614 pub evidence: EvidenceCounters,
616 pub privacy: PrivacyMetadata,
618 pub temporal_scope: TemporalScope,
620 #[serde(default)]
622 pub supersedes: Vec<MemoryId>,
623 #[serde(default)]
625 pub superseded_by: Option<MemoryId>,
626 #[serde(default)]
628 pub qualifier: Option<String>,
629}
630
631impl CanonicalMemory {
632 pub fn fingerprint(&self) -> FactFingerprint {
634 FactFingerprint::new(
635 &self.subject,
636 &self.predicate,
637 &self.value,
638 self.temporal_scope,
639 )
640 }
641
642 pub fn is_retrievable(&self, now: DateTime<Utc>) -> bool {
644 self.status.is_retrievable() && !self.temporal.is_expired(now)
645 }
646}
647
648#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
653pub struct MemoryObservation {
654 pub observation_id: ObservationId,
656 pub session_id: SessionId,
658 pub turn_id: TurnId,
660 pub subject: EntityRef,
662 pub predicate: CanonicalPredicate,
664 pub value: MemoryValue,
666 pub canonical_statement: String,
668 pub kind: MemoryKind,
670 pub explicitness: Explicitness,
672 pub confidence: f32,
674 pub persistence: ProposedPersistence,
676 pub temporal_scope: TemporalScope,
678 #[serde(default)]
680 pub valid_from: Option<DateTime<Utc>>,
681 #[serde(default)]
683 pub expected_expiry: Option<DateTime<Utc>>,
684 pub transcript_evidence: TranscriptEvidence,
686 pub speaker_attribution: SpeakerAttribution,
688 pub sensitivity: SensitivityClass,
690 #[serde(default)]
692 pub mutation_intent: Option<MutationIntent>,
693 #[serde(default)]
700 pub search_terms: Vec<String>,
701}
702
703impl MemoryObservation {
704 pub fn fingerprint(&self) -> FactFingerprint {
706 FactFingerprint::new(
707 &self.subject,
708 &self.predicate,
709 &self.value,
710 self.temporal_scope,
711 )
712 }
713}
714
715#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
717#[serde(rename_all = "snake_case")]
718pub enum MutationIntent {
719 Remember,
721 Correct,
723 Forget,
725 Delete,
727 List,
729}
730
731#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
733pub struct TranscriptEvidence {
734 pub utterance: String,
736 pub utterance_hash: String,
738}
739
740impl TranscriptEvidence {
741 pub fn new(utterance: impl Into<String>) -> Self {
743 let utterance = utterance.into();
744 let hash = stable_hash(&utterance);
745 Self {
746 utterance,
747 utterance_hash: hash,
748 }
749 }
750}
751
752pub fn normalize_token(raw: &str) -> String {
754 let lowered: String = raw
755 .chars()
756 .map(|c| {
757 if c.is_alphanumeric() {
758 c.to_lowercase().next().unwrap_or(c)
759 } else {
760 ' '
761 }
762 })
763 .collect();
764 lowered.split_whitespace().collect::<Vec<_>>().join(" ")
765}
766
767pub fn stable_hash(input: &str) -> String {
771 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
773 for byte in input.as_bytes() {
774 hash ^= u64::from(*byte);
775 hash = hash.wrapping_mul(0x100_0000_01b3);
776 }
777 format!("{hash:016x}")
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn predicates_normalize_to_snake_case() {
786 assert_eq!(
787 CanonicalPredicate::new("Dietary Identity").as_str(),
788 "dietary_identity"
789 );
790 assert_eq!(
791 CanonicalPredicate::new(" dietary--identity ").as_str(),
792 "dietary_identity"
793 );
794 }
795
796 #[test]
797 fn identical_facts_fingerprint_identically() {
798 let subject = EntityRef::user();
799 let predicate = CanonicalPredicate::new("dietary_identity");
800 let a = FactFingerprint::new(
801 &subject,
802 &predicate,
803 &MemoryValue::Text("Pescatarian".into()),
804 TemporalScope::Persistent,
805 );
806 let b = FactFingerprint::new(
807 &subject,
808 &predicate,
809 &MemoryValue::Text("pescatarian".into()),
810 TemporalScope::Persistent,
811 );
812 assert_eq!(a, b);
813 assert_eq!(a.as_str(), "user|dietary_identity|pescatarian");
814 }
815
816 #[test]
817 fn fingerprints_expose_their_subject() {
818 let fp = FactFingerprint::new(
819 &EntityRef::named("Rhea"),
820 &CanonicalPredicate::new("venue_preference"),
821 &MemoryValue::Text("quiet".into()),
822 TemporalScope::Persistent,
823 );
824 assert_eq!(fp.subject(), "rhea");
825 }
826
827 #[test]
828 fn fingerprints_expose_the_subject_predicate_prefix() {
829 let fp = FactFingerprint::new(
830 &EntityRef::user(),
831 &CanonicalPredicate::new("dietary_identity"),
832 &MemoryValue::Text("vegetarian".into()),
833 TemporalScope::Persistent,
834 );
835 assert_eq!(fp.subject_predicate(), "user|dietary_identity");
836 }
837
838 #[test]
839 fn temporal_scope_separates_fingerprints() {
840 let persistent = FactFingerprint::new(
841 &EntityRef::user(),
842 &CanonicalPredicate::new("activity"),
843 &MemoryValue::Text("travelling".into()),
844 TemporalScope::Persistent,
845 );
846 let momentary = FactFingerprint::new(
847 &EntityRef::user(),
848 &CanonicalPredicate::new("activity"),
849 &MemoryValue::Text("travelling".into()),
850 TemporalScope::Momentary,
851 );
852 assert_ne!(persistent, momentary);
853 }
854
855 #[test]
856 fn repetition_of_weak_evidence_cannot_manufacture_certainty() {
857 assert_eq!(Explicitness::WeakInference.confidence_ceiling(9), 0.75);
858 assert_eq!(Explicitness::ExplicitCommand.confidence_ceiling(1), 1.0);
859 }
860
861 #[test]
862 fn only_user_speech_may_be_stored() {
863 assert!(SpeakerAttribution::User.may_be_stored());
864 assert!(!SpeakerAttribution::Bystander.may_be_stored());
865 assert!(!SpeakerAttribution::Assistant.may_be_stored());
866 assert!(!SpeakerAttribution::Unknown.may_be_stored());
867 }
868
869 #[test]
870 fn expiry_is_evaluated_against_both_windows() {
871 let now = Utc::now();
872 let meta = TemporalMetadata::created_at(now).with_ttl(Duration::hours(6));
873 assert!(!meta.is_expired(now));
874 assert!(meta.is_expired(now + Duration::hours(7)));
875 }
876
877 #[test]
878 fn entity_surface_forms_are_normalized_and_deduped() {
879 let entity = EntityRef::named("Rhea")
880 .with_alias("my wife")
881 .with_alias("Rhea");
882 assert_eq!(entity.surface_forms(), vec!["my wife", "rhea"]);
883 }
884
885 #[test]
886 fn stable_hash_is_deterministic_and_distinguishing() {
887 assert_eq!(stable_hash("hello"), stable_hash("hello"));
888 assert_ne!(stable_hash("hello"), stable_hash("hellp"));
889 }
890}