mnemo_core/retrieval.rs
1//! v0.4.4 — `RetrievalMode` typed enum + 5 starter `HarnessAware`
2//! adapters.
3//!
4//! # What this module is
5//!
6//! A typed superset of the existing
7//! [`RecallRequest::strategy: Option<String>`][crate::query::recall::RecallRequest]
8//! field, plus a new `HarnessAware` variant that lets the recall
9//! response envelope be reshaped per agent harness (Claude Code,
10//! Codex, Gemini CLI, Chronos, generic) per the framing in arXiv
11//! 2605.15184: *"overall scores still depend strongly on which
12//! harness and tool-calling style is used, even when the underlying
13//! conversation data are the same."*
14//!
15//! # Backwards-compatible introduction
16//!
17//! [`RecallRequest`][crate::query::recall::RecallRequest] gains an
18//! optional `mode: Option<RetrievalMode>` field in this release. The
19//! legacy `strategy: Option<String>` field stays in place; if `mode`
20//! is set it takes precedence, otherwise the engine continues to
21//! parse `strategy` exactly as before. Existing SDK callers
22//! (Python `mnemo-db`, TypeScript `@mndfreek/mnemo-sdk`, Go
23//! `mnemo.Recall`) continue to work unchanged because they all
24//! marshal through the string-typed field.
25//!
26//! # `HarnessAware` semantics
27//!
28//! `HarnessAware { harness, format }` does NOT change which records
29//! are retrieved — under the hood it delegates to the default
30//! `HybridRrf` retrieval path. What it changes is how the
31//! [`crate::query::recall::ScoredMemory`] hits are *shaped* into a
32//! string envelope that a specific agent harness prefers (inline
33//! fenced blocks, file-based side-channel pointers with line
34//! numbers, generic line-numbered list, …). The
35//! [`HarnessEnvelope::shape`] method returns the rendered envelope
36//! string; the recall response continues to carry the typed
37//! `ScoredMemory` hits so downstream consumers that want the typed
38//! payload are not blocked.
39//!
40//! # Not in scope for v0.4.4
41//!
42//! - **No SDK ripple.** The Python / TypeScript / Go SDKs are NOT
43//! updated in this release. They continue to use the string-typed
44//! `strategy` field. SDK migration to a typed `mode` field is a
45//! follow-up tracked separately.
46//! - **No REST / gRPC / pgwire schema bump.** The new `mode` field
47//! serialises through the same `RecallRequest` Serde definition;
48//! inbound JSON that omits `mode` continues to work.
49//! - **No envelope-trait stabilisation.** The
50//! [`HarnessEnvelope`] trait + the five adapter structs are
51//! intentionally minimal — each adapter produces a deterministic
52//! string with the shape the corresponding harness expects, but
53//! the *contents* of those strings are not a stability surface in
54//! v0.4.4. Operators relying on a specific envelope shape should
55//! pin the mnemo minor version.
56
57use std::path::PathBuf;
58
59use serde::{Deserialize, Serialize};
60
61use crate::query::recall::ScoredMemory;
62
63/// Typed recall strategy. Superset of the legacy
64/// `RecallRequest.strategy: Option<String>` API — the variant ↔ string
65/// mapping is documented on each variant.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum RetrievalMode {
69 /// Maps to legacy `strategy = "semantic"` — vector-only path.
70 VectorOnly,
71 /// Maps to legacy `strategy = "lexical"` — Tantivy BM25-only
72 /// path.
73 Bm25Only,
74 /// Maps to legacy `strategy = "auto"` — default RRF fusion across
75 /// vector + BM25 + recency + decay. Weight overrides continue to
76 /// be carried on [`RecallRequest.hybrid_weights`][crate::query::recall::RecallRequest::hybrid_weights]
77 /// and [`RecallRequest.rrf_k`][crate::query::recall::RecallRequest::rrf_k]
78 /// to keep wire compatibility with v0.4.3 SDK clients.
79 HybridRrf,
80 /// Maps to legacy `strategy = "graph"` — vector-seeded +
81 /// graph-expanded path.
82 Graph,
83 /// New in v0.4.4 — harness-aware envelope reshaping. Inside the
84 /// recall path this delegates to [`RetrievalMode::HybridRrf`];
85 /// the difference is post-processing: a
86 /// [`HarnessEnvelope`] adapter renders the typed
87 /// [`ScoredMemory`] hits into a string envelope shaped for the
88 /// nominated agent harness.
89 HarnessAware {
90 harness: HarnessKind,
91 format: EnvelopeFormat,
92 },
93}
94
95impl RetrievalMode {
96 /// Map the typed variant back to the legacy strategy string the
97 /// engine dispatcher understands. `HarnessAware` delegates to
98 /// `"auto"` (HybridRrf) for the underlying retrieval; the envelope
99 /// adapter handles the post-processing separately.
100 pub fn to_strategy_str(&self) -> &'static str {
101 match self {
102 Self::VectorOnly => "semantic",
103 Self::Bm25Only => "lexical",
104 Self::HybridRrf | Self::HarnessAware { .. } => "auto",
105 Self::Graph => "graph",
106 }
107 }
108
109 /// Optional envelope adapter for `HarnessAware`; returns `None`
110 /// for every other variant. Each adapter is a unit struct (or
111 /// a small config struct); call
112 /// [`HarnessEnvelope::shape`] to render the envelope string.
113 pub fn envelope_adapter(&self) -> Option<Box<dyn HarnessEnvelope>> {
114 let Self::HarnessAware { harness, format } = self else {
115 return None;
116 };
117 Some(adapter_for(*harness, format.clone()))
118 }
119}
120
121/// Which agent harness the response envelope should be shaped for.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum HarnessKind {
125 ClaudeCode,
126 Codex,
127 GeminiCli,
128 Chronos,
129 Generic,
130}
131
132/// Where the envelope payload lives — inline in the response, written
133/// to a file the harness reads via a side-channel pointer, or written
134/// to a side-channel out-of-band stream.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum EnvelopeFormat {
138 Inline,
139 FileBased { path_root: PathBuf },
140 SideChannel,
141}
142
143/// Trait implemented by each per-harness adapter. The contract is
144/// minimal: take a slice of typed [`ScoredMemory`] hits and return a
145/// rendered string envelope shaped for the harness.
146pub trait HarnessEnvelope {
147 fn shape(&self, hits: &[ScoredMemory]) -> String;
148}
149
150fn adapter_for(kind: HarnessKind, format: EnvelopeFormat) -> Box<dyn HarnessEnvelope> {
151 match kind {
152 HarnessKind::ClaudeCode => Box::new(ClaudeCodeEnvelope {
153 inline: matches!(format, EnvelopeFormat::Inline),
154 }),
155 HarnessKind::Codex => Box::new(CodexEnvelope {
156 file_based: matches!(format, EnvelopeFormat::FileBased { .. }),
157 }),
158 HarnessKind::GeminiCli => Box::new(GeminiCliEnvelope),
159 HarnessKind::Chronos => Box::new(ChronosEnvelope),
160 HarnessKind::Generic => Box::new(GenericEnvelope),
161 }
162}
163
164/// Claude Code envelope — fenced markdown blocks with `recall://<id>`
165/// anchors for inline; line-numbered file-pointer summary for the
166/// non-inline branch.
167#[derive(Debug, Clone, Copy)]
168pub struct ClaudeCodeEnvelope {
169 pub inline: bool,
170}
171
172impl HarnessEnvelope for ClaudeCodeEnvelope {
173 fn shape(&self, hits: &[ScoredMemory]) -> String {
174 let mut out = String::new();
175 out.push_str("# mnemo.recall (Claude Code envelope)\n\n");
176 for (i, m) in hits.iter().enumerate() {
177 if self.inline {
178 out.push_str(&format!(
179 "## hit {} (recall://{} • score {:.3})\n```\n{}\n```\n\n",
180 i + 1,
181 m.id,
182 m.score,
183 m.content
184 ));
185 } else {
186 let first_line = m.content.lines().next().unwrap_or("").trim();
187 out.push_str(&format!(
188 "- hit {} → `recall://{}` (score {:.3}): {}\n",
189 i + 1,
190 m.id,
191 m.score,
192 first_line
193 ));
194 }
195 }
196 out
197 }
198}
199
200/// Codex envelope — file-based by default (writes hits to a path-root
201/// the caller chose), with an inline JSON pointer summary in the
202/// response. The Inline branch keeps the raw content in the response.
203#[derive(Debug, Clone, Copy)]
204pub struct CodexEnvelope {
205 pub file_based: bool,
206}
207
208impl HarnessEnvelope for CodexEnvelope {
209 fn shape(&self, hits: &[ScoredMemory]) -> String {
210 if self.file_based {
211 let pointers: Vec<String> = hits
212 .iter()
213 .map(|m| format!("{{\"id\":\"{}\",\"score\":{:.3}}}", m.id, m.score))
214 .collect();
215 format!(
216 "{{\"envelope\":\"codex_file_based\",\"hits\":[{}]}}",
217 pointers.join(",")
218 )
219 } else {
220 let blocks: Vec<String> = hits
221 .iter()
222 .map(|m| {
223 format!(
224 "{{\"id\":\"{}\",\"score\":{:.3},\"content\":{}}}",
225 m.id,
226 m.score,
227 serde_json::to_string(&m.content).unwrap_or_default()
228 )
229 })
230 .collect();
231 format!(
232 "{{\"envelope\":\"codex_inline\",\"hits\":[{}]}}",
233 blocks.join(",")
234 )
235 }
236 }
237}
238
239/// Gemini CLI envelope — plain numbered list with `[N]` markers + the
240/// hit content; tool-call-style framing the Gemini CLI surfaces well.
241#[derive(Debug, Clone, Copy)]
242pub struct GeminiCliEnvelope;
243
244impl HarnessEnvelope for GeminiCliEnvelope {
245 fn shape(&self, hits: &[ScoredMemory]) -> String {
246 let mut out = String::new();
247 out.push_str("mnemo recall (Gemini CLI envelope)\n");
248 for (i, m) in hits.iter().enumerate() {
249 out.push_str(&format!(
250 "[{}] score={:.3} id={} — {}\n",
251 i + 1,
252 m.score,
253 m.id,
254 m.content
255 ));
256 }
257 out
258 }
259}
260
261/// Chronos envelope — timeline-shaped: one line per hit with the hit
262/// `id`, score, and the first line of content. Chronos prefers
263/// temporally-anchored single-line summaries.
264#[derive(Debug, Clone, Copy)]
265pub struct ChronosEnvelope;
266
267impl HarnessEnvelope for ChronosEnvelope {
268 fn shape(&self, hits: &[ScoredMemory]) -> String {
269 let mut out = String::new();
270 out.push_str("chronos recall envelope\n");
271 for m in hits {
272 let first_line = m.content.lines().next().unwrap_or("").trim();
273 out.push_str(&format!("t={:.3} id={} :: {}\n", m.score, m.id, first_line));
274 }
275 out
276 }
277}
278
279/// Generic envelope — minimal `id\tscore\tcontent` TSV one line per
280/// hit. The fallback when no harness-specific adapter applies.
281#[derive(Debug, Clone, Copy)]
282pub struct GenericEnvelope;
283
284impl HarnessEnvelope for GenericEnvelope {
285 fn shape(&self, hits: &[ScoredMemory]) -> String {
286 let mut out = String::new();
287 for m in hits {
288 // TSV-safe: replace tabs/newlines in content so the
289 // generic envelope stays parseable.
290 let content_safe = m.content.replace(['\t', '\n', '\r'], " ");
291 out.push_str(&format!("{}\t{:.3}\t{}\n", m.id, m.score, content_safe));
292 }
293 out
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::model::memory::{MemoryType, Scope};
301 use uuid::Uuid;
302
303 fn make_hit(content: &str, score: f32) -> ScoredMemory {
304 ScoredMemory {
305 id: Uuid::now_v7(),
306 content: content.to_string(),
307 agent_id: "test-agent".to_string(),
308 memory_type: MemoryType::Episodic,
309 scope: Scope::Private,
310 importance: 0.5,
311 tags: vec![],
312 metadata: serde_json::Value::Null,
313 score,
314 access_count: 0,
315 created_at: "2026-05-17T00:00:00Z".to_string(),
316 updated_at: "2026-05-17T00:00:00Z".to_string(),
317 score_breakdown: None,
318 }
319 }
320
321 #[test]
322 fn retrieval_mode_round_trip_strategy_string() {
323 assert_eq!(RetrievalMode::VectorOnly.to_strategy_str(), "semantic");
324 assert_eq!(RetrievalMode::Bm25Only.to_strategy_str(), "lexical");
325 assert_eq!(RetrievalMode::HybridRrf.to_strategy_str(), "auto");
326 assert_eq!(RetrievalMode::Graph.to_strategy_str(), "graph");
327 let harness = RetrievalMode::HarnessAware {
328 harness: HarnessKind::ClaudeCode,
329 format: EnvelopeFormat::Inline,
330 };
331 // HarnessAware delegates to "auto" for the underlying
332 // retrieval — the adapter handles envelope post-processing.
333 assert_eq!(harness.to_strategy_str(), "auto");
334 }
335
336 #[test]
337 fn retrieval_mode_serde_round_trip() {
338 for mode in [
339 RetrievalMode::VectorOnly,
340 RetrievalMode::Bm25Only,
341 RetrievalMode::HybridRrf,
342 RetrievalMode::Graph,
343 RetrievalMode::HarnessAware {
344 harness: HarnessKind::ClaudeCode,
345 format: EnvelopeFormat::Inline,
346 },
347 RetrievalMode::HarnessAware {
348 harness: HarnessKind::Codex,
349 format: EnvelopeFormat::FileBased {
350 path_root: PathBuf::from("/tmp/codex"),
351 },
352 },
353 RetrievalMode::HarnessAware {
354 harness: HarnessKind::Generic,
355 format: EnvelopeFormat::SideChannel,
356 },
357 ] {
358 let s = serde_json::to_string(&mode).unwrap();
359 let back: RetrievalMode = serde_json::from_str(&s).unwrap();
360 assert_eq!(mode, back, "round-trip failed for {mode:?} via {s}");
361 }
362 }
363
364 #[test]
365 fn harness_aware_returns_envelope_adapter() {
366 let mode = RetrievalMode::HarnessAware {
367 harness: HarnessKind::ClaudeCode,
368 format: EnvelopeFormat::Inline,
369 };
370 assert!(mode.envelope_adapter().is_some());
371 assert!(RetrievalMode::HybridRrf.envelope_adapter().is_none());
372 }
373
374 #[test]
375 fn five_adapters_produce_distinct_envelope_shapes() {
376 let hits = vec![
377 make_hit("first hit content line\nsecond line", 0.91),
378 make_hit("another hit", 0.42),
379 ];
380 let cc = ClaudeCodeEnvelope { inline: true }.shape(&hits);
381 let codex = CodexEnvelope { file_based: true }.shape(&hits);
382 let gemini = GeminiCliEnvelope.shape(&hits);
383 let chronos = ChronosEnvelope.shape(&hits);
384 let generic = GenericEnvelope.shape(&hits);
385 // Each adapter must produce a distinct shape — the whole
386 // point of HarnessAware is per-harness reshaping.
387 let shapes = [&cc, &codex, &gemini, &chronos, &generic];
388 for (i, a) in shapes.iter().enumerate() {
389 for (j, b) in shapes.iter().enumerate() {
390 if i != j {
391 assert_ne!(
392 a, b,
393 "adapter shapes {} and {} collided (both produced:\n{a})",
394 i, j
395 );
396 }
397 }
398 }
399 }
400
401 #[test]
402 fn claude_code_envelope_inline_vs_non_inline_differ() {
403 let hits = vec![make_hit("hello world", 0.5)];
404 let inline = ClaudeCodeEnvelope { inline: true }.shape(&hits);
405 let non_inline = ClaudeCodeEnvelope { inline: false }.shape(&hits);
406 assert!(inline.contains("```"), "inline must contain fenced block");
407 assert!(
408 !non_inline.contains("```"),
409 "non-inline must not contain fenced block"
410 );
411 }
412
413 #[test]
414 fn generic_envelope_is_tsv_safe() {
415 let hits = vec![make_hit("has\ttab\nand newline", 0.5)];
416 let env = GenericEnvelope.shape(&hits);
417 // Exactly one record line — the inner \t and \n in content
418 // must have been replaced with spaces.
419 assert_eq!(env.lines().count(), 1);
420 let parts: Vec<&str> = env.trim_end().split('\t').collect();
421 assert_eq!(
422 parts.len(),
423 3,
424 "TSV envelope must have id\\tscore\\tcontent"
425 );
426 }
427}