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.pad(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// ── FunctionalType ────────────────────────────────────────────────────────────
334
335/// MemGuard-inspired functional-role classification of a memory source (spec 004-16, #6086).
336///
337/// Each variant names one of the memory sources composed during context assembly
338/// (`schedule_context_fetchers` in `zeph-context`) — not a storage tier
339/// ([`CompressionLevel`]) and not a routing backend ([`MemoryRoute`]). The two axes are
340/// orthogonal: a `zeph_conversations` vector is `Episodic`-tier *and* the `Episodic`
341/// functional type, while a `Semantic`-tier consolidated fact lives under the `UserFact`
342/// functional type. Placed here (rather than in `zeph-memory`) because `zeph-context` — the
343/// crate that gates fetchers by this type — deliberately has no `zeph-memory` dependency
344/// (see the module doc above and issue #3665); `zeph-memory` re-exports this type at its
345/// crate root for taxonomy discoverability.
346///
347/// `#[non_exhaustive]`: additional functional sources may be added later without a breaking
348/// change; an unrecognised variant is always-composed until explicitly gated (never silently
349/// dropped).
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
351#[serde(rename_all = "snake_case")]
352#[non_exhaustive]
353pub enum FunctionalType {
354 /// Raw episodic conversation recall (`fetch_semantic_recall` → `zeph_conversations`).
355 Episodic,
356 /// User preference/attribute facts (`fetch_persona_facts` → SQL `persona_memory`,
357 /// **not** the `zeph_key_facts` collection — that surface is out of scope, see spec 004-16 §4).
358 UserFact,
359 /// Past user corrections (`fetch_corrections` → `zeph_corrections`).
360 ///
361 /// Safety-critical: this type is never gated out by type-aware composition — context
362 /// assembly always schedules `fetch_corrections` regardless of the active set.
363 BehavioralRule,
364 /// Distilled `ReasoningBank` strategies (`fetch_reasoning_strategies` → `reasoning_strategies`).
365 ReasoningStrategy,
366 /// Cross-session summaries (`fetch_summaries` / `fetch_cross_session` → `zeph_session_summaries`).
367 CrossSessionSummary,
368 /// Knowledge graph facts (`fetch_graph_facts` → `zeph_graph_entities`).
369 GraphFact,
370}
371
372impl FunctionalType {
373 /// Return the canonical lowercase string for this functional type.
374 ///
375 /// # Examples
376 ///
377 /// ```
378 /// use zeph_common::memory::FunctionalType;
379 ///
380 /// assert_eq!(FunctionalType::UserFact.as_str(), "user_fact");
381 /// assert_eq!(FunctionalType::BehavioralRule.as_str(), "behavioral_rule");
382 /// ```
383 #[must_use]
384 pub const fn as_str(self) -> &'static str {
385 match self {
386 Self::Episodic => "episodic",
387 Self::UserFact => "user_fact",
388 Self::BehavioralRule => "behavioral_rule",
389 Self::ReasoningStrategy => "reasoning_strategy",
390 Self::CrossSessionSummary => "cross_session_summary",
391 Self::GraphFact => "graph_fact",
392 }
393 }
394}
395
396impl fmt::Display for FunctionalType {
397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398 f.pad(self.as_str())
399 }
400}
401
402impl FromStr for FunctionalType {
403 type Err = String;
404
405 /// Strict parse: an unrecognised string is a hard error, never a silent fallback.
406 ///
407 /// This is deliberate (spec 004-16 §4, critic finding S4): a config typo in
408 /// `default_compose_types` must fail config load, not silently widen to "all types".
409 fn from_str(s: &str) -> Result<Self, Self::Err> {
410 match s {
411 "episodic" => Ok(Self::Episodic),
412 "user_fact" => Ok(Self::UserFact),
413 "behavioral_rule" => Ok(Self::BehavioralRule),
414 "reasoning_strategy" => Ok(Self::ReasoningStrategy),
415 "cross_session_summary" => Ok(Self::CrossSessionSummary),
416 "graph_fact" => Ok(Self::GraphFact),
417 other => Err(format!("unknown functional memory type: {other}")),
418 }
419 }
420}
421
422// ── Marker constants ──────────────────────────────────────────────────────────
423
424/// MAGMA causal edge markers used by `classify_graph_subgraph`.
425pub const CAUSAL_MARKERS: &[&str] = &[
426 "why",
427 "because",
428 "caused",
429 "cause",
430 "reason",
431 "result",
432 "led to",
433 "consequence",
434 "trigger",
435 "effect",
436 "blame",
437 "fault",
438];
439
440/// MAGMA temporal edge markers for subgraph classification.
441pub const TEMPORAL_MARKERS: &[&str] = &[
442 "before", "after", "first", "then", "timeline", "sequence", "preceded", "followed", "started",
443 "ended", "during", "prior",
444];
445
446/// MAGMA entity/structural markers.
447pub const ENTITY_MARKERS: &[&str] = &[
448 "is a",
449 "type of",
450 "kind of",
451 "part of",
452 "instance",
453 "same as",
454 "alias",
455 "subtype",
456 "subclass",
457 "belongs to",
458];
459
460/// Single-word temporal tokens that require word-boundary checking.
461pub const WORD_BOUNDARY_TEMPORAL: &[&str] = &["ago"];
462
463/// Classify a query into MAGMA edge types to use for subgraph-scoped BFS retrieval.
464///
465/// Pure heuristic, zero latency — no LLM call. Returns a prioritised list of [`EdgeType`]s.
466///
467/// # Example
468///
469/// ```
470/// use zeph_common::memory::{classify_graph_subgraph, EdgeType};
471///
472/// let types = classify_graph_subgraph("why did X happen");
473/// assert!(types.contains(&EdgeType::Causal));
474/// assert!(types.contains(&EdgeType::Semantic));
475/// ```
476#[must_use]
477pub fn classify_graph_subgraph(query: &str) -> Vec<EdgeType> {
478 let lower = query.to_ascii_lowercase();
479 let mut types: Vec<EdgeType> = Vec::new();
480
481 if CAUSAL_MARKERS.iter().any(|m| lower.contains(m)) {
482 types.push(EdgeType::Causal);
483 }
484 if TEMPORAL_MARKERS.iter().any(|m| lower.contains(m)) {
485 types.push(EdgeType::Temporal);
486 }
487 if ENTITY_MARKERS.iter().any(|m| lower.contains(m)) {
488 types.push(EdgeType::Entity);
489 }
490
491 if !types.contains(&EdgeType::Semantic) {
492 types.push(EdgeType::Semantic);
493 }
494
495 types
496}
497
498/// Parse a route name string into a [`MemoryRoute`], falling back to `fallback` on unknown values.
499///
500/// # Examples
501///
502/// ```
503/// use zeph_common::memory::{parse_route_str, MemoryRoute};
504///
505/// assert_eq!(parse_route_str("semantic", MemoryRoute::Hybrid), MemoryRoute::Semantic);
506/// assert_eq!(parse_route_str("unknown", MemoryRoute::Hybrid), MemoryRoute::Hybrid);
507/// ```
508#[must_use]
509pub fn parse_route_str(s: &str, fallback: MemoryRoute) -> MemoryRoute {
510 match s {
511 "keyword" => MemoryRoute::Keyword,
512 "semantic" => MemoryRoute::Semantic,
513 "hybrid" => MemoryRoute::Hybrid,
514 "graph" => MemoryRoute::Graph,
515 "episodic" => MemoryRoute::Episodic,
516 _ => fallback,
517 }
518}
519
520// ── TokenCounting trait ───────────────────────────────────────────────────────
521
522/// Minimal token-counting interface used by `zeph-context` for budget enforcement.
523///
524/// Defined here in Layer 0 so `zeph-context` can accept a `&dyn TokenCounting`
525/// without importing `zeph-memory`. `zeph-memory::TokenCounter` implements this trait.
526pub trait TokenCounting: Send + Sync {
527 /// Count tokens in a plain text string.
528 fn count_tokens(&self, text: &str) -> usize;
529 /// Count tokens for a JSON schema value (tool definitions).
530 fn count_tool_schema_tokens(&self, schema: &serde_json::Value) -> usize;
531}
532
533// ── Context memory DTOs ───────────────────────────────────────────────────────
534//
535// Plain data-transfer structs used by `ContextMemoryBackend`. They mirror the
536// fields that `zeph-context::assembler` actually reads from `zeph-memory` row
537// types. Keeping them here (Layer 0) allows `zeph-context` (Layer 1) to depend
538// only on `zeph-common` rather than `zeph-memory`.
539
540/// A persona fact row projection used by context assembly.
541#[derive(Debug, Clone)]
542pub struct MemPersonaFact {
543 /// Fact category label (e.g. `"preference"`, `"domain"`).
544 pub category: String,
545 /// Fact content injected into the system prompt.
546 pub content: String,
547}
548
549/// A memory tree node projection used by context assembly.
550#[derive(Debug, Clone)]
551pub struct MemTreeNode {
552 /// Node content injected into the system prompt.
553 pub content: String,
554}
555
556/// A conversation summary projection used by context assembly.
557#[derive(Debug, Clone)]
558pub struct MemSummary {
559 /// Row ID of the first message covered by this summary, if known.
560 pub first_message_id: Option<i64>,
561 /// Row ID of the last message covered by this summary, if known.
562 pub last_message_id: Option<i64>,
563 /// Summary text.
564 pub content: String,
565}
566
567/// A reasoning strategy projection used by context assembly.
568#[derive(Debug, Clone)]
569pub struct MemReasoningStrategy {
570 /// Unique strategy identifier (used by `mark_reasoning_used`).
571 pub id: String,
572 /// Outcome label (e.g. `"success"`, `"failure"`).
573 pub outcome: String,
574 /// Distilled strategy summary injected into the system prompt.
575 pub summary: String,
576}
577
578/// A user correction projection used by context assembly.
579#[derive(Debug, Clone)]
580pub struct MemCorrection {
581 /// The correction text to inject into the system prompt.
582 pub correction_text: String,
583}
584
585/// A recalled message projection used by context assembly.
586#[derive(Debug, Clone)]
587pub struct MemRecalledMessage {
588 /// Message role: `"user"`, `"assistant"`, or `"system"`.
589 pub role: String,
590 /// Message content.
591 pub content: String,
592 /// Similarity score in `[0, 1]`.
593 pub score: f32,
594}
595
596/// A neighbor fact in a graph recall result.
597#[derive(Debug, Clone)]
598pub struct MemGraphNeighbor {
599 /// Neighbor fact text.
600 pub fact: String,
601 /// Confidence score in `[0, 1]`.
602 pub confidence: f32,
603}
604
605/// A graph fact projection used by context assembly.
606#[derive(Debug, Clone)]
607pub struct MemGraphFact {
608 /// Fact text.
609 pub fact: String,
610 /// Confidence score in `[0, 1]`.
611 pub confidence: f32,
612 /// Spreading-activation score, if applicable.
613 pub activation_score: Option<f32>,
614 /// `ZoomOut` 1-hop neighbors, if view-aware expansion was requested.
615 pub neighbors: Vec<MemGraphNeighbor>,
616 /// `ZoomIn` provenance snippet, if view-aware provenance was requested.
617 pub provenance_snippet: Option<String>,
618}
619
620/// A cross-session summary search result used by context assembly.
621#[derive(Debug, Clone)]
622pub struct MemSessionSummary {
623 /// Summary text from the matched session.
624 pub summary_text: String,
625 /// Similarity score in `[0, 1]`.
626 pub score: f32,
627}
628
629/// A document chunk search result used by context assembly.
630#[derive(Debug, Clone)]
631pub struct MemDocumentChunk {
632 /// Chunk text extracted from the `"text"` payload key.
633 pub text: String,
634}
635
636/// A trajectory entry projection used by context assembly.
637#[derive(Debug, Clone)]
638pub struct MemTrajectoryEntry {
639 /// Intent description for the trajectory entry.
640 pub intent: String,
641 /// Outcome description.
642 pub outcome: String,
643 /// Confidence score in `[0, 1]`.
644 pub confidence: f64,
645}
646
647// ── GraphRecallParams ─────────────────────────────────────────────────────────
648
649/// Parameters for a graph-view recall call, used by [`ContextMemoryBackend::recall_graph_facts`].
650#[derive(Debug)]
651pub struct GraphRecallParams<'a> {
652 /// Maximum number of graph facts to return.
653 pub limit: usize,
654 /// Enrichment view (head, zoom-in, zoom-out).
655 pub view: RecallView,
656 /// Cap on `ZoomOut` neighbor expansion.
657 pub zoom_out_neighbor_cap: usize,
658 /// Maximum BFS hops during graph traversal.
659 pub max_hops: u32,
660 /// Rate at which older facts are downweighted.
661 pub temporal_decay_rate: f64,
662 /// Edge type filters for subgraph-scoped BFS.
663 pub edge_types: &'a [EdgeType],
664 /// Spreading activation parameters. `None` disables spreading activation.
665 pub spreading_activation: Option<SpreadingActivationParams>,
666}
667
668// ── ContextMemoryBackend trait ────────────────────────────────────────────────
669
670/// Abstraction over `SemanticMemory` that `zeph-context` uses for all memory
671/// operations during context assembly.
672///
673/// Defined in Layer 0 (`zeph-common`) so that `zeph-context` (Layer 1) can hold
674/// `Option<Arc<dyn ContextMemoryBackend>>` without importing `zeph-memory`.
675/// `zeph-core` (Layer 4) provides the concrete implementation that wraps
676/// `SemanticMemory`.
677///
678/// All async methods use `Pin<Box<dyn Future<...>>>` for dyn-compatibility.
679#[allow(clippy::type_complexity)]
680pub trait ContextMemoryBackend: Send + Sync {
681 /// Load persona facts with at least `min_confidence`.
682 fn load_persona_facts<'a>(
683 &'a self,
684 min_confidence: f64,
685 ) -> std::pin::Pin<
686 Box<
687 dyn std::future::Future<
688 Output = Result<Vec<MemPersonaFact>, Box<dyn std::error::Error + Send + Sync>>,
689 > + Send
690 + 'a,
691 >,
692 >;
693
694 /// Load `top_k` trajectory entries for the given `tier` filter (e.g. `"procedural"`).
695 fn load_trajectory_entries<'a>(
696 &'a self,
697 tier: Option<&'a str>,
698 top_k: usize,
699 ) -> std::pin::Pin<
700 Box<
701 dyn std::future::Future<
702 Output = Result<
703 Vec<MemTrajectoryEntry>,
704 Box<dyn std::error::Error + Send + Sync>,
705 >,
706 > + Send
707 + 'a,
708 >,
709 >;
710
711 /// Load `top_k` memory tree nodes at the given level.
712 fn load_tree_nodes<'a>(
713 &'a self,
714 level: u32,
715 top_k: usize,
716 ) -> std::pin::Pin<
717 Box<
718 dyn std::future::Future<
719 Output = Result<Vec<MemTreeNode>, Box<dyn std::error::Error + Send + Sync>>,
720 > + Send
721 + 'a,
722 >,
723 >;
724
725 /// Load all summaries for the given conversation (raw row ID).
726 fn load_summaries<'a>(
727 &'a self,
728 conversation_id: i64,
729 ) -> std::pin::Pin<
730 Box<
731 dyn std::future::Future<
732 Output = Result<Vec<MemSummary>, Box<dyn std::error::Error + Send + Sync>>,
733 > + Send
734 + 'a,
735 >,
736 >;
737
738 /// Retrieve the top-`top_k` reasoning strategies for `query`.
739 fn retrieve_reasoning_strategies<'a>(
740 &'a self,
741 query: &'a str,
742 top_k: usize,
743 ) -> std::pin::Pin<
744 Box<
745 dyn std::future::Future<
746 Output = Result<
747 Vec<MemReasoningStrategy>,
748 Box<dyn std::error::Error + Send + Sync>,
749 >,
750 > + Send
751 + 'a,
752 >,
753 >;
754
755 /// Mark reasoning strategies as used (fire-and-forget; best-effort).
756 fn mark_reasoning_used<'a>(
757 &'a self,
758 ids: &'a [String],
759 ) -> std::pin::Pin<
760 Box<
761 dyn std::future::Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>>
762 + Send
763 + 'a,
764 >,
765 >;
766
767 /// Retrieve corrections similar to `query`, up to `limit` with `min_score`.
768 fn retrieve_corrections<'a>(
769 &'a self,
770 query: &'a str,
771 limit: usize,
772 min_score: f32,
773 ) -> std::pin::Pin<
774 Box<
775 dyn std::future::Future<
776 Output = Result<Vec<MemCorrection>, Box<dyn std::error::Error + Send + Sync>>,
777 > + Send
778 + 'a,
779 >,
780 >;
781
782 /// Recall semantically similar messages for `query`, up to `limit`.
783 fn recall<'a>(
784 &'a self,
785 query: &'a str,
786 limit: usize,
787 router: Option<&'a dyn AsyncMemoryRouter>,
788 ) -> std::pin::Pin<
789 Box<
790 dyn std::future::Future<
791 Output = Result<
792 Vec<MemRecalledMessage>,
793 Box<dyn std::error::Error + Send + Sync>,
794 >,
795 > + Send
796 + 'a,
797 >,
798 >;
799
800 /// Recall graph facts for `query` with view-aware enrichment.
801 fn recall_graph_facts<'a>(
802 &'a self,
803 query: &'a str,
804 params: GraphRecallParams<'a>,
805 ) -> std::pin::Pin<
806 Box<
807 dyn std::future::Future<
808 Output = Result<Vec<MemGraphFact>, Box<dyn std::error::Error + Send + Sync>>,
809 > + Send
810 + 'a,
811 >,
812 >;
813
814 /// Search cross-session summaries for `query`, excluding `current_conversation_id`.
815 fn search_session_summaries<'a>(
816 &'a self,
817 query: &'a str,
818 limit: usize,
819 current_conversation_id: Option<i64>,
820 ) -> std::pin::Pin<
821 Box<
822 dyn std::future::Future<
823 Output = Result<
824 Vec<MemSessionSummary>,
825 Box<dyn std::error::Error + Send + Sync>,
826 >,
827 > + Send
828 + 'a,
829 >,
830 >;
831
832 /// Search a named document collection for `query`, returning `top_k` chunks.
833 fn search_document_collection<'a>(
834 &'a self,
835 collection: &'a str,
836 query: &'a str,
837 top_k: usize,
838 ) -> std::pin::Pin<
839 Box<
840 dyn std::future::Future<
841 Output = Result<
842 Vec<MemDocumentChunk>,
843 Box<dyn std::error::Error + Send + Sync>,
844 >,
845 > + Send
846 + 'a,
847 >,
848 >;
849}
850
851#[cfg(test)]
852mod tests {
853 use super::{EdgeType, FunctionalType, MemoryRoute};
854 use std::str::FromStr;
855
856 /// Locks in the `f.pad` fix (#6066): `f.write_str` ignores width/fill/align flags.
857 /// `f.pad` must reproduce the same padding a plain `&str` would get under an
858 /// identical width specifier.
859 #[test]
860 fn edge_type_display_respects_width() {
861 assert_eq!(
862 format!("{:<10}", EdgeType::Causal),
863 format!("{:<10}", "causal")
864 );
865 assert_eq!(
866 format!("{:>10}", EdgeType::Semantic),
867 format!("{:>10}", "semantic")
868 );
869 }
870
871 #[test]
872 fn memory_route_serde_roundtrip() {
873 let cases = [
874 ("\"keyword\"", MemoryRoute::Keyword),
875 ("\"semantic\"", MemoryRoute::Semantic),
876 ("\"hybrid\"", MemoryRoute::Hybrid),
877 ("\"graph\"", MemoryRoute::Graph),
878 ("\"episodic\"", MemoryRoute::Episodic),
879 ];
880 for (json_str, expected) in cases {
881 let got: MemoryRoute = serde_json::from_str(json_str).unwrap();
882 assert_eq!(got, expected);
883 let serialized = serde_json::to_string(&got).unwrap();
884 let roundtrip: MemoryRoute = serde_json::from_str(&serialized).unwrap();
885 assert_eq!(roundtrip, expected);
886 }
887 }
888
889 #[test]
890 fn memory_route_default_is_hybrid() {
891 assert_eq!(MemoryRoute::default(), MemoryRoute::Hybrid);
892 }
893
894 #[test]
895 fn functional_type_from_str_round_trips_every_variant() {
896 let all = [
897 FunctionalType::Episodic,
898 FunctionalType::UserFact,
899 FunctionalType::BehavioralRule,
900 FunctionalType::ReasoningStrategy,
901 FunctionalType::CrossSessionSummary,
902 FunctionalType::GraphFact,
903 ];
904 for variant in all {
905 assert_eq!(FunctionalType::from_str(variant.as_str()), Ok(variant));
906 }
907 }
908
909 #[test]
910 fn functional_type_from_str_rejects_unknown_string() {
911 // S4: unknown/typo'd type strings must fail closed, not silently widen.
912 assert!(FunctionalType::from_str("user_facts").is_err());
913 assert!(FunctionalType::from_str("").is_err());
914 }
915
916 #[test]
917 fn functional_type_serde_roundtrip() {
918 let cases = [
919 ("\"episodic\"", FunctionalType::Episodic),
920 ("\"user_fact\"", FunctionalType::UserFact),
921 ("\"behavioral_rule\"", FunctionalType::BehavioralRule),
922 ("\"reasoning_strategy\"", FunctionalType::ReasoningStrategy),
923 (
924 "\"cross_session_summary\"",
925 FunctionalType::CrossSessionSummary,
926 ),
927 ("\"graph_fact\"", FunctionalType::GraphFact),
928 ];
929 for (json_str, expected) in cases {
930 let got: FunctionalType = serde_json::from_str(json_str).unwrap();
931 assert_eq!(got, expected);
932 let serialized = serde_json::to_string(&got).unwrap();
933 let roundtrip: FunctionalType = serde_json::from_str(&serialized).unwrap();
934 assert_eq!(roundtrip, expected);
935 }
936 }
937
938 #[test]
939 fn functional_type_serde_rejects_unknown_variant() {
940 let result: Result<FunctionalType, _> = serde_json::from_str("\"user_facts\"");
941 assert!(result.is_err());
942 }
943
944 #[test]
945 fn functional_type_display_respects_width() {
946 assert_eq!(
947 format!("{:<12}", FunctionalType::UserFact),
948 format!("{:<12}", "user_fact")
949 );
950 }
951}