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.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 064, #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 064 §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 064 §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}