Skip to main content

mnemo_core/query/
retained.rs

1//! Budgeted evidence retention for recall (EMBER, arXiv:2606.05894).
2//!
3//! The default recall path returns whole memory records. For an LLM
4//! caller that must keep evidence *resident* in a bounded context
5//! window, dumping raw chunks burns the budget fast: a handful of full
6//! records and the window is full, even though most of each record is
7//! filler around one salient fact.
8//!
9//! EMBER (*Efficient Memory By Evidence Retention*, arXiv:2606.05894)
10//! frames this as a **writer** problem: under a fixed retained-token
11//! budget, learn to keep compact, *recoverable* evidence rather than raw
12//! text. This module is a **v0 stand-in** for that learned writer:
13//!
14//! - Instead of dumping raw chunks, it emits verbatim **evidence
15//!   capsules** — a short verbatim excerpt of the record plus a
16//!   **retrieval key** (the record id) so the caller can re-fetch the
17//!   full chunk on demand. A capsule costs a fraction of the raw chunk,
18//!   so many more distinct facts survive the same budget.
19//! - Capsules are packed greedily under the budget, ranked by a simple
20//!   **recoverability heuristic** — `recency × retrieval-hit-rate` —
21//!   the v0 stand-in for EMBER's learned retention score. Recently
22//!   written and frequently-retrieved records are the cheapest to keep
23//!   resident because they are the most likely to be needed again.
24//!
25//! # Wiring
26//!
27//! [`RecallRequest::retained_token_budget`](crate::query::recall::RecallRequest::retained_token_budget)
28//! carries the optional cap. When `Some(budget)`, the engine builds a
29//! [`RetentionReport`] from the final recall result and returns it in
30//! [`RecallResponse::retained_evidence`](crate::query::recall::RecallResponse::retained_evidence).
31//! It is purely **additive**: the `memories` list is unchanged, so every
32//! existing caller is unaffected; the capsule view is an extra,
33//! opt-in projection of the same hits.
34//!
35//! # What this is NOT
36//!
37//! - **Not EMBER's learned writer.** The recoverability score is a
38//!   hand-rolled `recency × hit-rate` heuristic, not a trained model.
39//!   The arXiv:2606.05894 reference is a framing anchor, not a
40//!   reproduction claim.
41//! - **Not lossy on the store.** Capsules are a *read-side* projection;
42//!   nothing is mutated or deleted. The retrieval key recovers the full
43//!   record verbatim from the backend.
44//! - **Not a tokenizer.** Token counts are the standard `ceil(chars/4)`
45//!   rough-cut estimate; absolute counts are approximate, the
46//!   budget-vs-naive *comparison* is the signal.
47
48use serde::{Deserialize, Serialize};
49use uuid::Uuid;
50
51/// Recency half-life (hours) for the recoverability score. Matches the
52/// engine's 1-week recency default elsewhere.
53const RECENCY_HALF_LIFE_HOURS: f64 = 168.0;
54/// Laplace-style smoothing for the hit-rate term so a never-accessed
55/// (but freshly written) record still scores non-zero.
56const HIT_RATE_SMOOTH: f64 = 5.0;
57/// Default verbatim excerpt cap per capsule, in estimated tokens.
58pub const DEFAULT_EXCERPT_TOKENS: usize = 64;
59/// A capsule below this excerpt size carries too little verbatim
60/// evidence to be worth a slot; once the remaining budget cannot fund
61/// it, packing stops.
62const MIN_EXCERPT_TOKENS: usize = 16;
63
64/// Rough-cut token estimate: `ceil(chars / 4)`. There is no tokenizer
65/// in the core, so this is a deliberate approximation (see the
66/// module-level "what this is NOT").
67pub fn est_tokens(text: &str) -> usize {
68    text.chars().count().div_ceil(4)
69}
70
71/// Truncate `text` to at most `max_chars` characters on a char
72/// boundary (never splits a multi-byte scalar).
73fn truncate_chars(text: &str, max_chars: usize) -> String {
74    text.chars().take(max_chars).collect()
75}
76
77/// `recency × retrieval-hit-rate` — the v0 recoverability stand-in for
78/// EMBER's learned retention score.
79///
80/// - **recency** decays with a 1-week half-life: `0.5^(age / 168h)`,
81///   in `(0, 1]`.
82/// - **hit-rate** is the smoothed access frequency
83///   `(access + 1) / (access + 1 + 5)`, in `(0, 1)` — monotonically
84///   increasing in `access_count`, and non-zero even at `access = 0`.
85pub fn recoverability(age_hours: f64, access_count: u64) -> f32 {
86    let recency = 0.5f64.powf(age_hours.max(0.0) / RECENCY_HALF_LIFE_HOURS);
87    let ac = access_count as f64;
88    let hit_rate = (ac + 1.0) / (ac + 1.0 + HIT_RATE_SMOOTH);
89    (recency * hit_rate) as f32
90}
91
92/// One candidate record handed to [`retain_within_budget`].
93#[derive(Debug, Clone)]
94pub struct RetentionCandidate<'a> {
95    /// Record id — becomes the capsule's retrieval key.
96    pub id: Uuid,
97    /// Verbatim record content.
98    pub content: &'a str,
99    /// How many times the record has been retrieved (hit-rate signal).
100    pub access_count: u64,
101    /// Age of the record in hours (recency signal).
102    pub age_hours: f64,
103    /// Fused retrieval score — tie-breaker only.
104    pub retrieval_score: f32,
105}
106
107/// A verbatim evidence capsule: a short excerpt plus the retrieval key
108/// that recovers the full record.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct EvidenceCapsule {
111    /// Record id (also the retrieval key, parsed).
112    pub id: Uuid,
113    /// Retrieval key the caller uses to re-fetch the full record.
114    pub retrieval_key: String,
115    /// Verbatim prefix of the record content (never paraphrased).
116    pub excerpt: String,
117    /// Estimated tokens this capsule costs (excerpt + key).
118    pub tokens: usize,
119    /// The `recency × hit-rate` score this capsule was ranked by.
120    pub recoverability: f32,
121}
122
123/// Diagnostics + capsules produced under a retained-token budget.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RetentionReport {
126    /// The cap the caller requested.
127    pub budget_tokens: usize,
128    /// Estimated tokens actually retained across all capsules.
129    pub retained_tokens: usize,
130    /// The packed capsules, in recoverability order.
131    pub capsules: Vec<EvidenceCapsule>,
132    /// How many candidate records were considered.
133    pub candidates_examined: usize,
134    /// Candidates that did not fit the budget.
135    pub dropped: usize,
136}
137
138/// Pack the highest-recoverability evidence into `budget_tokens`,
139/// preferring compact capsules over raw chunk dumps.
140///
141/// Candidates are ranked by [`recoverability`] (ties broken by
142/// retrieval score, then input order) and greedily packed: each capsule
143/// reserves its retrieval-key tokens and fills the rest with a verbatim
144/// excerpt of up to `excerpt_tokens`. Packing stops once the remaining
145/// budget cannot fund another minimally-useful capsule. The function is
146/// pure — it never mutates or reorders the caller's records.
147pub fn retain_within_budget(
148    candidates: &[RetentionCandidate<'_>],
149    budget_tokens: usize,
150    excerpt_tokens: usize,
151) -> RetentionReport {
152    let mut order: Vec<usize> = (0..candidates.len()).collect();
153    order.sort_by(|&a, &b| {
154        let ra = recoverability(candidates[a].age_hours, candidates[a].access_count);
155        let rb = recoverability(candidates[b].age_hours, candidates[b].access_count);
156        rb.partial_cmp(&ra)
157            .unwrap_or(std::cmp::Ordering::Equal)
158            .then(
159                candidates[b]
160                    .retrieval_score
161                    .partial_cmp(&candidates[a].retrieval_score)
162                    .unwrap_or(std::cmp::Ordering::Equal),
163            )
164            .then(a.cmp(&b))
165    });
166
167    let mut remaining = budget_tokens;
168    let mut capsules = Vec::new();
169    let mut retained_tokens = 0usize;
170
171    for &i in &order {
172        let c = &candidates[i];
173        let key = c.id.to_string();
174        let key_tokens = est_tokens(&key);
175        // All keys are uniform-length UUIDs, so once a minimally-useful
176        // capsule no longer fits, none of the remaining ones will.
177        if remaining < key_tokens + MIN_EXCERPT_TOKENS {
178            break;
179        }
180        let excerpt_budget = (remaining - key_tokens).min(excerpt_tokens);
181        let excerpt = truncate_chars(c.content, excerpt_budget * 4);
182        let excerpt_cost = est_tokens(&excerpt);
183        let cost = excerpt_cost + key_tokens;
184        if cost > remaining {
185            continue;
186        }
187        remaining -= cost;
188        retained_tokens += cost;
189        capsules.push(EvidenceCapsule {
190            id: c.id,
191            retrieval_key: key,
192            excerpt,
193            tokens: cost,
194            recoverability: recoverability(c.age_hours, c.access_count),
195        });
196    }
197
198    RetentionReport {
199        budget_tokens,
200        retained_tokens,
201        dropped: candidates.len().saturating_sub(capsules.len()),
202        candidates_examined: candidates.len(),
203        capsules,
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn cand<'a>(id: Uuid, content: &'a str, access: u64, age_hours: f64) -> RetentionCandidate<'a> {
212        RetentionCandidate {
213            id,
214            content,
215            access_count: access,
216            age_hours,
217            retrieval_score: 1.0,
218        }
219    }
220
221    #[test]
222    fn est_tokens_is_ceil_div_four() {
223        assert_eq!(est_tokens(""), 0);
224        assert_eq!(est_tokens("abcd"), 1);
225        assert_eq!(est_tokens("abcde"), 2);
226    }
227
228    #[test]
229    fn recoverability_rewards_recent_and_frequent() {
230        // Fresh + frequently hit beats stale + never hit.
231        let fresh_hot = recoverability(1.0, 50);
232        let stale_cold = recoverability(720.0, 0);
233        assert!(fresh_hot > stale_cold);
234        // Monotonic in access_count at fixed age.
235        assert!(recoverability(10.0, 20) > recoverability(10.0, 1));
236        // Monotonic (decreasing) in age at fixed hits.
237        assert!(recoverability(1.0, 10) > recoverability(500.0, 10));
238        // Non-zero even when never accessed.
239        assert!(recoverability(0.0, 0) > 0.0);
240    }
241
242    #[test]
243    fn packing_never_exceeds_budget() {
244        let ids: Vec<Uuid> = (0..20).map(|_| Uuid::now_v7()).collect();
245        let body = "the salient fact is alpha-bravo-charlie ".repeat(20);
246        let cands: Vec<RetentionCandidate> = ids
247            .iter()
248            .enumerate()
249            .map(|(i, id)| cand(*id, &body, i as u64, i as f64))
250            .collect();
251        let report = retain_within_budget(&cands, 512, DEFAULT_EXCERPT_TOKENS);
252        assert!(report.retained_tokens <= report.budget_tokens);
253        assert_eq!(report.candidates_examined, 20);
254        assert_eq!(report.capsules.len() + report.dropped, 20);
255        // Capsules are emitted in non-increasing recoverability order.
256        for w in report.capsules.windows(2) {
257            assert!(w[0].recoverability >= w[1].recoverability);
258        }
259    }
260
261    #[test]
262    fn capsules_beat_raw_dumps_on_count_under_budget() {
263        // Each record is a large raw chunk; the salient fact is at the
264        // front. Capsules keep only the front excerpt + key, so far more
265        // distinct records survive the same budget than raw dumps do.
266        let ids: Vec<Uuid> = (0..40).map(|_| Uuid::now_v7()).collect();
267        let chunk = format!("FACT {} ", "x".repeat(600));
268        let cands: Vec<RetentionCandidate> =
269            ids.iter().map(|id| cand(*id, &chunk, 1, 1.0)).collect();
270        let budget = 2048;
271        let report = retain_within_budget(&cands, budget, DEFAULT_EXCERPT_TOKENS);
272
273        // Naive truncation: pack whole raw chunks until the budget runs
274        // out. chunk ~= est_tokens(600+ chars) ~= 152 tokens.
275        let chunk_tokens = est_tokens(&chunk);
276        let naive_fit = budget / chunk_tokens;
277        assert!(
278            report.capsules.len() > naive_fit,
279            "capsules {} should beat naive {} under {budget} tokens",
280            report.capsules.len(),
281            naive_fit
282        );
283        // And every retained capsule still carries the verbatim "FACT"
284        // prefix (recoverable evidence, not a paraphrase).
285        assert!(
286            report
287                .capsules
288                .iter()
289                .all(|c| c.excerpt.starts_with("FACT"))
290        );
291    }
292
293    #[test]
294    fn zero_budget_keeps_nothing() {
295        let id = Uuid::now_v7();
296        let report =
297            retain_within_budget(&[cand(id, "anything", 1, 1.0)], 0, DEFAULT_EXCERPT_TOKENS);
298        assert!(report.capsules.is_empty());
299        assert_eq!(report.dropped, 1);
300        assert_eq!(report.retained_tokens, 0);
301    }
302}