1use 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
109pub struct SemanticMemoryBackend {
111 inner: std::sync::Arc<SemanticMemory>,
112}
113
114impl SemanticMemoryBackend {
115 #[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)] 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 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
492pub struct TokenCounterAdapter(std::sync::Arc<zeph_memory::TokenCounter>);
495
496impl TokenCounterAdapter {
497 #[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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 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 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}