1use std::collections::{HashMap, HashSet};
5use std::time::{SystemTime, UNIX_EPOCH};
6#[allow(unused_imports)]
7use zeph_db::sql;
8
9use crate::embedding_store::EmbeddingStore;
10use crate::error::MemoryError;
11
12use super::activation::{ActivatedFact, SpreadingActivation, SpreadingActivationParams};
13use super::store::GraphStore;
14use super::types::{EdgeType, GraphFact};
15
16#[tracing::instrument(name = "memory.graph.retrieval.graph_recall", skip_all, err)]
39#[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub async fn graph_recall(
41 store: &GraphStore,
42 embeddings: Option<&crate::embedding_store::EmbeddingStore>,
43 provider: &zeph_llm::any::AnyProvider,
44 query: &str,
45 limit: usize,
46 max_hops: u32,
47 at_timestamp: Option<&str>,
48 temporal_decay_rate: f64,
49 edge_types: &[EdgeType],
50 hebbian_enabled: bool,
51 hebbian_lr: f32,
52 embed_timeout: std::time::Duration,
53) -> Result<Vec<GraphFact>, MemoryError> {
54 const DEFAULT_STRUCTURAL_WEIGHT: f32 = 0.4;
56 const DEFAULT_COMMUNITY_CAP: usize = 3;
57
58 if limit == 0 {
59 return Ok(Vec::new());
60 }
61
62 let entity_scores = find_seed_entities(
64 store,
65 embeddings,
66 provider,
67 query,
68 limit,
69 DEFAULT_STRUCTURAL_WEIGHT,
70 DEFAULT_COMMUNITY_CAP,
71 embed_timeout,
72 )
73 .await?;
74
75 if entity_scores.is_empty() {
76 return Ok(Vec::new());
77 }
78
79 let now_secs: i64 = SystemTime::now()
81 .duration_since(UNIX_EPOCH)
82 .map_or(0, |d| d.as_secs().cast_signed());
83
84 let mut all_facts: Vec<GraphFact> = Vec::new();
86
87 for (seed_id, seed_score) in &entity_scores {
88 let (entities, edges, depth_map) = if let Some(ts) = at_timestamp {
89 store.bfs_at_timestamp(*seed_id, max_hops, ts).await?
90 } else if !edge_types.is_empty() {
91 store.bfs_typed(*seed_id, max_hops, edge_types).await?
92 } else {
93 store.bfs_with_depth(*seed_id, max_hops).await?
94 };
95
96 let name_map: HashMap<i64, &str> = entities
99 .iter()
100 .map(|e| (e.id.0, e.canonical_name.as_str()))
101 .collect();
102
103 let traversed_edge_ids: Vec<i64> = edges.iter().map(|e| e.id).collect();
105
106 for edge in &edges {
107 let Some(&hop_distance) = depth_map
108 .get(&edge.source_entity_id)
109 .or_else(|| depth_map.get(&edge.target_entity_id))
110 else {
111 continue;
112 };
113
114 let entity_name = name_map
115 .get(&edge.source_entity_id)
116 .copied()
117 .unwrap_or_default();
118 let target_name = name_map
119 .get(&edge.target_entity_id)
120 .copied()
121 .unwrap_or_default();
122
123 if entity_name.is_empty() || target_name.is_empty() {
124 continue;
125 }
126
127 all_facts.push(GraphFact {
128 entity_name: entity_name.to_owned(),
129 relation: edge.relation.clone(),
130 target_name: target_name.to_owned(),
131 fact: edge.fact.clone(),
132 entity_match_score: *seed_score,
133 hop_distance,
134 confidence: edge.confidence,
135 valid_from: Some(edge.valid_from.clone()),
136 edge_type: edge.edge_type,
137 retrieval_count: edge.retrieval_count,
138 edge_id: Some(edge.id),
139 });
140 }
141
142 if !traversed_edge_ids.is_empty()
144 && let Err(e) = store.record_edge_retrieval(&traversed_edge_ids).await
145 {
146 tracing::warn!(error = %e, "graph_recall: failed to record edge retrieval");
147 }
148 if hebbian_enabled
150 && !traversed_edge_ids.is_empty()
151 && let Err(e) = store
152 .apply_hebbian_increment(&traversed_edge_ids, hebbian_lr)
153 .await
154 {
155 tracing::warn!(error = %e, "graph_recall: hebbian increment failed");
156 }
157 }
158
159 let mut scored: Vec<(f32, GraphFact)> = all_facts
163 .into_iter()
164 .map(|f| {
165 let s = f.score_with_decay(temporal_decay_rate, now_secs);
166 (s, f)
167 })
168 .collect();
169 scored.sort_by(|(sa, _), (sb, _)| sb.total_cmp(sa));
170 let mut all_facts: Vec<GraphFact> = scored.into_iter().map(|(_, f)| f).collect();
171
172 let mut seen: HashSet<(String, String, String, EdgeType)> = HashSet::new();
176 all_facts.retain(|f| {
177 seen.insert((
178 f.entity_name.clone(),
179 f.relation.clone(),
180 f.target_name.clone(),
181 f.edge_type,
182 ))
183 });
184
185 all_facts.truncate(limit);
187
188 Ok(all_facts)
189}
190
191#[tracing::instrument(
210 name = "memory.graph.retrieval.seed_embedding_fallback",
211 skip_all,
212 level = "debug"
213)]
214async fn seed_embedding_fallback(
215 store: &GraphStore,
216 emb_store: &EmbeddingStore,
217 provider: &zeph_llm::any::AnyProvider,
218 query: &str,
219 limit: usize,
220 fts_map: &mut HashMap<i64, (super::types::Entity, f32)>,
221 embed_timeout: std::time::Duration,
222) -> bool {
223 use zeph_llm::LlmProvider as _;
224 const ENTITY_COLLECTION: &str = "zeph_graph_entities";
225 let embedding = match tokio::time::timeout(embed_timeout, provider.embed(query)).await {
226 Ok(Ok(v)) => v,
227 Ok(Err(e)) => {
228 tracing::warn!(error = %e, "seed fallback: embed() failed, returning empty seeds");
229 return false;
230 }
231 Err(_) => {
232 tracing::warn!("seed fallback: embed() timed out, returning empty seeds");
233 return false;
234 }
235 };
236 match emb_store
237 .search_collection(ENTITY_COLLECTION, &embedding, limit, None)
238 .await
239 {
240 Ok(results) => {
241 for result in results {
242 if let Some(entity_id) = result
243 .payload
244 .get("entity_id")
245 .and_then(serde_json::Value::as_i64)
246 && let Ok(Some(entity)) = store.find_entity_by_id(entity_id).await
247 {
248 fts_map.insert(entity_id, (entity, result.score));
249 }
250 }
251 }
252 Err(e) => {
253 tracing::warn!(error = %e, "seed fallback: embedding search failed");
254 }
255 }
256 true
257}
258
259#[tracing::instrument(
260 name = "memory.graph.retrieval.find_seed_entities",
261 skip_all,
262 err,
263 level = "debug"
264)]
265#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
266pub(crate) async fn find_seed_entities(
267 store: &GraphStore,
268 embeddings: Option<&EmbeddingStore>,
269 provider: &zeph_llm::any::AnyProvider,
270 query: &str,
271 limit: usize,
272 structural_weight: f32,
273 community_cap: usize,
274 embed_timeout: std::time::Duration,
275) -> Result<HashMap<i64, f32>, MemoryError> {
276 use crate::graph::types::ScoredEntity;
277
278 const MAX_WORDS: usize = 5;
279
280 let filtered: Vec<&str> = query
281 .split_whitespace()
282 .filter(|w| w.len() >= 3)
283 .take(MAX_WORDS)
284 .collect();
285 let words: Vec<&str> = if filtered.is_empty() && !query.is_empty() {
286 vec![query]
287 } else {
288 filtered
289 };
290
291 let mut fts_map: HashMap<i64, (super::types::Entity, f32)> = HashMap::new();
293 for word in &words {
294 let ranked = store.find_entities_ranked(word, limit * 2).await?;
295 for (entity, fts_score) in ranked {
296 fts_map
297 .entry(entity.id.0)
298 .and_modify(|(_, s)| *s = s.max(fts_score))
299 .or_insert((entity, fts_score));
300 }
301 }
302
303 if fts_map.is_empty()
305 && let Some(emb_store) = embeddings
306 && !seed_embedding_fallback(
307 store,
308 emb_store,
309 provider,
310 query,
311 limit,
312 &mut fts_map,
313 embed_timeout,
314 )
315 .await
316 {
317 return Ok(HashMap::new());
318 }
319
320 if fts_map.is_empty() {
321 return Ok(HashMap::new());
322 }
323
324 let entity_ids: Vec<i64> = fts_map.keys().copied().collect();
325
326 let structural_scores = store.entity_structural_scores(&entity_ids).await?;
328
329 #[cfg(any(feature = "sqlite", feature = "postgres"))]
331 let community_ids = store.entity_community_ids(&entity_ids).await?;
332 #[cfg(not(any(feature = "sqlite", feature = "postgres")))]
333 let community_ids: HashMap<i64, i64> = HashMap::new();
334
335 let fts_weight = 1.0 - structural_weight;
337 let mut scored: Vec<ScoredEntity> = fts_map
338 .into_values()
339 .map(|(entity, fts_score)| {
340 let struct_score = structural_scores.get(&entity.id.0).copied().unwrap_or(0.0);
341 let community_id = community_ids.get(&entity.id.0).copied();
342 ScoredEntity {
343 entity,
344 fts_score,
345 structural_score: struct_score,
346 community_id,
347 }
348 })
349 .collect();
350
351 scored.sort_by(|a, b| {
353 let score_a = a.fts_score * fts_weight + a.structural_score * structural_weight;
354 let score_b = b.fts_score * fts_weight + b.structural_score * structural_weight;
355 score_b.total_cmp(&score_a)
356 });
357
358 let capped: Vec<&ScoredEntity> = if community_cap == 0 {
360 scored.iter().collect()
361 } else {
362 let mut community_counts: HashMap<i64, usize> = HashMap::new();
363 let mut result: Vec<&ScoredEntity> = Vec::new();
364 for se in &scored {
365 match se.community_id {
366 Some(cid) => {
367 let count = community_counts.entry(cid).or_insert(0);
368 if *count < community_cap {
369 *count += 1;
370 result.push(se);
371 }
372 }
373 None => {
374 result.push(se);
376 }
377 }
378 }
379 result
380 };
381
382 let selected: Vec<&ScoredEntity> = if capped.is_empty() && !scored.is_empty() {
384 scored.iter().take(limit).collect()
385 } else {
386 capped.into_iter().take(limit).collect()
387 };
388
389 let entity_scores: HashMap<i64, f32> = selected
390 .into_iter()
391 .map(|se| {
392 let hybrid = se.fts_score * fts_weight + se.structural_score * structural_weight;
393 (se.entity.id.0, hybrid.clamp(0.1, 1.0))
395 })
396 .collect();
397
398 Ok(entity_scores)
399}
400
401#[tracing::instrument(name = "memory.graph.retrieval.graph_recall_activated", skip_all, err)]
416#[allow(clippy::too_many_arguments)] pub async fn graph_recall_activated(
418 store: &GraphStore,
419 embeddings: Option<&EmbeddingStore>,
420 provider: &zeph_llm::any::AnyProvider,
421 query: &str,
422 limit: usize,
423 params: SpreadingActivationParams,
424 edge_types: &[EdgeType],
425 hebbian_enabled: bool,
426 hebbian_lr: f32,
427 embed_timeout: std::time::Duration,
428) -> Result<Vec<ActivatedFact>, MemoryError> {
429 if limit == 0 {
430 return Ok(Vec::new());
431 }
432
433 let entity_scores = find_seed_entities(
434 store,
435 embeddings,
436 provider,
437 query,
438 limit,
439 params.seed_structural_weight,
440 params.seed_community_cap,
441 embed_timeout,
442 )
443 .await?;
444
445 if entity_scores.is_empty() {
446 return Ok(Vec::new());
447 }
448
449 tracing::debug!(
450 seeds = entity_scores.len(),
451 "spreading activation: starting recall"
452 );
453
454 let sa = SpreadingActivation::new(params);
455 let (_, mut facts) = sa.spread(store, entity_scores, edge_types).await?;
456
457 let edge_ids: Vec<i64> = facts.iter().map(|f| f.edge.id).collect();
459 if !edge_ids.is_empty()
460 && let Err(e) = store.record_edge_retrieval(&edge_ids).await
461 {
462 tracing::warn!(error = %e, "graph_recall_activated: failed to record edge retrieval");
463 }
464 if hebbian_enabled
466 && !edge_ids.is_empty()
467 && let Err(e) = store.apply_hebbian_increment(&edge_ids, hebbian_lr).await
468 {
469 tracing::warn!(error = %e, "graph_recall_activated: hebbian increment failed");
470 }
471
472 facts.sort_by(|a, b| b.activation_score.total_cmp(&a.activation_score));
474
475 let mut seen: HashSet<(i64, String, i64, EdgeType)> = HashSet::new();
477 facts.retain(|f| {
478 seen.insert((
479 f.edge.source_entity_id,
480 f.edge.relation.clone(),
481 f.edge.target_entity_id,
482 f.edge.edge_type,
483 ))
484 });
485
486 facts.truncate(limit);
487
488 tracing::debug!(
489 result_count = facts.len(),
490 "spreading activation: recall complete"
491 );
492
493 Ok(facts)
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use crate::graph::store::GraphStore;
500 use crate::graph::types::EntityType;
501 use crate::store::SqliteStore;
502 use zeph_llm::any::AnyProvider;
503 use zeph_llm::mock::MockProvider;
504
505 async fn setup_store() -> GraphStore {
506 let store = SqliteStore::new(":memory:").await.unwrap();
507 GraphStore::new(store.pool().clone())
508 }
509
510 fn mock_provider() -> AnyProvider {
511 AnyProvider::Mock(MockProvider::default())
512 }
513
514 #[tokio::test]
515 async fn graph_recall_empty_graph_returns_empty() {
516 let store = setup_store().await;
517 let provider = mock_provider();
518 let result = graph_recall(
519 &store,
520 None,
521 &provider,
522 "anything",
523 10,
524 2,
525 None,
526 0.0,
527 &[],
528 false,
529 0.0,
530 std::time::Duration::from_secs(5),
531 )
532 .await
533 .unwrap();
534 assert!(result.is_empty());
535 }
536
537 #[tokio::test]
538 async fn graph_recall_zero_limit_returns_empty() {
539 let store = setup_store().await;
540 let provider = mock_provider();
541 let result = graph_recall(
542 &store,
543 None,
544 &provider,
545 "user",
546 0,
547 2,
548 None,
549 0.0,
550 &[],
551 false,
552 0.0,
553 std::time::Duration::from_secs(5),
554 )
555 .await
556 .unwrap();
557 assert!(result.is_empty());
558 }
559
560 #[tokio::test]
561 async fn graph_recall_fuzzy_match_returns_facts() {
562 let store = setup_store().await;
563 let user_id = store
564 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
565 .await
566 .unwrap()
567 .0;
568 let tool_id = store
569 .upsert_entity("neovim", "neovim", EntityType::Tool, None, None)
570 .await
571 .unwrap()
572 .0;
573 store
574 .insert_edge(
575 user_id,
576 tool_id,
577 "uses",
578 "Alice uses neovim",
579 0.9,
580 None,
581 None,
582 )
583 .await
584 .unwrap();
585
586 let provider = mock_provider();
587 let result = graph_recall(
589 &store,
590 None,
591 &provider,
592 "Ali neovim",
593 10,
594 2,
595 None,
596 0.0,
597 &[],
598 false,
599 0.0,
600 std::time::Duration::from_secs(5),
601 )
602 .await
603 .unwrap();
604 assert!(!result.is_empty());
605 assert_eq!(result[0].relation, "uses");
606 }
607
608 #[tokio::test]
609 async fn graph_recall_respects_max_hops() {
610 let store = setup_store().await;
611 let a = store
612 .upsert_entity("Alpha", "Alpha", EntityType::Person, None, None)
613 .await
614 .unwrap()
615 .0;
616 let b = store
617 .upsert_entity("Beta", "Beta", EntityType::Person, None, None)
618 .await
619 .unwrap()
620 .0;
621 let c = store
622 .upsert_entity("Gamma", "Gamma", EntityType::Person, None, None)
623 .await
624 .unwrap()
625 .0;
626 store
627 .insert_edge(a, b, "knows", "Alpha knows Beta", 0.8, None, None)
628 .await
629 .unwrap();
630 store
631 .insert_edge(b, c, "knows", "Beta knows Gamma", 0.8, None, None)
632 .await
633 .unwrap();
634
635 let provider = mock_provider();
636 let result = graph_recall(
638 &store,
639 None,
640 &provider,
641 "Alp",
642 10,
643 1,
644 None,
645 0.0,
646 &[],
647 false,
648 0.0,
649 std::time::Duration::from_secs(5),
650 )
651 .await
652 .unwrap();
653 assert!(result.iter().all(|f| f.hop_distance <= 1));
655 }
656
657 #[tokio::test]
658 async fn graph_recall_deduplicates_facts() {
659 let store = setup_store().await;
660 let alice = store
661 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
662 .await
663 .unwrap()
664 .0;
665 let bob = store
666 .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
667 .await
668 .unwrap()
669 .0;
670 store
671 .insert_edge(alice, bob, "knows", "Alice knows Bob", 0.9, None, None)
672 .await
673 .unwrap();
674
675 let provider = mock_provider();
676 let result = graph_recall(
678 &store,
679 None,
680 &provider,
681 "Ali Bob",
682 10,
683 2,
684 None,
685 0.0,
686 &[],
687 false,
688 0.0,
689 std::time::Duration::from_secs(5),
690 )
691 .await
692 .unwrap();
693
694 let mut seen = std::collections::HashSet::new();
696 for f in &result {
697 let key = (&f.entity_name, &f.relation, &f.target_name);
698 assert!(seen.insert(key), "duplicate fact found: {f:?}");
699 }
700 }
701
702 #[tokio::test]
703 async fn graph_recall_sorts_by_composite_score() {
704 let store = setup_store().await;
705 let a = store
706 .upsert_entity("Alpha", "Alpha", EntityType::Person, None, None)
707 .await
708 .unwrap()
709 .0;
710 let b = store
711 .upsert_entity("Beta", "Beta", EntityType::Tool, None, None)
712 .await
713 .unwrap()
714 .0;
715 let c = store
716 .upsert_entity("AlphaGadget", "AlphaGadget", EntityType::Tool, None, None)
717 .await
718 .unwrap()
719 .0;
720 store
722 .insert_edge(a, b, "uses", "Alpha uses Beta", 1.0, None, None)
723 .await
724 .unwrap();
725 store
727 .insert_edge(
728 a,
729 c,
730 "mentions",
731 "Alpha mentions AlphaGadget",
732 0.1,
733 None,
734 None,
735 )
736 .await
737 .unwrap();
738
739 let provider = mock_provider();
740 let result = graph_recall(
741 &store,
742 None,
743 &provider,
744 "Alp",
745 10,
746 2,
747 None,
748 0.0,
749 &[],
750 false,
751 0.0,
752 std::time::Duration::from_secs(5),
753 )
754 .await
755 .unwrap();
756
757 assert!(result.len() >= 2);
759 let s0 = result[0].composite_score();
760 let s1 = result[1].composite_score();
761 assert!(s0 >= s1, "expected sorted desc: {s0} >= {s1}");
762 }
763
764 #[tokio::test]
765 async fn graph_recall_limit_truncates() {
766 let store = setup_store().await;
767 let root = store
768 .upsert_entity("Root", "Root", EntityType::Person, None, None)
769 .await
770 .unwrap()
771 .0;
772 for i in 0..10 {
773 let target = store
774 .upsert_entity(
775 &format!("Target{i}"),
776 &format!("Target{i}"),
777 EntityType::Tool,
778 None,
779 None,
780 )
781 .await
782 .unwrap()
783 .0;
784 store
785 .insert_edge(
786 root,
787 target,
788 "has",
789 &format!("Root has Target{i}"),
790 0.8,
791 None,
792 None,
793 )
794 .await
795 .unwrap();
796 }
797
798 let provider = mock_provider();
799 let result = graph_recall(
800 &store,
801 None,
802 &provider,
803 "Roo",
804 3,
805 2,
806 None,
807 0.0,
808 &[],
809 false,
810 0.0,
811 std::time::Duration::from_secs(5),
812 )
813 .await
814 .unwrap();
815 assert!(result.len() <= 3);
816 }
817
818 #[tokio::test]
819 async fn graph_recall_at_timestamp_excludes_future_edges() {
820 let store = setup_store().await;
821 let alice = store
822 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
823 .await
824 .unwrap()
825 .0;
826 let bob = store
827 .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
828 .await
829 .unwrap()
830 .0;
831 zeph_db::query(
833 sql!("INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence, valid_from)
834 VALUES (?1, ?2, 'knows', 'Alice knows Bob', 0.9, '2100-01-01 00:00:00')"),
835 )
836 .bind(alice)
837 .bind(bob)
838 .execute(store.pool())
839 .await
840 .unwrap();
841
842 let provider = mock_provider();
843 let result = graph_recall(
845 &store,
846 None,
847 &provider,
848 "Ali",
849 10,
850 2,
851 Some("2026-01-01 00:00:00"),
852 0.0,
853 &[],
854 false,
855 0.0,
856 std::time::Duration::from_secs(5),
857 )
858 .await
859 .unwrap();
860 assert!(result.is_empty(), "future edge should be excluded");
861 }
862
863 #[tokio::test]
864 async fn graph_recall_at_timestamp_excludes_invalidated_edges() {
865 let store = setup_store().await;
866 let alice = store
867 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
868 .await
869 .unwrap()
870 .0;
871 let carol = store
872 .upsert_entity("Carol", "Carol", EntityType::Person, None, None)
873 .await
874 .unwrap()
875 .0;
876 zeph_db::query(
878 sql!("INSERT INTO graph_edges
879 (source_entity_id, target_entity_id, relation, fact, confidence, valid_from, valid_to, expired_at)
880 VALUES (?1, ?2, 'manages', 'Alice manages Carol', 0.8,
881 '2020-01-01 00:00:00', '2021-01-01 00:00:00', '2021-01-01 00:00:00')"),
882 )
883 .bind(alice)
884 .bind(carol)
885 .execute(store.pool())
886 .await
887 .unwrap();
888
889 let provider = mock_provider();
890
891 let result_current = graph_recall(
893 &store,
894 None,
895 &provider,
896 "Ali",
897 10,
898 2,
899 None,
900 0.0,
901 &[],
902 false,
903 0.0,
904 std::time::Duration::from_secs(5),
905 )
906 .await
907 .unwrap();
908 assert!(
909 result_current.is_empty(),
910 "expired edge should be invisible at current time"
911 );
912
913 let result_historical = graph_recall(
915 &store,
916 None,
917 &provider,
918 "Ali",
919 10,
920 2,
921 Some("2020-06-01 00:00:00"),
922 0.0,
923 &[],
924 false,
925 0.0,
926 std::time::Duration::from_secs(5),
927 )
928 .await
929 .unwrap();
930 assert!(
931 !result_historical.is_empty(),
932 "edge should be visible within its validity window"
933 );
934 }
935
936 #[tokio::test]
942 async fn graph_recall_community_cap_guard_non_empty() {
943 let store = setup_store().await;
944 let mut entity_ids = Vec::new();
946 for i in 0..5usize {
947 let id = store
948 .upsert_entity(
949 &format!("Entity{i}"),
950 &format!("entity{i}"),
951 crate::graph::types::EntityType::Concept,
952 None,
953 None,
954 )
955 .await
956 .unwrap()
957 .0;
958 entity_ids.push(id);
959 }
960
961 let community_id = store
963 .upsert_community("TestComm", "test", &entity_ids, Some("fp"))
964 .await
965 .unwrap();
966 let _ = community_id;
967
968 let hub = store
970 .upsert_entity(
971 "Hub",
972 "hub",
973 crate::graph::types::EntityType::Concept,
974 None,
975 None,
976 )
977 .await
978 .unwrap()
979 .0;
980 for &target in &entity_ids {
981 store
982 .insert_edge(hub, target, "has", "Hub has entity", 0.9, None, None)
983 .await
984 .unwrap();
985 }
986
987 let provider = mock_provider();
988 let result = graph_recall(
992 &store,
993 None,
994 &provider,
995 "entity",
996 10,
997 2,
998 None,
999 0.0,
1000 &[],
1001 false,
1002 0.0,
1003 std::time::Duration::from_secs(5),
1004 )
1005 .await
1006 .unwrap();
1007 assert!(
1009 !result.is_empty(),
1010 "SA-INV-10: community cap must not zero out all seeds"
1011 );
1012 }
1013
1014 #[tokio::test]
1017 async fn graph_recall_no_fts_match_no_embeddings_returns_empty() {
1018 let store = setup_store().await;
1019 let a = store
1021 .upsert_entity(
1022 "Zephyr",
1023 "zephyr",
1024 crate::graph::types::EntityType::Concept,
1025 None,
1026 None,
1027 )
1028 .await
1029 .unwrap()
1030 .0;
1031 let b = store
1032 .upsert_entity(
1033 "Concept",
1034 "concept",
1035 crate::graph::types::EntityType::Concept,
1036 None,
1037 None,
1038 )
1039 .await
1040 .unwrap()
1041 .0;
1042 store
1043 .insert_edge(a, b, "rel", "Zephyr rel Concept", 0.9, None, None)
1044 .await
1045 .unwrap();
1046
1047 let provider = mock_provider();
1048 let result = graph_recall(
1050 &store,
1051 None,
1052 &provider,
1053 "xyzzyquuxfrob",
1054 10,
1055 2,
1056 None,
1057 0.0,
1058 &[],
1059 false,
1060 0.0,
1061 std::time::Duration::from_secs(5),
1062 )
1063 .await
1064 .unwrap();
1065 assert!(
1066 result.is_empty(),
1067 "must return empty (not error) when FTS5 returns 0 and no embeddings available"
1068 );
1069 }
1070
1071 #[tokio::test]
1072 async fn graph_recall_temporal_decay_preserves_order_with_zero_rate() {
1073 let store = setup_store().await;
1074 let a = store
1075 .upsert_entity("Alpha", "Alpha", EntityType::Person, None, None)
1076 .await
1077 .unwrap()
1078 .0;
1079 let b = store
1080 .upsert_entity("Beta", "Beta", EntityType::Tool, None, None)
1081 .await
1082 .unwrap()
1083 .0;
1084 let c = store
1085 .upsert_entity("AlphaGadget", "AlphaGadget", EntityType::Tool, None, None)
1086 .await
1087 .unwrap()
1088 .0;
1089 store
1090 .insert_edge(a, b, "uses", "Alpha uses Beta", 1.0, None, None)
1091 .await
1092 .unwrap();
1093 store
1094 .insert_edge(
1095 a,
1096 c,
1097 "mentions",
1098 "Alpha mentions AlphaGadget",
1099 0.1,
1100 None,
1101 None,
1102 )
1103 .await
1104 .unwrap();
1105
1106 let provider = mock_provider();
1107 let result = graph_recall(
1109 &store,
1110 None,
1111 &provider,
1112 "Alp",
1113 10,
1114 2,
1115 None,
1116 0.0,
1117 &[],
1118 false,
1119 0.0,
1120 std::time::Duration::from_secs(5),
1121 )
1122 .await
1123 .unwrap();
1124 assert!(result.len() >= 2);
1125 let s0 = result[0].composite_score();
1126 let s1 = result[1].composite_score();
1127 assert!(s0 >= s1, "expected sorted desc: {s0} >= {s1}");
1128 }
1129
1130 #[tokio::test]
1133 async fn test_graph_recall_hebbian_enabled_increments_weight() {
1134 let store = setup_store().await;
1135 let provider = mock_provider();
1136
1137 let user = store
1138 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
1139 .await
1140 .unwrap()
1141 .0;
1142 let tool = store
1143 .upsert_entity("Vim", "Vim", EntityType::Tool, None, None)
1144 .await
1145 .unwrap()
1146 .0;
1147 let eid = store
1148 .insert_edge(user, tool, "uses", "Alice uses Vim", 0.9, None, None)
1149 .await
1150 .unwrap();
1151
1152 let weight_before: f64 = sqlx::query_scalar("SELECT weight FROM graph_edges WHERE id = ?")
1154 .bind(eid)
1155 .fetch_one(store.pool())
1156 .await
1157 .unwrap();
1158 assert!((weight_before - 1.0).abs() < 1e-6);
1159
1160 let _ = graph_recall(
1162 &store,
1163 None,
1164 &provider,
1165 "Alice Vim",
1166 10,
1167 2,
1168 None,
1169 0.0,
1170 &[],
1171 true,
1172 0.5,
1173 std::time::Duration::from_secs(5),
1174 )
1175 .await
1176 .unwrap();
1177
1178 let weight_after: f64 = sqlx::query_scalar("SELECT weight FROM graph_edges WHERE id = ?")
1179 .bind(eid)
1180 .fetch_one(store.pool())
1181 .await
1182 .unwrap();
1183 assert!(
1184 weight_after > weight_before,
1185 "weight must increase after hebbian recall, before={weight_before} after={weight_after}"
1186 );
1187 }
1188
1189 #[tokio::test]
1190 async fn test_graph_recall_hebbian_disabled_no_weight_change() {
1191 let store = setup_store().await;
1192 let provider = mock_provider();
1193
1194 let user = store
1195 .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
1196 .await
1197 .unwrap()
1198 .0;
1199 let tool = store
1200 .upsert_entity("Emacs", "Emacs", EntityType::Tool, None, None)
1201 .await
1202 .unwrap()
1203 .0;
1204 let eid = store
1205 .insert_edge(user, tool, "uses", "Bob uses Emacs", 0.9, None, None)
1206 .await
1207 .unwrap();
1208
1209 let _ = graph_recall(
1210 &store,
1211 None,
1212 &provider,
1213 "Bob Emacs",
1214 10,
1215 2,
1216 None,
1217 0.0,
1218 &[],
1219 false,
1220 0.5,
1221 std::time::Duration::from_secs(5),
1222 )
1223 .await
1224 .unwrap();
1225
1226 let weight_after: f64 = sqlx::query_scalar("SELECT weight FROM graph_edges WHERE id = ?")
1227 .bind(eid)
1228 .fetch_one(store.pool())
1229 .await
1230 .unwrap();
1231 assert!(
1232 (weight_after - 1.0).abs() < 1e-6,
1233 "weight must remain 1.0 when hebbian is disabled, got {weight_after}"
1234 );
1235 }
1236
1237 #[tokio::test]
1239 async fn seed_embedding_fallback_embed_timeout_returns_false() {
1240 let store = setup_store().await;
1242 let emb_store_pool = store.pool().clone();
1243
1244 tokio::time::pause();
1245 let slow = zeph_llm::any::AnyProvider::Mock(
1248 zeph_llm::mock::MockProvider::default().with_embed_delay(10_000),
1249 );
1250
1251 let mut fts_map = std::collections::HashMap::new();
1252
1253 let emb_store = EmbeddingStore::with_store(
1256 Box::new(crate::in_memory_store::InMemoryVectorStore::new()),
1257 emb_store_pool,
1258 );
1259
1260 let fut = seed_embedding_fallback(
1261 &store,
1262 &emb_store,
1263 &slow,
1264 "query",
1265 5,
1266 &mut fts_map,
1267 std::time::Duration::from_secs(5),
1268 );
1269 let (result, ()) = tokio::join!(fut, async {
1270 tokio::time::advance(std::time::Duration::from_secs(6)).await;
1271 });
1272
1273 assert!(
1274 !result,
1275 "seed_embedding_fallback must return false on embed timeout"
1276 );
1277 assert!(
1278 fts_map.is_empty(),
1279 "fts_map must remain empty when embed timed out"
1280 );
1281 }
1282}