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