1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum MemoryKind {
14 User,
16 Feedback,
18 Project,
20 Reference,
22}
23
24impl MemoryKind {
25 pub fn label(self) -> &'static str {
26 match self {
27 Self::User => "user",
28 Self::Feedback => "feedback",
29 Self::Project => "project",
30 Self::Reference => "reference",
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct MemoryScope {
42 pub tenant_id: String,
43 pub namespace: String,
44}
45
46impl MemoryScope {
47 pub fn new(tenant_id: impl Into<String>, namespace: impl Into<String>) -> Self {
48 Self {
49 tenant_id: tenant_id.into(),
50 namespace: namespace.into(),
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct MemoryKey {
59 pub scope: MemoryScope,
60 pub kind: MemoryKind,
61 pub name: String,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum MemoryAuthor {
68 Model,
69 Host,
70 Extraction,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum MemoryTrustLevel {
77 Untrusted,
78 UserAsserted,
79 HostVerified,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct MemoryProvenance {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub session_id: Option<String>,
88 pub author: MemoryAuthor,
89 pub trust: MemoryTrustLevel,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub evidence_refs: Vec<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct MemoryRecord {
98 pub record_id: String,
99 pub scope: MemoryScope,
100 pub name: String,
101 pub kind: MemoryKind,
102 pub content: String,
103 pub description: String,
104 pub provenance: MemoryProvenance,
105 pub created_at: u64,
106 pub updated_at: u64,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub last_recalled_at: Option<u64>,
109 #[serde(default)]
110 pub recall_count: u64,
111 pub confidence: f64,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub links: Vec<String>,
114 #[serde(default)]
115 pub pinned: bool,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub ttl_days: Option<u32>,
118}
119
120impl MemoryRecord {
121 pub fn key(&self) -> MemoryKey {
122 MemoryKey {
123 scope: self.scope.clone(),
124 kind: self.kind,
125 name: self.name.clone(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum MemoryUpsertOutcome {
132 Inserted { record_id: String },
133 Updated { record_id: String },
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum MemoryUpsertError {
138 RecordIdConflict {
139 record_id: String,
140 existing_key: MemoryKey,
141 incoming_key: MemoryKey,
142 },
143}
144
145impl MemoryUpsertError {
146 pub fn record_id(&self) -> &str {
147 match self {
148 Self::RecordIdConflict { record_id, .. } => record_id,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Default)]
155pub struct MemoryRecordStore {
156 records: BTreeMap<MemoryKey, MemoryRecord>,
157 keys_by_id: BTreeMap<String, MemoryKey>,
158}
159
160impl MemoryRecordStore {
161 pub fn upsert(
162 &mut self,
163 mut incoming: MemoryRecord,
164 ) -> Result<MemoryUpsertOutcome, MemoryUpsertError> {
165 let key = incoming.key();
166
167 if let Some(existing) = self.records.get(&key) {
168 if incoming.record_id != existing.record_id {
169 if let Some(existing_key) = self.keys_by_id.get(&incoming.record_id) {
170 if existing_key != &key {
171 return Err(MemoryUpsertError::RecordIdConflict {
172 record_id: incoming.record_id,
173 existing_key: existing_key.clone(),
174 incoming_key: key,
175 });
176 }
177 }
178 }
179
180 let stable_id = existing.record_id.clone();
181 incoming.record_id = stable_id.clone();
182 incoming.created_at = existing.created_at;
183 incoming.updated_at = incoming.updated_at.max(existing.updated_at);
184 incoming.last_recalled_at = existing.last_recalled_at;
185 incoming.recall_count = existing.recall_count;
186
187 self.records.insert(key, incoming);
188 return Ok(MemoryUpsertOutcome::Updated {
189 record_id: stable_id,
190 });
191 }
192
193 if let Some(existing_key) = self.keys_by_id.get(&incoming.record_id) {
194 return Err(MemoryUpsertError::RecordIdConflict {
195 record_id: incoming.record_id,
196 existing_key: existing_key.clone(),
197 incoming_key: key,
198 });
199 }
200
201 let record_id = incoming.record_id.clone();
202 self.keys_by_id.insert(record_id.clone(), key.clone());
203 self.records.insert(key, incoming);
204 Ok(MemoryUpsertOutcome::Inserted { record_id })
205 }
206
207 pub fn get(&self, scope: &MemoryScope, kind: MemoryKind, name: &str) -> Option<&MemoryRecord> {
208 self.records.get(&MemoryKey {
209 scope: scope.clone(),
210 kind,
211 name: name.to_owned(),
212 })
213 }
214
215 pub fn get_by_id(&self, record_id: &str) -> Option<&MemoryRecord> {
216 self.keys_by_id
217 .get(record_id)
218 .and_then(|key| self.records.get(key))
219 }
220
221 pub fn len(&self) -> usize {
222 self.records.len()
223 }
224
225 pub fn is_empty(&self) -> bool {
226 self.records.is_empty()
227 }
228
229 pub fn record_recall(&mut self, record_id: &str, recalled_at: u64) -> Option<&MemoryRecord> {
236 let key = self.keys_by_id.get(record_id)?.clone();
237 let record = self.records.get_mut(&key)?;
238 record.recall_count = record.recall_count.saturating_add(1);
239 record.last_recalled_at = Some(recalled_at);
240 Some(record)
241 }
242
243 pub fn promotion_suggested(&self, record_id: &str, threshold: u64) -> bool {
244 self.get_by_id(record_id)
245 .is_some_and(|record| !record.pinned && record.recall_count >= threshold)
246 }
247}
248
249pub fn memory_retention_score(
257 record: &MemoryRecord,
258 current_turn: u64,
259 stale_discount_ppm: u32,
260) -> i64 {
261 use crate::mm::value::{RetentionFeatures, RetentionKind, deterministic_retention_score};
262 let kind = match record.kind {
263 MemoryKind::User => RetentionKind::User,
264 MemoryKind::Feedback => RetentionKind::Feedback,
265 MemoryKind::Project => RetentionKind::Project,
266 MemoryKind::Reference => RetentionKind::Reference,
267 };
268 let confidence_ppm = (record.confidence.clamp(0.0, 1.0) * 1_000_000.0) as u32;
269 let tokens = (record.content.len() / 4).min(u32::MAX as usize) as u32;
271 deterministic_retention_score(RetentionFeatures {
272 pinned: record.pinned,
273 use_count: record.recall_count,
274 last_used_step: record.last_recalled_at,
275 current_step: current_turn,
276 lease_remaining_steps: None,
277 kind,
278 tokens,
279 confidence_ppm,
280 stale_discount_ppm,
281 })
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct MemoryQuery {
289 pub scope: MemoryScope,
290 pub query: String,
291 #[serde(default = "default_top_k")]
292 pub top_k: usize,
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 pub kinds: Vec<MemoryKind>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub min_score: Option<f64>,
297}
298
299fn default_top_k() -> usize {
300 5
301}
302
303impl Default for MemoryQuery {
304 fn default() -> Self {
305 Self {
306 scope: MemoryScope::new(String::new(), String::new()),
307 query: String::new(),
308 top_k: default_top_k(),
309 kinds: Vec::new(),
310 min_score: None,
311 }
312 }
313}
314
315impl MemoryQuery {
316 pub fn validate(&self) -> Result<(), String> {
317 if self.scope.tenant_id.is_empty() || self.scope.namespace.is_empty() {
318 return Err("memory query scope tenant_id and namespace must be non-empty".into());
319 }
320 if self.query.trim().is_empty() {
321 return Err("memory query text must be non-empty".into());
322 }
323 if self.top_k == 0 {
324 return Err("memory query top_k must be greater than zero".into());
325 }
326 if self
327 .min_score
328 .is_some_and(|score| !score.is_finite() || !(0.0..=1.0).contains(&score))
329 {
330 return Err("memory query min_score must be finite and between zero and one".into());
331 }
332 Ok(())
333 }
334
335 pub fn validate_hits(&self, hits: &[MemoryRecall], requested_k: usize) -> Result<(), String> {
336 if hits.len() > requested_k {
337 return Err(format!(
338 "memory query returned {} hits but requested at most {requested_k}",
339 hits.len()
340 ));
341 }
342 let mut record_ids = std::collections::BTreeSet::new();
343 for hit in hits {
344 if hit.record.scope != self.scope {
345 return Err(format!(
346 "memory recall {} escaped the requested scope",
347 hit.record.record_id
348 ));
349 }
350 if hit.record.record_id.is_empty() || !record_ids.insert(hit.record.record_id.as_str())
351 {
352 return Err("memory recall record_id must be non-empty and unique".into());
353 }
354 if !hit.score.is_finite() || !(0.0..=1.0).contains(&hit.score) {
355 return Err(format!(
356 "memory recall {} score must be finite and between zero and one",
357 hit.record.record_id
358 ));
359 }
360 if self.min_score.is_some_and(|minimum| hit.score < minimum) {
361 return Err(format!(
362 "memory recall {} score is below min_score",
363 hit.record.record_id
364 ));
365 }
366 if !self.kinds.is_empty() && !self.kinds.contains(&hit.record.kind) {
367 return Err(format!(
368 "memory recall {} kind was not requested",
369 hit.record.record_id
370 ));
371 }
372 }
373 Ok(())
374 }
375}
376
377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379#[serde(deny_unknown_fields)]
380pub struct MemoryRecall {
381 pub record: MemoryRecord,
382 pub score: f64,
383 pub why: String,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(deny_unknown_fields)]
390pub struct MemoryRecallLifecycle {
391 pub record_id: String,
392 pub recall_count: u64,
393 pub last_recalled_at: u64,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(tag = "error_kind", rename_all = "snake_case")]
399pub enum MemoryValidationError {
400 MissingRequiredField { field: String },
401 ContentTooLarge { size: u32, limit: u32 },
402 ForbiddenPattern { pattern: String, reason: String },
403 InvalidKind { kind: String },
404 NameTooLong { length: usize, limit: usize },
405}
406
407#[derive(Debug, Clone)]
409pub struct MemoryValidation {
410 pub max_size_bytes: u32,
411 pub max_name_length: usize,
412 pub required_fields: Vec<String>,
413 pub forbidden_patterns: Vec<(String, &'static str)>,
414}
415
416impl MemoryValidation {
417 pub fn validate(&self, record: &MemoryRecord) -> Result<(), MemoryValidationError> {
419 for field in &self.required_fields {
421 match field.as_str() {
422 "record_id" if record.record_id.is_empty() => {
423 return Err(MemoryValidationError::MissingRequiredField {
424 field: "record_id".into(),
425 });
426 }
427 "scope.tenant_id" if record.scope.tenant_id.is_empty() => {
428 return Err(MemoryValidationError::MissingRequiredField {
429 field: "scope.tenant_id".into(),
430 });
431 }
432 "scope.namespace" if record.scope.namespace.is_empty() => {
433 return Err(MemoryValidationError::MissingRequiredField {
434 field: "scope.namespace".into(),
435 });
436 }
437 "name" if record.name.is_empty() => {
438 return Err(MemoryValidationError::MissingRequiredField {
439 field: "name".into(),
440 });
441 }
442 "description" if record.description.is_empty() => {
443 return Err(MemoryValidationError::MissingRequiredField {
444 field: "description".into(),
445 });
446 }
447 _ => {}
448 }
449 }
450
451 if record.name.len() > self.max_name_length {
453 return Err(MemoryValidationError::NameTooLong {
454 length: record.name.len(),
455 limit: self.max_name_length,
456 });
457 }
458
459 if record.content.len() > self.max_size_bytes as usize {
461 return Err(MemoryValidationError::ContentTooLarge {
462 size: record.content.len() as u32,
463 limit: self.max_size_bytes,
464 });
465 }
466
467 for (pattern, reason) in &self.forbidden_patterns {
469 if record.content.contains(pattern) {
470 return Err(MemoryValidationError::ForbiddenPattern {
471 pattern: pattern.clone(),
472 reason: reason.to_string(),
473 });
474 }
475 }
476
477 Ok(())
478 }
479}
480
481pub fn validate_memory_write(record: &MemoryRecord) -> Result<(), MemoryValidationError> {
483 MemoryValidation::default().validate(record)
484}
485
486impl Default for MemoryValidation {
488 fn default() -> Self {
489 Self {
490 max_size_bytes: 10_000,
491 max_name_length: 100,
492 required_fields: vec![
493 "record_id".into(),
494 "scope.tenant_id".into(),
495 "scope.namespace".into(),
496 "name".into(),
497 "description".into(),
498 ],
499 forbidden_patterns: Vec::new(),
502 }
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 fn scope(namespace: &str) -> MemoryScope {
511 MemoryScope::new("tenant-a", namespace)
512 }
513
514 fn record(record_id: &str, namespace: &str, name: &str, content: &str) -> MemoryRecord {
515 MemoryRecord {
516 record_id: record_id.into(),
517 scope: scope(namespace),
518 name: name.into(),
519 kind: MemoryKind::Project,
520 content: content.into(),
521 description: format!("description for {name}"),
522 provenance: MemoryProvenance {
523 session_id: Some("session-1".into()),
524 author: MemoryAuthor::Extraction,
525 trust: MemoryTrustLevel::Untrusted,
526 evidence_refs: vec!["turn:1".into()],
527 },
528 created_at: 10,
529 updated_at: 10,
530 last_recalled_at: None,
531 recall_count: 0,
532 confidence: 0.8,
533 links: Vec::new(),
534 pinned: false,
535 ttl_days: Some(30),
536 }
537 }
538
539 #[test]
540 fn memory_kind_labels_correct() {
541 assert_eq!(MemoryKind::User.label(), "user");
542 assert_eq!(MemoryKind::Feedback.label(), "feedback");
543 assert_eq!(MemoryKind::Project.label(), "project");
544 assert_eq!(MemoryKind::Reference.label(), "reference");
545 }
546
547 #[test]
548 fn validation_passes_for_valid_request() {
549 let validation = MemoryValidation::default();
550 let record = record("mem-1", "project:p1", "test-memory", "This is fine");
551 assert!(validation.validate(&record).is_ok());
552 }
553
554 #[test]
555 fn validation_rejects_missing_name() {
556 let validation = MemoryValidation::default();
557 let mut record = record("mem-1", "project:p1", "name", "content");
558 record.name.clear();
559 assert!(matches!(
560 validation.validate(&record),
561 Err(MemoryValidationError::MissingRequiredField { field }) if field == "name"
562 ));
563 }
564
565 #[test]
566 fn validation_rejects_host_configured_forbidden_pattern() {
567 let mut validation = MemoryValidation::default();
570 assert!(validation.forbidden_patterns.is_empty(), "no defaults");
571 validation
572 .forbidden_patterns
573 .push(("TODO:".into(), "transient tasks do not belong in memory"));
574 let record = record("mem-1", "project:p1", "bad-memory", "TODO: ship it");
575 assert!(matches!(
576 validation.validate(&record),
577 Err(MemoryValidationError::ForbiddenPattern { .. })
578 ));
579 }
580
581 #[test]
582 fn validation_rejects_oversized_content() {
583 let validation = MemoryValidation::default();
584 let record = record("mem-1", "project:p1", "huge-memory", &"x".repeat(20_000));
585 assert!(matches!(
586 validation.validate(&record),
587 Err(MemoryValidationError::ContentTooLarge { .. })
588 ));
589 }
590
591 #[test]
592 fn memory_query_defaults_top_k_to_5() {
593 let query = MemoryQuery {
594 scope: scope("project:p1"),
595 query: "test".into(),
596 ..Default::default()
597 };
598 assert_eq!(query.top_k, 5);
599 }
600
601 #[test]
602 fn removed_memory_wire_shapes_are_rejected() {
603 assert!(
604 serde_json::from_value::<MemoryQuery>(serde_json::json!({
605 "current_context": "removed",
606 "top_k": 5
607 }))
608 .is_err()
609 );
610 assert!(
611 serde_json::from_value::<MemoryRecord>(serde_json::json!({
612 "metadata": { "name": "removed" },
613 "content": "old write request"
614 }))
615 .is_err()
616 );
617 }
618
619 #[test]
620 fn memory_record_wire_shape_carries_identity_scope_provenance_and_lifecycle() {
621 let value = serde_json::to_value(record("mem-1", "project:p1", "build", "use cargo"))
622 .expect("record serializes");
623
624 assert_eq!(value["record_id"], "mem-1");
625 assert_eq!(value["scope"]["tenant_id"], "tenant-a");
626 assert_eq!(value["scope"]["namespace"], "project:p1");
627 assert_eq!(value["kind"], "project");
628 assert_eq!(value["provenance"]["author"], "extraction");
629 assert_eq!(value["provenance"]["trust"], "untrusted");
630 assert_eq!(value["recall_count"], 0);
631 assert_eq!(value["ttl_days"], 30);
632 }
633
634 #[test]
635 fn scored_recall_updates_lifecycle_and_suggests_promotion() {
636 let mut store = MemoryRecordStore::default();
637 let record = record("recall-me", "agent-a", "preferences", "Use terse answers");
638 store.upsert(record).unwrap();
639
640 let recalled = store.record_recall("recall-me", 42).expect("record exists");
641 assert_eq!(recalled.recall_count, 1);
642 assert_eq!(recalled.last_recalled_at, Some(42));
643 assert!(!store.promotion_suggested("recall-me", 2));
644
645 store.record_recall("recall-me", 43).unwrap();
646 assert!(store.promotion_suggested("recall-me", 2));
647 }
648
649 #[test]
650 fn memory_retention_score_ranks_recalled_and_pinned_above_cold() {
651 let mut hot = record("hot", "agent-a", "hot", "frequently useful fact");
654 hot.recall_count = 3;
655 hot.last_recalled_at = Some(9);
656 let cold = record("cold", "agent-a", "cold", "never referenced fact");
657 assert!(
658 memory_retention_score(&hot, 10, 0) > memory_retention_score(&cold, 10, 0),
659 "a recalled record beats a cold one"
660 );
661
662 let mut pinned = cold.clone();
663 pinned.pinned = true;
664 assert_eq!(
665 memory_retention_score(&pinned, 10, 0),
666 i64::MAX,
667 "pin is absolute"
668 );
669
670 assert!(memory_retention_score(&cold, 10, 500_000) < memory_retention_score(&cold, 10, 0));
672 }
673
674 #[test]
675 fn memory_key_is_scope_kind_and_name() {
676 let project = record("mem-project", "project:p1", "build", "cargo");
677 let other_scope = record("mem-project-2", "project:p2", "build", "npm");
678 let mut other_kind = project.clone();
679 other_kind.record_id = "mem-user".into();
680 other_kind.kind = MemoryKind::User;
681
682 assert_ne!(project.key(), other_scope.key());
683 assert_ne!(project.key(), other_kind.key());
684 assert_eq!(project.key().name, "build");
685 }
686
687 #[test]
688 fn scoped_upsert_preserves_stable_identity_and_recall_lifecycle() {
689 let mut store = MemoryRecordStore::default();
690 let mut existing = record("stable-id", "project:p1", "build", "cargo build");
691 existing.recall_count = 7;
692 existing.last_recalled_at = Some(80);
693 assert!(matches!(
694 store.upsert(existing).unwrap(),
695 MemoryUpsertOutcome::Inserted { .. }
696 ));
697
698 let mut replacement = record("incoming-id", "project:p1", "build", "cargo nextest");
699 replacement.created_at = 90;
700 replacement.updated_at = 100;
701 replacement.provenance.author = MemoryAuthor::Host;
702 replacement.provenance.trust = MemoryTrustLevel::HostVerified;
703 let outcome = store.upsert(replacement).unwrap();
704
705 assert_eq!(
706 outcome,
707 MemoryUpsertOutcome::Updated {
708 record_id: "stable-id".into()
709 }
710 );
711 let stored = store
712 .get(&scope("project:p1"), MemoryKind::Project, "build")
713 .unwrap();
714 assert_eq!(stored.record_id, "stable-id");
715 assert_eq!(stored.created_at, 10);
716 assert_eq!(stored.updated_at, 100);
717 assert_eq!(stored.content, "cargo nextest");
718 assert_eq!(stored.recall_count, 7);
719 assert_eq!(stored.last_recalled_at, Some(80));
720 assert_eq!(stored.provenance.author, MemoryAuthor::Host);
721 assert_eq!(stored.provenance.trust, MemoryTrustLevel::HostVerified);
722
723 let mut stale_update = record("another-id", "project:p1", "build", "older fact");
724 stale_update.updated_at = 50;
725 store.upsert(stale_update).unwrap();
726 assert_eq!(
727 store
728 .get(&scope("project:p1"), MemoryKind::Project, "build")
729 .unwrap()
730 .updated_at,
731 100,
732 "upsert cannot move the lifecycle clock backwards"
733 );
734 }
735
736 #[test]
737 fn same_name_in_a_different_scope_inserts_a_distinct_record() {
738 let mut store = MemoryRecordStore::default();
739 store
740 .upsert(record("mem-p1", "project:p1", "build", "cargo"))
741 .unwrap();
742 store
743 .upsert(record("mem-p2", "project:p2", "build", "npm"))
744 .unwrap();
745
746 assert_eq!(store.len(), 2);
747 assert_eq!(
748 store
749 .get(&scope("project:p2"), MemoryKind::Project, "build")
750 .unwrap()
751 .record_id,
752 "mem-p2"
753 );
754 }
755
756 #[test]
757 fn record_id_collision_across_keys_is_rejected() {
758 let mut store = MemoryRecordStore::default();
759 store
760 .upsert(record("same-id", "project:p1", "build", "cargo"))
761 .unwrap();
762
763 let error = store
764 .upsert(record("same-id", "project:p2", "deploy", "ship"))
765 .expect_err("record id cannot alias another scoped key");
766
767 assert!(matches!(
768 error,
769 MemoryUpsertError::RecordIdConflict { record_id, .. } if record_id == "same-id"
770 ));
771 assert_eq!(store.len(), 1);
772 }
773}