Skip to main content

zeph_agent_context/
memory_backend.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Adapters that bridge `zeph-memory` concrete types to `zeph-common` traits consumed
5//! by `zeph-context`.
6//!
7//! This module is the only place in the workspace where both `zeph-memory` and
8//! `zeph-context` interface types are visible simultaneously — by design. `zeph-core`
9//! builds adapters here at Layer 4 so that `zeph-context` (Layer 1) never imports
10//! `zeph-memory` (Layer 1).
11
12use std::pin::Pin;
13
14use zeph_common::memory::{
15    AsyncMemoryRouter, ContextMemoryBackend, GraphRecallParams, GraphRetrievalStrategy,
16    MemCorrection, MemDocumentChunk, MemGraphFact, MemGraphNeighbor, MemPersonaFact,
17    MemReasoningStrategy, MemRecalledMessage, MemSessionSummary, MemSummary, MemTrajectoryEntry,
18    MemTreeNode, RecallView,
19};
20use zeph_memory::semantic::SemanticMemory;
21use zeph_memory::{ConversationId, RecallView as MemRecallView, RecalledFact};
22
23fn box_err<E: std::error::Error + Send + Sync + 'static>(
24    e: E,
25) -> Box<dyn std::error::Error + Send + Sync> {
26    Box::new(e)
27}
28
29fn map_persona_fact(r: zeph_memory::PersonaFactRow) -> MemPersonaFact {
30    MemPersonaFact {
31        category: r.category,
32        content: r.content,
33    }
34}
35
36fn map_trajectory_entry(r: zeph_memory::TrajectoryEntryRow) -> MemTrajectoryEntry {
37    MemTrajectoryEntry {
38        intent: r.intent,
39        outcome: r.outcome,
40        confidence: r.confidence,
41    }
42}
43
44fn map_tree_node(r: zeph_memory::MemoryTreeRow) -> MemTreeNode {
45    MemTreeNode { content: r.content }
46}
47
48fn map_summary(r: zeph_memory::semantic::Summary) -> MemSummary {
49    MemSummary {
50        first_message_id: r.first_message_id.map(|m| m.0),
51        last_message_id: r.last_message_id.map(|m| m.0),
52        content: r.content,
53    }
54}
55
56fn map_reasoning_strategy(s: zeph_memory::ReasoningStrategy) -> MemReasoningStrategy {
57    MemReasoningStrategy {
58        id: s.id,
59        outcome: s.outcome.as_str().to_owned(),
60        summary: s.summary,
61    }
62}
63
64fn map_correction(c: zeph_memory::UserCorrectionRow) -> MemCorrection {
65    MemCorrection {
66        correction_text: c.correction_text,
67    }
68}
69
70fn map_recalled_message(r: zeph_memory::RecalledMessage) -> MemRecalledMessage {
71    use zeph_llm::provider::Role;
72    let role = match r.message.role {
73        Role::Assistant => "assistant",
74        Role::System => "system",
75        Role::User | _ => "user",
76    }
77    .to_owned();
78    MemRecalledMessage {
79        role,
80        content: r.message.content,
81        score: r.score,
82    }
83}
84
85fn map_graph_fact(rf: RecalledFact) -> MemGraphFact {
86    MemGraphFact {
87        fact: rf.fact.fact,
88        confidence: rf.fact.confidence,
89        activation_score: rf.activation_score,
90        neighbors: rf
91            .neighbors
92            .into_iter()
93            .map(|n| MemGraphNeighbor {
94                fact: n.fact,
95                confidence: n.confidence,
96            })
97            .collect(),
98        provenance_snippet: rf.provenance_snippet,
99    }
100}
101
102fn map_session_summary(r: zeph_memory::semantic::SessionSummaryResult) -> MemSessionSummary {
103    MemSessionSummary {
104        summary_text: r.summary_text,
105        score: r.score,
106    }
107}
108
109/// Adapter that implements [`ContextMemoryBackend`] by delegating to [`SemanticMemory`].
110pub struct SemanticMemoryBackend {
111    inner: std::sync::Arc<SemanticMemory>,
112}
113
114impl SemanticMemoryBackend {
115    /// Wrap an `Arc<SemanticMemory>` in the backend adapter.
116    #[must_use]
117    pub fn new(inner: std::sync::Arc<SemanticMemory>) -> Self {
118        Self { inner }
119    }
120}
121
122type BoxFut<'a, T> = Pin<
123    Box<
124        dyn std::future::Future<Output = Result<T, Box<dyn std::error::Error + Send + Sync>>>
125            + Send
126            + 'a,
127    >,
128>;
129
130impl ContextMemoryBackend for SemanticMemoryBackend {
131    fn load_persona_facts(&self, min_confidence: f64) -> BoxFut<'_, Vec<MemPersonaFact>> {
132        Box::pin(async move {
133            let rows = self
134                .inner
135                .sqlite()
136                .load_persona_facts(min_confidence)
137                .await
138                .map_err(box_err)?;
139            Ok(rows.into_iter().map(map_persona_fact).collect())
140        })
141    }
142
143    fn load_trajectory_entries<'a>(
144        &'a self,
145        tier: Option<&'a str>,
146        top_k: usize,
147    ) -> BoxFut<'a, Vec<MemTrajectoryEntry>> {
148        Box::pin(async move {
149            let rows = self
150                .inner
151                .sqlite()
152                .load_trajectory_entries(tier, top_k)
153                .await
154                .map_err(box_err)?;
155            Ok(rows.into_iter().map(map_trajectory_entry).collect())
156        })
157    }
158
159    fn load_tree_nodes(&self, level: u32, top_k: usize) -> BoxFut<'_, Vec<MemTreeNode>> {
160        Box::pin(async move {
161            let rows = self
162                .inner
163                .sqlite()
164                .load_tree_level(level.into(), top_k)
165                .await
166                .map_err(box_err)?;
167            Ok(rows.into_iter().map(map_tree_node).collect())
168        })
169    }
170
171    fn load_summaries(&self, conversation_id: i64) -> BoxFut<'_, Vec<MemSummary>> {
172        Box::pin(async move {
173            let cid = ConversationId(conversation_id);
174            let rows = self.inner.load_summaries(cid).await.map_err(box_err)?;
175            Ok(rows.into_iter().map(map_summary).collect())
176        })
177    }
178
179    fn retrieve_reasoning_strategies<'a>(
180        &'a self,
181        query: &'a str,
182        top_k: usize,
183    ) -> BoxFut<'a, Vec<MemReasoningStrategy>> {
184        Box::pin(async move {
185            let strategies = self
186                .inner
187                .retrieve_reasoning_strategies(query, top_k)
188                .await
189                .map_err(box_err)?;
190            Ok(strategies.into_iter().map(map_reasoning_strategy).collect())
191        })
192    }
193
194    fn mark_reasoning_used<'a>(&'a self, ids: &'a [String]) -> BoxFut<'a, ()> {
195        Box::pin(async move {
196            if let Some(ref reasoning) = self.inner.reasoning {
197                reasoning.mark_used(ids).await.map_err(box_err)?;
198            }
199            Ok(())
200        })
201    }
202
203    fn retrieve_corrections<'a>(
204        &'a self,
205        query: &'a str,
206        limit: usize,
207        min_score: f32,
208    ) -> BoxFut<'a, Vec<MemCorrection>> {
209        Box::pin(async move {
210            let corrections = self
211                .inner
212                .retrieve_similar_corrections(query, limit, min_score)
213                .await
214                .map_err(box_err)?;
215            Ok(corrections.into_iter().map(map_correction).collect())
216        })
217    }
218
219    fn recall<'a>(
220        &'a self,
221        query: &'a str,
222        limit: usize,
223        router: Option<&'a dyn AsyncMemoryRouter>,
224    ) -> BoxFut<'a, Vec<MemRecalledMessage>> {
225        Box::pin(async move {
226            let recalled = if let Some(r) = router {
227                self.inner
228                    .recall_routed_async(query, limit, None, r, None)
229                    .await
230                    .map_err(box_err)?
231            } else {
232                self.inner
233                    .recall(query, limit, None)
234                    .await
235                    .map_err(box_err)?
236            };
237            Ok(recalled.into_iter().map(map_recalled_message).collect())
238        })
239    }
240
241    #[allow(clippy::too_many_lines)] // one match arm per GraphRetrievalStrategy variant
242    fn recall_graph_facts<'a>(
243        &'a self,
244        query: &'a str,
245        params: GraphRecallParams<'a>,
246    ) -> BoxFut<'a, Vec<MemGraphFact>> {
247        Box::pin(async move {
248            let mem_view = match params.view {
249                RecallView::ZoomIn => MemRecallView::ZoomIn,
250                RecallView::ZoomOut => MemRecallView::ZoomOut,
251                _ => MemRecallView::Head,
252            };
253            let mem_edge_types: Vec<zeph_memory::EdgeType> = params
254                .edge_types
255                .iter()
256                .map(|e| {
257                    use zeph_common::memory::EdgeType as CE;
258                    use zeph_memory::EdgeType as ME;
259                    match e {
260                        CE::Temporal => ME::Temporal,
261                        CE::Causal => ME::Causal,
262                        CE::Entity => ME::Entity,
263                        _ => ME::Semantic,
264                    }
265                })
266                .collect();
267            let sa_params = params.spreading_activation.map(|p| {
268                zeph_memory::graph::SpreadingActivationParams {
269                    decay_lambda: p.decay_lambda,
270                    max_hops: p.max_hops,
271                    activation_threshold: p.activation_threshold,
272                    inhibition_threshold: p.inhibition_threshold,
273                    max_activated_nodes: p.max_activated_nodes,
274                    temporal_decay_rate: p.temporal_decay_rate,
275                    seed_structural_weight: p.seed_structural_weight,
276                    seed_community_cap: p.seed_community_cap,
277                    alpha: p.alpha,
278                }
279            });
280
281            let recalled: Vec<RecalledFact> = match params.retrieval_strategy {
282                GraphRetrievalStrategy::Synapse => {
283                    let Some(sa_params) = sa_params else {
284                        tracing::warn!(
285                            "recall_graph_facts: Synapse strategy selected but no \
286                             spreading_activation params supplied; returning empty result"
287                        );
288                        return Ok(Vec::new());
289                    };
290                    self.inner
291                        .recall_graph_activated(query, params.limit, sa_params, &mem_edge_types)
292                        .await
293                        .map_err(box_err)?
294                        .into_iter()
295                        .map(RecalledFact::from_activated_fact)
296                        .collect()
297                }
298                GraphRetrievalStrategy::Bfs => self
299                    .inner
300                    .recall_graph(
301                        query,
302                        params.limit,
303                        params.max_hops,
304                        None,
305                        params.temporal_decay_rate,
306                        &mem_edge_types,
307                    )
308                    .await
309                    .map_err(box_err)?
310                    .into_iter()
311                    .map(RecalledFact::from_graph_fact)
312                    .collect(),
313                GraphRetrievalStrategy::AStar => self
314                    .inner
315                    .recall_graph_astar(
316                        query,
317                        params.limit,
318                        params.max_hops,
319                        params.temporal_decay_rate,
320                        &mem_edge_types,
321                    )
322                    .await
323                    .map_err(box_err)?
324                    .into_iter()
325                    .map(RecalledFact::from_graph_fact)
326                    .collect(),
327                GraphRetrievalStrategy::WaterCircles => self
328                    .inner
329                    .recall_graph_watercircles(
330                        query,
331                        params.limit,
332                        params.max_hops,
333                        params.ring_limit,
334                        params.temporal_decay_rate,
335                        &mem_edge_types,
336                    )
337                    .await
338                    .map_err(box_err)?
339                    .into_iter()
340                    .map(RecalledFact::from_graph_fact)
341                    .collect(),
342                GraphRetrievalStrategy::BeamSearch => self
343                    .inner
344                    .recall_graph_beam(
345                        query,
346                        params.limit,
347                        params.beam_width,
348                        params.max_hops,
349                        params.temporal_decay_rate,
350                        &mem_edge_types,
351                    )
352                    .await
353                    .map_err(box_err)?
354                    .into_iter()
355                    .map(RecalledFact::from_graph_fact)
356                    .collect(),
357                GraphRetrievalStrategy::Hybrid => {
358                    let classified = self.inner.classify_graph_strategy(query).await;
359                    match classified.as_str() {
360                        "astar" => self
361                            .inner
362                            .recall_graph_astar(
363                                query,
364                                params.limit,
365                                params.max_hops,
366                                params.temporal_decay_rate,
367                                &mem_edge_types,
368                            )
369                            .await
370                            .map_err(box_err)?
371                            .into_iter()
372                            .map(RecalledFact::from_graph_fact)
373                            .collect(),
374                        "watercircles" => self
375                            .inner
376                            .recall_graph_watercircles(
377                                query,
378                                params.limit,
379                                params.max_hops,
380                                params.ring_limit,
381                                params.temporal_decay_rate,
382                                &mem_edge_types,
383                            )
384                            .await
385                            .map_err(box_err)?
386                            .into_iter()
387                            .map(RecalledFact::from_graph_fact)
388                            .collect(),
389                        "beam_search" => self
390                            .inner
391                            .recall_graph_beam(
392                                query,
393                                params.limit,
394                                params.beam_width,
395                                params.max_hops,
396                                params.temporal_decay_rate,
397                                &mem_edge_types,
398                            )
399                            .await
400                            .map_err(box_err)?
401                            .into_iter()
402                            .map(RecalledFact::from_graph_fact)
403                            .collect(),
404                        _ => {
405                            let Some(sa_params) = sa_params else {
406                                tracing::warn!(
407                                    "recall_graph_facts: Hybrid classified as synapse but no \
408                                     spreading_activation params supplied; returning empty result"
409                                );
410                                return Ok(Vec::new());
411                            };
412                            self.inner
413                                .recall_graph_activated(
414                                    query,
415                                    params.limit,
416                                    sa_params,
417                                    &mem_edge_types,
418                                )
419                                .await
420                                .map_err(box_err)?
421                                .into_iter()
422                                .map(RecalledFact::from_activated_fact)
423                                .collect()
424                        }
425                    }
426                }
427            };
428
429            // View-aware enrichment (ZoomIn provenance / ZoomOut neighbors) is orthogonal to
430            // which strategy produced the base fact set, so it's applied as a single
431            // post-dispatch pass shared by all 6 strategies rather than duplicated per arm.
432            let enriched = self
433                .inner
434                .enrich_recall_view(
435                    recalled,
436                    mem_view,
437                    params.zoom_out_neighbor_cap,
438                    params.limit,
439                    &mem_edge_types,
440                )
441                .await
442                .map_err(box_err)?;
443            Ok(enriched.into_iter().map(map_graph_fact).collect())
444        })
445    }
446
447    fn search_session_summaries<'a>(
448        &'a self,
449        query: &'a str,
450        limit: usize,
451        current_conversation_id: Option<i64>,
452    ) -> BoxFut<'a, Vec<MemSessionSummary>> {
453        Box::pin(async move {
454            let cid = current_conversation_id.map(ConversationId);
455            let results = self
456                .inner
457                .search_session_summaries(query, limit, cid)
458                .await
459                .map_err(box_err)?;
460            Ok(results.into_iter().map(map_session_summary).collect())
461        })
462    }
463
464    fn search_document_collection<'a>(
465        &'a self,
466        collection: &'a str,
467        query: &'a str,
468        top_k: usize,
469    ) -> BoxFut<'a, Vec<MemDocumentChunk>> {
470        Box::pin(async move {
471            let points = self
472                .inner
473                .search_document_collection(collection, query, top_k)
474                .await
475                .map_err(box_err)?;
476            Ok(points
477                .into_iter()
478                .map(|p| {
479                    let text = p
480                        .payload
481                        .get("text")
482                        .and_then(|v| v.as_str())
483                        .unwrap_or_default()
484                        .to_owned();
485                    MemDocumentChunk { text }
486                })
487                .collect())
488        })
489    }
490}
491
492/// Adapter implementing [`zeph_context::summarization::MessageTokenCounter`] for
493/// [`zeph_memory::TokenCounter`].
494pub struct TokenCounterAdapter(std::sync::Arc<zeph_memory::TokenCounter>);
495
496impl TokenCounterAdapter {
497    /// Wrap an `Arc<TokenCounter>` in the adapter.
498    #[must_use]
499    pub fn new(inner: std::sync::Arc<zeph_memory::TokenCounter>) -> Self {
500        Self(inner)
501    }
502}
503
504impl zeph_context::summarization::MessageTokenCounter for TokenCounterAdapter {
505    fn count_message_tokens(&self, msg: &zeph_llm::provider::Message) -> usize {
506        self.0.count_message_tokens(msg)
507    }
508}
509
510/// Build a memory router from the context manager's routing configuration.
511///
512/// Moved from `ContextManager::build_router()` to `zeph-agent-context` (Layer 4)
513/// so that `zeph-context` (Layer 1) no longer needs to import concrete router types
514/// from `zeph-memory` (Layer 1).
515///
516/// Returns a `Box<dyn AsyncMemoryRouter>` compatible with `ContextAssemblyInput::router`.
517#[must_use]
518pub fn build_memory_router(
519    manager: &zeph_context::manager::ContextManager,
520) -> Box<dyn zeph_common::memory::AsyncMemoryRouter + Send + Sync> {
521    use zeph_config::StoreRoutingStrategy;
522
523    if !manager.routing.enabled {
524        return Box::new(zeph_memory::HeuristicRouter);
525    }
526    let fallback = manager.routing.fallback_route;
527    match manager.routing.strategy {
528        StoreRoutingStrategy::Llm => {
529            let Some(provider) = manager.store_routing_provider.clone() else {
530                tracing::warn!(
531                    "store_routing: strategy=llm but no provider resolved; \
532                     falling back to heuristic"
533                );
534                return Box::new(zeph_memory::HeuristicRouter);
535            };
536            Box::new(zeph_memory::LlmRouter::new(provider, fallback))
537        }
538        StoreRoutingStrategy::Hybrid => {
539            let Some(provider) = manager.store_routing_provider.clone() else {
540                tracing::warn!(
541                    "store_routing: strategy=hybrid but no provider resolved; \
542                     falling back to heuristic"
543                );
544                return Box::new(zeph_memory::HeuristicRouter);
545            };
546            Box::new(zeph_memory::HybridRouter::new(
547                provider,
548                fallback,
549                manager.routing.confidence_threshold,
550            ))
551        }
552        _ => Box::new(zeph_memory::HeuristicRouter),
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use zeph_llm::provider::{Message, Role};
559    use zeph_memory::graph::types::{EdgeType, GraphFact};
560    use zeph_memory::semantic::{SessionSummaryResult, Summary};
561    use zeph_memory::types::{ConversationId, MessageId};
562    use zeph_memory::{
563        MemoryTreeRow, Outcome, PersonaFactRow, ReasoningStrategy, RecalledMessage,
564        TrajectoryEntryRow, UserCorrectionRow,
565    };
566
567    use super::*;
568
569    fn make_persona_row() -> PersonaFactRow {
570        PersonaFactRow {
571            id: 1,
572            category: "preference".to_owned(),
573            content: "prefers short answers".to_owned(),
574            confidence: 0.9,
575            evidence_count: 3,
576            source_conversation_id: None,
577            supersedes_id: None,
578            created_at: "2026-01-01".to_owned(),
579            updated_at: "2026-01-02".to_owned(),
580        }
581    }
582
583    fn make_trajectory_row() -> TrajectoryEntryRow {
584        TrajectoryEntryRow {
585            id: 1,
586            conversation_id: Some(42),
587            turn_index: 5,
588            kind: "procedural".to_owned(),
589            intent: "read a file".to_owned(),
590            outcome: "file read successfully".to_owned(),
591            tools_used: "read_file".to_owned(),
592            confidence: 0.85,
593            created_at: "2026-01-01".to_owned(),
594            updated_at: "2026-01-01".to_owned(),
595        }
596    }
597
598    fn make_tree_row() -> MemoryTreeRow {
599        MemoryTreeRow {
600            id: 1,
601            level: 0,
602            parent_id: None,
603            content: "node content here".to_owned(),
604            source_ids: "1,2,3".to_owned(),
605            token_count: 10,
606            consolidated_at: None,
607            created_at: "2026-01-01".to_owned(),
608        }
609    }
610
611    fn make_summary() -> Summary {
612        Summary {
613            id: 1,
614            conversation_id: ConversationId(10),
615            content: "summary of the conversation".to_owned(),
616            first_message_id: Some(MessageId(5)),
617            last_message_id: Some(MessageId(20)),
618            token_estimate: 100,
619        }
620    }
621
622    fn make_reasoning_strategy() -> ReasoningStrategy {
623        ReasoningStrategy {
624            id: "strat-uuid-1".to_owned(),
625            summary: "break the problem into parts".to_owned(),
626            outcome: Outcome::Success,
627            task_hint: "code refactoring task".to_owned(),
628            created_at: 1_700_000_000,
629            last_used_at: 1_700_000_100,
630            use_count: 3,
631            embedded_at: Some(1_700_000_050),
632        }
633    }
634
635    fn make_correction_row() -> UserCorrectionRow {
636        UserCorrectionRow {
637            id: 1,
638            session_id: Some(7),
639            original_output: "wrong output".to_owned(),
640            correction_text: "use bullet points".to_owned(),
641            skill_name: Some("formatting".to_owned()),
642            correction_kind: "explicit_rejection".to_owned(),
643            created_at: "2026-01-01".to_owned(),
644        }
645    }
646
647    fn make_recalled_message(role: Role) -> RecalledMessage {
648        RecalledMessage {
649            message: Message {
650                role,
651                content: "hello world".to_owned(),
652                ..Default::default()
653            },
654            score: 0.75,
655        }
656    }
657
658    fn make_graph_fact() -> GraphFact {
659        GraphFact {
660            entity_name: "Rust".to_owned(),
661            relation: "uses".to_owned(),
662            target_name: "LLVM".to_owned(),
663            fact: "Rust uses LLVM".to_owned(),
664            entity_match_score: 0.9,
665            hop_distance: 0,
666            confidence: 0.95,
667            valid_from: None,
668            edge_type: EdgeType::Semantic,
669            retrieval_count: 1,
670            edge_id: Some(10),
671        }
672    }
673
674    fn make_activated_fact(activation_score: f32) -> zeph_memory::graph::activation::ActivatedFact {
675        zeph_memory::graph::activation::ActivatedFact {
676            edge: zeph_memory::graph::types::Edge {
677                fact: "Rust uses LLVM".to_owned(),
678                confidence: 0.95,
679                ..zeph_memory::graph::types::Edge::synthetic_anchor(1)
680            },
681            activation_score,
682            is_implicit_conflict: false,
683            conflict_candidate_id: None,
684        }
685    }
686
687    fn make_session_summary() -> SessionSummaryResult {
688        SessionSummaryResult {
689            summary_text: "yesterday's session about Rust".to_owned(),
690            score: 0.88,
691            conversation_id: ConversationId(99),
692        }
693    }
694
695    // ── map_persona_fact ──────────────────────────────────────────────────────
696
697    #[test]
698    fn persona_fact_maps_fields() {
699        let row = make_persona_row();
700        let dto = map_persona_fact(row);
701        assert_eq!(dto.category, "preference");
702        assert_eq!(dto.content, "prefers short answers");
703    }
704
705    // ── map_trajectory_entry ──────────────────────────────────────────────────
706
707    #[test]
708    fn trajectory_entry_maps_fields() {
709        let row = make_trajectory_row();
710        let dto = map_trajectory_entry(row);
711        assert_eq!(dto.intent, "read a file");
712        assert_eq!(dto.outcome, "file read successfully");
713        assert!((dto.confidence - 0.85).abs() < f64::EPSILON);
714    }
715
716    // ── map_tree_node ─────────────────────────────────────────────────────────
717
718    #[test]
719    fn tree_node_maps_content() {
720        let row = make_tree_row();
721        let dto = map_tree_node(row);
722        assert_eq!(dto.content, "node content here");
723    }
724
725    // ── map_summary ───────────────────────────────────────────────────────────
726
727    #[test]
728    fn summary_maps_all_fields() {
729        let s = make_summary();
730        let dto = map_summary(s);
731        assert_eq!(dto.first_message_id, Some(5));
732        assert_eq!(dto.last_message_id, Some(20));
733        assert_eq!(dto.content, "summary of the conversation");
734    }
735
736    #[test]
737    fn summary_none_message_ids_stay_none() {
738        let s = Summary {
739            id: 2,
740            conversation_id: ConversationId(1),
741            content: "shutdown summary".to_owned(),
742            first_message_id: None,
743            last_message_id: None,
744            token_estimate: 50,
745        };
746        let dto = map_summary(s);
747        assert!(dto.first_message_id.is_none());
748        assert!(dto.last_message_id.is_none());
749    }
750
751    // ── map_reasoning_strategy ────────────────────────────────────────────────
752
753    #[test]
754    fn reasoning_strategy_maps_success_outcome() {
755        let s = make_reasoning_strategy();
756        let dto = map_reasoning_strategy(s);
757        assert_eq!(dto.id, "strat-uuid-1");
758        assert_eq!(dto.outcome, "success");
759        assert_eq!(dto.summary, "break the problem into parts");
760    }
761
762    #[test]
763    fn reasoning_strategy_maps_failure_outcome() {
764        let mut s = make_reasoning_strategy();
765        s.outcome = Outcome::Failure;
766        let dto = map_reasoning_strategy(s);
767        assert_eq!(dto.outcome, "failure");
768    }
769
770    // ── map_correction ────────────────────────────────────────────────────────
771
772    #[test]
773    fn correction_maps_text() {
774        let row = make_correction_row();
775        let dto = map_correction(row);
776        assert_eq!(dto.correction_text, "use bullet points");
777    }
778
779    // ── map_recalled_message ──────────────────────────────────────────────────
780
781    #[test]
782    fn recalled_message_maps_user_role() {
783        let rm = make_recalled_message(Role::User);
784        let dto = map_recalled_message(rm);
785        assert_eq!(dto.role, "user");
786        assert_eq!(dto.content, "hello world");
787        assert!((dto.score - 0.75).abs() < f32::EPSILON);
788    }
789
790    #[test]
791    fn recalled_message_maps_assistant_role() {
792        let rm = make_recalled_message(Role::Assistant);
793        let dto = map_recalled_message(rm);
794        assert_eq!(dto.role, "assistant");
795        assert!((dto.score - 0.75).abs() < f32::EPSILON);
796    }
797
798    #[test]
799    fn recalled_message_maps_system_role() {
800        let rm = make_recalled_message(Role::System);
801        let dto = map_recalled_message(rm);
802        assert_eq!(dto.role, "system");
803        assert!((dto.score - 0.75).abs() < f32::EPSILON);
804    }
805
806    // ── map_graph_fact ────────────────────────────────────────────────────────
807
808    #[test]
809    fn graph_fact_maps_basic_fields_with_no_enrichment() {
810        let rf = RecalledFact::from_graph_fact(make_graph_fact());
811        let dto = map_graph_fact(rf);
812        assert_eq!(dto.fact, "Rust uses LLVM");
813        assert!((dto.confidence - 0.95).abs() < f32::EPSILON);
814        assert!(dto.activation_score.is_none());
815        assert!(dto.neighbors.is_empty());
816        assert!(dto.provenance_snippet.is_none());
817    }
818
819    #[test]
820    fn graph_fact_maps_neighbors() {
821        let mut rf = RecalledFact::from_graph_fact(make_graph_fact());
822        rf.neighbors.push(GraphFact {
823            entity_name: "LLVM".to_owned(),
824            relation: "supports".to_owned(),
825            target_name: "WebAssembly".to_owned(),
826            fact: "LLVM supports WebAssembly".to_owned(),
827            entity_match_score: 0.5,
828            hop_distance: 1,
829            confidence: 0.8,
830            valid_from: None,
831            edge_type: EdgeType::Semantic,
832            retrieval_count: 0,
833            edge_id: None,
834        });
835        let dto = map_graph_fact(rf);
836        assert_eq!(dto.neighbors.len(), 1);
837        assert_eq!(dto.neighbors[0].fact, "LLVM supports WebAssembly");
838        assert!((dto.neighbors[0].confidence - 0.8).abs() < f32::EPSILON);
839    }
840
841    #[test]
842    fn graph_fact_maps_provenance_snippet() {
843        let mut rf = RecalledFact::from_graph_fact(make_graph_fact());
844        rf.provenance_snippet = Some("Rust compiler snippet".to_owned());
845        let dto = map_graph_fact(rf);
846        assert_eq!(
847            dto.provenance_snippet.as_deref(),
848            Some("Rust compiler snippet")
849        );
850    }
851
852    #[test]
853    fn activated_fact_maps_edge_fields_and_activation_score() {
854        let rf = RecalledFact::from_activated_fact(make_activated_fact(0.82));
855        let dto = map_graph_fact(rf);
856        assert_eq!(dto.fact, "Rust uses LLVM");
857        assert!((dto.confidence - 0.95).abs() < f32::EPSILON);
858        assert!(
859            dto.activation_score
860                .is_some_and(|s| (s - 0.82_f32).abs() < f32::EPSILON)
861        );
862        assert!(dto.neighbors.is_empty());
863        assert!(dto.provenance_snippet.is_none());
864    }
865
866    // ── map_session_summary ───────────────────────────────────────────────────
867
868    #[test]
869    fn session_summary_maps_fields() {
870        let r = make_session_summary();
871        let dto = map_session_summary(r);
872        assert_eq!(dto.summary_text, "yesterday's session about Rust");
873        assert!((dto.score - 0.88).abs() < f32::EPSILON);
874    }
875
876    #[test]
877    fn session_summary_score_zero() {
878        let r = SessionSummaryResult {
879            summary_text: "empty session".to_owned(),
880            score: 0.0,
881            conversation_id: ConversationId(1),
882        };
883        let dto = map_session_summary(r);
884        assert!(dto.score.abs() < f32::EPSILON);
885    }
886
887    #[test]
888    fn session_summary_score_one() {
889        let r = SessionSummaryResult {
890            summary_text: "perfect match".to_owned(),
891            score: 1.0,
892            conversation_id: ConversationId(1),
893        };
894        let dto = map_session_summary(r);
895        assert!((dto.score - 1.0_f32).abs() < f32::EPSILON);
896    }
897
898    // ── recall_graph_facts strategy dispatch (issue #6566 regression) ────────
899
900    /// Build a `SemanticMemoryBackend` over a real in-memory `SemanticMemory`, seeded with a
901    /// two-hop fixture: `beamseed -> strong` (high confidence), `beamseed -> weak` (low
902    /// confidence), and `weak -> hidden` (a hop-2 fact reachable only through the low-
903    /// confidence branch).
904    ///
905    /// `graph_recall_beam` (`retrieval_beam.rs`) keeps only the top-`beam_width` scoring
906    /// entities when propagating to the next hop, but does not prune the edges already
907    /// collected at the current hop. So with `beam_width = 1`, hop 1 still yields both
908    /// `beamseed -> strong` and `beamseed -> weak`, but only `strong` (the higher-confidence
909    /// neighbor) survives to seed hop 2 — the `weak -> hidden` edge is never reached. Plain
910    /// BFS (`graph_recall`) has no such pruning and reaches all three edges within
911    /// `max_hops = 2`. Comparing the two strategies against the same fixture/query/limit
912    /// therefore proves `retrieval_strategy` actually selects a different concrete
913    /// `SemanticMemory` method, not just that the dispatch compiles.
914    async fn seeded_beam_two_hop_backend() -> SemanticMemoryBackend {
915        let provider = zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default());
916        let memory = SemanticMemory::new(
917            ":memory:",
918            "http://127.0.0.1:1",
919            None,
920            provider,
921            "test-model",
922        )
923        .await
924        .unwrap();
925        let graph_store =
926            std::sync::Arc::new(zeph_memory::GraphStore::new(memory.sqlite().pool().clone()));
927
928        let seed_id = graph_store
929            .upsert_entity(
930                "beamseed",
931                "beamseed",
932                zeph_memory::EntityType::Concept,
933                None,
934                None,
935            )
936            .await
937            .unwrap()
938            .0;
939        let strong_id = graph_store
940            .upsert_entity(
941                "strong",
942                "strong",
943                zeph_memory::EntityType::Concept,
944                None,
945                None,
946            )
947            .await
948            .unwrap()
949            .0;
950        let weak_id = graph_store
951            .upsert_entity("weak", "weak", zeph_memory::EntityType::Concept, None, None)
952            .await
953            .unwrap()
954            .0;
955        let hidden_id = graph_store
956            .upsert_entity(
957                "hidden",
958                "hidden",
959                zeph_memory::EntityType::Concept,
960                None,
961                None,
962            )
963            .await
964            .unwrap()
965            .0;
966
967        graph_store
968            .insert_edge(
969                seed_id,
970                strong_id,
971                "relates_to",
972                "beamseed relates to strong",
973                0.95,
974                None,
975                None,
976            )
977            .await
978            .unwrap();
979        graph_store
980            .insert_edge(
981                seed_id,
982                weak_id,
983                "relates_to",
984                "beamseed relates to weak",
985                0.2,
986                None,
987                None,
988            )
989            .await
990            .unwrap();
991        graph_store
992            .insert_edge(
993                weak_id,
994                hidden_id,
995                "relates_to",
996                "weak relates to hidden",
997                0.9,
998                None,
999                None,
1000            )
1001            .await
1002            .unwrap();
1003
1004        let memory = std::sync::Arc::new(memory.with_graph_store(graph_store));
1005        SemanticMemoryBackend::new(memory)
1006    }
1007
1008    #[tokio::test]
1009    async fn recall_graph_facts_dispatches_bfs_and_beam_search_to_different_results() {
1010        let backend = seeded_beam_two_hop_backend().await;
1011
1012        let bfs_facts = backend
1013            .recall_graph_facts(
1014                "beamseed",
1015                GraphRecallParams {
1016                    limit: 10,
1017                    view: RecallView::Head,
1018                    zoom_out_neighbor_cap: 0,
1019                    max_hops: 2,
1020                    temporal_decay_rate: 0.0,
1021                    edge_types: &[],
1022                    spreading_activation: None,
1023                    retrieval_strategy: GraphRetrievalStrategy::Bfs,
1024                    beam_width: 0,
1025                    ring_limit: 0,
1026                },
1027            )
1028            .await
1029            .unwrap();
1030
1031        let beam_facts = backend
1032            .recall_graph_facts(
1033                "beamseed",
1034                GraphRecallParams {
1035                    limit: 10,
1036                    view: RecallView::Head,
1037                    zoom_out_neighbor_cap: 0,
1038                    max_hops: 2,
1039                    temporal_decay_rate: 0.0,
1040                    edge_types: &[],
1041                    spreading_activation: None,
1042                    retrieval_strategy: GraphRetrievalStrategy::BeamSearch,
1043                    beam_width: 1,
1044                    ring_limit: 0,
1045                },
1046            )
1047            .await
1048            .unwrap();
1049
1050        assert!(
1051            !beam_facts.is_empty(),
1052            "beam search with width=1 should still return the top candidate"
1053        );
1054        assert!(
1055            bfs_facts.len() > beam_facts.len(),
1056            "expected unbounded BFS to return more facts than beam_width=1 beam search; \
1057             bfs={}, beam={}",
1058            bfs_facts.len(),
1059            beam_facts.len()
1060        );
1061    }
1062
1063    // ── recall_graph_facts: AStar strategy (issue #6566) ─────────────────────
1064
1065    /// Build a backend seeded with a fixture where the shortest path to `far` runs through
1066    /// `near` (two cheap/high-confidence edges) rather than the direct low-confidence edge
1067    /// `astarseed -> far`.
1068    ///
1069    /// `graph_recall_astar` (`retrieval_astar.rs`) only keeps edges that participate in some
1070    /// shortest path between a seed and a reachable node — edge cost is `1.0 - confidence`, so
1071    /// the direct low-confidence edge (`cost = 0.9`) loses to the two-hop high-confidence path
1072    /// (`cost = 0.1 + 0.1 = 0.2`) and is dropped entirely. Plain BFS (`graph_recall`) has no
1073    /// such shortest-path filtering and keeps all three edges within `max_hops`. Comparing the
1074    /// two proves `retrieval_strategy = AStar` actually dispatches to `recall_graph_astar`, not
1075    /// just that the match arm compiles.
1076    async fn seeded_astar_three_hop_backend() -> SemanticMemoryBackend {
1077        let provider = zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default());
1078        let memory = SemanticMemory::new(
1079            ":memory:",
1080            "http://127.0.0.1:1",
1081            None,
1082            provider,
1083            "test-model",
1084        )
1085        .await
1086        .unwrap();
1087        let graph_store =
1088            std::sync::Arc::new(zeph_memory::GraphStore::new(memory.sqlite().pool().clone()));
1089
1090        let seed_id = graph_store
1091            .upsert_entity(
1092                "astarseed",
1093                "astarseed",
1094                zeph_memory::EntityType::Concept,
1095                None,
1096                None,
1097            )
1098            .await
1099            .unwrap()
1100            .0;
1101        let near_id = graph_store
1102            .upsert_entity("near", "near", zeph_memory::EntityType::Concept, None, None)
1103            .await
1104            .unwrap()
1105            .0;
1106        let far_id = graph_store
1107            .upsert_entity("far", "far", zeph_memory::EntityType::Concept, None, None)
1108            .await
1109            .unwrap()
1110            .0;
1111
1112        graph_store
1113            .insert_edge(
1114                seed_id,
1115                near_id,
1116                "relates_to",
1117                "astarseed relates to near",
1118                0.9,
1119                None,
1120                None,
1121            )
1122            .await
1123            .unwrap();
1124        graph_store
1125            .insert_edge(
1126                seed_id,
1127                far_id,
1128                "relates_to",
1129                "astarseed relates to far",
1130                0.1,
1131                None,
1132                None,
1133            )
1134            .await
1135            .unwrap();
1136        graph_store
1137            .insert_edge(
1138                near_id,
1139                far_id,
1140                "relates_to",
1141                "near relates to far",
1142                0.9,
1143                None,
1144                None,
1145            )
1146            .await
1147            .unwrap();
1148
1149        let memory = std::sync::Arc::new(memory.with_graph_store(graph_store));
1150        SemanticMemoryBackend::new(memory)
1151    }
1152
1153    #[tokio::test]
1154    async fn recall_graph_facts_dispatches_bfs_and_astar_to_different_results() {
1155        let backend = seeded_astar_three_hop_backend().await;
1156
1157        let bfs_facts = backend
1158            .recall_graph_facts(
1159                "astarseed",
1160                GraphRecallParams {
1161                    limit: 10,
1162                    view: RecallView::Head,
1163                    zoom_out_neighbor_cap: 0,
1164                    max_hops: 2,
1165                    temporal_decay_rate: 0.0,
1166                    edge_types: &[],
1167                    spreading_activation: None,
1168                    retrieval_strategy: GraphRetrievalStrategy::Bfs,
1169                    beam_width: 0,
1170                    ring_limit: 0,
1171                },
1172            )
1173            .await
1174            .unwrap();
1175
1176        let astar_facts = backend
1177            .recall_graph_facts(
1178                "astarseed",
1179                GraphRecallParams {
1180                    limit: 10,
1181                    view: RecallView::Head,
1182                    zoom_out_neighbor_cap: 0,
1183                    max_hops: 2,
1184                    temporal_decay_rate: 0.0,
1185                    edge_types: &[],
1186                    spreading_activation: None,
1187                    retrieval_strategy: GraphRetrievalStrategy::AStar,
1188                    beam_width: 0,
1189                    ring_limit: 0,
1190                },
1191            )
1192            .await
1193            .unwrap();
1194
1195        assert!(
1196            bfs_facts
1197                .iter()
1198                .any(|f| f.fact == "astarseed relates to far"),
1199            "expected plain BFS to include the direct low-confidence edge; facts={bfs_facts:?}"
1200        );
1201        assert!(
1202            !astar_facts
1203                .iter()
1204                .any(|f| f.fact == "astarseed relates to far"),
1205            "expected A* to exclude the direct edge in favor of the cheaper two-hop path; \
1206             facts={astar_facts:?}"
1207        );
1208        assert!(
1209            bfs_facts.len() > astar_facts.len(),
1210            "expected BFS to return more facts than A*'s shortest-path-only set; \
1211             bfs={}, astar={}",
1212            bfs_facts.len(),
1213            astar_facts.len()
1214        );
1215    }
1216
1217    // ── recall_graph_facts: WaterCircles strategy (issue #6566) ──────────────
1218
1219    /// Build a backend seeded with a single hop fan-out: `watercircleseed -> strong`
1220    /// (high confidence) and `watercircleseed -> weak` (low confidence).
1221    async fn seeded_watercircles_ring_backend() -> SemanticMemoryBackend {
1222        let provider = zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default());
1223        let memory = SemanticMemory::new(
1224            ":memory:",
1225            "http://127.0.0.1:1",
1226            None,
1227            provider,
1228            "test-model",
1229        )
1230        .await
1231        .unwrap();
1232        let graph_store =
1233            std::sync::Arc::new(zeph_memory::GraphStore::new(memory.sqlite().pool().clone()));
1234
1235        let seed_id = graph_store
1236            .upsert_entity(
1237                "watercircleseed",
1238                "watercircleseed",
1239                zeph_memory::EntityType::Concept,
1240                None,
1241                None,
1242            )
1243            .await
1244            .unwrap()
1245            .0;
1246        let strong_id = graph_store
1247            .upsert_entity(
1248                "strong",
1249                "strong",
1250                zeph_memory::EntityType::Concept,
1251                None,
1252                None,
1253            )
1254            .await
1255            .unwrap()
1256            .0;
1257        let weak_id = graph_store
1258            .upsert_entity("weak", "weak", zeph_memory::EntityType::Concept, None, None)
1259            .await
1260            .unwrap()
1261            .0;
1262
1263        graph_store
1264            .insert_edge(
1265                seed_id,
1266                strong_id,
1267                "relates_to",
1268                "watercircleseed relates to strong",
1269                0.95,
1270                None,
1271                None,
1272            )
1273            .await
1274            .unwrap();
1275        graph_store
1276            .insert_edge(
1277                seed_id,
1278                weak_id,
1279                "relates_to",
1280                "watercircleseed relates to weak",
1281                0.2,
1282                None,
1283                None,
1284            )
1285            .await
1286            .unwrap();
1287
1288        let memory = std::sync::Arc::new(memory.with_graph_store(graph_store));
1289        SemanticMemoryBackend::new(memory)
1290    }
1291
1292    /// Proves `retrieval_strategy = WaterCircles` dispatches to `recall_graph_watercircles`
1293    /// (a genuinely different code path than `Bfs`), rather than proving a specific pruning
1294    /// outcome.
1295    ///
1296    /// The fixture seeds two depth-1 edges from `watercircleseed`: `strong` (confidence 0.95)
1297    /// and `weak` (confidence 0.2). With `ring_limit = 1`, `WaterCircles` caps ring 1 to its
1298    /// single highest-scoring edge (`strong`), while plain `Bfs` returns both edges
1299    /// unfiltered — the length divergence (1 vs 2) proves the dispatch reaches
1300    /// `recall_graph_watercircles` rather than falling through to BFS.
1301    #[tokio::test]
1302    async fn recall_graph_facts_dispatches_bfs_and_watercircles_to_different_results() {
1303        let backend = seeded_watercircles_ring_backend().await;
1304
1305        let bfs_facts = backend
1306            .recall_graph_facts(
1307                "watercircleseed",
1308                GraphRecallParams {
1309                    limit: 10,
1310                    view: RecallView::Head,
1311                    zoom_out_neighbor_cap: 0,
1312                    max_hops: 1,
1313                    temporal_decay_rate: 0.0,
1314                    edge_types: &[],
1315                    spreading_activation: None,
1316                    retrieval_strategy: GraphRetrievalStrategy::Bfs,
1317                    beam_width: 0,
1318                    ring_limit: 0,
1319                },
1320            )
1321            .await
1322            .unwrap();
1323
1324        let watercircles_facts = backend
1325            .recall_graph_facts(
1326                "watercircleseed",
1327                GraphRecallParams {
1328                    limit: 10,
1329                    view: RecallView::Head,
1330                    zoom_out_neighbor_cap: 0,
1331                    max_hops: 1,
1332                    temporal_decay_rate: 0.0,
1333                    edge_types: &[],
1334                    spreading_activation: None,
1335                    retrieval_strategy: GraphRetrievalStrategy::WaterCircles,
1336                    beam_width: 0,
1337                    ring_limit: 1,
1338                },
1339            )
1340            .await
1341            .unwrap();
1342
1343        assert_eq!(
1344            bfs_facts.len(),
1345            2,
1346            "expected plain BFS to return both edges; facts={bfs_facts:?}"
1347        );
1348        assert_eq!(
1349            watercircles_facts.len(),
1350            1,
1351            "WaterCircles ring_limit=1 should keep only the higher-scoring edge (strong); \
1352             facts={watercircles_facts:?}"
1353        );
1354        assert_ne!(
1355            bfs_facts.len(),
1356            watercircles_facts.len(),
1357            "the divergence itself proves retrieval_strategy = WaterCircles reaches \
1358             recall_graph_watercircles rather than silently falling through to BFS"
1359        );
1360    }
1361
1362    // ── recall_graph_facts: Synapse strategy + Hybrid classifier-fallback arm ─
1363    // (issue #6566)
1364
1365    #[tokio::test]
1366    async fn recall_graph_facts_dispatches_synapse_activation_when_strategy_is_synapse() {
1367        let backend = seeded_beam_two_hop_backend().await;
1368        let sa_params = zeph_common::memory::SpreadingActivationParams {
1369            decay_lambda: 0.85,
1370            max_hops: 3,
1371            activation_threshold: 0.1,
1372            inhibition_threshold: 0.8,
1373            max_activated_nodes: 50,
1374            temporal_decay_rate: 0.0,
1375            seed_structural_weight: 0.4,
1376            seed_community_cap: 3,
1377            alpha: 0.3,
1378        };
1379
1380        let facts = backend
1381            .recall_graph_facts(
1382                "beamseed",
1383                GraphRecallParams {
1384                    limit: 10,
1385                    view: RecallView::Head,
1386                    zoom_out_neighbor_cap: 0,
1387                    max_hops: 2,
1388                    temporal_decay_rate: 0.0,
1389                    edge_types: &[],
1390                    spreading_activation: Some(sa_params),
1391                    retrieval_strategy: GraphRetrievalStrategy::Synapse,
1392                    beam_width: 0,
1393                    ring_limit: 0,
1394                },
1395            )
1396            .await
1397            .unwrap();
1398
1399        assert!(
1400            !facts.is_empty(),
1401            "expected Synapse strategy to recall at least one activated fact"
1402        );
1403        assert!(
1404            facts.iter().all(|f| f.activation_score.is_some()),
1405            "expected every fact from the Synapse arm to carry an activation_score \
1406             (proves recall_graph_activated was called, not a BFS-family method); facts={facts:?}"
1407        );
1408    }
1409
1410    /// Proves the `Hybrid` dispatch's classifier-fallback arm: when
1411    /// `classify_graph_strategy` returns anything other than `"astar"`/`"watercircles"`/
1412    /// `"beam_search"` (in practice always `"synapse"` — `classify_retrieval_strategy`
1413    /// normalizes any unrecognized LLM response to `"synapse"` itself), the `_` arm must
1414    /// call `recall_graph_activated` (Synapse), not fall through to plain BFS.
1415    ///
1416    /// `MockProvider::default()`'s `chat()` returns `"mock response"`, which the classifier
1417    /// does not recognize and therefore normalizes to `"synapse"` — driving the fallback arm
1418    /// without needing a dedicated provider fixture.
1419    #[tokio::test]
1420    async fn recall_graph_facts_hybrid_falls_back_to_synapse_when_classifier_is_unrecognized() {
1421        let backend = seeded_beam_two_hop_backend().await;
1422        let sa_params = zeph_common::memory::SpreadingActivationParams {
1423            decay_lambda: 0.85,
1424            max_hops: 3,
1425            activation_threshold: 0.1,
1426            inhibition_threshold: 0.8,
1427            max_activated_nodes: 50,
1428            temporal_decay_rate: 0.0,
1429            seed_structural_weight: 0.4,
1430            seed_community_cap: 3,
1431            alpha: 0.3,
1432        };
1433
1434        let facts = backend
1435            .recall_graph_facts(
1436                "beamseed",
1437                GraphRecallParams {
1438                    limit: 10,
1439                    view: RecallView::Head,
1440                    zoom_out_neighbor_cap: 0,
1441                    max_hops: 2,
1442                    temporal_decay_rate: 0.0,
1443                    edge_types: &[],
1444                    spreading_activation: Some(sa_params),
1445                    retrieval_strategy: GraphRetrievalStrategy::Hybrid,
1446                    beam_width: 0,
1447                    ring_limit: 0,
1448                },
1449            )
1450            .await
1451            .unwrap();
1452
1453        assert!(
1454            !facts.is_empty(),
1455            "expected the Hybrid fallback arm to recall at least one activated fact"
1456        );
1457        assert!(
1458            facts.iter().all(|f| f.activation_score.is_some()),
1459            "expected every fact from Hybrid's classifier-fallback arm to carry an \
1460             activation_score (proves it reached recall_graph_activated, not a BFS-family \
1461             method); facts={facts:?}"
1462        );
1463    }
1464
1465    // ── recall_graph_facts view enrichment across strategies (issue #6566 S2) ────────
1466    //
1467    // `recall_graph_facts`'s strategy dispatch produces raw facts from whichever concrete
1468    // `SemanticMemory` method fired, then runs `SemanticMemory::enrich_recall_view` as a
1469    // single post-dispatch pass shared by all 6 strategies. These tests prove that pass
1470    // actually attaches `ZoomIn`/`ZoomOut` enrichment for two different strategies (Synapse
1471    // and BeamSearch), not just that the dispatch match compiles.
1472
1473    /// Build a `SemanticMemoryBackend` seeded with one message and one edge carrying that
1474    /// message as its `episode_id` (source-message provenance), for `ZoomIn` tests. Returns
1475    /// the backend plus the exact snippet text the enrichment pass should surface.
1476    async fn seeded_zoomin_provenance_backend() -> (SemanticMemoryBackend, String) {
1477        let provider = zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default());
1478        let memory = SemanticMemory::new(
1479            ":memory:",
1480            "http://127.0.0.1:1",
1481            None,
1482            provider,
1483            "test-model",
1484        )
1485        .await
1486        .unwrap();
1487        let graph_store =
1488            std::sync::Arc::new(zeph_memory::GraphStore::new(memory.sqlite().pool().clone()));
1489
1490        let cid = memory.sqlite().create_conversation().await.unwrap();
1491        let snippet = "the message that introduced this fact";
1492        let message_id = memory
1493            .sqlite()
1494            .save_message(cid, "user", snippet)
1495            .await
1496            .unwrap();
1497
1498        let seed_id = graph_store
1499            .upsert_entity(
1500                "zoominseed",
1501                "zoominseed",
1502                zeph_memory::EntityType::Concept,
1503                None,
1504                None,
1505            )
1506            .await
1507            .unwrap()
1508            .0;
1509        let target_id = graph_store
1510            .upsert_entity(
1511                "zoomintarget",
1512                "zoomintarget",
1513                zeph_memory::EntityType::Concept,
1514                None,
1515                None,
1516            )
1517            .await
1518            .unwrap()
1519            .0;
1520        graph_store
1521            .insert_edge(
1522                seed_id,
1523                target_id,
1524                "relates_to",
1525                "zoominseed relates to zoomintarget",
1526                0.9,
1527                Some(message_id),
1528                None,
1529            )
1530            .await
1531            .unwrap();
1532
1533        let memory = std::sync::Arc::new(memory.with_graph_store(graph_store));
1534        (SemanticMemoryBackend::new(memory), snippet.to_owned())
1535    }
1536
1537    #[tokio::test]
1538    async fn recall_graph_facts_zoomin_enrichment_present_for_synapse_strategy() {
1539        let (backend, snippet) = seeded_zoomin_provenance_backend().await;
1540        let sa_params = zeph_common::memory::SpreadingActivationParams {
1541            decay_lambda: 0.85,
1542            max_hops: 3,
1543            activation_threshold: 0.1,
1544            inhibition_threshold: 0.8,
1545            max_activated_nodes: 50,
1546            temporal_decay_rate: 0.0,
1547            seed_structural_weight: 0.4,
1548            seed_community_cap: 3,
1549            alpha: 0.3,
1550        };
1551
1552        let facts = backend
1553            .recall_graph_facts(
1554                "zoominseed",
1555                GraphRecallParams {
1556                    limit: 10,
1557                    view: RecallView::ZoomIn,
1558                    zoom_out_neighbor_cap: 0,
1559                    max_hops: 2,
1560                    temporal_decay_rate: 0.0,
1561                    edge_types: &[],
1562                    spreading_activation: Some(sa_params),
1563                    retrieval_strategy: GraphRetrievalStrategy::Synapse,
1564                    beam_width: 0,
1565                    ring_limit: 0,
1566                },
1567            )
1568            .await
1569            .unwrap();
1570
1571        assert!(!facts.is_empty(), "expected at least one Synapse fact");
1572        assert!(
1573            facts
1574                .iter()
1575                .any(|f| f.provenance_snippet.as_deref() == Some(snippet.as_str())),
1576            "expected ZoomIn enrichment to attach the source-message snippet for the Synapse \
1577             strategy after the post-dispatch enrich_recall_view pass; facts={facts:?}"
1578        );
1579    }
1580
1581    /// Build a `SemanticMemoryBackend` seeded with a fan-out from one seed entity to a
1582    /// high-confidence "head" target and a low-confidence "neighbor" target, for `ZoomOut`
1583    /// tests. With `limit = 1`, the strategy's own result is truncated to the head edge only —
1584    /// the neighbor edge is only surfaced via `ZoomOut`'s post-dispatch 1-hop expansion.
1585    async fn seeded_zoomout_neighbor_backend() -> SemanticMemoryBackend {
1586        let provider = zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default());
1587        let memory = SemanticMemory::new(
1588            ":memory:",
1589            "http://127.0.0.1:1",
1590            None,
1591            provider,
1592            "test-model",
1593        )
1594        .await
1595        .unwrap();
1596        let graph_store =
1597            std::sync::Arc::new(zeph_memory::GraphStore::new(memory.sqlite().pool().clone()));
1598
1599        let seed_id = graph_store
1600            .upsert_entity(
1601                "zoomoutseed",
1602                "zoomoutseed",
1603                zeph_memory::EntityType::Concept,
1604                None,
1605                None,
1606            )
1607            .await
1608            .unwrap()
1609            .0;
1610        let head_id = graph_store
1611            .upsert_entity(
1612                "zoomouthead",
1613                "zoomouthead",
1614                zeph_memory::EntityType::Concept,
1615                None,
1616                None,
1617            )
1618            .await
1619            .unwrap()
1620            .0;
1621        let neighbor_id = graph_store
1622            .upsert_entity(
1623                "zoomoutneighbor",
1624                "zoomoutneighbor",
1625                zeph_memory::EntityType::Concept,
1626                None,
1627                None,
1628            )
1629            .await
1630            .unwrap()
1631            .0;
1632
1633        graph_store
1634            .insert_edge(
1635                seed_id,
1636                head_id,
1637                "relates_to",
1638                "zoomoutseed relates to zoomouthead",
1639                0.95,
1640                None,
1641                None,
1642            )
1643            .await
1644            .unwrap();
1645        graph_store
1646            .insert_edge(
1647                seed_id,
1648                neighbor_id,
1649                "relates_to",
1650                "zoomoutseed relates to zoomoutneighbor",
1651                0.3,
1652                None,
1653                None,
1654            )
1655            .await
1656            .unwrap();
1657
1658        let memory = std::sync::Arc::new(memory.with_graph_store(graph_store));
1659        SemanticMemoryBackend::new(memory)
1660    }
1661
1662    #[tokio::test]
1663    async fn recall_graph_facts_zoomout_enrichment_present_for_beam_search_strategy() {
1664        let backend = seeded_zoomout_neighbor_backend().await;
1665
1666        let facts = backend
1667            .recall_graph_facts(
1668                "zoomoutseed",
1669                GraphRecallParams {
1670                    limit: 1,
1671                    view: RecallView::ZoomOut,
1672                    zoom_out_neighbor_cap: 5,
1673                    max_hops: 1,
1674                    temporal_decay_rate: 0.0,
1675                    edge_types: &[],
1676                    spreading_activation: None,
1677                    retrieval_strategy: GraphRetrievalStrategy::BeamSearch,
1678                    beam_width: 1,
1679                    ring_limit: 0,
1680                },
1681            )
1682            .await
1683            .unwrap();
1684
1685        assert_eq!(
1686            facts.len(),
1687            1,
1688            "limit=1 should truncate BeamSearch's own result to the single highest-confidence \
1689             edge; facts={facts:?}"
1690        );
1691        assert!(
1692            !facts[0].neighbors.is_empty(),
1693            "expected ZoomOut enrichment to surface the lower-confidence sibling edge as a \
1694             1-hop neighbor for the BeamSearch strategy after the post-dispatch \
1695             enrich_recall_view pass; facts={facts:?}"
1696        );
1697        assert_eq!(
1698            facts[0].neighbors[0].fact,
1699            "zoomoutseed relates to zoomoutneighbor"
1700        );
1701    }
1702}