1use std::path::PathBuf;
58
59use serde::{Deserialize, Serialize};
60
61use crate::query::recall::ScoredMemory;
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum RetrievalMode {
69 VectorOnly,
71 Bm25Only,
74 HybridRrf,
80 Graph,
83 HarnessAware {
90 harness: HarnessKind,
91 format: EnvelopeFormat,
92 },
93 DomainScoped,
103 Reconstruct,
114}
115
116impl RetrievalMode {
117 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 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154pub struct DomainScope {
155 pub org_id: Option<String>,
157 pub namespace: Option<String>,
160 pub doc_class: Option<String>,
163 pub tags: Option<Vec<String>>,
165}
166
167impl DomainScope {
168 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum ReasoningAuthorship {
235 ModelAuthored,
237 UserProvided,
239 ToolVerified,
241 Injected,
244 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#[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 pub const METADATA_KEY: &'static str = "reasoning_provenance";
276
277 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 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 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 pub fn from_record(record: &crate::model::memory::MemoryRecord) -> Self {
310 Self::from_metadata(&record.metadata)
311 }
312
313 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "snake_case")]
327pub enum ReasoningTrustAction {
328 Quarantine,
331 DownWeight,
334}
335
336fn default_down_weight() -> f32 {
337 0.1
338}
339
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347pub struct ReasoningTrustPolicy {
348 pub trusted: Vec<ReasoningAuthorship>,
350 pub action: ReasoningTrustAction,
352 #[serde(default = "default_down_weight")]
355 pub down_weight_factor: f32,
356}
357
358impl Default for ReasoningTrustPolicy {
359 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 pub fn quarantine_untrusted() -> Self {
377 Self::default()
378 }
379
380 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 pub fn admits_record(&self, record: &crate::model::memory::MemoryRecord) -> bool {
396 self.admits_metadata(&record.metadata)
397 }
398
399 pub fn excludes_record(&self, record: &crate::model::memory::MemoryRecord) -> bool {
403 matches!(self.action, ReasoningTrustAction::Quarantine) && !self.admits_record(record)
404 }
405
406 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#[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#[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
458pub 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#[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#[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#[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#[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#[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 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 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 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); 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 assert_eq!(hits[0].content, "clean");
714 assert_eq!(hits.len(), 2); }
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 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 let empty = DomainScope::default();
780 assert!(empty.is_empty());
781 assert!(empty.matches(&rec(Some("alpha"), &[], serde_json::Value::Null)));
782
783 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 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 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 assert!(!combo.matches(&rec(
813 Some("beta"),
814 &[],
815 serde_json::json!({"doc_class": "contract"})
816 )));
817 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 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 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}