Skip to main content

zeph_common/
memory.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shared memory interface types used by both `zeph-memory` (Layer 1) and
5//! `zeph-context` (Layer 1) without a cross-layer dependency.
6//!
7//! Moving these pure interface types here resolves the same-layer violation
8//! `zeph-context → zeph-memory` (issue #3665).
9
10use std::fmt;
11use std::str::FromStr;
12
13use serde::{Deserialize, Serialize};
14
15// ── MemoryRoute ───────────────────────────────────────────────────────────────
16
17/// Classification of which memory backend(s) to query.
18///
19/// Used in routing configuration and at runtime to dispatch memory operations.
20/// Serialises with `snake_case` names (`keyword`, `semantic`, `hybrid`, `graph`, `episodic`).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum MemoryRoute {
25    /// Full-text search only (`SQLite` FTS5). Fast, good for keyword/exact queries.
26    Keyword,
27    /// Vector search only (Qdrant). Good for semantic/conceptual queries.
28    Semantic,
29    /// Both backends, results merged by reciprocal rank fusion.
30    #[default]
31    Hybrid,
32    /// Graph-based retrieval via BFS traversal.
33    Graph,
34    /// FTS5 search with a timestamp-range filter. Used for temporal/episodic queries.
35    Episodic,
36}
37
38/// Routing decision with confidence and optional LLM reasoning.
39#[derive(Debug, Clone)]
40pub struct RoutingDecision {
41    pub route: MemoryRoute,
42    /// Confidence in `[0, 1]`. `1.0` = certain, `0.5` = ambiguous.
43    pub confidence: f32,
44    /// Only populated when an LLM classifier was used.
45    pub reasoning: Option<String>,
46}
47
48/// Decides which memory backend(s) to query for a given input.
49pub trait MemoryRouter: Send + Sync {
50    /// Route a query to the appropriate backend(s).
51    fn route(&self, query: &str) -> MemoryRoute;
52
53    /// Route with a confidence signal. Default implementation wraps `route()` with confidence 1.0.
54    fn route_with_confidence(&self, query: &str) -> RoutingDecision {
55        RoutingDecision {
56            route: self.route(query),
57            confidence: 1.0,
58            reasoning: None,
59        }
60    }
61}
62
63/// Async extension for LLM-capable routers.
64pub trait AsyncMemoryRouter: MemoryRouter {
65    fn route_async<'a>(
66        &'a self,
67        query: &'a str,
68    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RoutingDecision> + Send + 'a>>;
69}
70
71// ── RecallView ────────────────────────────────────────────────────────────────
72
73/// Enrichment level for view-aware graph recall.
74///
75/// # Examples
76///
77/// ```
78/// use zeph_common::memory::RecallView;
79///
80/// assert_eq!(RecallView::default(), RecallView::Head);
81/// ```
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83#[non_exhaustive]
84pub enum RecallView {
85    /// Standard retrieval — no enrichment beyond what the base method provides.
86    #[default]
87    Head,
88    /// Retrieval + source-message provenance.
89    ZoomIn,
90    /// Retrieval + 1-hop neighbor expansion.
91    ZoomOut,
92}
93
94// ── CompressionLevel ─────────────────────────────────────────────────────────
95
96/// The three abstraction levels in the compression spectrum.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98#[non_exhaustive]
99pub enum CompressionLevel {
100    /// Raw episodic messages — full fidelity, high token cost.
101    Episodic,
102    /// Abstracted procedural knowledge (how-to, tool patterns).
103    Procedural,
104    /// Stable declarative facts and reference material.
105    Declarative,
106}
107
108impl CompressionLevel {
109    /// A relative token-cost factor for budgeting purposes.
110    ///
111    /// `Episodic = 1.0` (baseline), `Procedural = 0.6`, `Declarative = 0.3`.
112    #[must_use]
113    pub const fn cost_factor(self) -> f32 {
114        match self {
115            Self::Episodic => 1.0,
116            Self::Procedural => 0.6,
117            Self::Declarative => 0.3,
118        }
119    }
120}
121
122// ── AnchoredSummary ───────────────────────────────────────────────────────────
123
124/// Structured compaction summary with anchored sections.
125///
126/// Produced by the structured summarization path during hard compaction.
127/// Replaces the free-form 9-section prose when `[memory] structured_summaries = true`.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
130pub struct AnchoredSummary {
131    /// What the user is ultimately trying to accomplish in this session.
132    pub session_intent: String,
133    /// File paths, function names, structs/enums touched or referenced.
134    pub files_modified: Vec<String>,
135    /// Architectural or implementation decisions made, with rationale.
136    pub decisions_made: Vec<String>,
137    /// Unresolved questions, ambiguities, or blocked items.
138    pub open_questions: Vec<String>,
139    /// Concrete next actions the agent should take immediately.
140    pub next_steps: Vec<String>,
141}
142
143impl AnchoredSummary {
144    /// Returns true if the mandatory sections (`session_intent`, `next_steps`) are populated.
145    #[must_use]
146    pub fn is_complete(&self) -> bool {
147        !self.session_intent.trim().is_empty() && !self.next_steps.is_empty()
148    }
149
150    /// Render as Markdown for context injection into the LLM.
151    #[must_use]
152    pub fn to_markdown(&self) -> String {
153        let mut out = String::with_capacity(512);
154        out.push_str("[anchored summary]\n");
155        out.push_str("## Session Intent\n");
156        out.push_str(&self.session_intent);
157        out.push('\n');
158
159        if !self.files_modified.is_empty() {
160            out.push_str("\n## Files Modified\n");
161            for entry in &self.files_modified {
162                let clean = entry.trim_start_matches("- ");
163                out.push_str("- ");
164                out.push_str(clean);
165                out.push('\n');
166            }
167        }
168
169        if !self.decisions_made.is_empty() {
170            out.push_str("\n## Decisions Made\n");
171            for entry in &self.decisions_made {
172                let clean = entry.trim_start_matches("- ");
173                out.push_str("- ");
174                out.push_str(clean);
175                out.push('\n');
176            }
177        }
178
179        if !self.open_questions.is_empty() {
180            out.push_str("\n## Open Questions\n");
181            for entry in &self.open_questions {
182                let clean = entry.trim_start_matches("- ");
183                out.push_str("- ");
184                out.push_str(clean);
185                out.push('\n');
186            }
187        }
188
189        if !self.next_steps.is_empty() {
190            out.push_str("\n## Next Steps\n");
191            for entry in &self.next_steps {
192                let clean = entry.trim_start_matches("- ");
193                out.push_str("- ");
194                out.push_str(clean);
195                out.push('\n');
196            }
197        }
198
199        out
200    }
201
202    /// Validate per-field length limits to guard against bloated LLM output.
203    ///
204    /// # Errors
205    ///
206    /// Returns `Err` with a descriptive message if any field exceeds its limit.
207    #[must_use = "validation result must be checked"]
208    pub fn validate(&self) -> Result<(), String> {
209        const MAX_INTENT: usize = 2_000;
210        const MAX_ENTRY: usize = 500;
211        const MAX_VEC_LEN: usize = 50;
212
213        if self.session_intent.len() > MAX_INTENT {
214            return Err(format!(
215                "session_intent exceeds {MAX_INTENT} chars (got {})",
216                self.session_intent.len()
217            ));
218        }
219        for (field, entries) in [
220            ("files_modified", &self.files_modified),
221            ("decisions_made", &self.decisions_made),
222            ("open_questions", &self.open_questions),
223            ("next_steps", &self.next_steps),
224        ] {
225            if entries.len() > MAX_VEC_LEN {
226                return Err(format!(
227                    "{field} has {} entries (max {MAX_VEC_LEN})",
228                    entries.len()
229                ));
230            }
231            for entry in entries {
232                if entry.len() > MAX_ENTRY {
233                    return Err(format!(
234                        "{field} entry exceeds {MAX_ENTRY} chars (got {})",
235                        entry.len()
236                    ));
237                }
238            }
239        }
240        Ok(())
241    }
242
243    /// Serialize to JSON for storage in `summaries.content`.
244    ///
245    /// # Panics
246    ///
247    /// Panics if serialization fails. Since all fields are `String`/`Vec<String>`,
248    /// serialization is infallible in practice.
249    #[must_use]
250    pub fn to_json(&self) -> String {
251        serde_json::to_string(self).expect("AnchoredSummary serialization is infallible")
252    }
253}
254
255// ── SpreadingActivationParams ─────────────────────────────────────────────────
256
257/// Parameters for spreading activation graph retrieval.
258#[derive(Debug, Clone)]
259pub struct SpreadingActivationParams {
260    pub decay_lambda: f32,
261    pub max_hops: u32,
262    pub activation_threshold: f32,
263    pub inhibition_threshold: f32,
264    pub max_activated_nodes: usize,
265    pub temporal_decay_rate: f64,
266    /// Weight of structural score in hybrid seed ranking. Range: `[0.0, 1.0]`. Default: `0.4`.
267    pub seed_structural_weight: f32,
268    /// Maximum seeds per community ID. `0` = unlimited. Default: `3`.
269    pub seed_community_cap: usize,
270    /// SYNAPSE blend coefficient for Benna-Fusi fast/slow variables (#3709).
271    ///
272    /// Blends `confidence_fast` and `confidence_slow` for edge weight in spreading activation:
273    /// `blended = alpha * fast + (1 - alpha) * slow`.
274    /// Range: `[0.0, 1.0]`. Default: `0.3` (favors the stable slow variable).
275    pub alpha: f32,
276}
277
278// ── EdgeType ──────────────────────────────────────────────────────────────────
279
280/// MAGMA edge type: the semantic category of a relationship between two entities.
281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
282#[serde(rename_all = "snake_case")]
283#[non_exhaustive]
284pub enum EdgeType {
285    #[default]
286    Semantic,
287    Temporal,
288    Causal,
289    Entity,
290}
291
292impl EdgeType {
293    /// Return the canonical lowercase string for this edge type.
294    ///
295    /// # Examples
296    ///
297    /// ```
298    /// use zeph_common::memory::EdgeType;
299    ///
300    /// assert_eq!(EdgeType::Causal.as_str(), "causal");
301    /// ```
302    #[must_use]
303    pub const fn as_str(self) -> &'static str {
304        match self {
305            Self::Semantic => "semantic",
306            Self::Temporal => "temporal",
307            Self::Causal => "causal",
308            Self::Entity => "entity",
309        }
310    }
311}
312
313impl fmt::Display for EdgeType {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        f.write_str(self.as_str())
316    }
317}
318
319impl FromStr for EdgeType {
320    type Err = String;
321
322    fn from_str(s: &str) -> Result<Self, Self::Err> {
323        match s {
324            "semantic" => Ok(Self::Semantic),
325            "temporal" => Ok(Self::Temporal),
326            "causal" => Ok(Self::Causal),
327            "entity" => Ok(Self::Entity),
328            other => Err(format!("unknown edge type: {other}")),
329        }
330    }
331}
332
333// ── Marker constants ──────────────────────────────────────────────────────────
334
335/// MAGMA causal edge markers used by `classify_graph_subgraph`.
336pub const CAUSAL_MARKERS: &[&str] = &[
337    "why",
338    "because",
339    "caused",
340    "cause",
341    "reason",
342    "result",
343    "led to",
344    "consequence",
345    "trigger",
346    "effect",
347    "blame",
348    "fault",
349];
350
351/// MAGMA temporal edge markers for subgraph classification.
352pub const TEMPORAL_MARKERS: &[&str] = &[
353    "before", "after", "first", "then", "timeline", "sequence", "preceded", "followed", "started",
354    "ended", "during", "prior",
355];
356
357/// MAGMA entity/structural markers.
358pub const ENTITY_MARKERS: &[&str] = &[
359    "is a",
360    "type of",
361    "kind of",
362    "part of",
363    "instance",
364    "same as",
365    "alias",
366    "subtype",
367    "subclass",
368    "belongs to",
369];
370
371/// Single-word temporal tokens that require word-boundary checking.
372pub const WORD_BOUNDARY_TEMPORAL: &[&str] = &["ago"];
373
374/// Classify a query into MAGMA edge types to use for subgraph-scoped BFS retrieval.
375///
376/// Pure heuristic, zero latency — no LLM call. Returns a prioritised list of [`EdgeType`]s.
377///
378/// # Example
379///
380/// ```
381/// use zeph_common::memory::{classify_graph_subgraph, EdgeType};
382///
383/// let types = classify_graph_subgraph("why did X happen");
384/// assert!(types.contains(&EdgeType::Causal));
385/// assert!(types.contains(&EdgeType::Semantic));
386/// ```
387#[must_use]
388pub fn classify_graph_subgraph(query: &str) -> Vec<EdgeType> {
389    let lower = query.to_ascii_lowercase();
390    let mut types: Vec<EdgeType> = Vec::new();
391
392    if CAUSAL_MARKERS.iter().any(|m| lower.contains(m)) {
393        types.push(EdgeType::Causal);
394    }
395    if TEMPORAL_MARKERS.iter().any(|m| lower.contains(m)) {
396        types.push(EdgeType::Temporal);
397    }
398    if ENTITY_MARKERS.iter().any(|m| lower.contains(m)) {
399        types.push(EdgeType::Entity);
400    }
401
402    if !types.contains(&EdgeType::Semantic) {
403        types.push(EdgeType::Semantic);
404    }
405
406    types
407}
408
409/// Parse a route name string into a [`MemoryRoute`], falling back to `fallback` on unknown values.
410///
411/// # Examples
412///
413/// ```
414/// use zeph_common::memory::{parse_route_str, MemoryRoute};
415///
416/// assert_eq!(parse_route_str("semantic", MemoryRoute::Hybrid), MemoryRoute::Semantic);
417/// assert_eq!(parse_route_str("unknown", MemoryRoute::Hybrid), MemoryRoute::Hybrid);
418/// ```
419#[must_use]
420pub fn parse_route_str(s: &str, fallback: MemoryRoute) -> MemoryRoute {
421    match s {
422        "keyword" => MemoryRoute::Keyword,
423        "semantic" => MemoryRoute::Semantic,
424        "hybrid" => MemoryRoute::Hybrid,
425        "graph" => MemoryRoute::Graph,
426        "episodic" => MemoryRoute::Episodic,
427        _ => fallback,
428    }
429}
430
431// ── TokenCounting trait ───────────────────────────────────────────────────────
432
433/// Minimal token-counting interface used by `zeph-context` for budget enforcement.
434///
435/// Defined here in Layer 0 so `zeph-context` can accept a `&dyn TokenCounting`
436/// without importing `zeph-memory`. `zeph-memory::TokenCounter` implements this trait.
437pub trait TokenCounting: Send + Sync {
438    /// Count tokens in a plain text string.
439    fn count_tokens(&self, text: &str) -> usize;
440    /// Count tokens for a JSON schema value (tool definitions).
441    fn count_tool_schema_tokens(&self, schema: &serde_json::Value) -> usize;
442}
443
444// ── Context memory DTOs ───────────────────────────────────────────────────────
445//
446// Plain data-transfer structs used by `ContextMemoryBackend`. They mirror the
447// fields that `zeph-context::assembler` actually reads from `zeph-memory` row
448// types. Keeping them here (Layer 0) allows `zeph-context` (Layer 1) to depend
449// only on `zeph-common` rather than `zeph-memory`.
450
451/// A persona fact row projection used by context assembly.
452#[derive(Debug, Clone)]
453pub struct MemPersonaFact {
454    /// Fact category label (e.g. `"preference"`, `"domain"`).
455    pub category: String,
456    /// Fact content injected into the system prompt.
457    pub content: String,
458}
459
460/// A memory tree node projection used by context assembly.
461#[derive(Debug, Clone)]
462pub struct MemTreeNode {
463    /// Node content injected into the system prompt.
464    pub content: String,
465}
466
467/// A conversation summary projection used by context assembly.
468#[derive(Debug, Clone)]
469pub struct MemSummary {
470    /// Row ID of the first message covered by this summary, if known.
471    pub first_message_id: Option<i64>,
472    /// Row ID of the last message covered by this summary, if known.
473    pub last_message_id: Option<i64>,
474    /// Summary text.
475    pub content: String,
476}
477
478/// A reasoning strategy projection used by context assembly.
479#[derive(Debug, Clone)]
480pub struct MemReasoningStrategy {
481    /// Unique strategy identifier (used by `mark_reasoning_used`).
482    pub id: String,
483    /// Outcome label (e.g. `"success"`, `"failure"`).
484    pub outcome: String,
485    /// Distilled strategy summary injected into the system prompt.
486    pub summary: String,
487}
488
489/// A user correction projection used by context assembly.
490#[derive(Debug, Clone)]
491pub struct MemCorrection {
492    /// The correction text to inject into the system prompt.
493    pub correction_text: String,
494}
495
496/// A recalled message projection used by context assembly.
497#[derive(Debug, Clone)]
498pub struct MemRecalledMessage {
499    /// Message role: `"user"`, `"assistant"`, or `"system"`.
500    pub role: String,
501    /// Message content.
502    pub content: String,
503    /// Similarity score in `[0, 1]`.
504    pub score: f32,
505}
506
507/// A neighbor fact in a graph recall result.
508#[derive(Debug, Clone)]
509pub struct MemGraphNeighbor {
510    /// Neighbor fact text.
511    pub fact: String,
512    /// Confidence score in `[0, 1]`.
513    pub confidence: f32,
514}
515
516/// A graph fact projection used by context assembly.
517#[derive(Debug, Clone)]
518pub struct MemGraphFact {
519    /// Fact text.
520    pub fact: String,
521    /// Confidence score in `[0, 1]`.
522    pub confidence: f32,
523    /// Spreading-activation score, if applicable.
524    pub activation_score: Option<f32>,
525    /// `ZoomOut` 1-hop neighbors, if view-aware expansion was requested.
526    pub neighbors: Vec<MemGraphNeighbor>,
527    /// `ZoomIn` provenance snippet, if view-aware provenance was requested.
528    pub provenance_snippet: Option<String>,
529}
530
531/// A cross-session summary search result used by context assembly.
532#[derive(Debug, Clone)]
533pub struct MemSessionSummary {
534    /// Summary text from the matched session.
535    pub summary_text: String,
536    /// Similarity score in `[0, 1]`.
537    pub score: f32,
538}
539
540/// A document chunk search result used by context assembly.
541#[derive(Debug, Clone)]
542pub struct MemDocumentChunk {
543    /// Chunk text extracted from the `"text"` payload key.
544    pub text: String,
545}
546
547/// A trajectory entry projection used by context assembly.
548#[derive(Debug, Clone)]
549pub struct MemTrajectoryEntry {
550    /// Intent description for the trajectory entry.
551    pub intent: String,
552    /// Outcome description.
553    pub outcome: String,
554    /// Confidence score in `[0, 1]`.
555    pub confidence: f64,
556}
557
558// ── GraphRecallParams ─────────────────────────────────────────────────────────
559
560/// Parameters for a graph-view recall call, used by [`ContextMemoryBackend::recall_graph_facts`].
561#[derive(Debug)]
562pub struct GraphRecallParams<'a> {
563    /// Maximum number of graph facts to return.
564    pub limit: usize,
565    /// Enrichment view (head, zoom-in, zoom-out).
566    pub view: RecallView,
567    /// Cap on `ZoomOut` neighbor expansion.
568    pub zoom_out_neighbor_cap: usize,
569    /// Maximum BFS hops during graph traversal.
570    pub max_hops: u32,
571    /// Rate at which older facts are downweighted.
572    pub temporal_decay_rate: f64,
573    /// Edge type filters for subgraph-scoped BFS.
574    pub edge_types: &'a [EdgeType],
575    /// Spreading activation parameters. `None` disables spreading activation.
576    pub spreading_activation: Option<SpreadingActivationParams>,
577}
578
579// ── ContextMemoryBackend trait ────────────────────────────────────────────────
580
581/// Abstraction over `SemanticMemory` that `zeph-context` uses for all memory
582/// operations during context assembly.
583///
584/// Defined in Layer 0 (`zeph-common`) so that `zeph-context` (Layer 1) can hold
585/// `Option<Arc<dyn ContextMemoryBackend>>` without importing `zeph-memory`.
586/// `zeph-core` (Layer 4) provides the concrete implementation that wraps
587/// `SemanticMemory`.
588///
589/// All async methods use `Pin<Box<dyn Future<...>>>` for dyn-compatibility.
590#[allow(clippy::type_complexity)]
591pub trait ContextMemoryBackend: Send + Sync {
592    /// Load persona facts with at least `min_confidence`.
593    fn load_persona_facts<'a>(
594        &'a self,
595        min_confidence: f64,
596    ) -> std::pin::Pin<
597        Box<
598            dyn std::future::Future<
599                    Output = Result<Vec<MemPersonaFact>, Box<dyn std::error::Error + Send + Sync>>,
600                > + Send
601                + 'a,
602        >,
603    >;
604
605    /// Load `top_k` trajectory entries for the given `tier` filter (e.g. `"procedural"`).
606    fn load_trajectory_entries<'a>(
607        &'a self,
608        tier: Option<&'a str>,
609        top_k: usize,
610    ) -> std::pin::Pin<
611        Box<
612            dyn std::future::Future<
613                    Output = Result<
614                        Vec<MemTrajectoryEntry>,
615                        Box<dyn std::error::Error + Send + Sync>,
616                    >,
617                > + Send
618                + 'a,
619        >,
620    >;
621
622    /// Load `top_k` memory tree nodes at the given level.
623    fn load_tree_nodes<'a>(
624        &'a self,
625        level: u32,
626        top_k: usize,
627    ) -> std::pin::Pin<
628        Box<
629            dyn std::future::Future<
630                    Output = Result<Vec<MemTreeNode>, Box<dyn std::error::Error + Send + Sync>>,
631                > + Send
632                + 'a,
633        >,
634    >;
635
636    /// Load all summaries for the given conversation (raw row ID).
637    fn load_summaries<'a>(
638        &'a self,
639        conversation_id: i64,
640    ) -> std::pin::Pin<
641        Box<
642            dyn std::future::Future<
643                    Output = Result<Vec<MemSummary>, Box<dyn std::error::Error + Send + Sync>>,
644                > + Send
645                + 'a,
646        >,
647    >;
648
649    /// Retrieve the top-`top_k` reasoning strategies for `query`.
650    fn retrieve_reasoning_strategies<'a>(
651        &'a self,
652        query: &'a str,
653        top_k: usize,
654    ) -> std::pin::Pin<
655        Box<
656            dyn std::future::Future<
657                    Output = Result<
658                        Vec<MemReasoningStrategy>,
659                        Box<dyn std::error::Error + Send + Sync>,
660                    >,
661                > + Send
662                + 'a,
663        >,
664    >;
665
666    /// Mark reasoning strategies as used (fire-and-forget; best-effort).
667    fn mark_reasoning_used<'a>(
668        &'a self,
669        ids: &'a [String],
670    ) -> std::pin::Pin<
671        Box<
672            dyn std::future::Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>>
673                + Send
674                + 'a,
675        >,
676    >;
677
678    /// Retrieve corrections similar to `query`, up to `limit` with `min_score`.
679    fn retrieve_corrections<'a>(
680        &'a self,
681        query: &'a str,
682        limit: usize,
683        min_score: f32,
684    ) -> std::pin::Pin<
685        Box<
686            dyn std::future::Future<
687                    Output = Result<Vec<MemCorrection>, Box<dyn std::error::Error + Send + Sync>>,
688                > + Send
689                + 'a,
690        >,
691    >;
692
693    /// Recall semantically similar messages for `query`, up to `limit`.
694    fn recall<'a>(
695        &'a self,
696        query: &'a str,
697        limit: usize,
698        router: Option<&'a dyn AsyncMemoryRouter>,
699    ) -> std::pin::Pin<
700        Box<
701            dyn std::future::Future<
702                    Output = Result<
703                        Vec<MemRecalledMessage>,
704                        Box<dyn std::error::Error + Send + Sync>,
705                    >,
706                > + Send
707                + 'a,
708        >,
709    >;
710
711    /// Recall graph facts for `query` with view-aware enrichment.
712    fn recall_graph_facts<'a>(
713        &'a self,
714        query: &'a str,
715        params: GraphRecallParams<'a>,
716    ) -> std::pin::Pin<
717        Box<
718            dyn std::future::Future<
719                    Output = Result<Vec<MemGraphFact>, Box<dyn std::error::Error + Send + Sync>>,
720                > + Send
721                + 'a,
722        >,
723    >;
724
725    /// Search cross-session summaries for `query`, excluding `current_conversation_id`.
726    fn search_session_summaries<'a>(
727        &'a self,
728        query: &'a str,
729        limit: usize,
730        current_conversation_id: Option<i64>,
731    ) -> std::pin::Pin<
732        Box<
733            dyn std::future::Future<
734                    Output = Result<
735                        Vec<MemSessionSummary>,
736                        Box<dyn std::error::Error + Send + Sync>,
737                    >,
738                > + Send
739                + 'a,
740        >,
741    >;
742
743    /// Search a named document collection for `query`, returning `top_k` chunks.
744    fn search_document_collection<'a>(
745        &'a self,
746        collection: &'a str,
747        query: &'a str,
748        top_k: usize,
749    ) -> std::pin::Pin<
750        Box<
751            dyn std::future::Future<
752                    Output = Result<
753                        Vec<MemDocumentChunk>,
754                        Box<dyn std::error::Error + Send + Sync>,
755                    >,
756                > + Send
757                + 'a,
758        >,
759    >;
760}
761
762#[cfg(test)]
763mod tests {
764    use super::MemoryRoute;
765
766    #[test]
767    fn memory_route_serde_roundtrip() {
768        let cases = [
769            ("\"keyword\"", MemoryRoute::Keyword),
770            ("\"semantic\"", MemoryRoute::Semantic),
771            ("\"hybrid\"", MemoryRoute::Hybrid),
772            ("\"graph\"", MemoryRoute::Graph),
773            ("\"episodic\"", MemoryRoute::Episodic),
774        ];
775        for (json_str, expected) in cases {
776            let got: MemoryRoute = serde_json::from_str(json_str).unwrap();
777            assert_eq!(got, expected);
778            let serialized = serde_json::to_string(&got).unwrap();
779            let roundtrip: MemoryRoute = serde_json::from_str(&serialized).unwrap();
780            assert_eq!(roundtrip, expected);
781        }
782    }
783
784    #[test]
785    fn memory_route_default_is_hybrid() {
786        assert_eq!(MemoryRoute::default(), MemoryRoute::Hybrid);
787    }
788}