mnemo_engine/memory.rs
1//! The agent memory model (Phase 5 of the build plan).
2//!
3//! This is what makes Mnemo a *memory engine* rather than a generic vector
4//! store. Memories are typed (episodic / semantic / procedural / working),
5//! carry lifecycle metadata (importance, access stats, optional TTL), and are
6//! retrieved with a **multi-signal score** that blends semantic similarity
7//! with recency, importance, and access frequency.
8
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13use ulid::Ulid;
14
15/// Current unix time in whole seconds.
16pub fn now_secs() -> i64 {
17 SystemTime::now()
18 .duration_since(UNIX_EPOCH)
19 .map(|d| d.as_secs() as i64)
20 .unwrap_or(0)
21}
22
23/// The four kinds of agent memory.
24#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Hash)]
25pub enum MemoryType {
26 /// What happened — conversation turns, events, interactions.
27 Episodic,
28 /// What is known — facts, entities, relationships, beliefs.
29 Semantic,
30 /// What to do — preferences, behavioral patterns, rules.
31 Procedural,
32 /// Right now — current-session context and scratchpad.
33 Working,
34}
35
36impl MemoryType {
37 /// Parse from a lowercase string (used by the CLI / SDK surface).
38 pub fn parse(s: &str) -> Option<MemoryType> {
39 match s.to_ascii_lowercase().as_str() {
40 "episodic" => Some(MemoryType::Episodic),
41 "semantic" => Some(MemoryType::Semantic),
42 "procedural" => Some(MemoryType::Procedural),
43 "working" => Some(MemoryType::Working),
44 _ => None,
45 }
46 }
47
48 /// Lowercase string form.
49 pub fn as_str(&self) -> &'static str {
50 match self {
51 MemoryType::Episodic => "episodic",
52 MemoryType::Semantic => "semantic",
53 MemoryType::Procedural => "procedural",
54 MemoryType::Working => "working",
55 }
56 }
57}
58
59/// Visibility of a memory across agents sharing one `.mnemo` file.
60#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
61pub enum Scope {
62 /// Visible only to the agent that created it.
63 Private,
64 /// Visible to every agent with read access.
65 Shared,
66}
67
68/// A single memory entry.
69#[derive(Serialize, Deserialize, Clone, Debug)]
70pub struct Memory {
71 /// Time-sortable unique ID.
72 pub id: Ulid,
73 /// Cognitive category of this memory.
74 pub memory_type: MemoryType,
75 /// Embedding of `content`.
76 pub vector: Vec<f32>,
77 /// The actual text/data of the memory.
78 pub content: String,
79 /// Free-form structured metadata.
80 pub metadata: Map<String, Value>,
81 /// Which agent created this memory.
82 pub agent_id: String,
83 /// Which session produced it (relevant for episodic memory).
84 pub session_id: Option<String>,
85 /// Visibility scope (private to its agent, or shared).
86 pub scope: Scope,
87 /// Unix seconds at creation.
88 pub created_at: i64,
89 /// Unix seconds of the most recent retrieval. Updated by `recall`.
90 pub accessed_at: i64,
91 /// How many times this memory has been retrieved.
92 pub access_count: u32,
93 /// Importance in `[0.0, 1.0]`; higher importance resists decay.
94 pub importance: f32,
95 /// Optional lifetime in seconds. After expiry the memory is skipped by
96 /// `recall` and removed by `compact`.
97 pub ttl_secs: Option<i64>,
98}
99
100impl Memory {
101 /// Create a new memory with sensible defaults. The ID and timestamps are
102 /// assigned now; tune the rest with the `with_*` builders.
103 pub fn new(content: impl Into<String>, memory_type: MemoryType, vector: Vec<f32>) -> Self {
104 let now = now_secs();
105 Memory {
106 id: Ulid::new(),
107 memory_type,
108 vector,
109 content: content.into(),
110 metadata: Map::new(),
111 agent_id: "default".to_string(),
112 session_id: None,
113 scope: Scope::Private,
114 created_at: now,
115 accessed_at: now,
116 access_count: 0,
117 importance: 0.5,
118 ttl_secs: None,
119 }
120 }
121
122 /// Set the owning agent.
123 pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
124 self.agent_id = agent_id.into();
125 self
126 }
127 /// Set the session ID.
128 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
129 self.session_id = Some(session_id.into());
130 self
131 }
132 /// Set the importance score (clamped to `[0,1]`).
133 pub fn with_importance(mut self, importance: f32) -> Self {
134 self.importance = importance.clamp(0.0, 1.0);
135 self
136 }
137 /// Set the visibility scope.
138 pub fn with_scope(mut self, scope: Scope) -> Self {
139 self.scope = scope;
140 self
141 }
142 /// Attach a TTL in seconds.
143 pub fn with_ttl(mut self, ttl_secs: i64) -> Self {
144 self.ttl_secs = Some(ttl_secs);
145 self
146 }
147 /// Attach a metadata key/value pair.
148 pub fn with_meta(mut self, key: impl Into<String>, value: Value) -> Self {
149 self.metadata.insert(key.into(), value);
150 self
151 }
152
153 /// True if the memory's TTL has elapsed as of `now`.
154 pub fn is_expired(&self, now: i64) -> bool {
155 match self.ttl_secs {
156 Some(ttl) => now - self.created_at >= ttl,
157 None => false,
158 }
159 }
160
161 /// Build the **canonical scaffold manifest** for a freshly-initialised
162 /// database. This is the default "I am a brand-new MNemo file" memory
163 /// inserted by `mnemo init` (and surfaced by `mnemo about`) so every new
164 /// database is self-describing from birth — no opt-in step required.
165 ///
166 /// The scaffold is a placeholder: the vector is all-zeros (it doesn't
167 /// pretend to carry semantic content), and `metadata.scaffold = true`
168 /// marks it as auto-generated so tooling and humans can tell it apart
169 /// from a hand-written manifest. The expected workflow is for the file's
170 /// author to replace this memory with one that records the project's
171 /// actual embedder, agent_id default, and conventions.
172 ///
173 /// See [`crate::Mnemo::about`] for the rest of the convention.
174 pub fn scaffold_manifest(dimensions: usize) -> Self {
175 let content = format!(
176 "MNEMO MANIFEST (scaffold) — Fresh {}-dimensional database created \
177 by `mnemo init`. This is a placeholder. Replace it with a memory \
178 that records your project's embedder (name, dimensions, normalize), \
179 default agent_id, and metadata conventions; keep the metadata keys \
180 `area=\"onboarding\"` and `topic=\"manifest\"` so `mnemo about` \
181 continues to surface it as the headline orientation point. Drop \
182 `metadata.scaffold` once you've replaced this entry so tooling \
183 knows the manifest has been curated.",
184 dimensions
185 );
186 let mut metadata = Map::new();
187 metadata.insert("area".into(), Value::String("onboarding".into()));
188 metadata.insert("topic".into(), Value::String("manifest".into()));
189 metadata.insert("scaffold".into(), Value::Bool(true));
190 metadata.insert(
191 "engine_version".into(),
192 Value::String(env!("CARGO_PKG_VERSION").into()),
193 );
194 metadata.insert(
195 "dimensions".into(),
196 Value::Number(serde_json::Number::from(dimensions as u64)),
197 );
198 // Sentinel vector: a single-axis unit spike `[1, 0, 0, …]`.
199 // A zero vector would now trip the Phase 3 `put` guard
200 // (all-zero refused — see store.rs). This spike is non-zero
201 // (so it passes), unit-normalized (so it reads cleanly to any
202 // downstream inspector), and uncorrelated with any real
203 // embedder's output (so it ranks near-last on any user query —
204 // appropriate for a placeholder the user is expected to
205 // replace).
206 let mut vector = vec![0.0_f32; dimensions];
207 if dimensions > 0 {
208 vector[0] = 1.0;
209 }
210 let now = now_secs();
211 Memory {
212 id: Ulid::new(),
213 memory_type: MemoryType::Semantic,
214 vector,
215 content,
216 metadata,
217 agent_id: "mnemo".to_string(),
218 session_id: None,
219 scope: Scope::Shared,
220 created_at: now,
221 accessed_at: now,
222 access_count: 0,
223 importance: 1.0,
224 ttl_secs: None,
225 }
226 }
227}
228
229/// Distance/similarity metric for vector comparison.
230#[derive(Clone, Copy, Debug, PartialEq, Eq)]
231pub enum Metric {
232 /// Cosine similarity (orientation only).
233 Cosine,
234 /// Negative Euclidean distance, mapped into a `(0,1]` similarity.
235 L2,
236 /// Raw dot product.
237 Dot,
238}
239
240/// Compute a *similarity* (higher = more relevant) under the given metric.
241pub fn similarity(metric: Metric, a: &[f32], b: &[f32]) -> f32 {
242 match metric {
243 Metric::Cosine => cosine(a, b),
244 Metric::Dot => dot(a, b),
245 Metric::L2 => {
246 let d = euclidean(a, b);
247 1.0 / (1.0 + d)
248 }
249 }
250}
251
252fn dot(a: &[f32], b: &[f32]) -> f32 {
253 a.iter().zip(b).map(|(x, y)| x * y).sum()
254}
255
256/// Cosine similarity in `[-1.0, 1.0]`; returns `0.0` if either vector
257/// has zero magnitude. `pub(crate)` so [`crate::Mnemo::cache_get_semantic`]
258/// (Phase 10.2) can reuse the same scoring routine that recall uses,
259/// keeping the two paths semantically identical.
260pub(crate) fn cosine(a: &[f32], b: &[f32]) -> f32 {
261 let na = dot(a, a).sqrt();
262 let nb = dot(b, b).sqrt();
263 if na == 0.0 || nb == 0.0 {
264 0.0
265 } else {
266 dot(a, b) / (na * nb)
267 }
268}
269
270fn euclidean(a: &[f32], b: &[f32]) -> f32 {
271 a.iter()
272 .zip(b)
273 .map(|(x, y)| (x - y) * (x - y))
274 .sum::<f32>()
275 .sqrt()
276}
277
278/// Weights for the multi-signal recall score (scoring v2, shipped in
279/// v0.7.0).
280///
281/// ```text
282/// score = similarity
283/// + max(similarity, 0)
284/// · ( β · recency
285/// + γ · (importance − 0.5)
286/// + δ · tanh(access_count / 5) )
287///
288/// recency = exp( -ln(2) · age_secs / half_life_secs )
289/// age_secs = now - memory.created_at
290/// ```
291///
292/// Design notes (from the 2026-11-06 scoring-v2 bake-off; see the
293/// `ranking_v2` harness at `mnemo/examples/ranking_v2.rs`):
294/// * **Similarity-gated.** The recency / importance / usage terms are
295/// multiplied by `max(similarity, 0)`, so a stale-but-semantically-off
296/// memory can't ride recency or importance past a semantically-on-point
297/// one. Pre-v0.7.0 scoring was purely additive.
298/// * **Importance is centered at 0.5.** A default-importance memory
299/// contributes zero importance bonus; importance only pushes a score up
300/// or down from that reference point.
301/// * **Usage is saturating.** `tanh(uses/5)` caps the feedback bonus so
302/// a heavily-returned memory can't keep accumulating weight past a
303/// plateau. At 5 uses the bonus is ~76% of its asymptote; at 10, ~96%.
304/// * **Recency is decoupled from the feedback loop.** Age is measured
305/// from `created_at`, not `accessed_at`, so a `recall()` call does not
306/// bump its own results' recency scores on later queries.
307///
308/// The output of this scorer is on a **different scale** from the
309/// pre-v0.7.0 formula. Callers that compared raw scores to a fixed
310/// threshold should re-pick the threshold against the new scale.
311#[derive(Clone, Copy, Debug)]
312pub struct ScoreWeights {
313 /// Weight on semantic similarity. Kept in the struct for calibration
314 /// hooks and symmetry with the other weights; scoring v2 always uses
315 /// it as `1.0` because the leading `similarity` term is ungated and
316 /// the whole score sits on the cosine-similarity scale by design.
317 pub alpha: f32,
318 /// Weight on the recency bonus (multiplied by `max(sim, 0)`).
319 pub beta: f32,
320 /// Weight on the importance bonus `(importance − 0.5)`, centered at
321 /// the default-importance 0.5 (multiplied by `max(sim, 0)`).
322 pub gamma: f32,
323 /// Weight on the saturating usage bonus `tanh(access_count / 5)`
324 /// (multiplied by `max(sim, 0)`).
325 pub delta: f32,
326 /// Half-life of the recency term, in seconds.
327 pub half_life_secs: f32,
328}
329
330impl Default for ScoreWeights {
331 fn default() -> Self {
332 // Tuned on the Phase 6 corpus + the ranking_v2 age-spread
333 // fixture; see `ranking_v2.checks` in perf_v0.5.0.json.
334 Self {
335 alpha: 1.0,
336 beta: 0.3,
337 gamma: 0.2,
338 delta: 0.1,
339 half_life_secs: 7.0 * 24.0 * 3600.0,
340 }
341 }
342}
343
344/// Saturation constant K for the usage term `tanh(access_count / K)`.
345/// K = 5 means ~76% of the asymptote at 5 uses and ~96% at 10 — matches
346/// the "a few repeats is signal; past that it's a habit" intuition.
347const USAGE_SAT_K: f32 = 5.0;
348
349impl ScoreWeights {
350 /// Combine the four signals into a single score. See the struct docs
351 /// for the full formula and the scoring-v2 rationale.
352 pub fn score(&self, similarity: f32, age_secs: f32, importance: f32, access_count: u32) -> f32 {
353 // Recency is in [0, 1]: 1.0 at age 0, 0.5 at one half-life, etc.
354 let recency = (-std::f32::consts::LN_2 * age_secs.max(0.0) / self.half_life_secs).exp();
355 // Saturating usage in [0, 1); tanh caps feedback-loop drift.
356 let usage = (access_count as f32 / USAGE_SAT_K).tanh();
357 // Gate the non-similarity bonuses by `max(sim, 0)` so a
358 // negative-cosine candidate (should be rare after the metric
359 // layer, but possible with `Metric::Dot` or short-text L2)
360 // can't collect positive bonuses, and a zero-similarity
361 // candidate collects no bonus either.
362 let gate = similarity.max(0.0);
363 self.alpha * similarity
364 + gate * (self.beta * recency + self.gamma * (importance - 0.5) + self.delta * usage)
365 }
366}