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}