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/// Patch body for [`crate::Mnemo::update`] (Phase 12.1): every field is
268/// optional, and only the `Some(...)` fields overwrite the corresponding
269/// field on the target memory. All fields can be set independently.
270///
271/// `content` paired with `vector` updates both at once — the engine does
272/// not re-embed under `update` (use `update_text` for that). A `content`
273/// change without a new `vector` is rejected to prevent the embedding
274/// drifting silently out of sync with the text.
275///
276/// `metadata` **replaces** the full user-metadata map — the engine's own
277/// `_mnemo.*` reserved keys on the target record are preserved across
278/// the replacement (updated-at timestamp, supersession markers, etc.).
279/// Passing an empty map drops every user-visible metadata key but keeps
280/// the reserved ones.
281///
282/// `ttl_secs` is `Option<Option<i64>>` so a caller can distinguish
283/// "leave it alone" (`None`) from "set it to a value" (`Some(Some(s))`)
284/// and "clear it" (`Some(None)`).
285#[derive(Debug, Clone, Default)]
286pub struct MemoryPatch {
287    /// New text content. If set, a matching `vector` must also be set
288    /// (or call `update_text`, which re-embeds through the registered
289    /// embedder).
290    pub content: Option<String>,
291    /// New embedding. Must match the database's dimensionality.
292    pub vector: Option<Vec<f32>>,
293    /// New importance in `[0, 1]`.
294    pub importance: Option<f32>,
295    /// Replace the memory type.
296    pub memory_type: Option<MemoryType>,
297    /// Replace the user-visible metadata (preserves `_mnemo.*` reserved
298    /// keys).
299    pub metadata: Option<Map<String, Value>>,
300    /// Change the TTL. Outer `None` → leave unchanged; `Some(Some(s))` →
301    /// set TTL to `s`; `Some(None)` → clear any existing TTL.
302    pub ttl_secs: Option<Option<i64>>,
303}
304
305/// Scope selector for a keyed slot (Phase 12.1.1). A keyed
306/// `remember`'s slot is always identified by `(scope_owner, key)`:
307/// `Private(agent_id)` slots are per-agent, `Shared` slots are per-file.
308/// `history_in(key, scope)` uses the same selector so the lookup matches
309/// the write exactly.
310///
311/// The convenience [`crate::Mnemo::history`] is equivalent to
312/// `history_in(key, KeyScope::Private(agent.unwrap_or("default")))` —
313/// i.e. `history(key, None)` resolves to the default agent's private
314/// slot, matching the `remember(…, agent_id=None, scope=Private, key=K)`
315/// write path. Use [`crate::Mnemo::history_in`] with
316/// [`KeyScope::Shared`] when you need the shared slot.
317#[derive(Debug, Clone, PartialEq, Eq, Hash)]
318pub enum KeyScope {
319    /// Per-agent slot. The agent id is the one carried on `Memory.agent_id`
320    /// at write time; `remember` with no explicit agent uses `"default"`.
321    Private(String),
322    /// Per-file slot shared across every agent.
323    Shared,
324}
325
326impl KeyScope {
327    /// Default agent id used when `Memory::new(...)` is called without
328    /// `.with_agent(...)` — same `"default"` the [`Memory::new`]
329    /// constructor seeds into `Memory.agent_id`.
330    pub const DEFAULT_AGENT: &'static str = "default";
331
332    /// Build the private slot for the default agent. Shorthand for
333    /// `KeyScope::Private(KeyScope::DEFAULT_AGENT.to_string())`.
334    pub fn private_default() -> Self {
335        KeyScope::Private(Self::DEFAULT_AGENT.to_string())
336    }
337}
338
339/// Outcome of a 12.1 write operation (`remember` with `key`, or `update`).
340///
341/// Plain `remember` without a key always returns a ULID, not a
342/// `WriteOutcome`, because supersession and NOOP detection are tied to
343/// the key slot.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum WriteOutcome {
346    /// Keyed write with no prior record for this slot. A brand-new record
347    /// was inserted, no record was superseded.
348    Added,
349    /// Keyed write whose content / type / importance / user metadata
350    /// differs from the live record for this slot. A new record was
351    /// inserted and the prior one was marked
352    /// `_mnemo.superseded_by` / `_mnemo.invalid_at`.
353    Superseded,
354    /// No-op: the incoming keyed write is byte-equal to the live record
355    /// for this slot (content, memory type, importance, user metadata).
356    /// No record was written and no catalog page was dirtied. The
357    /// returned id is the existing record's id.
358    Unchanged,
359    /// In-place `update`: fields changed on the target record. The record
360    /// was rewritten copy-on-write so the previous version remains
361    /// visible through snapshots.
362    Updated,
363}
364
365// ---------------------------------------------------------------------------
366// Phase 12.6: recall filters (session_id + per-metadata-key conditions)
367// ---------------------------------------------------------------------------
368
369/// One AND-ed condition applied to a candidate memory's metadata during
370/// filtered recall (Phase 12.6). Terms are combined conjunctively — a
371/// memory passes the filter only if *every* term evaluates true against
372/// its `metadata` map. See [`Filter`] for the surface API.
373///
374/// Values use `serde_json::Value` so the same term shape serializes
375/// cleanly through the MCP / Python / CLI bridges without a parallel
376/// enum per surface.
377#[derive(Debug, Clone)]
378pub enum FilterTerm {
379    /// The memory's `metadata[key]` must be set to any value.
380    Exists(String),
381    /// `metadata[key]` must equal this JSON value (strict JSON equality:
382    /// strings compare case-sensitively, numbers compare as f64).
383    Eq(String, Value),
384    /// `metadata[key]` must equal one of these JSON values.
385    In(String, Vec<Value>),
386    /// `metadata[key]` must be a number in `[min, max]` (inclusive on
387    /// both ends; `None` on either bound means "no bound on that side").
388    /// A non-numeric metadata value fails the term.
389    Range {
390        /// Metadata key.
391        key: String,
392        /// Lower bound (inclusive) — `None` means unbounded below.
393        min: Option<f64>,
394        /// Upper bound (inclusive) — `None` means unbounded above.
395        max: Option<f64>,
396    },
397}
398
399/// Pre-scoring filter applied by [`crate::Mnemo::recall`] (Phase 12.6).
400///
401/// `session_id` restricts candidates to a single session (when the
402/// memory's `session_id` is set). The `terms` are AND-ed metadata
403/// conditions evaluated against each candidate's `metadata` map; see
404/// [`FilterTerm`] for the shapes.
405///
406/// When an ANN index is present, the filter is applied *after* the ANN
407/// shortlist step, so a selective filter can starve the result below
408/// `top_k`. Recall handles that automatically via the Phase 12.1
409/// short-results-on-indexed-path fix (widen 2×, then exact scan).
410#[derive(Debug, Clone, Default)]
411pub struct Filter {
412    /// Only candidates whose `Memory.session_id` equals this value.
413    /// Candidates without a session_id are excluded when this is set.
414    pub session_id: Option<String>,
415    /// AND-ed metadata conditions. Empty → no metadata constraints.
416    pub terms: Vec<FilterTerm>,
417}
418
419impl Filter {
420    /// Builder helper: a filter that keeps only memories in `session`.
421    pub fn new_session(session: impl Into<String>) -> Self {
422        Self {
423            session_id: Some(session.into()),
424            terms: Vec::new(),
425        }
426    }
427
428    /// Restrict to one session_id (builder form).
429    pub fn with_session(mut self, session: impl Into<String>) -> Self {
430        self.session_id = Some(session.into());
431        self
432    }
433
434    /// Require `metadata[key]` to exist (any value).
435    pub fn metadata_exists(mut self, key: impl Into<String>) -> Self {
436        self.terms.push(FilterTerm::Exists(key.into()));
437        self
438    }
439
440    /// Require `metadata[key]` to equal `value`.
441    pub fn metadata_eq(mut self, key: impl Into<String>, value: Value) -> Self {
442        self.terms.push(FilterTerm::Eq(key.into(), value));
443        self
444    }
445
446    /// Require `metadata[key]` to be one of `values`.
447    pub fn metadata_in(mut self, key: impl Into<String>, values: Vec<Value>) -> Self {
448        self.terms.push(FilterTerm::In(key.into(), values));
449        self
450    }
451
452    /// Require `metadata[key]` to be a number in `[min, max]`.
453    pub fn metadata_range(
454        mut self,
455        key: impl Into<String>,
456        min: Option<f64>,
457        max: Option<f64>,
458    ) -> Self {
459        self.terms.push(FilterTerm::Range {
460            key: key.into(),
461            min,
462            max,
463        });
464        self
465    }
466
467    /// `true` iff the filter has no terms and no `session_id`. The
468    /// recall hot path uses this to skip the per-candidate filter
469    /// check entirely.
470    pub fn is_empty(&self) -> bool {
471        self.session_id.is_none() && self.terms.is_empty()
472    }
473
474    /// Evaluate the filter against one memory. Returns `true` if the
475    /// memory passes every term.
476    pub fn matches(&self, m: &Memory) -> bool {
477        if let Some(s) = &self.session_id {
478            match &m.session_id {
479                Some(ms) if ms == s => {}
480                _ => return false,
481            }
482        }
483        for term in &self.terms {
484            if !term_matches(term, &m.metadata) {
485                return false;
486            }
487        }
488        true
489    }
490}
491
492fn term_matches(term: &FilterTerm, metadata: &Map<String, Value>) -> bool {
493    match term {
494        FilterTerm::Exists(key) => metadata.contains_key(key),
495        FilterTerm::Eq(key, v) => metadata.get(key).is_some_and(|got| values_equal(got, v)),
496        FilterTerm::In(key, options) => metadata
497            .get(key)
498            .is_some_and(|got| options.iter().any(|opt| values_equal(got, opt))),
499        FilterTerm::Range { key, min, max } => metadata
500            .get(key)
501            .and_then(|v| v.as_f64())
502            .is_some_and(|n| min.map_or(true, |m_| n >= m_) && max.map_or(true, |m_| n <= m_)),
503    }
504}
505
506/// JSON value equality for filter terms. serde_json's `Value: PartialEq`
507/// compares numbers by exact representation (integer vs float). The
508/// filter surface should treat `5` and `5.0` as equal, so we compare
509/// numbers through `as_f64` when both sides are numbers; everything
510/// else falls back to structural equality.
511fn values_equal(a: &Value, b: &Value) -> bool {
512    match (a, b) {
513        (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
514            (Some(xf), Some(yf)) => (xf - yf).abs() < f64::EPSILON,
515            _ => x == y,
516        },
517        (x, y) => x == y,
518    }
519}
520
521/// Distance/similarity metric for vector comparison.
522#[derive(Clone, Copy, Debug, PartialEq, Eq)]
523pub enum Metric {
524    /// Cosine similarity (orientation only).
525    Cosine,
526    /// Negative Euclidean distance, mapped into a `(0,1]` similarity.
527    L2,
528    /// Raw dot product.
529    Dot,
530}
531
532/// Compute a *similarity* (higher = more relevant) under the given metric.
533pub fn similarity(metric: Metric, a: &[f32], b: &[f32]) -> f32 {
534    match metric {
535        Metric::Cosine => cosine(a, b),
536        Metric::Dot => dot(a, b),
537        Metric::L2 => {
538            let d = euclidean(a, b);
539            1.0 / (1.0 + d)
540        }
541    }
542}
543
544fn dot(a: &[f32], b: &[f32]) -> f32 {
545    a.iter().zip(b).map(|(x, y)| x * y).sum()
546}
547
548/// Cosine similarity in `[-1.0, 1.0]`; returns `0.0` if either vector
549/// has zero magnitude. `pub(crate)` so [`crate::Mnemo::cache_get_semantic`]
550/// (Phase 10.2) can reuse the same scoring routine that recall uses,
551/// keeping the two paths semantically identical.
552pub(crate) fn cosine(a: &[f32], b: &[f32]) -> f32 {
553    let na = dot(a, a).sqrt();
554    let nb = dot(b, b).sqrt();
555    if na == 0.0 || nb == 0.0 {
556        0.0
557    } else {
558        dot(a, b) / (na * nb)
559    }
560}
561
562fn euclidean(a: &[f32], b: &[f32]) -> f32 {
563    a.iter()
564        .zip(b)
565        .map(|(x, y)| (x - y) * (x - y))
566        .sum::<f32>()
567        .sqrt()
568}
569
570/// Weights for the multi-signal recall score (scoring v2, shipped in
571/// v0.7.0).
572///
573/// ```text
574/// score = similarity
575///       + max(similarity, 0)
576///         · ( β · recency
577///           + γ · (importance − 0.5)
578///           + δ · tanh(access_count / 5) )
579///
580/// recency = exp( -ln(2) · age_secs / half_life_secs )
581/// age_secs = now - memory.created_at
582/// ```
583///
584/// Design notes (from the 2026-11-06 scoring-v2 bake-off; see the
585/// `ranking_v2` harness at `mnemo/examples/ranking_v2.rs`):
586/// * **Similarity-gated.** The recency / importance / usage terms are
587///   multiplied by `max(similarity, 0)`, so a stale-but-semantically-off
588///   memory can't ride recency or importance past a semantically-on-point
589///   one. Pre-v0.7.0 scoring was purely additive.
590/// * **Importance is centered at 0.5.** A default-importance memory
591///   contributes zero importance bonus; importance only pushes a score up
592///   or down from that reference point.
593/// * **Usage is saturating.** `tanh(uses/5)` caps the feedback bonus so
594///   a heavily-returned memory can't keep accumulating weight past a
595///   plateau. At 5 uses the bonus is ~76% of its asymptote; at 10, ~96%.
596/// * **Recency is decoupled from the feedback loop.** Age is measured
597///   from `created_at`, not `accessed_at`, so a `recall()` call does not
598///   bump its own results' recency scores on later queries.
599///
600/// The output of this scorer is on a **different scale** from the
601/// pre-v0.7.0 formula. Callers that compared raw scores to a fixed
602/// threshold should re-pick the threshold against the new scale.
603#[derive(Clone, Copy, Debug)]
604pub struct ScoreWeights {
605    /// Weight on semantic similarity. Kept in the struct for calibration
606    /// hooks and symmetry with the other weights; scoring v2 always uses
607    /// it as `1.0` because the leading `similarity` term is ungated and
608    /// the whole score sits on the cosine-similarity scale by design.
609    pub alpha: f32,
610    /// Weight on the recency bonus (multiplied by `max(sim, 0)`).
611    pub beta: f32,
612    /// Weight on the importance bonus `(importance − 0.5)`, centered at
613    /// the default-importance 0.5 (multiplied by `max(sim, 0)`).
614    pub gamma: f32,
615    /// Weight on the saturating usage bonus `tanh(access_count / 5)`
616    /// (multiplied by `max(sim, 0)`).
617    pub delta: f32,
618    /// Half-life of the recency term, in seconds.
619    pub half_life_secs: f32,
620}
621
622impl Default for ScoreWeights {
623    fn default() -> Self {
624        // Tuned on the Phase 6 corpus + the ranking_v2 age-spread
625        // fixture; see `ranking_v2.checks` in perf_v0.5.0.json.
626        Self {
627            alpha: 1.0,
628            beta: 0.3,
629            gamma: 0.2,
630            delta: 0.1,
631            half_life_secs: 7.0 * 24.0 * 3600.0,
632        }
633    }
634}
635
636/// Saturation constant K for the usage term `tanh(access_count / K)`.
637/// K = 5 means ~76% of the asymptote at 5 uses and ~96% at 10 — matches
638/// the "a few repeats is signal; past that it's a habit" intuition.
639const USAGE_SAT_K: f32 = 5.0;
640
641impl ScoreWeights {
642    /// Combine the four signals into a single score. See the struct docs
643    /// for the full formula and the scoring-v2 rationale.
644    pub fn score(&self, similarity: f32, age_secs: f32, importance: f32, access_count: u32) -> f32 {
645        // Recency is in [0, 1]: 1.0 at age 0, 0.5 at one half-life, etc.
646        let recency = (-std::f32::consts::LN_2 * age_secs.max(0.0) / self.half_life_secs).exp();
647        // Saturating usage in [0, 1); tanh caps feedback-loop drift.
648        let usage = (access_count as f32 / USAGE_SAT_K).tanh();
649        // Gate the non-similarity bonuses by `max(sim, 0)` so a
650        // negative-cosine candidate (should be rare after the metric
651        // layer, but possible with `Metric::Dot` or short-text L2)
652        // can't collect positive bonuses, and a zero-similarity
653        // candidate collects no bonus either.
654        let gate = similarity.max(0.0);
655        self.alpha * similarity
656            + gate * (self.beta * recency + self.gamma * (importance - 0.5) + self.delta * usage)
657    }
658}