Skip to main content

zeph_memory/graph/
retrieval.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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/// Retrieve graph facts relevant to `query` via BFS traversal from matched seed entities.
17///
18/// Algorithm:
19/// 1. Split query into words and search for entity matches via fuzzy LIKE for each word.
20/// 2. For each matched seed entity, run BFS up to `max_hops` hops (temporal BFS when
21///    `at_timestamp` is `Some`, typed BFS when `edge_types` is non-empty).
22/// 3. Build `GraphFact` structs from edges, using depth map for `hop_distance`.
23/// 4. Deduplicate by `(entity_name, relation, target_name, edge_type)` keeping highest score.
24/// 5. Sort by score desc, truncate to `limit`.
25///
26/// # Parameters
27///
28/// - `at_timestamp`: `SQLite` datetime string (`"YYYY-MM-DD HH:MM:SS"`). When `Some`, only edges
29///   valid at that point in time are traversed. When `None`, only currently active edges are used.
30/// - `temporal_decay_rate`: non-negative decay rate (units: 1/day). `0.0` preserves the original
31///   `composite_score` ordering with no temporal adjustment.
32/// - `edge_types`: MAGMA subgraph filter. When non-empty, only traverses edges of the given types.
33///   When empty, traverses all active edges (backward-compatible).
34///
35/// # Errors
36///
37/// Returns an error if any database query fails.
38#[tracing::instrument(name = "memory.graph.retrieval.graph_recall", skip_all, err)]
39#[allow(clippy::too_many_arguments, clippy::too_many_lines)] // complex algorithm function; both suppressions justified until the function is decomposed in a future refactor
40pub 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    // graph_recall has no SpreadingActivationParams — use spec defaults.
55    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    // Step 1: hybrid seed selection (FTS5 score + structural score + community cap).
63    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    // Capture current time once for consistent decay scoring across all facts.
80    let now_secs: i64 = SystemTime::now()
81        .duration_since(UNIX_EPOCH)
82        .map_or(0, |d| d.as_secs().cast_signed());
83
84    // Step 2: BFS from each seed entity, collect facts
85    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        // Use canonical_name for stable dedup keys (S5 fix): entities reached via different
97        // aliases have different display names but share canonical_name, preventing duplicates.
98        let name_map: HashMap<i64, &str> = entities
99            .iter()
100            .map(|e| (e.id.0, e.canonical_name.as_str()))
101            .collect();
102
103        // Collect edge IDs before conversion to GraphFact (critic: issue 7 fix).
104        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        // Record edge retrievals (fire-and-forget).
143        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        // HL-F2: Hebbian weight reinforcement (fire-and-forget).
149        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    // Step 3: sort by score desc (total_cmp for deterministic NaN ordering),
160    // then dedup keeping highest-scored fact per (entity, relation, target) key.
161    // Pre-compute scores to avoid recomputing composite_score() O(n log n) times.
162    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    // Dedup key includes edge_type (critic mitigation): the same (entity, relation, target)
173    // triple can legitimately exist with different edge types. Without edge_type in the key,
174    // typed BFS would return fewer facts than expected.
175    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    // Step 4: truncate to limit
186    all_facts.truncate(limit);
187
188    Ok(all_facts)
189}
190
191/// Find seed entities using hybrid ranking: FTS5 score + structural score + community cap.
192///
193/// Algorithm:
194/// 1. Run `find_entities_ranked()` per query word (up to 5 words).
195/// 2. If empty and `embeddings` is available, fall back to embedding similarity search.
196/// 3. Compute structural scores (degree + edge type diversity).
197/// 4. Look up community IDs.
198/// 5. Combine: `hybrid_score = fts_score * (1 - structural_weight) + structural_score * structural_weight`.
199/// 6. Apply community cap: keep top `seed_community_cap` per community (0 = unlimited).
200/// 7. Guard: if cap empties the result, return top-N ignoring cap (SA-INV-10).
201///
202/// # Errors
203///
204/// Returns an error if any database query fails.
205/// Fill `fts_map` via embedding similarity when FTS5 returned zero results.
206///
207/// Returns `false` when `embed()` fails (caller should return empty seeds).
208/// On search failure: logs warning and leaves map empty (caller continues normally).
209#[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    // Step 1: gather ranked FTS5 matches per word, merge by max fts_score.
292    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    // Step 2: embedding fallback when FTS5 returns nothing.
304    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    // Step 3: structural scores.
327    let structural_scores = store.entity_structural_scores(&entity_ids).await?;
328
329    // Step 4: community IDs.
330    #[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    // Step 5: compute hybrid scores.
336    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    // Sort by hybrid score descending.
352    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    // Step 6: apply community cap.
359    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                    // No community — unlimited.
375                    result.push(se);
376                }
377            }
378        }
379        result
380    };
381
382    // Step 7: SA-INV-10 guard — if cap zeroed out non-None-community seeds, fall back to top-N.
383    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            // Clamp to [0.1, 1.0] to keep hybrid seeds above activation_threshold.
394            (se.entity.id.0, hybrid.clamp(0.1, 1.0))
395        })
396        .collect();
397
398    Ok(entity_scores)
399}
400
401/// Retrieve graph facts via SYNAPSE spreading activation from seed entities.
402///
403/// Algorithm:
404/// 1. Find seed entities via fuzzy word search (same as [`graph_recall`]).
405/// 2. Run spreading activation from seeds using `config`.
406/// 3. Return `ActivatedFact` records (edges collected during propagation) sorted by
407///    activation score descending, truncated to `limit`.
408///
409/// Edge type filtering via `edge_types` ensures MAGMA subgraph scoping is preserved
410/// (mirrors [`graph_recall`]'s `bfs_typed` path, MAJOR-05 fix).
411///
412/// # Errors
413///
414/// Returns an error if any database query fails.
415#[tracing::instrument(name = "memory.graph.retrieval.graph_recall_activated", skip_all, err)]
416#[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
417pub 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    // Record edge retrievals from activated facts (fire-and-forget).
458    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    // HL-F2: Hebbian weight reinforcement (fire-and-forget).
465    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    // Sort by activation score descending and truncate to limit.
473    facts.sort_by(|a, b| b.activation_score.total_cmp(&a.activation_score));
474
475    // Deduplicate by (source, relation, target, edge_type) keeping highest activation.
476    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        // "Ali" matches "Alice" via LIKE
588        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        // max_hops=1: only the A→B edge should be reachable from A
637        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        // Should find A→B edge, but not B→C (which is hop 2 from A)
654        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        // Both "Ali" and "Bob" match and BFS from both seeds yields the same edge
677        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        // Should not have duplicate (Alice, knows, Bob) entries
695        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        // high-confidence direct edge
721        store
722            .insert_edge(a, b, "uses", "Alpha uses Beta", 1.0, None, None)
723            .await
724            .unwrap();
725        // low-confidence direct edge
726        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        // First result should have higher composite score than second
758        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        // Insert an edge with valid_from = year 2100 (far future).
832        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        // Query at 2026 — should not see the 2100 edge.
844        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        // Insert an edge valid 2020-01-01 → 2021-01-01 (already expired by 2026).
877        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        // Querying at 2026 (after valid_to) → no edge
892        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        // Querying at 2020-06-01 (during validity window) → edge visible
914        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    // Community cap guard (SA-INV-10): when all FTS5 seeds are in a single community and
937    // community_cap = 3 < total seeds, the result must still be non-empty.
938    //
939    // This tests the guard path in find_seed_entities: if after applying the community cap
940    // the result set is empty, the function falls back to top-N uncapped.
941    #[tokio::test]
942    async fn graph_recall_community_cap_guard_non_empty() {
943        let store = setup_store().await;
944        // Create 5 entities all in the same community
945        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        // Put all 5 in the same community
962        let community_id = store
963            .upsert_community("TestComm", "test", &entity_ids, Some("fp"))
964            .await
965            .unwrap();
966        let _ = community_id;
967
968        // Create a hub entity with edges to all 5 — so BFS from the hub yields facts
969        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        // "hub" query matches the Hub entity via FTS5; it has no community so cap doesn't apply.
989        // The community-capped entities are targets, not seeds — so this tests the bypass path
990        // (None community => unlimited). Use a query that matches the community entities.
991        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        // The key invariant: result must not be empty even with cap < total seeds
1008        assert!(
1009            !result.is_empty(),
1010            "SA-INV-10: community cap must not zero out all seeds"
1011        );
1012    }
1013
1014    // Embedding fallback: when FTS5 returns 0 results and embeddings=None,
1015    // graph_recall must return empty (not error).
1016    #[tokio::test]
1017    async fn graph_recall_no_fts_match_no_embeddings_returns_empty() {
1018        let store = setup_store().await;
1019        // Populate graph with entities that won't match the query
1020        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        // Query that won't match anything via FTS5; no embeddings available
1049        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        // With decay_rate=0.0 order must be identical to composite_score ordering.
1108        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    // ── HL-F2: Hebbian weight reinforcement via graph_recall ──────────────────
1131
1132    #[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        // Confirm default weight before recall.
1153        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        // Recall with hebbian_enabled=true and lr=0.5.
1161        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    /// `embed()` timeout in `seed_embedding_fallback` → returns `false` (fail-open, empty seeds).
1238    #[tokio::test]
1239    async fn seed_embedding_fallback_embed_timeout_returns_false() {
1240        // Create stores before pausing time — the SQLite pool uses tokio timers internally.
1241        let store = setup_store().await;
1242        let emb_store_pool = store.pool().clone();
1243
1244        tokio::time::pause();
1245        // No EmbeddingStore — pass None; timeout happens before the search anyway.
1246
1247        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        // Build an EmbeddingStore backed by InMemoryVectorStore — the timeout happens
1254        // before any store access, so no Qdrant infrastructure is needed.
1255        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}