Skip to main content

deepstrike_core/mm/
memory.rs

1//! Long-term memory management (Phase 7).
2//!
3//! Kernel defines memory types and validation rules; SDKs perform I/O and selection.
4//! No I/O in this module — pure classification and validation logic.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10/// Memory kind (4 types, mirroring Claude Code's taxonomy).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum MemoryKind {
14    /// User profile: who they are, expertise level, role.
15    User,
16    /// Behavior preference: what they like/dislike, approved patterns.
17    Feedback,
18    /// Project context: what's happening, milestones, phases.
19    Project,
20    /// External pointer: where to find things (tickets, docs).
21    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/// Isolation boundary for a memory record.
36///
37/// Both components participate in identity so records cannot collide across tenants or
38/// application-defined namespaces.
39#[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/// Stable logical key used for memory upserts.
56#[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/// Principal responsible for producing a memory record.
65#[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/// Explicit trust classification kept separate from authorship.
74#[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/// Origin and evidence attached to a memory record.
83#[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/// A durable fact with stable identity, provenance, and lifecycle state.
95#[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/// Pure in-kernel helper implementing scoped, identity-preserving upsert semantics.
154#[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    /// Commit a successful recall as part of the journaled query-result transaction.
230    ///
231    /// `recalled_at` is a monotonic turn counter, not a wall clock: recall lifecycle is a
232    /// deterministic kernel fact so it must replay byte-for-byte. Retention scoring consumes it as a
233    /// step, and the TTL/staleness discount (which does need a clock) is applied host-side at recall
234    /// ranking time.
235    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
249/// Deterministic retention score for a durable record, evaluated at `current_turn`.
250///
251/// This is the canonical reference the host mirrors when it bounds its own store: durable memory and
252/// context knowledge rank by the same "value" definition ([`deterministic_retention_score`]). The
253/// host owns the full cross-session record set, so it — not the kernel — enforces the capacity bound
254/// and applies the day-based TTL/staleness discount (which needs a wall clock the kernel lacks). The
255/// kernel's job is the recall/promotion signal derived from the routed hits.
256pub fn memory_retention_score(
257    record: &MemoryRecord,
258    current_turn: u64,
259    stale_discount_ppm: u32,
260) -> i64 {
261    use crate::mm::value::{deterministic_retention_score, RetentionFeatures, RetentionKind};
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    // Rough 4-bytes-per-token proxy keeps the size penalty on the same scale as context units.
270    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/// Scoped recall request. The host owns retrieval; the kernel validates this deterministic wire and
285/// caps `top_k` through [`MemoryPolicy`].
286#[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/// One scored host recall. `score` is relevance, distinct from the record's stored confidence.
378#[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/// Journaled recall lifecycle for one record (M3). Carried on `MemoryRecalled` so the host mirrors
387/// the kernel-authoritative counts into its durable store.
388#[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/// Memory validation error.
397#[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/// Memory validation rules (kernel-enforced).
408#[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    /// Validate a memory write request.
418    pub fn validate(&self, record: &MemoryRecord) -> Result<(), MemoryValidationError> {
419        // Check required fields
420        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        // Check name length
452        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        // Check content size
460        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        // Check forbidden patterns
468        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
481/// Validate a memory write request with default validation rules.
482pub fn validate_memory_write(record: &MemoryRecord) -> Result<(), MemoryValidationError> {
483    MemoryValidation::default().validate(record)
484}
485
486/// Declarative configuration for the kernel's long-term memory subsystem.
487///
488/// Installed via the `set_memory_policy` input event (opt-in). When no policy is installed the
489/// kernel preserves pre-policy behavior: every `write_memory` is validated with the default rules
490/// and `query_memory` uses the requested `top_k` verbatim. Installing a policy makes these knobs
491/// authoritative:
492/// - `validation_enabled = false` admits every write without validation.
493/// - `retrieval_top_k` is an upper bound: the emitted `requested_k` is `min(query.top_k, top_k)`.
494/// - `max_content_bytes` / `max_name_length` override the validation size limits when set.
495///
496/// `memory_path` and `stale_warning_days` are not enforced inside the kernel (the kernel performs
497/// no recall I/O); they are carried so the SDK consumes a single authoritative config.
498#[derive(Debug, Clone)]
499pub struct MemoryPolicy {
500    pub memory_path: String,
501    pub stale_warning_days: u32,
502    pub retrieval_top_k: usize,
503    pub validation_enabled: bool,
504    pub max_content_bytes: Option<u32>,
505    pub max_name_length: Option<usize>,
506    /// M4: recall count at which a record becomes a promotion candidate. When set, crossing it on a
507    /// recall emits an advisory `PromotionSuggested`; `None` disables the suggestion.
508    pub promotion_recall_threshold: Option<u64>,
509}
510
511impl Default for MemoryPolicy {
512    fn default() -> Self {
513        Self {
514            memory_path: String::new(),
515            stale_warning_days: 2,
516            retrieval_top_k: 5,
517            validation_enabled: true,
518            max_content_bytes: None,
519            max_name_length: None,
520            promotion_recall_threshold: None,
521        }
522    }
523}
524
525impl MemoryPolicy {
526    /// Build the validation rules this policy implies, starting from the kernel defaults and
527    /// applying any size / name-length overrides.
528    pub fn validation(&self) -> MemoryValidation {
529        let mut v = MemoryValidation::default();
530        if let Some(bytes) = self.max_content_bytes {
531            v.max_size_bytes = bytes;
532        }
533        if let Some(len) = self.max_name_length {
534            v.max_name_length = len;
535        }
536        v
537    }
538
539    /// Clamp a requested retrieval count to this policy's `retrieval_top_k` upper bound.
540    pub fn clamp_top_k(&self, requested: usize) -> usize {
541        requested.min(self.retrieval_top_k)
542    }
543}
544
545/// Default validation rules (aligned with Claude Code's "what NOT to store").
546impl Default for MemoryValidation {
547    fn default() -> Self {
548        Self {
549            max_size_bytes: 10_000,
550            max_name_length: 100,
551            required_fields: vec![
552                "record_id".into(),
553                "scope.tenant_id".into(),
554                "scope.namespace".into(),
555                "name".into(),
556                "description".into(),
557            ],
558            // P13: no baked-in content heuristics — what belongs in memory is host/model
559            // judgment. Hosts configure forbidden prefixes via MemoryPolicy when they want them.
560            forbidden_patterns: Vec::new(),
561        }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    fn scope(namespace: &str) -> MemoryScope {
570        MemoryScope::new("tenant-a", namespace)
571    }
572
573    fn record(record_id: &str, namespace: &str, name: &str, content: &str) -> MemoryRecord {
574        MemoryRecord {
575            record_id: record_id.into(),
576            scope: scope(namespace),
577            name: name.into(),
578            kind: MemoryKind::Project,
579            content: content.into(),
580            description: format!("description for {name}"),
581            provenance: MemoryProvenance {
582                session_id: Some("session-1".into()),
583                author: MemoryAuthor::Extraction,
584                trust: MemoryTrustLevel::Untrusted,
585                evidence_refs: vec!["turn:1".into()],
586            },
587            created_at: 10,
588            updated_at: 10,
589            last_recalled_at: None,
590            recall_count: 0,
591            confidence: 0.8,
592            links: Vec::new(),
593            pinned: false,
594            ttl_days: Some(30),
595        }
596    }
597
598    #[test]
599    fn memory_kind_labels_correct() {
600        assert_eq!(MemoryKind::User.label(), "user");
601        assert_eq!(MemoryKind::Feedback.label(), "feedback");
602        assert_eq!(MemoryKind::Project.label(), "project");
603        assert_eq!(MemoryKind::Reference.label(), "reference");
604    }
605
606    #[test]
607    fn validation_passes_for_valid_request() {
608        let validation = MemoryValidation::default();
609        let record = record("mem-1", "project:p1", "test-memory", "This is fine");
610        assert!(validation.validate(&record).is_ok());
611    }
612
613    #[test]
614    fn validation_rejects_missing_name() {
615        let validation = MemoryValidation::default();
616        let mut record = record("mem-1", "project:p1", "name", "content");
617        record.name.clear();
618        assert!(matches!(
619            validation.validate(&record),
620            Err(MemoryValidationError::MissingRequiredField { field }) if field == "name"
621        ));
622    }
623
624    #[test]
625    fn validation_rejects_host_configured_forbidden_pattern() {
626        // P13: no baked-in content heuristics — the mechanism only bites when a HOST
627        // configures forbidden prefixes on its MemoryPolicy.
628        let mut validation = MemoryValidation::default();
629        assert!(validation.forbidden_patterns.is_empty(), "no defaults");
630        validation
631            .forbidden_patterns
632            .push(("TODO:".into(), "transient tasks do not belong in memory"));
633        let record = record("mem-1", "project:p1", "bad-memory", "TODO: ship it");
634        assert!(matches!(
635            validation.validate(&record),
636            Err(MemoryValidationError::ForbiddenPattern { .. })
637        ));
638    }
639
640    #[test]
641    fn validation_rejects_oversized_content() {
642        let validation = MemoryValidation::default();
643        let record = record("mem-1", "project:p1", "huge-memory", &"x".repeat(20_000));
644        assert!(matches!(
645            validation.validate(&record),
646            Err(MemoryValidationError::ContentTooLarge { .. })
647        ));
648    }
649
650    #[test]
651    fn memory_query_defaults_top_k_to_5() {
652        let query = MemoryQuery {
653            scope: scope("project:p1"),
654            query: "test".into(),
655            ..Default::default()
656        };
657        assert_eq!(query.top_k, 5);
658    }
659
660    #[test]
661    fn legacy_memory_wire_shapes_are_rejected() {
662        assert!(
663            serde_json::from_value::<MemoryQuery>(serde_json::json!({
664                "current_context": "legacy",
665                "top_k": 5
666            }))
667            .is_err()
668        );
669        assert!(
670            serde_json::from_value::<MemoryRecord>(serde_json::json!({
671                "metadata": { "name": "legacy" },
672                "content": "old write request"
673            }))
674            .is_err()
675        );
676    }
677
678    #[test]
679    fn memory_record_wire_shape_carries_identity_scope_provenance_and_lifecycle() {
680        let value = serde_json::to_value(record("mem-1", "project:p1", "build", "use cargo"))
681            .expect("record serializes");
682
683        assert_eq!(value["record_id"], "mem-1");
684        assert_eq!(value["scope"]["tenant_id"], "tenant-a");
685        assert_eq!(value["scope"]["namespace"], "project:p1");
686        assert_eq!(value["kind"], "project");
687        assert_eq!(value["provenance"]["author"], "extraction");
688        assert_eq!(value["provenance"]["trust"], "untrusted");
689        assert_eq!(value["recall_count"], 0);
690        assert_eq!(value["ttl_days"], 30);
691    }
692
693    #[test]
694    fn scored_recall_updates_lifecycle_and_suggests_promotion() {
695        let mut store = MemoryRecordStore::default();
696        let record = record("recall-me", "agent-a", "preferences", "Use terse answers");
697        store.upsert(record).unwrap();
698
699        let recalled = store.record_recall("recall-me", 42).expect("record exists");
700        assert_eq!(recalled.recall_count, 1);
701        assert_eq!(recalled.last_recalled_at, Some(42));
702        assert!(!store.promotion_suggested("recall-me", 2));
703
704        store.record_recall("recall-me", 43).unwrap();
705        assert!(store.promotion_suggested("recall-me", 2));
706    }
707
708    #[test]
709    fn memory_retention_score_ranks_recalled_and_pinned_above_cold() {
710        // Reference formula the host mirrors when bounding its store: a recalled record outranks an
711        // untouched one, and a pin is absolute.
712        let mut hot = record("hot", "agent-a", "hot", "frequently useful fact");
713        hot.recall_count = 3;
714        hot.last_recalled_at = Some(9);
715        let cold = record("cold", "agent-a", "cold", "never referenced fact");
716        assert!(
717            memory_retention_score(&hot, 10, 0) > memory_retention_score(&cold, 10, 0),
718            "a recalled record beats a cold one"
719        );
720
721        let mut pinned = cold.clone();
722        pinned.pinned = true;
723        assert_eq!(memory_retention_score(&pinned, 10, 0), i64::MAX, "pin is absolute");
724
725        // The TTL/staleness discount (host-supplied, clock-based) lowers the score.
726        assert!(memory_retention_score(&cold, 10, 500_000) < memory_retention_score(&cold, 10, 0));
727    }
728
729    #[test]
730    fn memory_key_is_scope_kind_and_name() {
731        let project = record("mem-project", "project:p1", "build", "cargo");
732        let other_scope = record("mem-project-2", "project:p2", "build", "npm");
733        let mut other_kind = project.clone();
734        other_kind.record_id = "mem-user".into();
735        other_kind.kind = MemoryKind::User;
736
737        assert_ne!(project.key(), other_scope.key());
738        assert_ne!(project.key(), other_kind.key());
739        assert_eq!(project.key().name, "build");
740    }
741
742    #[test]
743    fn scoped_upsert_preserves_stable_identity_and_recall_lifecycle() {
744        let mut store = MemoryRecordStore::default();
745        let mut existing = record("stable-id", "project:p1", "build", "cargo build");
746        existing.recall_count = 7;
747        existing.last_recalled_at = Some(80);
748        assert!(matches!(
749            store.upsert(existing).unwrap(),
750            MemoryUpsertOutcome::Inserted { .. }
751        ));
752
753        let mut replacement = record("incoming-id", "project:p1", "build", "cargo nextest");
754        replacement.created_at = 90;
755        replacement.updated_at = 100;
756        replacement.provenance.author = MemoryAuthor::Host;
757        replacement.provenance.trust = MemoryTrustLevel::HostVerified;
758        let outcome = store.upsert(replacement).unwrap();
759
760        assert_eq!(
761            outcome,
762            MemoryUpsertOutcome::Updated {
763                record_id: "stable-id".into()
764            }
765        );
766        let stored = store
767            .get(&scope("project:p1"), MemoryKind::Project, "build")
768            .unwrap();
769        assert_eq!(stored.record_id, "stable-id");
770        assert_eq!(stored.created_at, 10);
771        assert_eq!(stored.updated_at, 100);
772        assert_eq!(stored.content, "cargo nextest");
773        assert_eq!(stored.recall_count, 7);
774        assert_eq!(stored.last_recalled_at, Some(80));
775        assert_eq!(stored.provenance.author, MemoryAuthor::Host);
776        assert_eq!(stored.provenance.trust, MemoryTrustLevel::HostVerified);
777
778        let mut stale_update = record("another-id", "project:p1", "build", "older fact");
779        stale_update.updated_at = 50;
780        store.upsert(stale_update).unwrap();
781        assert_eq!(
782            store
783                .get(&scope("project:p1"), MemoryKind::Project, "build")
784                .unwrap()
785                .updated_at,
786            100,
787            "upsert cannot move the lifecycle clock backwards"
788        );
789    }
790
791    #[test]
792    fn same_name_in_a_different_scope_inserts_a_distinct_record() {
793        let mut store = MemoryRecordStore::default();
794        store
795            .upsert(record("mem-p1", "project:p1", "build", "cargo"))
796            .unwrap();
797        store
798            .upsert(record("mem-p2", "project:p2", "build", "npm"))
799            .unwrap();
800
801        assert_eq!(store.len(), 2);
802        assert_eq!(
803            store
804                .get(&scope("project:p2"), MemoryKind::Project, "build")
805                .unwrap()
806                .record_id,
807            "mem-p2"
808        );
809    }
810
811    #[test]
812    fn record_id_collision_across_keys_is_rejected() {
813        let mut store = MemoryRecordStore::default();
814        store
815            .upsert(record("same-id", "project:p1", "build", "cargo"))
816            .unwrap();
817
818        let error = store
819            .upsert(record("same-id", "project:p2", "deploy", "ship"))
820            .expect_err("record id cannot alias another scoped key");
821
822        assert!(matches!(
823            error,
824            MemoryUpsertError::RecordIdConflict { record_id, .. } if record_id == "same-id"
825        ));
826        assert_eq!(store.len(), 1);
827    }
828}