Skip to main content

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    /// New in v0.4.15 — **domain-scoped** recall (anti
94    /// vector-search-dilution; MASDR-RAG, arXiv:2606.11350). Restricts
95    /// the candidate set to a metadata-defined sub-corpus *before* the
96    /// dense similarity step, then runs a single vector pass — so at
97    /// scale, off-domain-but-semantically-similar records cannot dilute
98    /// the top-k. The predicate rides on
99    /// [`RecallRequest.domain_scope`][crate::query::recall::RecallRequest::domain_scope]
100    /// (a [`DomainScope`]); selecting this mode without a predicate
101    /// degrades gracefully to a plain vector pass.
102    DomainScoped,
103    /// New in v0.5.1 — **active reconstruction** (MRAgent,
104    /// arXiv:2606.06036). Retrieves candidate memories for the cue, walks
105    /// the existing memory-graph edges to gather linked/causal context,
106    /// and synthesises a deterministic *belief-state* summary node that
107    /// the caller receives ALONGSIDE the raw hits in
108    /// [`RecallResponse.reconstruction`][crate::query::recall::RecallResponse::reconstruction].
109    /// Additive: the `memories` top-k is exactly what the default hybrid
110    /// (`auto`) path returns, so the raw read path is unchanged — this is
111    /// an option to A/B reconstruction vs. plain retrieval, not a
112    /// replacement for it.
113    Reconstruct,
114}
115
116impl RetrievalMode {
117    /// Map the typed variant back to the legacy strategy string the
118    /// engine dispatcher understands. `HarnessAware` delegates to
119    /// `"auto"` (HybridRrf) for the underlying retrieval; the envelope
120    /// adapter handles the post-processing separately.
121    pub fn to_strategy_str(&self) -> &'static str {
122        match self {
123            Self::VectorOnly => "semantic",
124            Self::Bm25Only => "lexical",
125            Self::HybridRrf | Self::HarnessAware { .. } => "auto",
126            Self::Graph => "graph",
127            Self::DomainScoped => "domain_scoped",
128            Self::Reconstruct => "reconstruct",
129        }
130    }
131
132    /// Optional envelope adapter for `HarnessAware`; returns `None`
133    /// for every other variant. Each adapter is a unit struct (or
134    /// a small config struct); call
135    /// [`HarnessEnvelope::shape`] to render the envelope string.
136    pub fn envelope_adapter(&self) -> Option<Box<dyn HarnessEnvelope>> {
137        let Self::HarnessAware { harness, format } = self else {
138            return None;
139        };
140        Some(adapter_for(*harness, format.clone()))
141    }
142}
143
144/// Metadata predicate that defines a recall **sub-corpus** for
145/// [`RetrievalMode::DomainScoped`] (MASDR-RAG, arXiv:2606.11350).
146///
147/// A record is *in domain* iff it matches **every** populated field
148/// (logical AND); empty fields are ignored. `org_id` matches the record's
149/// tenant; `namespace` matches either a record tag or
150/// `metadata["namespace"]`; `doc_class` matches `metadata["doc_class"]`;
151/// `tags` requires the record to carry **all** listed tags. An entirely
152/// empty scope ([`DomainScope::is_empty`]) imposes no restriction.
153#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154pub struct DomainScope {
155    /// Restrict to a single tenant / organization.
156    pub org_id: Option<String>,
157    /// Restrict to a namespace — matched against the record's tags or
158    /// its `metadata["namespace"]` value.
159    pub namespace: Option<String>,
160    /// Restrict to a document class — matched against the record's
161    /// `metadata["doc_class"]` value.
162    pub doc_class: Option<String>,
163    /// Require the record to carry all of these tags.
164    pub tags: Option<Vec<String>>,
165}
166
167impl DomainScope {
168    /// `true` when no predicate field is set (imposes no restriction).
169    pub fn is_empty(&self) -> bool {
170        self.org_id.is_none()
171            && self.namespace.is_none()
172            && self.doc_class.is_none()
173            && self.tags.as_ref().map(|t| t.is_empty()).unwrap_or(true)
174    }
175
176    /// Whether `record` belongs to this sub-corpus (logical AND over the
177    /// populated fields).
178    pub fn matches(&self, record: &crate::model::memory::MemoryRecord) -> bool {
179        if let Some(ref org) = self.org_id
180            && record.org_id.as_deref() != Some(org.as_str())
181        {
182            return false;
183        }
184        if let Some(ref ns) = self.namespace {
185            let tag_hit = record.tags.iter().any(|t| t == ns);
186            let meta_hit = record
187                .metadata
188                .get("namespace")
189                .and_then(|v| v.as_str())
190                .map(|v| v == ns)
191                .unwrap_or(false);
192            if !tag_hit && !meta_hit {
193                return false;
194            }
195        }
196        if let Some(ref dc) = self.doc_class {
197            let meta_hit = record
198                .metadata
199                .get("doc_class")
200                .and_then(|v| v.as_str())
201                .map(|v| v == dc)
202                .unwrap_or(false);
203            if !meta_hit {
204                return false;
205            }
206        }
207        if let Some(ref tags) = self.tags
208            && !tags.iter().all(|t| record.tags.contains(t))
209        {
210            return false;
211        }
212        true
213    }
214}
215
216/// Which agent harness the response envelope should be shaped for.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "snake_case")]
219pub enum HarnessKind {
220    ClaudeCode,
221    Codex,
222    GeminiCli,
223    Chronos,
224    Generic,
225}
226
227/// Where the envelope payload lives — inline in the response, written
228/// to a file the harness reads via a side-channel pointer, or written
229/// to a side-channel out-of-band stream.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum EnvelopeFormat {
233    Inline,
234    FileBased { path_root: PathBuf },
235    SideChannel,
236}
237
238/// Trait implemented by each per-harness adapter. The contract is
239/// minimal: take a slice of typed [`ScoredMemory`] hits and return a
240/// rendered string envelope shaped for the harness.
241pub trait HarnessEnvelope {
242    fn shape(&self, hits: &[ScoredMemory]) -> String;
243}
244
245fn adapter_for(kind: HarnessKind, format: EnvelopeFormat) -> Box<dyn HarnessEnvelope> {
246    match kind {
247        HarnessKind::ClaudeCode => Box::new(ClaudeCodeEnvelope {
248            inline: matches!(format, EnvelopeFormat::Inline),
249        }),
250        HarnessKind::Codex => Box::new(CodexEnvelope {
251            file_based: matches!(format, EnvelopeFormat::FileBased { .. }),
252        }),
253        HarnessKind::GeminiCli => Box::new(GeminiCliEnvelope),
254        HarnessKind::Chronos => Box::new(ChronosEnvelope),
255        HarnessKind::Generic => Box::new(GenericEnvelope),
256    }
257}
258
259/// Claude Code envelope — fenced markdown blocks with `recall://<id>`
260/// anchors for inline; line-numbered file-pointer summary for the
261/// non-inline branch.
262#[derive(Debug, Clone, Copy)]
263pub struct ClaudeCodeEnvelope {
264    pub inline: bool,
265}
266
267impl HarnessEnvelope for ClaudeCodeEnvelope {
268    fn shape(&self, hits: &[ScoredMemory]) -> String {
269        let mut out = String::new();
270        out.push_str("# mnemo.recall (Claude Code envelope)\n\n");
271        for (i, m) in hits.iter().enumerate() {
272            if self.inline {
273                out.push_str(&format!(
274                    "## hit {} (recall://{} • score {:.3})\n```\n{}\n```\n\n",
275                    i + 1,
276                    m.id,
277                    m.score,
278                    m.content
279                ));
280            } else {
281                let first_line = m.content.lines().next().unwrap_or("").trim();
282                out.push_str(&format!(
283                    "- hit {} → `recall://{}` (score {:.3}): {}\n",
284                    i + 1,
285                    m.id,
286                    m.score,
287                    first_line
288                ));
289            }
290        }
291        out
292    }
293}
294
295/// Codex envelope — file-based by default (writes hits to a path-root
296/// the caller chose), with an inline JSON pointer summary in the
297/// response. The Inline branch keeps the raw content in the response.
298#[derive(Debug, Clone, Copy)]
299pub struct CodexEnvelope {
300    pub file_based: bool,
301}
302
303impl HarnessEnvelope for CodexEnvelope {
304    fn shape(&self, hits: &[ScoredMemory]) -> String {
305        if self.file_based {
306            let pointers: Vec<String> = hits
307                .iter()
308                .map(|m| format!("{{\"id\":\"{}\",\"score\":{:.3}}}", m.id, m.score))
309                .collect();
310            format!(
311                "{{\"envelope\":\"codex_file_based\",\"hits\":[{}]}}",
312                pointers.join(",")
313            )
314        } else {
315            let blocks: Vec<String> = hits
316                .iter()
317                .map(|m| {
318                    format!(
319                        "{{\"id\":\"{}\",\"score\":{:.3},\"content\":{}}}",
320                        m.id,
321                        m.score,
322                        serde_json::to_string(&m.content).unwrap_or_default()
323                    )
324                })
325                .collect();
326            format!(
327                "{{\"envelope\":\"codex_inline\",\"hits\":[{}]}}",
328                blocks.join(",")
329            )
330        }
331    }
332}
333
334/// Gemini CLI envelope — plain numbered list with `[N]` markers + the
335/// hit content; tool-call-style framing the Gemini CLI surfaces well.
336#[derive(Debug, Clone, Copy)]
337pub struct GeminiCliEnvelope;
338
339impl HarnessEnvelope for GeminiCliEnvelope {
340    fn shape(&self, hits: &[ScoredMemory]) -> String {
341        let mut out = String::new();
342        out.push_str("mnemo recall (Gemini CLI envelope)\n");
343        for (i, m) in hits.iter().enumerate() {
344            out.push_str(&format!(
345                "[{}] score={:.3} id={} — {}\n",
346                i + 1,
347                m.score,
348                m.id,
349                m.content
350            ));
351        }
352        out
353    }
354}
355
356/// Chronos envelope — timeline-shaped: one line per hit with the hit
357/// `id`, score, and the first line of content. Chronos prefers
358/// temporally-anchored single-line summaries.
359#[derive(Debug, Clone, Copy)]
360pub struct ChronosEnvelope;
361
362impl HarnessEnvelope for ChronosEnvelope {
363    fn shape(&self, hits: &[ScoredMemory]) -> String {
364        let mut out = String::new();
365        out.push_str("chronos recall envelope\n");
366        for m in hits {
367            let first_line = m.content.lines().next().unwrap_or("").trim();
368            out.push_str(&format!("t={:.3} id={} :: {}\n", m.score, m.id, first_line));
369        }
370        out
371    }
372}
373
374/// Generic envelope — minimal `id\tscore\tcontent` TSV one line per
375/// hit. The fallback when no harness-specific adapter applies.
376#[derive(Debug, Clone, Copy)]
377pub struct GenericEnvelope;
378
379impl HarnessEnvelope for GenericEnvelope {
380    fn shape(&self, hits: &[ScoredMemory]) -> String {
381        let mut out = String::new();
382        for m in hits {
383            // TSV-safe: replace tabs/newlines in content so the
384            // generic envelope stays parseable.
385            let content_safe = m.content.replace(['\t', '\n', '\r'], " ");
386            out.push_str(&format!("{}\t{:.3}\t{}\n", m.id, m.score, content_safe));
387        }
388        out
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::model::memory::{MemoryType, Scope};
396    use uuid::Uuid;
397
398    fn make_hit(content: &str, score: f32) -> ScoredMemory {
399        ScoredMemory {
400            id: Uuid::now_v7(),
401            content: content.to_string(),
402            agent_id: "test-agent".to_string(),
403            memory_type: MemoryType::Episodic,
404            scope: Scope::Private,
405            importance: 0.5,
406            tags: vec![],
407            metadata: serde_json::Value::Null,
408            score,
409            access_count: 0,
410            created_at: "2026-05-17T00:00:00Z".to_string(),
411            updated_at: "2026-05-17T00:00:00Z".to_string(),
412            score_breakdown: None,
413        }
414    }
415
416    #[test]
417    fn retrieval_mode_round_trip_strategy_string() {
418        assert_eq!(RetrievalMode::VectorOnly.to_strategy_str(), "semantic");
419        assert_eq!(RetrievalMode::Bm25Only.to_strategy_str(), "lexical");
420        assert_eq!(RetrievalMode::HybridRrf.to_strategy_str(), "auto");
421        assert_eq!(RetrievalMode::Graph.to_strategy_str(), "graph");
422        assert_eq!(
423            RetrievalMode::DomainScoped.to_strategy_str(),
424            "domain_scoped"
425        );
426        assert_eq!(RetrievalMode::Reconstruct.to_strategy_str(), "reconstruct");
427        let harness = RetrievalMode::HarnessAware {
428            harness: HarnessKind::ClaudeCode,
429            format: EnvelopeFormat::Inline,
430        };
431        // HarnessAware delegates to "auto" for the underlying
432        // retrieval — the adapter handles envelope post-processing.
433        assert_eq!(harness.to_strategy_str(), "auto");
434    }
435
436    fn rec(
437        org: Option<&str>,
438        tags: &[&str],
439        metadata: serde_json::Value,
440    ) -> crate::model::memory::MemoryRecord {
441        use crate::model::memory::{ConsolidationState, SourceType};
442        crate::model::memory::MemoryRecord {
443            id: Uuid::now_v7(),
444            agent_id: "a".to_string(),
445            content: "c".to_string(),
446            memory_type: MemoryType::Episodic,
447            scope: Scope::Private,
448            importance: 0.5,
449            tags: tags.iter().map(|t| t.to_string()).collect(),
450            metadata,
451            embedding: None,
452            content_hash: vec![],
453            prev_hash: None,
454            source_type: SourceType::Agent,
455            source_id: None,
456            consolidation_state: ConsolidationState::Raw,
457            access_count: 0,
458            org_id: org.map(str::to_string),
459            thread_id: None,
460            created_at: "2026-06-13T00:00:00Z".to_string(),
461            updated_at: "2026-06-13T00:00:00Z".to_string(),
462            last_accessed_at: None,
463            expires_at: None,
464            deleted_at: None,
465            decay_rate: None,
466            created_by: None,
467            version: 1,
468            prev_version_id: None,
469            quarantined: false,
470            quarantine_reason: None,
471            decay_function: None,
472        }
473    }
474
475    #[test]
476    fn domain_scope_matches_logical_and() {
477        // Empty scope matches everything.
478        let empty = DomainScope::default();
479        assert!(empty.is_empty());
480        assert!(empty.matches(&rec(Some("alpha"), &[], serde_json::Value::Null)));
481
482        // org_id predicate.
483        let by_org = DomainScope {
484            org_id: Some("alpha".to_string()),
485            ..Default::default()
486        };
487        assert!(by_org.matches(&rec(Some("alpha"), &[], serde_json::Value::Null)));
488        assert!(!by_org.matches(&rec(Some("beta"), &[], serde_json::Value::Null)));
489
490        // namespace via tag OR metadata.
491        let by_ns = DomainScope {
492            namespace: Some("legal".to_string()),
493            ..Default::default()
494        };
495        assert!(by_ns.matches(&rec(None, &["legal"], serde_json::Value::Null)));
496        assert!(by_ns.matches(&rec(None, &[], serde_json::json!({"namespace": "legal"}))));
497        assert!(!by_ns.matches(&rec(None, &["hr"], serde_json::json!({"namespace": "hr"}))));
498
499        // doc_class via metadata; AND with org.
500        let combo = DomainScope {
501            org_id: Some("alpha".to_string()),
502            doc_class: Some("contract".to_string()),
503            ..Default::default()
504        };
505        assert!(combo.matches(&rec(
506            Some("alpha"),
507            &[],
508            serde_json::json!({"doc_class": "contract"})
509        )));
510        // right doc_class, wrong org → rejected (AND).
511        assert!(!combo.matches(&rec(
512            Some("beta"),
513            &[],
514            serde_json::json!({"doc_class": "contract"})
515        )));
516        // right org, wrong doc_class → rejected.
517        assert!(!combo.matches(&rec(
518            Some("alpha"),
519            &[],
520            serde_json::json!({"doc_class": "memo"})
521        )));
522    }
523
524    #[test]
525    fn retrieval_mode_serde_round_trip() {
526        for mode in [
527            RetrievalMode::VectorOnly,
528            RetrievalMode::Bm25Only,
529            RetrievalMode::HybridRrf,
530            RetrievalMode::Graph,
531            RetrievalMode::DomainScoped,
532            RetrievalMode::Reconstruct,
533            RetrievalMode::HarnessAware {
534                harness: HarnessKind::ClaudeCode,
535                format: EnvelopeFormat::Inline,
536            },
537            RetrievalMode::HarnessAware {
538                harness: HarnessKind::Codex,
539                format: EnvelopeFormat::FileBased {
540                    path_root: PathBuf::from("/tmp/codex"),
541                },
542            },
543            RetrievalMode::HarnessAware {
544                harness: HarnessKind::Generic,
545                format: EnvelopeFormat::SideChannel,
546            },
547        ] {
548            let s = serde_json::to_string(&mode).unwrap();
549            let back: RetrievalMode = serde_json::from_str(&s).unwrap();
550            assert_eq!(mode, back, "round-trip failed for {mode:?} via {s}");
551        }
552    }
553
554    #[test]
555    fn harness_aware_returns_envelope_adapter() {
556        let mode = RetrievalMode::HarnessAware {
557            harness: HarnessKind::ClaudeCode,
558            format: EnvelopeFormat::Inline,
559        };
560        assert!(mode.envelope_adapter().is_some());
561        assert!(RetrievalMode::HybridRrf.envelope_adapter().is_none());
562    }
563
564    #[test]
565    fn five_adapters_produce_distinct_envelope_shapes() {
566        let hits = vec![
567            make_hit("first hit content line\nsecond line", 0.91),
568            make_hit("another hit", 0.42),
569        ];
570        let cc = ClaudeCodeEnvelope { inline: true }.shape(&hits);
571        let codex = CodexEnvelope { file_based: true }.shape(&hits);
572        let gemini = GeminiCliEnvelope.shape(&hits);
573        let chronos = ChronosEnvelope.shape(&hits);
574        let generic = GenericEnvelope.shape(&hits);
575        // Each adapter must produce a distinct shape — the whole
576        // point of HarnessAware is per-harness reshaping.
577        let shapes = [&cc, &codex, &gemini, &chronos, &generic];
578        for (i, a) in shapes.iter().enumerate() {
579            for (j, b) in shapes.iter().enumerate() {
580                if i != j {
581                    assert_ne!(
582                        a, b,
583                        "adapter shapes {} and {} collided (both produced:\n{a})",
584                        i, j
585                    );
586                }
587            }
588        }
589    }
590
591    #[test]
592    fn claude_code_envelope_inline_vs_non_inline_differ() {
593        let hits = vec![make_hit("hello world", 0.5)];
594        let inline = ClaudeCodeEnvelope { inline: true }.shape(&hits);
595        let non_inline = ClaudeCodeEnvelope { inline: false }.shape(&hits);
596        assert!(inline.contains("```"), "inline must contain fenced block");
597        assert!(
598            !non_inline.contains("```"),
599            "non-inline must not contain fenced block"
600        );
601    }
602
603    #[test]
604    fn generic_envelope_is_tsv_safe() {
605        let hits = vec![make_hit("has\ttab\nand newline", 0.5)];
606        let env = GenericEnvelope.shape(&hits);
607        // Exactly one record line — the inner \t and \n in content
608        // must have been replaced with spaces.
609        assert_eq!(env.lines().count(), 1);
610        let parts: Vec<&str> = env.trim_end().split('\t').collect();
611        assert_eq!(
612            parts.len(),
613            3,
614            "TSV envelope must have id\\tscore\\tcontent"
615        );
616    }
617}