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// ---------------------------------------------------------------------------
230// Phase 12.1: reserved `_mnemo.*` metadata keys for in-place updates + keyed
231// supersession. These live in `Memory.metadata` (which is already
232// `Map<String, Value>` on the record), so nothing in the on-disk record
233// layout changes — v0.7.x readers see them as ordinary user metadata and
234// ignore them. Format bump to v9 (post-12.1) will promote the ones that
235// pay for a typed catalog column.
236// ---------------------------------------------------------------------------
237
238/// Namespace prefix used for every v0.8.0+ reserved metadata key (R-001.10).
239/// Keys outside this namespace are untouched user metadata.
240pub const MNEMO_META_NS: &str = "_mnemo.";
241
242/// Upsert slot name on a `remember(…, key = K)` write (12.1). Combined with
243/// the memory's `scope` (and `agent_id` for `Private` scope) to identify a
244/// single live memory per slot: a newer `remember(key=K)` supersedes the
245/// prior one.
246pub const MNEMO_META_KEY: &str = "_mnemo.key";
247
248/// Set on the newly-inserted record when a `remember(key=K)` supersedes an
249/// earlier record. Value: the superseded record's ULID string.
250pub const MNEMO_META_SUPERSEDES: &str = "_mnemo.supersedes";
251
252/// Set on the earlier record when a newer `remember(key=K)` has replaced
253/// it. Value: the new record's ULID string. Together with `_mnemo.invalid_at`
254/// this marks the record as "superseded, not current" — default `recall`,
255/// `about` and the key index skip it; `history(key)` walks the chain.
256pub const MNEMO_META_SUPERSEDED_BY: &str = "_mnemo.superseded_by";
257
258/// Set on the earlier record when superseded. Value: unix seconds (i64).
259/// Not interpreted by the engine's scorer — purely for audit / `history`.
260pub const MNEMO_META_INVALID_AT: &str = "_mnemo.invalid_at";
261
262/// Set on a record each time `Mnemo::update` succeeds. Value: unix seconds.
263/// Lets callers distinguish an unchanged-since-creation record from one
264/// that has been edited in place. Not interpreted by the scorer.
265pub const MNEMO_META_UPDATED_AT: &str = "_mnemo.updated_at";
266
267// ---------------------------------------------------------------------------
268// Phase 12.7a: thread archive (ingest / export / forget). Reserved
269// `_mnemo.*` keys that identify thread-turn records. All records with
270// `_mnemo.kind = "turn"` are:
271// * excluded from default recall (opt in with a 12.6
272// `_mnemo.kind = "turn"` filter or the `--thread` CLI flag),
273// * written by `Mnemo::ingest_thread` + the `mnemo thread ingest`
274// CLI from a transcript file in either a generic JSONL shape or
275// Claude Code's JSONL (`~/.claude/projects/<p>/<s>.jsonl`),
276// * grouped by `_mnemo.thread = <id>` for `thread export` /
277// `thread forget` and the 12.7b essence render.
278// ---------------------------------------------------------------------------
279
280/// Record kind for thread-archive turns (Phase 12.7a). Value: `"turn"`.
281pub const MNEMO_META_KIND: &str = "_mnemo.kind";
282
283/// Thread identifier the record belongs to (Phase 12.7a). Value: a
284/// caller-supplied string, typically the Claude Code session uuid or
285/// the planner-chosen thread slug.
286pub const MNEMO_META_THREAD: &str = "_mnemo.thread";
287
288/// Monotonically increasing sequence number within the thread
289/// (Phase 12.7a). Value: unsigned integer, 0-based. Lets
290/// `thread export` reconstruct the transcript in order.
291pub const MNEMO_META_SEQ: &str = "_mnemo.seq";
292
293/// Role of the turn's author (Phase 12.7a). Value: `"user"`,
294/// `"assistant"`, `"system"`, `"tool"`, or any other string the
295/// source transcript used.
296pub const MNEMO_META_ROLE: &str = "_mnemo.role";
297
298/// Opaque per-turn identifier from the source transcript
299/// (Phase 12.7a). Value: typically a uuid4. Used for idempotence —
300/// `ingest_thread` skips any turn whose `turn_id` already exists on a
301/// record in the target thread.
302pub const MNEMO_META_TURN_ID: &str = "_mnemo.turn_id";
303
304/// Zero-based part number for a turn that was chunked at the 8 KB
305/// boundary during ingest (Phase 12.7a). Absent → the turn fits in a
306/// single record.
307pub const MNEMO_META_PART: &str = "_mnemo.part";
308
309/// Hex-encoded SHA-256 of the original tool-result content
310/// (Phase 12.7a). Set when the content exceeded the 16 KB truncation
311/// cap during ingest; absent otherwise.
312pub const MNEMO_META_SHA256: &str = "_mnemo.sha256";
313
314/// Byte length of the original tool-result content (Phase 12.7a). Set
315/// alongside `_mnemo.sha256` when truncation happened; absent
316/// otherwise.
317pub const MNEMO_META_LENGTH: &str = "_mnemo.length";
318
319/// Marker value for `_mnemo.kind` on thread-turn records.
320pub const MNEMO_META_KIND_TURN: &str = "turn";
321
322/// Maximum content size for a single turn record before chunking kicks
323/// in (Phase 12.7a). 8 KB per chunk keeps each record small enough
324/// that the per-record page cost dominates — and so that a thread of
325/// mostly short messages ingests as one record each.
326pub const THREAD_TURN_CHUNK_BYTES: usize = 8 * 1024;
327
328/// Maximum content size for a tool-result turn before truncation
329/// (Phase 12.7a). Tool results frequently run tens of kilobytes;
330/// stored truncated with `_mnemo.sha256` + `_mnemo.length` so the
331/// original can be recovered (or verified) out-of-band.
332pub const THREAD_TOOL_RESULT_TRUNCATE_BYTES: usize = 16 * 1024;
333
334// ---------------------------------------------------------------------------
335// Phase 12.7b: status-record core + essence (timeline + sections). The
336// essence layer rides on the same reserved-metadata namespace as the
337// thread archive, so a file keeps the single `_mnemo.*` convention:
338//
339// - `_mnemo.kind = "essence"` identifies an essence record. Section
340// is in `_mnemo.section`. Scope — thread or project — is in
341// `_mnemo.thread` (string) or absent (`None` means project scope).
342// - `_mnemo.status` carries the lifecycle for section items
343// (`"open"` → `"resolved"`). Timeline entries never get a status.
344// - `_mnemo.status_history` records the status transitions. One
345// element per transition: `{"status": "...", "at": <unix_secs>}`.
346// - `_mnemo.refs` is an optional array of free-form reference
347// strings (turn ids, memory ids, commits, paths).
348// ---------------------------------------------------------------------------
349
350/// Reserved `_mnemo.section` key — which essence section an item
351/// belongs to (Phase 12.7b). Values: `"timeline"`, `"intent"`,
352/// `"conventions"`, `"decisions"`, `"state"`, `"open"`, `"next"`,
353/// `"lessons"`. Timeline entries are append-only.
354pub const MNEMO_META_SECTION: &str = "_mnemo.section";
355
356/// Reserved `_mnemo.status` key — current status of a status-record.
357/// Values are per-kind (Phase 12.7b: essence uses `"open"` and
358/// `"resolved"`). Timeline entries never carry this.
359pub const MNEMO_META_STATUS: &str = "_mnemo.status";
360
361/// Reserved `_mnemo.status_history` key — array of status transitions,
362/// each `{"status": <str>, "at": <unix_secs>}`. Append-only — the
363/// first entry records creation, each later entry records a
364/// transition.
365pub const MNEMO_META_STATUS_HISTORY: &str = "_mnemo.status_history";
366
367/// Reserved `_mnemo.refs` key — optional array of free-form reference
368/// strings (turn ids, memory ids, commits, paths) attached to an
369/// essence record (Phase 12.7b).
370pub const MNEMO_META_REFS: &str = "_mnemo.refs";
371
372/// Marker value for `_mnemo.kind` on essence records (Phase 12.7b).
373pub const MNEMO_META_KIND_ESSENCE: &str = "essence";
374
375/// Maximum content length for a timeline entry (Phase 12.7b).
376/// Entries over this are rejected, not truncated — the planner's
377/// spec is explicit about this because truncation would lose the
378/// narrative, and splitting across records would lose ordering.
379pub const ESSENCE_TIMELINE_MAX_BYTES: usize = 600;
380
381/// Maximum content length for a section item (Phase 12.7b).
382/// intent / conventions / decisions / state / open / next / lessons
383/// all share the same single-line cap. Items over this are rejected.
384pub const ESSENCE_SECTION_MAX_BYTES: usize = 280;
385
386/// Default byte budget for [`crate::Mnemo::essence`] rendering
387/// (Phase 12.7b). Matches the planner's "max_bytes = 6144" spec,
388/// which sits comfortably below a 4K-token context window.
389pub const ESSENCE_DEFAULT_RENDER_BYTES: usize = 6144;
390
391/// Which essence section an item belongs to.
392///
393/// Rendering order is fixed at this type's declaration order:
394/// intent → conventions → decisions → state → open → next → lessons
395/// → timeline. See [`EssenceSection::render_order`] for the explicit
396/// list and the [`crate::Mnemo::essence`] docs for per-section rules
397/// (timeline is append-only, every other section is item-edited).
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
399pub enum EssenceSection {
400 /// What the thread is trying to do.
401 Intent,
402 /// How the user works — conventions that survive across threads.
403 Conventions,
404 /// Decisions made and why (newest first on render).
405 Decisions,
406 /// The current state of the work.
407 State,
408 /// Open problems.
409 Open,
410 /// What to do next.
411 Next,
412 /// Lessons learned — mistakes caught and their fixes.
413 Lessons,
414 /// Append-only milestone timeline. Entries capped at
415 /// [`ESSENCE_TIMELINE_MAX_BYTES`] and never editable.
416 Timeline,
417}
418
419impl EssenceSection {
420 /// Section → stable `_mnemo.section` string.
421 pub fn as_str(&self) -> &'static str {
422 match self {
423 EssenceSection::Intent => "intent",
424 EssenceSection::Conventions => "conventions",
425 EssenceSection::Decisions => "decisions",
426 EssenceSection::State => "state",
427 EssenceSection::Open => "open",
428 EssenceSection::Next => "next",
429 EssenceSection::Lessons => "lessons",
430 EssenceSection::Timeline => "timeline",
431 }
432 }
433
434 /// Parse a string from `_mnemo.section` into the typed enum.
435 /// Case-insensitive; returns `None` on unknown input.
436 pub fn parse(s: &str) -> Option<EssenceSection> {
437 match s.to_ascii_lowercase().as_str() {
438 "intent" => Some(EssenceSection::Intent),
439 "conventions" => Some(EssenceSection::Conventions),
440 "decisions" => Some(EssenceSection::Decisions),
441 "state" => Some(EssenceSection::State),
442 "open" => Some(EssenceSection::Open),
443 "next" => Some(EssenceSection::Next),
444 "lessons" => Some(EssenceSection::Lessons),
445 "timeline" => Some(EssenceSection::Timeline),
446 _ => None,
447 }
448 }
449
450 /// Fixed render order (intent → conventions → decisions → state
451 /// → open → next → lessons → timeline). The [`crate::Mnemo::essence`]
452 /// render walks this list, allocating a per-section byte budget
453 /// proportional to section importance in the user-centric order.
454 pub fn render_order() -> &'static [EssenceSection] {
455 &[
456 EssenceSection::Intent,
457 EssenceSection::Conventions,
458 EssenceSection::Decisions,
459 EssenceSection::State,
460 EssenceSection::Open,
461 EssenceSection::Next,
462 EssenceSection::Lessons,
463 EssenceSection::Timeline,
464 ]
465 }
466
467 /// `true` for sections whose items carry an `open → resolved`
468 /// status lifecycle (every section except [`EssenceSection::Timeline`]).
469 pub fn has_status_lifecycle(&self) -> bool {
470 !matches!(self, EssenceSection::Timeline)
471 }
472
473 /// Per-section byte cap for new items. Timeline: 600 bytes.
474 /// Every other section: 280 bytes.
475 pub fn item_max_bytes(&self) -> usize {
476 match self {
477 EssenceSection::Timeline => ESSENCE_TIMELINE_MAX_BYTES,
478 _ => ESSENCE_SECTION_MAX_BYTES,
479 }
480 }
481}
482
483/// Scope selector for an essence item (Phase 12.7b). Thread-scoped
484/// items belong to one `_mnemo.thread`; project-scoped items are
485/// shared across every thread in the file.
486#[derive(Debug, Clone, PartialEq, Eq, Hash)]
487pub enum EssenceScope {
488 /// Belongs to one thread (the string). Shows up in that
489 /// thread's render only.
490 Thread(String),
491 /// Shared across every thread in the file. Shows up in every
492 /// thread's render, and in the `about` briefing: the CLI and MCP
493 /// `about` append the project render after the manifest, from
494 /// `Mnemo::about_essence()` (12.7d).
495 Project,
496}
497
498/// Status values the essence section supports (Phase 12.7b). More
499/// kinds (tasks: `queued → active → ready → done | blocked`;
500/// findings: `open → accepted | deferred | rejected | needs-user →
501/// verified`) ship with Phase 13 reusing the same status-record
502/// core; the transition tables for those are declared as constants
503/// in this crate but currently unused.
504pub mod status {
505 /// Freshly-added section item.
506 pub const OPEN: &str = "open";
507 /// The user-facing concern has been addressed.
508 pub const RESOLVED: &str = "resolved";
509}
510
511/// Task transition table (declared for Phase 13; currently unused
512/// in the engine). `queued → active → ready → done | blocked`,
513/// with `blocked → active` as the only back-edge.
514pub const TASK_TRANSITIONS: &[(&str, &str)] = &[
515 ("queued", "active"),
516 ("active", "ready"),
517 ("active", "blocked"),
518 ("blocked", "active"),
519 ("ready", "done"),
520];
521
522/// Finding transition table (declared for Phase 13; currently unused
523/// in the engine). `open → {accepted | deferred | rejected |
524/// needs-user} → verified`.
525pub const FINDING_TRANSITIONS: &[(&str, &str)] = &[
526 ("open", "accepted"),
527 ("open", "deferred"),
528 ("open", "rejected"),
529 ("open", "needs-user"),
530 ("accepted", "verified"),
531 ("deferred", "verified"),
532 ("needs-user", "verified"),
533];
534
535/// Essence transition table for Phase 12.7b. Only `open → resolved`
536/// is allowed. Timeline entries don't use this (they're append-only
537/// and have no status).
538pub const ESSENCE_TRANSITIONS: &[(&str, &str)] = &[(status::OPEN, status::RESOLVED)];
539
540// ---------------------------------------------------------------------------
541// Phase 12.7c: continuation + commit_deltas
542// ---------------------------------------------------------------------------
543
544/// Reserved `_mnemo.continues` key (Phase 12.7c). Set on a
545/// thread-start marker record to point at the previous thread whose
546/// context the new thread continues from. Value: the prior thread id
547/// (string). Rendering a continued thread pulls the prior thread's
548/// open items, `next` and last-N timeline entries ahead of the new
549/// thread's own essence.
550pub const MNEMO_META_CONTINUES: &str = "_mnemo.continues";
551
552/// Marker value for `_mnemo.kind` on a thread-start record (one per
553/// `thread_start(new, --from old)` call, carrying the link in
554/// `_mnemo.continues`).
555pub const MNEMO_META_KIND_THREAD_LINK: &str = "thread_link";
556
557/// How many timeline entries from the predecessor thread the
558/// continuation render pulls in (Phase 12.7c). Small enough to keep
559/// the render under the default 6 KB budget, large enough to carry a
560/// handful of milestones.
561pub const CONTINUATION_TIMELINE_ENTRIES: usize = 10;
562
563/// What [`crate::Mnemo::commit_deltas`] does with the `turns` carried
564/// in the batch (Phase 12.7c).
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
566pub enum TurnPolicy {
567 /// Archive every turn in the batch via
568 /// [`crate::Mnemo::ingest_thread`]. Default — matches the
569 /// "lossless flow preservation" story of 12.7.
570 #[default]
571 Archive,
572 /// Store turns with a TTL (unix-seconds expiry).
573 ExpireAfter(i64),
574 /// Drop turns on the floor (no archive record written).
575 Drop,
576}
577
578/// Which op a [`EssenceOp`] carries. Mirrors the per-method
579/// `Mnemo::essence_*` surface so one batch can mix section + timeline
580/// edits in one call.
581#[derive(Debug, Clone, PartialEq, Eq)]
582pub enum EssenceOpKind {
583 /// Section item add (any section except timeline).
584 Add,
585 /// Append-only timeline entry.
586 AppendTimeline,
587 /// In-place update of an existing section item.
588 Update,
589 /// Transition an item `open → resolved`.
590 Resolve,
591 /// Soft-delete an item.
592 Remove,
593}
594
595/// One essence op inside a [`CommitBatch`] (Phase 12.7c). Validation
596/// (missing/extra fields per kind) runs up front in
597/// [`crate::Mnemo::commit_deltas`] — a single bad op aborts the
598/// whole batch before any write lands.
599#[derive(Debug, Clone)]
600pub struct EssenceOp {
601 /// Which op to apply.
602 pub kind: EssenceOpKind,
603 /// Scope — required for [`EssenceOpKind::Add`] and
604 /// [`EssenceOpKind::AppendTimeline`].
605 pub scope: Option<EssenceScope>,
606 /// Section — required for [`EssenceOpKind::Add`].
607 pub section: Option<EssenceSection>,
608 /// Target record id — required for [`EssenceOpKind::Update`],
609 /// [`EssenceOpKind::Resolve`], [`EssenceOpKind::Remove`].
610 pub id: Option<Ulid>,
611 /// Content text — required for [`EssenceOpKind::Add`],
612 /// [`EssenceOpKind::AppendTimeline`], [`EssenceOpKind::Update`].
613 pub text: Option<String>,
614 /// Optional refs for ops that carry text.
615 pub refs: Option<Vec<String>>,
616}
617
618/// One keyed fact inside a [`CommitBatch`] (Phase 12.7c).
619#[derive(Debug, Clone)]
620pub struct KeyedMemory {
621 /// The memory to store. An **empty `vector`** means "embed
622 /// `content` with the registered embedder" (12.7d): every such fact
623 /// in a batch is embedded together in one `Embedder::embed` call.
624 pub memory: Memory,
625 /// Optional 12.1 upsert key.
626 pub key: Option<String>,
627}
628
629/// Atomic write batch for [`crate::Mnemo::commit_deltas`]
630/// (Phase 12.7c). The planner's "one call that composes facts +
631/// essence + turns" shape: everything validates up front, then every
632/// write runs under a single `flush()`.
633#[derive(Debug, Clone, Default)]
634pub struct CommitBatch {
635 /// Keyed-or-not facts to remember (12.1).
636 pub facts: Vec<KeyedMemory>,
637 /// Essence ops to apply (12.7b).
638 pub essence_ops: Vec<EssenceOp>,
639 /// Thread archive turns to ingest (12.7a).
640 pub turns: Vec<ThreadTurn>,
641 /// Destination thread id for `turns`. Required when `turns` is
642 /// non-empty.
643 pub thread_id: Option<String>,
644 /// What to do with `turns` — defaults to `Archive`.
645 pub turn_policy: TurnPolicy,
646}
647
648/// Result of [`crate::Mnemo::commit_deltas`] (Phase 12.7c).
649#[derive(Debug, Clone, Default, PartialEq, Eq)]
650pub struct CommitReport {
651 /// Facts added (plain or `WriteOutcome::Added`).
652 pub facts_added: usize,
653 /// Facts whose keyed write superseded a prior record.
654 pub facts_superseded: usize,
655 /// Keyed facts whose write was a no-op (matched live record).
656 pub facts_unchanged: usize,
657 /// Essence ops that produced an Added/Updated result.
658 pub essence_ops_applied: usize,
659 /// Essence ops that were no-ops (e.g. `resolve` on resolved).
660 pub essence_ops_unchanged: usize,
661 /// Turn records written to the archive.
662 pub turns_added: usize,
663 /// Source turns skipped by the archive's idempotence check.
664 pub turns_skipped: usize,
665 /// Source turns whose content was truncated at the 16 KB cap.
666 pub turns_truncated: usize,
667 /// Source turns split into multiple 8 KB chunks.
668 pub turns_chunked: usize,
669}
670
671/// Patch body for [`crate::Mnemo::update`] (Phase 12.1): every field is
672/// optional, and only the `Some(...)` fields overwrite the corresponding
673/// field on the target memory. All fields can be set independently.
674///
675/// `content` paired with `vector` updates both at once — the engine does
676/// not re-embed under `update` (use `update_text` for that). A `content`
677/// change without a new `vector` is rejected to prevent the embedding
678/// drifting silently out of sync with the text.
679///
680/// `metadata` **replaces** the full user-metadata map — the engine's own
681/// `_mnemo.*` reserved keys on the target record are preserved across
682/// the replacement (updated-at timestamp, supersession markers, etc.).
683/// Passing an empty map drops every user-visible metadata key but keeps
684/// the reserved ones.
685///
686/// `ttl_secs` is `Option<Option<i64>>` so a caller can distinguish
687/// "leave it alone" (`None`) from "set it to a value" (`Some(Some(s))`)
688/// and "clear it" (`Some(None)`).
689#[derive(Debug, Clone, Default)]
690pub struct MemoryPatch {
691 /// New text content. If set, a matching `vector` must also be set
692 /// (or call `update_text`, which re-embeds through the registered
693 /// embedder).
694 pub content: Option<String>,
695 /// New embedding. Must match the database's dimensionality.
696 pub vector: Option<Vec<f32>>,
697 /// New importance in `[0, 1]`.
698 pub importance: Option<f32>,
699 /// Replace the memory type.
700 pub memory_type: Option<MemoryType>,
701 /// Replace the user-visible metadata (preserves `_mnemo.*` reserved
702 /// keys).
703 pub metadata: Option<Map<String, Value>>,
704 /// Change the TTL. Outer `None` → leave unchanged; `Some(Some(s))` →
705 /// set TTL to `s`; `Some(None)` → clear any existing TTL.
706 pub ttl_secs: Option<Option<i64>>,
707}
708
709/// One turn fed into [`crate::Mnemo::ingest_thread`] (Phase 12.7a).
710///
711/// The caller supplies `turn_id` (opaque, for idempotence), `role`
712/// (the transcript's author tag; no interpretation), and `content`
713/// (UTF-8 text; chunked at 8 KB, or truncated at 16 KB when `role ==
714/// "tool"`). The ingest path assigns `_mnemo.seq` monotonically within
715/// the destination thread and embeds through the registered embedder.
716///
717/// Tool messages whose content exceeds [`THREAD_TOOL_RESULT_TRUNCATE_BYTES`]
718/// are stored truncated. Other roles over [`THREAD_TURN_CHUNK_BYTES`]
719/// are chunked.
720#[derive(Debug, Clone)]
721pub struct ThreadTurn {
722 /// Opaque per-turn id from the source transcript (e.g. a Claude
723 /// Code message uuid). Used for `ingest_thread`'s idempotence
724 /// check: a turn whose `turn_id` already exists in the target
725 /// thread is skipped.
726 pub turn_id: String,
727 /// Author role verbatim from the transcript (`"user"`,
728 /// `"assistant"`, `"system"`, `"tool"`, …). `"tool"` triggers
729 /// the 16 KB truncation branch; all other roles go through the
730 /// 8 KB chunking branch.
731 pub role: String,
732 /// UTF-8 message content.
733 pub content: String,
734 /// Optional caller-supplied creation timestamp (unix seconds).
735 /// If `None`, the ingest path uses `now_secs()` so the record's
736 /// `created_at` still sorts the thread in order. Transcripts
737 /// that carry a per-turn timestamp should pass it through so a
738 /// future `thread export --by-time` can honour it.
739 pub timestamp: Option<i64>,
740}
741
742/// One turn of a thread's archive in the **ingest shape** (Phase 12.7d),
743/// as returned by [`crate::Mnemo::thread_export_turns`] and by Python
744/// `db.thread_export`, MCP `thread_export` and `mnemo thread export`.
745///
746/// Turns come back in `_mnemo.seq` order, one entry per source turn: the
747/// 8 KB chunks of a long turn are joined back into one `content`, so
748/// feeding the list to `ingest_thread` (same thread: every turn is
749/// skipped; a new thread: the turns are re-chunked identically) round
750/// trips. A tool result that was truncated at 16 KB carries `sha256` and
751/// `original_length` of the original content.
752#[derive(Debug, Clone, PartialEq, Eq)]
753pub struct ExportedTurn {
754 /// Source `turn_id` (`_mnemo.turn_id`).
755 pub turn_id: String,
756 /// Author role (`_mnemo.role`).
757 pub role: String,
758 /// Stored text, with chunks joined in `_mnemo.part` order.
759 pub content: String,
760 /// The turn's timestamp (unix seconds; the record's `created_at`).
761 pub timestamp: i64,
762 /// Hex SHA-256 of the original content, set only for a truncated
763 /// tool result.
764 pub sha256: Option<String>,
765 /// Byte length of the original content, set only for a truncated
766 /// tool result.
767 pub original_length: Option<u64>,
768}
769
770impl ExportedTurn {
771 /// The JSON object shared by the MCP tool and the CLI JSONL export:
772 /// `{turn_id, role, content, timestamp}` plus `sha256` and
773 /// `original_length` when the turn was truncated.
774 pub fn to_json(&self) -> serde_json::Value {
775 let mut obj = serde_json::json!({
776 "turn_id": self.turn_id,
777 "role": self.role,
778 "content": self.content,
779 "timestamp": self.timestamp,
780 });
781 if let Some(sha) = &self.sha256 {
782 obj["sha256"] = serde_json::Value::String(sha.clone());
783 }
784 if let Some(len) = self.original_length {
785 obj["original_length"] = serde_json::Value::Number(len.into());
786 }
787 obj
788 }
789}
790
791/// Result of one [`crate::Mnemo::ingest_thread`] call (Phase 12.7a).
792#[derive(Debug, Clone, Default, PartialEq, Eq)]
793pub struct IngestReport {
794 /// Number of **new** records written (turn chunks after chunking
795 /// / truncation). A long turn counts as however many chunks it
796 /// produced.
797 pub added_records: usize,
798 /// Number of **source turns** that already had a record in the
799 /// target thread and were skipped by the idempotence check.
800 pub skipped_existing_turns: usize,
801 /// Number of turns whose content was truncated at the 16 KB cap.
802 /// Only `role == "tool"` turns can trigger this branch.
803 pub truncated_turns: usize,
804 /// Number of turns that were split into multiple chunks at the
805 /// 8 KB boundary. A truncated turn counts as 0 here even if its
806 /// pre-truncation content would have chunked.
807 pub chunked_turns: usize,
808}
809
810/// Scope selector for a keyed slot (Phase 12.1.1). A keyed
811/// `remember`'s slot is always identified by `(scope_owner, key)`:
812/// `Private(agent_id)` slots are per-agent, `Shared` slots are per-file.
813/// `history_in(key, scope)` uses the same selector so the lookup matches
814/// the write exactly.
815///
816/// The convenience [`crate::Mnemo::history`] is equivalent to
817/// `history_in(key, KeyScope::Private(agent.unwrap_or("default")))` —
818/// i.e. `history(key, None)` resolves to the default agent's private
819/// slot, matching the `remember(…, agent_id=None, scope=Private, key=K)`
820/// write path. Use [`crate::Mnemo::history_in`] with
821/// [`KeyScope::Shared`] when you need the shared slot.
822#[derive(Debug, Clone, PartialEq, Eq, Hash)]
823pub enum KeyScope {
824 /// Per-agent slot. The agent id is the one carried on `Memory.agent_id`
825 /// at write time; `remember` with no explicit agent uses `"default"`.
826 Private(String),
827 /// Per-file slot shared across every agent.
828 Shared,
829}
830
831impl KeyScope {
832 /// Default agent id used when `Memory::new(...)` is called without
833 /// `.with_agent(...)` — same `"default"` the [`Memory::new`]
834 /// constructor seeds into `Memory.agent_id`.
835 pub const DEFAULT_AGENT: &'static str = "default";
836
837 /// Build the private slot for the default agent. Shorthand for
838 /// `KeyScope::Private(KeyScope::DEFAULT_AGENT.to_string())`.
839 pub fn private_default() -> Self {
840 KeyScope::Private(Self::DEFAULT_AGENT.to_string())
841 }
842}
843
844/// Outcome of a 12.1 write operation (`remember` with `key`, or `update`).
845///
846/// Plain `remember` without a key always returns a ULID, not a
847/// `WriteOutcome`, because supersession and NOOP detection are tied to
848/// the key slot.
849#[derive(Debug, Clone, Copy, PartialEq, Eq)]
850pub enum WriteOutcome {
851 /// Keyed write with no prior record for this slot. A brand-new record
852 /// was inserted, no record was superseded.
853 Added,
854 /// Keyed write whose content / type / importance / user metadata
855 /// differs from the live record for this slot. A new record was
856 /// inserted and the prior one was marked
857 /// `_mnemo.superseded_by` / `_mnemo.invalid_at`.
858 Superseded,
859 /// No-op: the incoming keyed write is byte-equal to the live record
860 /// for this slot (content, memory type, importance, user metadata).
861 /// No record was written and no catalog page was dirtied. The
862 /// returned id is the existing record's id.
863 Unchanged,
864 /// In-place `update`: fields changed on the target record. The record
865 /// was rewritten copy-on-write so the previous version remains
866 /// visible through snapshots.
867 Updated,
868}
869
870// ---------------------------------------------------------------------------
871// Phase 12.6: recall filters (session_id + per-metadata-key conditions)
872// ---------------------------------------------------------------------------
873
874/// One AND-ed condition applied to a candidate memory's metadata during
875/// filtered recall (Phase 12.6). Terms are combined conjunctively — a
876/// memory passes the filter only if *every* term evaluates true against
877/// its `metadata` map. See [`Filter`] for the surface API.
878///
879/// Values use `serde_json::Value` so the same term shape serializes
880/// cleanly through the MCP / Python / CLI bridges without a parallel
881/// enum per surface.
882#[derive(Debug, Clone)]
883pub enum FilterTerm {
884 /// The memory's `metadata[key]` must be set to any value.
885 Exists(String),
886 /// `metadata[key]` must equal this JSON value (strict JSON equality:
887 /// strings compare case-sensitively, numbers compare as f64).
888 Eq(String, Value),
889 /// `metadata[key]` must equal one of these JSON values.
890 In(String, Vec<Value>),
891 /// `metadata[key]` must be a number in `[min, max]` (inclusive on
892 /// both ends; `None` on either bound means "no bound on that side").
893 /// A non-numeric metadata value fails the term.
894 Range {
895 /// Metadata key.
896 key: String,
897 /// Lower bound (inclusive) — `None` means unbounded below.
898 min: Option<f64>,
899 /// Upper bound (inclusive) — `None` means unbounded above.
900 max: Option<f64>,
901 },
902}
903
904/// Pre-scoring filter applied by [`crate::Mnemo::recall`] (Phase 12.6).
905///
906/// `session_id` restricts candidates to a single session (when the
907/// memory's `session_id` is set). The `terms` are AND-ed metadata
908/// conditions evaluated against each candidate's `metadata` map; see
909/// [`FilterTerm`] for the shapes.
910///
911/// When an ANN index is present, the filter is applied *after* the ANN
912/// shortlist step, so a selective filter can starve the result below
913/// `top_k`. Recall handles that automatically via the Phase 12.1
914/// short-results-on-indexed-path fix (widen 2×, then exact scan).
915#[derive(Debug, Clone, Default)]
916pub struct Filter {
917 /// Only candidates whose `Memory.session_id` equals this value.
918 /// Candidates without a session_id are excluded when this is set.
919 pub session_id: Option<String>,
920 /// AND-ed metadata conditions. Empty → no metadata constraints.
921 pub terms: Vec<FilterTerm>,
922}
923
924impl Filter {
925 /// Builder helper: a filter that keeps only memories in `session`.
926 pub fn new_session(session: impl Into<String>) -> Self {
927 Self {
928 session_id: Some(session.into()),
929 terms: Vec::new(),
930 }
931 }
932
933 /// Restrict to one session_id (builder form).
934 pub fn with_session(mut self, session: impl Into<String>) -> Self {
935 self.session_id = Some(session.into());
936 self
937 }
938
939 /// Require `metadata[key]` to exist (any value).
940 pub fn metadata_exists(mut self, key: impl Into<String>) -> Self {
941 self.terms.push(FilterTerm::Exists(key.into()));
942 self
943 }
944
945 /// Require `metadata[key]` to equal `value`.
946 pub fn metadata_eq(mut self, key: impl Into<String>, value: Value) -> Self {
947 self.terms.push(FilterTerm::Eq(key.into(), value));
948 self
949 }
950
951 /// Require `metadata[key]` to be one of `values`.
952 pub fn metadata_in(mut self, key: impl Into<String>, values: Vec<Value>) -> Self {
953 self.terms.push(FilterTerm::In(key.into(), values));
954 self
955 }
956
957 /// Require `metadata[key]` to be a number in `[min, max]`.
958 pub fn metadata_range(
959 mut self,
960 key: impl Into<String>,
961 min: Option<f64>,
962 max: Option<f64>,
963 ) -> Self {
964 self.terms.push(FilterTerm::Range {
965 key: key.into(),
966 min,
967 max,
968 });
969 self
970 }
971
972 /// `true` iff the filter has no terms and no `session_id`. The
973 /// recall hot path uses this to skip the per-candidate filter
974 /// check entirely.
975 pub fn is_empty(&self) -> bool {
976 self.session_id.is_none() && self.terms.is_empty()
977 }
978
979 /// Phase 12.7a: `true` iff the filter mentions any reserved
980 /// `_mnemo.*` metadata key. Used by `recall` to decide whether
981 /// to default-exclude thread-archive turns
982 /// (`_mnemo.kind = "turn"`). A caller that wants turns in the
983 /// result opts in by adding any `_mnemo.*` term to the filter
984 /// — equality, `$in`, `$exists`, or range — because those are the
985 /// only queries that can usefully target turn records.
986 pub fn mentions_mnemo_meta(&self) -> bool {
987 self.terms.iter().any(|term| {
988 let key = match term {
989 FilterTerm::Exists(k) => k,
990 FilterTerm::Eq(k, _) => k,
991 FilterTerm::In(k, _) => k,
992 FilterTerm::Range { key, .. } => key,
993 };
994 key.starts_with(MNEMO_META_NS)
995 })
996 }
997
998 /// Evaluate the filter against one memory. Returns `true` if the
999 /// memory passes every term.
1000 pub fn matches(&self, m: &Memory) -> bool {
1001 if let Some(s) = &self.session_id {
1002 match &m.session_id {
1003 Some(ms) if ms == s => {}
1004 _ => return false,
1005 }
1006 }
1007 for term in &self.terms {
1008 if !term_matches(term, &m.metadata) {
1009 return false;
1010 }
1011 }
1012 true
1013 }
1014}
1015
1016fn term_matches(term: &FilterTerm, metadata: &Map<String, Value>) -> bool {
1017 match term {
1018 FilterTerm::Exists(key) => metadata.contains_key(key),
1019 FilterTerm::Eq(key, v) => metadata.get(key).is_some_and(|got| values_equal(got, v)),
1020 FilterTerm::In(key, options) => metadata
1021 .get(key)
1022 .is_some_and(|got| options.iter().any(|opt| values_equal(got, opt))),
1023 FilterTerm::Range { key, min, max } => metadata
1024 .get(key)
1025 .and_then(|v| v.as_f64())
1026 .is_some_and(|n| min.map_or(true, |m_| n >= m_) && max.map_or(true, |m_| n <= m_)),
1027 }
1028}
1029
1030/// JSON value equality for filter terms. serde_json's `Value: PartialEq`
1031/// compares numbers by exact representation (integer vs float). The
1032/// filter surface should treat `5` and `5.0` as equal, so we compare
1033/// numbers through `as_f64` when both sides are numbers; everything
1034/// else falls back to structural equality.
1035fn values_equal(a: &Value, b: &Value) -> bool {
1036 match (a, b) {
1037 (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
1038 (Some(xf), Some(yf)) => (xf - yf).abs() < f64::EPSILON,
1039 _ => x == y,
1040 },
1041 (x, y) => x == y,
1042 }
1043}
1044
1045/// Distance/similarity metric for vector comparison.
1046#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1047pub enum Metric {
1048 /// Cosine similarity (orientation only).
1049 Cosine,
1050 /// Negative Euclidean distance, mapped into a `(0,1]` similarity.
1051 L2,
1052 /// Raw dot product.
1053 Dot,
1054}
1055
1056/// Compute a *similarity* (higher = more relevant) under the given metric.
1057pub fn similarity(metric: Metric, a: &[f32], b: &[f32]) -> f32 {
1058 match metric {
1059 Metric::Cosine => cosine(a, b),
1060 Metric::Dot => dot(a, b),
1061 Metric::L2 => {
1062 let d = euclidean(a, b);
1063 1.0 / (1.0 + d)
1064 }
1065 }
1066}
1067
1068fn dot(a: &[f32], b: &[f32]) -> f32 {
1069 a.iter().zip(b).map(|(x, y)| x * y).sum()
1070}
1071
1072/// Cosine similarity in `[-1.0, 1.0]`; returns `0.0` if either vector
1073/// has zero magnitude. `pub(crate)` so [`crate::Mnemo::cache_get_semantic`]
1074/// (Phase 10.2) can reuse the same scoring routine that recall uses,
1075/// keeping the two paths semantically identical.
1076pub(crate) fn cosine(a: &[f32], b: &[f32]) -> f32 {
1077 let na = dot(a, a).sqrt();
1078 let nb = dot(b, b).sqrt();
1079 if na == 0.0 || nb == 0.0 {
1080 0.0
1081 } else {
1082 dot(a, b) / (na * nb)
1083 }
1084}
1085
1086fn euclidean(a: &[f32], b: &[f32]) -> f32 {
1087 a.iter()
1088 .zip(b)
1089 .map(|(x, y)| (x - y) * (x - y))
1090 .sum::<f32>()
1091 .sqrt()
1092}
1093
1094/// Weights for the multi-signal recall score (scoring v2, shipped in
1095/// v0.7.0).
1096///
1097/// ```text
1098/// score = similarity
1099/// + max(similarity, 0)
1100/// · ( β · recency
1101/// + γ · (importance − 0.5)
1102/// + δ · tanh(access_count / 5) )
1103///
1104/// recency = exp( -ln(2) · age_secs / half_life_secs )
1105/// age_secs = now - memory.created_at
1106/// ```
1107///
1108/// Design notes (from the 2026-11-06 scoring-v2 bake-off; see the
1109/// `ranking_v2` harness at `mnemo/examples/ranking_v2.rs`):
1110/// * **Similarity-gated.** The recency / importance / usage terms are
1111/// multiplied by `max(similarity, 0)`, so a stale-but-semantically-off
1112/// memory can't ride recency or importance past a semantically-on-point
1113/// one. Pre-v0.7.0 scoring was purely additive.
1114/// * **Importance is centered at 0.5.** A default-importance memory
1115/// contributes zero importance bonus; importance only pushes a score up
1116/// or down from that reference point.
1117/// * **Usage is saturating.** `tanh(uses/5)` caps the feedback bonus so
1118/// a heavily-returned memory can't keep accumulating weight past a
1119/// plateau. At 5 uses the bonus is ~76% of its asymptote; at 10, ~96%.
1120/// * **Recency is decoupled from the feedback loop.** Age is measured
1121/// from `created_at`, not `accessed_at`, so a `recall()` call does not
1122/// bump its own results' recency scores on later queries.
1123///
1124/// The output of this scorer is on a **different scale** from the
1125/// pre-v0.7.0 formula. Callers that compared raw scores to a fixed
1126/// threshold should re-pick the threshold against the new scale.
1127#[derive(Clone, Copy, Debug)]
1128pub struct ScoreWeights {
1129 /// Weight on semantic similarity. Kept in the struct for calibration
1130 /// hooks and symmetry with the other weights; scoring v2 always uses
1131 /// it as `1.0` because the leading `similarity` term is ungated and
1132 /// the whole score sits on the cosine-similarity scale by design.
1133 pub alpha: f32,
1134 /// Weight on the recency bonus (multiplied by `max(sim, 0)`).
1135 pub beta: f32,
1136 /// Weight on the importance bonus `(importance − 0.5)`, centered at
1137 /// the default-importance 0.5 (multiplied by `max(sim, 0)`).
1138 pub gamma: f32,
1139 /// Weight on the saturating usage bonus `tanh(access_count / 5)`
1140 /// (multiplied by `max(sim, 0)`).
1141 pub delta: f32,
1142 /// Half-life of the recency term, in seconds.
1143 pub half_life_secs: f32,
1144}
1145
1146impl Default for ScoreWeights {
1147 fn default() -> Self {
1148 // Tuned on the Phase 6 corpus + the ranking_v2 age-spread
1149 // fixture; see `ranking_v2.checks` in perf_v0.5.0.json.
1150 Self {
1151 alpha: 1.0,
1152 beta: 0.3,
1153 gamma: 0.2,
1154 delta: 0.1,
1155 half_life_secs: 7.0 * 24.0 * 3600.0,
1156 }
1157 }
1158}
1159
1160/// Saturation constant K for the usage term `tanh(access_count / K)`.
1161/// K = 5 means ~76% of the asymptote at 5 uses and ~96% at 10 — matches
1162/// the "a few repeats is signal; past that it's a habit" intuition.
1163const USAGE_SAT_K: f32 = 5.0;
1164
1165impl ScoreWeights {
1166 /// Combine the four signals into a single score. See the struct docs
1167 /// for the full formula and the scoring-v2 rationale.
1168 pub fn score(&self, similarity: f32, age_secs: f32, importance: f32, access_count: u32) -> f32 {
1169 // Recency is in [0, 1]: 1.0 at age 0, 0.5 at one half-life, etc.
1170 let recency = (-std::f32::consts::LN_2 * age_secs.max(0.0) / self.half_life_secs).exp();
1171 // Saturating usage in [0, 1); tanh caps feedback-loop drift.
1172 let usage = (access_count as f32 / USAGE_SAT_K).tanh();
1173 // Gate the non-similarity bonuses by `max(sim, 0)` so a
1174 // negative-cosine candidate (should be rare after the metric
1175 // layer, but possible with `Metric::Dot` or short-text L2)
1176 // can't collect positive bonuses, and a zero-similarity
1177 // candidate collects no bonus either.
1178 let gate = similarity.max(0.0);
1179 self.alpha * similarity
1180 + gate * (self.beta * recency + self.gamma * (importance - 0.5) + self.delta * usage)
1181 }
1182}