Skip to main content

mw_memory/
lib.rs

1//! `mw-memory` — explainable retrieval for MemoryWhale.
2//!
3//! The differentiator: retrieval that returns not just *which* memories, but
4//! *why* — a blended score over interpretable signals (similarity, recency,
5//! importance, reinforcement, task-relevance), each with a human-readable reason.
6//!
7//! Everything is behind a [`engine::MemoryEngine`] interface that MemoryWhale
8//! owns, so the storage/retrieval backend is pluggable: the built-in scorer by
9//! default, or — with the off-by-default `mempalace` feature — an
10//! `engine::MemPalaceEngine` that talks to a local `mempalace-mcp` server over
11//! MCP. Callers never change.
12
13pub mod embed;
14pub mod engine;
15#[cfg(feature = "mempalace")]
16mod mcp;
17pub mod scorer;
18pub mod sqlite;
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22
23/// A single memory item. (The built-in engine scores over these; the MemPalace
24/// adapter maps its hits into the same shape.)
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Memory {
27    pub id: i64,
28    pub text: String,
29    pub created_at: DateTime<Utc>,
30    /// When this memory was last retrieved/used — drives recency.
31    pub last_used: DateTime<Utc>,
32    /// How many times it has been reinforced (mentioned/used).
33    pub mentions: u32,
34    /// Stored importance weight in [0,1].
35    pub importance: f32,
36    /// Links / entities / repo associations (used for task relevance and graph).
37    pub tags: Vec<String>,
38    /// Optional precomputed embedding. When present (and the query is embedded),
39    /// similarity is semantic (cosine); otherwise it falls back to lexical.
40    #[serde(default)]
41    pub embedding: Option<Vec<f32>>,
42}
43
44/// A retrieval request plus the context that makes scoring explainable.
45#[derive(Debug, Clone)]
46pub struct Query {
47    pub text: String,
48    /// "Now" is passed in (not read from the clock) so scoring is deterministic
49    /// and unit-testable.
50    pub now: DateTime<Utc>,
51    /// Current task context (e.g. repo/file/topic) for task-relevance.
52    pub task_tags: Vec<String>,
53    /// Recency half-life in days (score halves every `half_life_days`).
54    pub half_life_days: f32,
55}
56
57impl Query {
58    pub fn new(text: impl Into<String>, now: DateTime<Utc>) -> Self {
59        Self {
60            text: text.into(),
61            now,
62            task_tags: Vec::new(),
63            half_life_days: 14.0,
64        }
65    }
66
67    pub fn with_task(mut self, tags: Vec<String>) -> Self {
68        self.task_tags = tags;
69        self
70    }
71}
72
73/// How much each signal counts toward the blended score.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Weights {
76    pub similarity: f32,
77    pub recency: f32,
78    pub importance: f32,
79    pub reinforcement: f32,
80    pub task: f32,
81}
82
83impl Default for Weights {
84    fn default() -> Self {
85        Self {
86            similarity: 0.40,
87            recency: 0.20,
88            importance: 0.15,
89            reinforcement: 0.10,
90            task: 0.15,
91        }
92    }
93}
94
95/// One interpretable scoring signal and its contribution.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Signal {
98    pub name: String,
99    pub weight: f32,
100    /// Raw signal strength in [0,1].
101    pub score: f32,
102    /// Whether this signal applies (e.g. task-relevance is inert with no task).
103    pub applicable: bool,
104    /// Human-readable explanation, e.g. "mentioned 27×" or "last used today".
105    pub detail: String,
106}
107
108impl Signal {
109    pub fn contribution(&self) -> f32 {
110        if self.applicable {
111            self.weight * self.score
112        } else {
113            0.0
114        }
115    }
116}
117
118/// A memory with its blended score and the signals that produced it.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ScoredMemory {
121    pub memory: Memory,
122    /// Blended relevance in [0,1].
123    pub score: f32,
124    pub signals: Vec<Signal>,
125}
126
127impl ScoredMemory {
128    pub fn percent(&self) -> u32 {
129        (self.score * 100.0).round().clamp(0.0, 100.0) as u32
130    }
131
132    /// Top human-readable reasons, strongest contribution first.
133    pub fn reasons(&self) -> Vec<String> {
134        let mut active: Vec<&Signal> = self
135            .signals
136            .iter()
137            .filter(|s| s.applicable && s.score >= 0.15)
138            .collect();
139        active.sort_by(|a, b| {
140            b.contribution()
141                .partial_cmp(&a.contribution())
142                .unwrap_or(std::cmp::Ordering::Equal)
143        });
144        active.into_iter().map(|s| s.detail.clone()).collect()
145    }
146
147    /// A full, inspectable breakdown — the `memory explain <id>` view.
148    pub fn explain(&self) -> String {
149        let m = &self.memory;
150        let mut out = String::new();
151        out.push_str(&format!(
152            "memory explain {}\n  \"{}\"\n",
153            m.id,
154            truncate(&m.text, 72)
155        ));
156        out.push_str(&format!(
157            "  created {} · last used {} · mentioned {}× · importance {:.2}\n",
158            m.created_at.date_naive(),
159            m.last_used.date_naive(),
160            m.mentions,
161            m.importance
162        ));
163        if !m.tags.is_empty() {
164            out.push_str(&format!("  links: {}\n", m.tags.join(", ")));
165        }
166        out.push_str(&format!("  score {}%  =\n", self.percent()));
167        for s in &self.signals {
168            let mark = if s.applicable { " " } else { "·" };
169            out.push_str(&format!(
170                "   {} {:<13} w{:.2} × {:.2} = {:+.3}   {}\n",
171                mark,
172                s.name,
173                s.weight,
174                s.score,
175                s.contribution(),
176                s.detail
177            ));
178        }
179        out
180    }
181}
182
183pub(crate) fn truncate(s: &str, max: usize) -> String {
184    let s = s.trim();
185    if s.chars().count() <= max {
186        s.to_string()
187    } else {
188        let cut: String = s.chars().take(max).collect();
189        format!("{cut}…")
190    }
191}