Skip to main content

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}