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// ---------------------------------------------------------------------------
217// Forged-reasoning defense (v0.5.17) — reasoning-provenance trust filter.
218//
219// Threat: an attacker plants a fabricated chain-of-thought / justification into
220// a memory entry so later retrieval treats a lie as "already-reasoned truth".
221// This is distinct from content poisoning — the *content* may look plausible;
222// what is forged is the entry's *reasoning provenance*. Defense: record whether
223// the stored reasoning was model-authored (trusted) vs injected/unverified, and
224// let recall exclude (or down-weight) entries whose reasoning trace fails the
225// check. Reuses `MemoryRecord.metadata` (as `DomainScope` reuses
226// `metadata["doc_class"]`) — no schema migration — and composes with any
227// retrieval strategy via the shared recall post-filter.
228// ---------------------------------------------------------------------------
229
230/// Who actually produced a memory entry's stored *reasoning* / justification —
231/// the signal a forged-reasoning attack spoofs.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum ReasoningAuthorship {
235    /// Produced by the agent's own model at write time.
236    ModelAuthored,
237    /// Supplied directly by a human user.
238    UserProvided,
239    /// Produced by a verified/trusted tool.
240    ToolVerified,
241    /// Arrived via an indirect-ingest / untrusted path yet presented as if
242    /// already reasoned — the forged-reasoning threat.
243    Injected,
244    /// No authorship signal present or unparseable — the **fail-closed**
245    /// default (a memory that never declared how its reasoning was produced
246    /// cannot be trusted as "already reasoned").
247    Unverified,
248}
249
250impl ReasoningAuthorship {
251    pub fn as_str(&self) -> &'static str {
252        match self {
253            Self::ModelAuthored => "model_authored",
254            Self::UserProvided => "user_provided",
255            Self::ToolVerified => "tool_verified",
256            Self::Injected => "injected",
257            Self::Unverified => "unverified",
258        }
259    }
260}
261
262/// Per-entry reasoning provenance, carried in
263/// `MemoryRecord.metadata["reasoning_provenance"]`.
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265pub struct ReasoningProvenance {
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub source: Option<String>,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub written_at: Option<String>,
270    pub authorship: ReasoningAuthorship,
271}
272
273impl ReasoningProvenance {
274    /// Metadata key under which the provenance rides.
275    pub const METADATA_KEY: &'static str = "reasoning_provenance";
276
277    /// Convenience constructor for a model-authored (trusted) reasoning trace.
278    pub fn model_authored(source: impl Into<String>) -> Self {
279        Self {
280            source: Some(source.into()),
281            written_at: None,
282            authorship: ReasoningAuthorship::ModelAuthored,
283        }
284    }
285
286    /// Convenience constructor for an injected (forged) reasoning trace.
287    pub fn injected(source: impl Into<String>) -> Self {
288        Self {
289            source: Some(source.into()),
290            written_at: None,
291            authorship: ReasoningAuthorship::Injected,
292        }
293    }
294
295    /// Parse provenance from a record's `metadata`. **Fail-closed:** absent or
296    /// unparseable → [`ReasoningAuthorship::Unverified`].
297    pub fn from_metadata(metadata: &serde_json::Value) -> Self {
298        metadata
299            .get(Self::METADATA_KEY)
300            .and_then(|v| serde_json::from_value::<ReasoningProvenance>(v.clone()).ok())
301            .unwrap_or(Self {
302                source: None,
303                written_at: None,
304                authorship: ReasoningAuthorship::Unverified,
305            })
306    }
307
308    /// Parse provenance from a [`MemoryRecord`].
309    pub fn from_record(record: &crate::model::memory::MemoryRecord) -> Self {
310        Self::from_metadata(&record.metadata)
311    }
312
313    /// Write this provenance into a metadata object (for writers / benches).
314    pub fn attach(&self, metadata: &mut serde_json::Value) {
315        if !metadata.is_object() {
316            *metadata = serde_json::json!({});
317        }
318        if let Ok(v) = serde_json::to_value(self) {
319            metadata[Self::METADATA_KEY] = v;
320        }
321    }
322}
323
324/// What to do with an entry whose reasoning provenance fails the trust check.
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "snake_case")]
327pub enum ReasoningTrustAction {
328    /// Exclude the entry from recall results entirely (read-time quarantine).
329    /// This is the action the engine read path enforces in `passes_filters`.
330    Quarantine,
331    /// Keep the entry but multiply its score by `down_weight_factor`, applied
332    /// by callers via [`ReasoningTrustPolicy::rerank`] on the result set.
333    DownWeight,
334}
335
336fn default_down_weight() -> f32 {
337    0.1
338}
339
340/// Opt-in read-side defense against forged-reasoning memory injection. An entry
341/// is **admitted** iff its [`ReasoningProvenance::authorship`] is in `trusted`;
342/// otherwise [`action`](Self::action) applies. Carried on
343/// [`RecallRequest.reasoning_trust`][crate::query::recall::RecallRequest::reasoning_trust];
344/// default `None` keeps the read path unchanged. Orthogonal to retrieval
345/// strategy — composes with vector / hybrid / graph alike.
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347pub struct ReasoningTrustPolicy {
348    /// Authorship values considered trustworthy.
349    pub trusted: Vec<ReasoningAuthorship>,
350    /// What to do with a non-trusted entry.
351    pub action: ReasoningTrustAction,
352    /// Score multiplier for [`ReasoningTrustAction::DownWeight`] (ignored for
353    /// `Quarantine`).
354    #[serde(default = "default_down_weight")]
355    pub down_weight_factor: f32,
356}
357
358impl Default for ReasoningTrustPolicy {
359    /// Quarantine anything not model-authored, user-provided, or tool-verified.
360    fn default() -> Self {
361        Self {
362            trusted: vec![
363                ReasoningAuthorship::ModelAuthored,
364                ReasoningAuthorship::UserProvided,
365                ReasoningAuthorship::ToolVerified,
366            ],
367            action: ReasoningTrustAction::Quarantine,
368            down_weight_factor: default_down_weight(),
369        }
370    }
371}
372
373impl ReasoningTrustPolicy {
374    /// The strict default: quarantine every entry whose reasoning is not from a
375    /// trusted author (injected / unverified).
376    pub fn quarantine_untrusted() -> Self {
377        Self::default()
378    }
379
380    /// Soft variant: down-weight (rather than drop) untrusted entries.
381    pub fn down_weight_untrusted(factor: f32) -> Self {
382        Self {
383            action: ReasoningTrustAction::DownWeight,
384            down_weight_factor: factor,
385            ..Self::default()
386        }
387    }
388
389    fn admits_metadata(&self, metadata: &serde_json::Value) -> bool {
390        self.trusted
391            .contains(&ReasoningProvenance::from_metadata(metadata).authorship)
392    }
393
394    /// Whether `record`'s reasoning provenance is trusted under this policy.
395    pub fn admits_record(&self, record: &crate::model::memory::MemoryRecord) -> bool {
396        self.admits_metadata(&record.metadata)
397    }
398
399    /// Whether the engine read path should **exclude** `record` (i.e. the
400    /// `Quarantine` action fired). `DownWeight` never excludes here — it is
401    /// applied to results via [`Self::rerank`].
402    pub fn excludes_record(&self, record: &crate::model::memory::MemoryRecord) -> bool {
403        matches!(self.action, ReasoningTrustAction::Quarantine) && !self.admits_record(record)
404    }
405
406    /// Apply the policy to a scored result set in place. `Quarantine` drops
407    /// untrusted hits; `DownWeight` multiplies their score by
408    /// `down_weight_factor` and re-sorts. Returns the number of entries
409    /// dropped or down-weighted.
410    pub fn rerank(&self, hits: &mut Vec<ScoredMemory>) -> usize {
411        match self.action {
412            ReasoningTrustAction::Quarantine => {
413                let before = hits.len();
414                hits.retain(|h| self.admits_metadata(&h.metadata));
415                before - hits.len()
416            }
417            ReasoningTrustAction::DownWeight => {
418                let mut affected = 0;
419                for h in hits.iter_mut() {
420                    if !self.admits_metadata(&h.metadata) {
421                        h.score *= self.down_weight_factor;
422                        affected += 1;
423                    }
424                }
425                hits.sort_by(|a, b| {
426                    b.score
427                        .partial_cmp(&a.score)
428                        .unwrap_or(std::cmp::Ordering::Equal)
429                });
430                affected
431            }
432        }
433    }
434}
435
436/// Which agent harness the response envelope should be shaped for.
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
438#[serde(rename_all = "snake_case")]
439pub enum HarnessKind {
440    ClaudeCode,
441    Codex,
442    GeminiCli,
443    Chronos,
444    Generic,
445}
446
447/// Where the envelope payload lives — inline in the response, written
448/// to a file the harness reads via a side-channel pointer, or written
449/// to a side-channel out-of-band stream.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(rename_all = "snake_case")]
452pub enum EnvelopeFormat {
453    Inline,
454    FileBased { path_root: PathBuf },
455    SideChannel,
456}
457
458/// Trait implemented by each per-harness adapter. The contract is
459/// minimal: take a slice of typed [`ScoredMemory`] hits and return a
460/// rendered string envelope shaped for the harness.
461pub trait HarnessEnvelope {
462    fn shape(&self, hits: &[ScoredMemory]) -> String;
463}
464
465fn adapter_for(kind: HarnessKind, format: EnvelopeFormat) -> Box<dyn HarnessEnvelope> {
466    match kind {
467        HarnessKind::ClaudeCode => Box::new(ClaudeCodeEnvelope {
468            inline: matches!(format, EnvelopeFormat::Inline),
469        }),
470        HarnessKind::Codex => Box::new(CodexEnvelope {
471            file_based: matches!(format, EnvelopeFormat::FileBased { .. }),
472        }),
473        HarnessKind::GeminiCli => Box::new(GeminiCliEnvelope),
474        HarnessKind::Chronos => Box::new(ChronosEnvelope),
475        HarnessKind::Generic => Box::new(GenericEnvelope),
476    }
477}
478
479/// Claude Code envelope — fenced markdown blocks with `recall://<id>`
480/// anchors for inline; line-numbered file-pointer summary for the
481/// non-inline branch.
482#[derive(Debug, Clone, Copy)]
483pub struct ClaudeCodeEnvelope {
484    pub inline: bool,
485}
486
487impl HarnessEnvelope for ClaudeCodeEnvelope {
488    fn shape(&self, hits: &[ScoredMemory]) -> String {
489        let mut out = String::new();
490        out.push_str("# mnemo.recall (Claude Code envelope)\n\n");
491        for (i, m) in hits.iter().enumerate() {
492            if self.inline {
493                out.push_str(&format!(
494                    "## hit {} (recall://{} • score {:.3})\n```\n{}\n```\n\n",
495                    i + 1,
496                    m.id,
497                    m.score,
498                    m.content
499                ));
500            } else {
501                let first_line = m.content.lines().next().unwrap_or("").trim();
502                out.push_str(&format!(
503                    "- hit {} → `recall://{}` (score {:.3}): {}\n",
504                    i + 1,
505                    m.id,
506                    m.score,
507                    first_line
508                ));
509            }
510        }
511        out
512    }
513}
514
515/// Codex envelope — file-based by default (writes hits to a path-root
516/// the caller chose), with an inline JSON pointer summary in the
517/// response. The Inline branch keeps the raw content in the response.
518#[derive(Debug, Clone, Copy)]
519pub struct CodexEnvelope {
520    pub file_based: bool,
521}
522
523impl HarnessEnvelope for CodexEnvelope {
524    fn shape(&self, hits: &[ScoredMemory]) -> String {
525        if self.file_based {
526            let pointers: Vec<String> = hits
527                .iter()
528                .map(|m| format!("{{\"id\":\"{}\",\"score\":{:.3}}}", m.id, m.score))
529                .collect();
530            format!(
531                "{{\"envelope\":\"codex_file_based\",\"hits\":[{}]}}",
532                pointers.join(",")
533            )
534        } else {
535            let blocks: Vec<String> = hits
536                .iter()
537                .map(|m| {
538                    format!(
539                        "{{\"id\":\"{}\",\"score\":{:.3},\"content\":{}}}",
540                        m.id,
541                        m.score,
542                        serde_json::to_string(&m.content).unwrap_or_default()
543                    )
544                })
545                .collect();
546            format!(
547                "{{\"envelope\":\"codex_inline\",\"hits\":[{}]}}",
548                blocks.join(",")
549            )
550        }
551    }
552}
553
554/// Gemini CLI envelope — plain numbered list with `[N]` markers + the
555/// hit content; tool-call-style framing the Gemini CLI surfaces well.
556#[derive(Debug, Clone, Copy)]
557pub struct GeminiCliEnvelope;
558
559impl HarnessEnvelope for GeminiCliEnvelope {
560    fn shape(&self, hits: &[ScoredMemory]) -> String {
561        let mut out = String::new();
562        out.push_str("mnemo recall (Gemini CLI envelope)\n");
563        for (i, m) in hits.iter().enumerate() {
564            out.push_str(&format!(
565                "[{}] score={:.3} id={} — {}\n",
566                i + 1,
567                m.score,
568                m.id,
569                m.content
570            ));
571        }
572        out
573    }
574}
575
576/// Chronos envelope — timeline-shaped: one line per hit with the hit
577/// `id`, score, and the first line of content. Chronos prefers
578/// temporally-anchored single-line summaries.
579#[derive(Debug, Clone, Copy)]
580pub struct ChronosEnvelope;
581
582impl HarnessEnvelope for ChronosEnvelope {
583    fn shape(&self, hits: &[ScoredMemory]) -> String {
584        let mut out = String::new();
585        out.push_str("chronos recall envelope\n");
586        for m in hits {
587            let first_line = m.content.lines().next().unwrap_or("").trim();
588            out.push_str(&format!("t={:.3} id={} :: {}\n", m.score, m.id, first_line));
589        }
590        out
591    }
592}
593
594/// Generic envelope — minimal `id\tscore\tcontent` TSV one line per
595/// hit. The fallback when no harness-specific adapter applies.
596#[derive(Debug, Clone, Copy)]
597pub struct GenericEnvelope;
598
599impl HarnessEnvelope for GenericEnvelope {
600    fn shape(&self, hits: &[ScoredMemory]) -> String {
601        let mut out = String::new();
602        for m in hits {
603            // TSV-safe: replace tabs/newlines in content so the
604            // generic envelope stays parseable.
605            let content_safe = m.content.replace(['\t', '\n', '\r'], " ");
606            out.push_str(&format!("{}\t{:.3}\t{}\n", m.id, m.score, content_safe));
607        }
608        out
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::model::memory::{MemoryType, Scope};
616    use uuid::Uuid;
617
618    fn make_hit(content: &str, score: f32) -> ScoredMemory {
619        ScoredMemory {
620            id: Uuid::now_v7(),
621            content: content.to_string(),
622            agent_id: "test-agent".to_string(),
623            memory_type: MemoryType::Episodic,
624            scope: Scope::Private,
625            importance: 0.5,
626            tags: vec![],
627            metadata: serde_json::Value::Null,
628            score,
629            access_count: 0,
630            created_at: "2026-05-17T00:00:00Z".to_string(),
631            updated_at: "2026-05-17T00:00:00Z".to_string(),
632            score_breakdown: None,
633        }
634    }
635
636    fn hit_with(content: &str, score: f32, auth: ReasoningAuthorship) -> ScoredMemory {
637        let mut h = make_hit(content, score);
638        ReasoningProvenance {
639            source: Some("t".into()),
640            written_at: None,
641            authorship: auth,
642        }
643        .attach(&mut h.metadata);
644        h
645    }
646
647    fn rec_with(auth: ReasoningAuthorship) -> crate::model::memory::MemoryRecord {
648        let mut r = crate::model::memory::MemoryRecord::new("a".into(), "c".into());
649        ReasoningProvenance {
650            source: None,
651            written_at: None,
652            authorship: auth,
653        }
654        .attach(&mut r.metadata);
655        r
656    }
657
658    #[test]
659    fn reasoning_provenance_fails_closed_to_unverified() {
660        let r = crate::model::memory::MemoryRecord::new("a".into(), "c".into());
661        // No `reasoning_provenance` in metadata → Unverified (never trusted).
662        assert_eq!(
663            ReasoningProvenance::from_record(&r).authorship,
664            ReasoningAuthorship::Unverified
665        );
666        assert!(!ReasoningTrustPolicy::default().admits_record(&r));
667    }
668
669    #[test]
670    fn injected_reasoning_is_excluded_but_model_authored_is_admitted() {
671        let policy = ReasoningTrustPolicy::quarantine_untrusted();
672        let injected = rec_with(ReasoningAuthorship::Injected);
673        let authored = rec_with(ReasoningAuthorship::ModelAuthored);
674        assert!(policy.excludes_record(&injected));
675        assert!(!policy.admits_record(&injected));
676        assert!(!policy.excludes_record(&authored));
677        assert!(policy.admits_record(&authored));
678        // Round-trips through metadata JSON.
679        assert_eq!(
680            ReasoningProvenance::from_record(&injected).authorship,
681            ReasoningAuthorship::Injected
682        );
683    }
684
685    #[test]
686    fn rerank_quarantine_drops_only_untrusted() {
687        let policy = ReasoningTrustPolicy::quarantine_untrusted();
688        let mut hits = vec![
689            hit_with("clean", 0.9, ReasoningAuthorship::ModelAuthored),
690            hit_with("forged", 0.8, ReasoningAuthorship::Injected),
691            hit_with("user", 0.7, ReasoningAuthorship::UserProvided),
692            hit_with("unknown", 0.6, ReasoningAuthorship::Unverified),
693        ];
694        let dropped = policy.rerank(&mut hits);
695        assert_eq!(dropped, 2); // injected + unverified
696        assert_eq!(hits.len(), 2);
697        assert!(
698            hits.iter()
699                .all(|h| h.content == "clean" || h.content == "user")
700        );
701    }
702
703    #[test]
704    fn rerank_downweight_demotes_forged_below_clean() {
705        let policy = ReasoningTrustPolicy::down_weight_untrusted(0.1);
706        let mut hits = vec![
707            hit_with("forged", 0.9, ReasoningAuthorship::Injected),
708            hit_with("clean", 0.5, ReasoningAuthorship::ModelAuthored),
709        ];
710        let affected = policy.rerank(&mut hits);
711        assert_eq!(affected, 1);
712        // The forged hit started higher (0.9) but is demoted to 0.09 < 0.5.
713        assert_eq!(hits[0].content, "clean");
714        assert_eq!(hits.len(), 2); // down-weight keeps, does not drop
715    }
716
717    #[test]
718    fn retrieval_mode_round_trip_strategy_string() {
719        assert_eq!(RetrievalMode::VectorOnly.to_strategy_str(), "semantic");
720        assert_eq!(RetrievalMode::Bm25Only.to_strategy_str(), "lexical");
721        assert_eq!(RetrievalMode::HybridRrf.to_strategy_str(), "auto");
722        assert_eq!(RetrievalMode::Graph.to_strategy_str(), "graph");
723        assert_eq!(
724            RetrievalMode::DomainScoped.to_strategy_str(),
725            "domain_scoped"
726        );
727        assert_eq!(RetrievalMode::Reconstruct.to_strategy_str(), "reconstruct");
728        let harness = RetrievalMode::HarnessAware {
729            harness: HarnessKind::ClaudeCode,
730            format: EnvelopeFormat::Inline,
731        };
732        // HarnessAware delegates to "auto" for the underlying
733        // retrieval — the adapter handles envelope post-processing.
734        assert_eq!(harness.to_strategy_str(), "auto");
735    }
736
737    fn rec(
738        org: Option<&str>,
739        tags: &[&str],
740        metadata: serde_json::Value,
741    ) -> crate::model::memory::MemoryRecord {
742        use crate::model::memory::{ConsolidationState, SourceType};
743        crate::model::memory::MemoryRecord {
744            id: Uuid::now_v7(),
745            agent_id: "a".to_string(),
746            content: "c".to_string(),
747            memory_type: MemoryType::Episodic,
748            scope: Scope::Private,
749            importance: 0.5,
750            tags: tags.iter().map(|t| t.to_string()).collect(),
751            metadata,
752            embedding: None,
753            content_hash: vec![],
754            prev_hash: None,
755            source_type: SourceType::Agent,
756            source_id: None,
757            consolidation_state: ConsolidationState::Raw,
758            access_count: 0,
759            org_id: org.map(str::to_string),
760            thread_id: None,
761            created_at: "2026-06-13T00:00:00Z".to_string(),
762            updated_at: "2026-06-13T00:00:00Z".to_string(),
763            last_accessed_at: None,
764            expires_at: None,
765            deleted_at: None,
766            decay_rate: None,
767            created_by: None,
768            version: 1,
769            prev_version_id: None,
770            quarantined: false,
771            quarantine_reason: None,
772            decay_function: None,
773        }
774    }
775
776    #[test]
777    fn domain_scope_matches_logical_and() {
778        // Empty scope matches everything.
779        let empty = DomainScope::default();
780        assert!(empty.is_empty());
781        assert!(empty.matches(&rec(Some("alpha"), &[], serde_json::Value::Null)));
782
783        // org_id predicate.
784        let by_org = DomainScope {
785            org_id: Some("alpha".to_string()),
786            ..Default::default()
787        };
788        assert!(by_org.matches(&rec(Some("alpha"), &[], serde_json::Value::Null)));
789        assert!(!by_org.matches(&rec(Some("beta"), &[], serde_json::Value::Null)));
790
791        // namespace via tag OR metadata.
792        let by_ns = DomainScope {
793            namespace: Some("legal".to_string()),
794            ..Default::default()
795        };
796        assert!(by_ns.matches(&rec(None, &["legal"], serde_json::Value::Null)));
797        assert!(by_ns.matches(&rec(None, &[], serde_json::json!({"namespace": "legal"}))));
798        assert!(!by_ns.matches(&rec(None, &["hr"], serde_json::json!({"namespace": "hr"}))));
799
800        // doc_class via metadata; AND with org.
801        let combo = DomainScope {
802            org_id: Some("alpha".to_string()),
803            doc_class: Some("contract".to_string()),
804            ..Default::default()
805        };
806        assert!(combo.matches(&rec(
807            Some("alpha"),
808            &[],
809            serde_json::json!({"doc_class": "contract"})
810        )));
811        // right doc_class, wrong org → rejected (AND).
812        assert!(!combo.matches(&rec(
813            Some("beta"),
814            &[],
815            serde_json::json!({"doc_class": "contract"})
816        )));
817        // right org, wrong doc_class → rejected.
818        assert!(!combo.matches(&rec(
819            Some("alpha"),
820            &[],
821            serde_json::json!({"doc_class": "memo"})
822        )));
823    }
824
825    #[test]
826    fn retrieval_mode_serde_round_trip() {
827        for mode in [
828            RetrievalMode::VectorOnly,
829            RetrievalMode::Bm25Only,
830            RetrievalMode::HybridRrf,
831            RetrievalMode::Graph,
832            RetrievalMode::DomainScoped,
833            RetrievalMode::Reconstruct,
834            RetrievalMode::HarnessAware {
835                harness: HarnessKind::ClaudeCode,
836                format: EnvelopeFormat::Inline,
837            },
838            RetrievalMode::HarnessAware {
839                harness: HarnessKind::Codex,
840                format: EnvelopeFormat::FileBased {
841                    path_root: PathBuf::from("/tmp/codex"),
842                },
843            },
844            RetrievalMode::HarnessAware {
845                harness: HarnessKind::Generic,
846                format: EnvelopeFormat::SideChannel,
847            },
848        ] {
849            let s = serde_json::to_string(&mode).unwrap();
850            let back: RetrievalMode = serde_json::from_str(&s).unwrap();
851            assert_eq!(mode, back, "round-trip failed for {mode:?} via {s}");
852        }
853    }
854
855    #[test]
856    fn harness_aware_returns_envelope_adapter() {
857        let mode = RetrievalMode::HarnessAware {
858            harness: HarnessKind::ClaudeCode,
859            format: EnvelopeFormat::Inline,
860        };
861        assert!(mode.envelope_adapter().is_some());
862        assert!(RetrievalMode::HybridRrf.envelope_adapter().is_none());
863    }
864
865    #[test]
866    fn five_adapters_produce_distinct_envelope_shapes() {
867        let hits = vec![
868            make_hit("first hit content line\nsecond line", 0.91),
869            make_hit("another hit", 0.42),
870        ];
871        let cc = ClaudeCodeEnvelope { inline: true }.shape(&hits);
872        let codex = CodexEnvelope { file_based: true }.shape(&hits);
873        let gemini = GeminiCliEnvelope.shape(&hits);
874        let chronos = ChronosEnvelope.shape(&hits);
875        let generic = GenericEnvelope.shape(&hits);
876        // Each adapter must produce a distinct shape — the whole
877        // point of HarnessAware is per-harness reshaping.
878        let shapes = [&cc, &codex, &gemini, &chronos, &generic];
879        for (i, a) in shapes.iter().enumerate() {
880            for (j, b) in shapes.iter().enumerate() {
881                if i != j {
882                    assert_ne!(
883                        a, b,
884                        "adapter shapes {} and {} collided (both produced:\n{a})",
885                        i, j
886                    );
887                }
888            }
889        }
890    }
891
892    #[test]
893    fn claude_code_envelope_inline_vs_non_inline_differ() {
894        let hits = vec![make_hit("hello world", 0.5)];
895        let inline = ClaudeCodeEnvelope { inline: true }.shape(&hits);
896        let non_inline = ClaudeCodeEnvelope { inline: false }.shape(&hits);
897        assert!(inline.contains("```"), "inline must contain fenced block");
898        assert!(
899            !non_inline.contains("```"),
900            "non-inline must not contain fenced block"
901        );
902    }
903
904    #[test]
905    fn generic_envelope_is_tsv_safe() {
906        let hits = vec![make_hit("has\ttab\nand newline", 0.5)];
907        let env = GenericEnvelope.shape(&hits);
908        // Exactly one record line — the inner \t and \n in content
909        // must have been replaced with spaces.
910        assert_eq!(env.lines().count(), 1);
911        let parts: Vec<&str> = env.trim_end().split('\t').collect();
912        assert_eq!(
913            parts.len(),
914            3,
915            "TSV envelope must have id\\tscore\\tcontent"
916        );
917    }
918}