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}