Skip to main content

issundb_retrieval/
retrieve.rs

1use std::collections::HashMap;
2
3use crate::error::RetrievalError;
4use ahash::{AHashMap, AHashSet};
5use issundb_core::{EdgeId, Graph, NodeId};
6use issundb_text::{TextGraphExt, TextSearchOptions};
7use issundb_vector::{VectorError, VectorGraphExt, VectorSearchOptions};
8
9/// A subgraph extracted by a retrieval call.
10///
11/// Expansion is undirected: each hop from a seed follows outgoing and
12/// incoming edges alike, and `edges` holds every stored edge whose two
13/// endpoints are both in `nodes`, whichever way it points.
14///
15/// `nodes` and `edges` are deduplicated but unordered. `scores` maps each seed
16/// node to its relevance value; expansion-only nodes are absent from the map.
17/// For [`retrieve`] and [`retrieve_with`] the value is the seed's cosine
18/// distance from the query (lower is closer). For [`retrieve_hybrid`] it is the
19/// fused score produced by the configured [`FusionStrategy`] over the vector
20/// and text seeds (higher is more relevant). `truncated` is true when the
21/// `max_nodes` cap cut off seeds or expansion that would otherwise have been
22/// included, so a capped subgraph (whose missing edges would otherwise read as
23/// "these nodes are unconnected") is distinguishable from a complete one.
24#[derive(Debug)]
25pub struct Subgraph {
26    pub nodes: Vec<NodeId>,
27    pub edges: Vec<EdgeId>,
28    pub scores: HashMap<NodeId, f32>,
29    pub truncated: bool,
30}
31
32/// Options for `retrieve_with`.
33pub struct RetrieveOptions {
34    /// Number of seed nodes returned by the vector search.
35    pub k: usize,
36    /// BFS expansion depth from each seed node. Each hop is undirected,
37    /// following outgoing and incoming edges alike.
38    pub hops: u8,
39    /// Maximum cosine distance for a vector hit to qualify as a seed.
40    /// Hits with `distance > max_distance` are dropped before BFS expansion.
41    /// Defaults to `f32::MAX`, which keeps all `k` hits.
42    pub max_distance: f32,
43    /// Hard cap on the total number of nodes in the returned subgraph.
44    /// BFS stops as soon as this limit is reached.
45    /// `None` means no cap.
46    pub max_nodes: Option<usize>,
47}
48
49impl Default for RetrieveOptions {
50    fn default() -> Self {
51        Self {
52            k: 10,
53            hops: 2,
54            max_distance: f32::MAX,
55            max_nodes: None,
56        }
57    }
58}
59
60/// Multi-source breadth-first expansion over both edge directions.
61///
62/// Retrieval treats the graph as undirected: a seed related only through
63/// incoming edges (a chunk that MENTIONS the seeded entity) is context worth
64/// returning, so each hop follows outgoing and incoming edges alike. The cap
65/// and truncation semantics mirror `Graph::bfs_multi_source`: seeds are
66/// admitted in input order and count once each, a hop that would exceed
67/// `max_nodes` keeps the lowest node ids up to the cap, and the flag is true
68/// when the cap cut off seeds or reachable nodes. A seed no node holds
69/// contributes nothing rather than erroring.
70fn bfs_multi_source_undirected(
71    graph: &Graph,
72    seeds: &[NodeId],
73    hops: u8,
74    max_nodes: Option<usize>,
75) -> Result<(Vec<NodeId>, bool), RetrievalError> {
76    let mut visited: AHashSet<NodeId> = AHashSet::new();
77    let mut frontier: Vec<NodeId> = Vec::new();
78    let mut truncated = false;
79    for &seed in seeds {
80        if visited.contains(&seed) || !graph.node_exists(seed)? {
81            continue;
82        }
83        if max_nodes.is_some_and(|max| visited.len() >= max) {
84            truncated = true;
85            break;
86        }
87        visited.insert(seed);
88        frontier.push(seed);
89    }
90    if visited.is_empty() {
91        return Ok((Vec::new(), truncated));
92    }
93
94    for _ in 0..hops {
95        let mut discovered: AHashSet<NodeId> = AHashSet::new();
96        for incoming in [false, true] {
97            for (_, _, other) in graph.expand_bulk(&frontier, None, incoming)? {
98                if !visited.contains(&other) {
99                    discovered.insert(other);
100                }
101            }
102        }
103        if discovered.is_empty() {
104            break;
105        }
106        let mut next: Vec<NodeId> = discovered.into_iter().collect();
107        next.sort_unstable();
108        if let Some(max) = max_nodes {
109            if visited.len() >= max {
110                truncated = true;
111                break;
112            }
113            if visited.len() + next.len() > max {
114                truncated = true;
115                next.truncate(max - visited.len());
116                visited.extend(&next);
117                break;
118            }
119        }
120        visited.extend(&next);
121        frontier = next;
122    }
123
124    let mut nodes: Vec<NodeId> = visited.into_iter().collect();
125    nodes.sort_unstable();
126    Ok((nodes, truncated))
127}
128
129/// Wraps a vector search to `k` seeds and a `hops`-hop undirected BFS
130/// expansion into one subgraph materialization.
131pub fn retrieve(graph: &Graph, q: &[f32], k: usize, hops: u8) -> Result<Subgraph, RetrievalError> {
132    retrieve_with(
133        graph,
134        q,
135        &RetrieveOptions {
136            k,
137            hops,
138            ..Default::default()
139        },
140    )
141}
142
143/// Full retrieve with configurable options.
144///
145/// Runs an undirected multi-source breadth-first search from the filtered seed
146/// nodes up to `hops` hops, stopping early or capping the result when
147/// `max_nodes` is set and reached.
148pub fn retrieve_with(
149    graph: &Graph,
150    q: &[f32],
151    opts: &RetrieveOptions,
152) -> Result<Subgraph, RetrievalError> {
153    let hits = graph.vector_search(q, opts.k)?;
154
155    let mut scores: AHashMap<NodeId, f32> = AHashMap::new();
156    let mut seeds = Vec::new();
157    for hit in &hits {
158        if hit.distance <= opts.max_distance {
159            scores.insert(hit.node, hit.distance);
160            seeds.push(hit.node);
161        }
162    }
163
164    if seeds.is_empty() {
165        return Ok(Subgraph {
166            nodes: Vec::new(),
167            edges: Vec::new(),
168            scores: HashMap::new(),
169            truncated: false,
170        });
171    }
172
173    let (node_list, truncated) =
174        bfs_multi_source_undirected(graph, &seeds, opts.hops, opts.max_nodes)?;
175    let node_set: AHashSet<NodeId> = node_list.into_iter().collect();
176
177    // Keep only scores whose seed node appears in the expansion result. The
178    // expansion admits every existing seed, so this retain only matters if
179    // that ever breaks upstream.
180    scores.retain(|n, _| node_set.contains(n));
181
182    let edges = induced_edges(graph, &node_set)?;
183
184    Ok(Subgraph {
185        nodes: node_set.into_iter().collect(),
186        edges,
187        scores: scores.into_iter().collect(),
188        truncated,
189    })
190}
191
192/// Every edge whose two endpoints are both in `node_set`, direction included.
193/// Walking the outgoing adjacency of each included node is enough: any edge
194/// between two included nodes has its source among them, so its incoming
195/// appearance at the other endpoint names the same edge.
196fn induced_edges(
197    graph: &Graph,
198    node_set: &AHashSet<NodeId>,
199) -> Result<Vec<EdgeId>, RetrievalError> {
200    let mut edge_set: AHashSet<EdgeId> = AHashSet::new();
201    for &node in node_set {
202        for ne in graph.out_neighbors(node)? {
203            if node_set.contains(&ne.node) {
204                edge_set.insert(ne.edge);
205            }
206        }
207    }
208    Ok(edge_set.into_iter().collect())
209}
210
211/// Strategy for fusing vector and text relevance scores.
212#[derive(Debug, Clone)]
213pub enum FusionStrategy {
214    /// Reciprocal Rank Fusion, scoring an item as Σ 1 / (k + rank + 1) summed over
215    /// the modalities that ranked it, where `rank` is 0-based. `k` is a smoothing
216    /// constant; default 60.
217    Rrf { k: u32 },
218    /// Weighted linear combination, scoring an item as α·vector_score +
219    /// β·text_score.
220    WeightedSum {
221        vector_weight: f32,
222        text_weight: f32,
223    },
224}
225
226impl Default for FusionStrategy {
227    fn default() -> Self {
228        Self::Rrf { k: 60 }
229    }
230}
231
232/// Options for `retrieve_hybrid`.
233pub struct HybridRetrieveOptions {
234    /// Number of seed nodes from the vector search. `0` disables vector search.
235    pub vector_k: usize,
236    /// Number of seed nodes from the text search. `0` disables text search.
237    pub text_k: usize,
238    /// Label to restrict the text search. `None` searches all indexed labels.
239    pub text_label: Option<String>,
240    /// Property to restrict the text search. `None` searches all indexed properties.
241    pub text_property: Option<String>,
242    /// BFS expansion depth from each seed. Each hop is undirected,
243    /// following outgoing and incoming edges alike.
244    pub hops: u8,
245    /// Maximum cosine distance for a vector hit to qualify as a seed.
246    pub max_distance: f32,
247    /// Hard cap on total subgraph nodes.
248    pub max_nodes: Option<usize>,
249    /// If set, only nodes with this label qualify as vector-search seeds.
250    pub vector_label: Option<String>,
251    /// Score fusion strategy.
252    pub fusion: FusionStrategy,
253}
254
255impl Default for HybridRetrieveOptions {
256    fn default() -> Self {
257        Self {
258            vector_k: 10,
259            text_k: 10,
260            text_label: None,
261            text_property: None,
262            hops: 2,
263            max_distance: f32::MAX,
264            max_nodes: None,
265            vector_label: None,
266            fusion: FusionStrategy::default(),
267        }
268    }
269}
270
271/// Merges vector search seeds with full-text search seeds, fuses their scores
272/// using `opts.fusion`, then expands via undirected BFS.
273///
274/// Vector search is run when `opts.vector_k > 0` and `q` is non-empty.
275/// Text search is run when `opts.text_k > 0` and `text_query` is non-empty.
276/// Both may run simultaneously; their ranked lists are merged before BFS.
277/// When neither would run (both inputs empty or both disabled), the call
278/// returns `RetrievalError::NoQuery` instead of a silently empty subgraph.
279/// A graph with no embeddings fails the vector arm with
280/// `VectorError::EmptyIndex`; when the text arm is active that is treated as
281/// zero vector seeds, and the error propagates only when vector search is the
282/// sole active modality.
283pub fn retrieve_hybrid(
284    graph: &Graph,
285    q: &[f32],
286    text_query: &str,
287    opts: &HybridRetrieveOptions,
288) -> Result<Subgraph, RetrievalError> {
289    let vector_active = opts.vector_k > 0 && !q.is_empty();
290    let text_active = opts.text_k > 0 && !text_query.is_empty();
291    if !vector_active && !text_active {
292        return Err(RetrievalError::NoQuery);
293    }
294
295    // ---- collect vector hits -----------------------------------------------
296    let mut vec_ranks: AHashMap<NodeId, usize> = AHashMap::new();
297    let mut vec_scores: AHashMap<NodeId, f32> = AHashMap::new();
298
299    if vector_active {
300        let search = graph.vector_search_with(
301            q,
302            &VectorSearchOptions {
303                k: opts.vector_k,
304                label: opts.vector_label.clone(),
305                properties: None,
306                rescore_factor: None,
307            },
308        );
309        match search {
310            Ok(hits) => {
311                for (rank, hit) in hits.iter().enumerate() {
312                    if hit.distance <= opts.max_distance {
313                        vec_ranks.insert(hit.node, rank);
314                        vec_scores.insert(hit.node, hit.distance);
315                    }
316                }
317            }
318            // An empty vector index means zero vector seeds, not a failed
319            // call, when the text arm can still serve: the contract reserves
320            // an error for a request where neither modality would run. With
321            // vector as the only active modality the error still propagates.
322            Err(VectorError::EmptyIndex) if text_active => {}
323            Err(e) => return Err(e.into()),
324        }
325    }
326
327    // ---- collect text hits -------------------------------------------------
328    let mut text_ranks: AHashMap<NodeId, usize> = AHashMap::new();
329
330    if text_active {
331        let text_opts = TextSearchOptions {
332            label: opts.text_label.clone(),
333            property: opts.text_property.clone(),
334            limit: opts.text_k,
335            ..Default::default()
336        };
337        let text_hits = graph.text_search(text_query, &text_opts)?;
338        for (rank, hit) in text_hits.iter().enumerate() {
339            text_ranks.insert(hit.node, rank);
340        }
341    }
342
343    // ---- fuse scores -------------------------------------------------------
344    let mut fused: AHashMap<NodeId, f32> = AHashMap::new();
345
346    let all_nodes: AHashSet<NodeId> = vec_ranks.keys().chain(text_ranks.keys()).copied().collect();
347
348    for node in &all_nodes {
349        let score = match &opts.fusion {
350            FusionStrategy::Rrf { k } => {
351                let kf = *k as f32;
352                let vs = vec_ranks
353                    .get(node)
354                    .map(|r| 1.0 / (kf + *r as f32 + 1.0))
355                    .unwrap_or(0.0);
356                let ts = text_ranks
357                    .get(node)
358                    .map(|r| 1.0 / (kf + *r as f32 + 1.0))
359                    .unwrap_or(0.0);
360                vs + ts
361            }
362            FusionStrategy::WeightedSum {
363                vector_weight,
364                text_weight,
365            } => {
366                let total_vec = opts.vector_k.max(1) as f32;
367                let total_txt = opts.text_k.max(1) as f32;
368                let vs = vec_ranks
369                    .get(node)
370                    .map(|r| (total_vec - *r as f32) / total_vec)
371                    .unwrap_or(0.0);
372                let ts = text_ranks
373                    .get(node)
374                    .map(|r| (total_txt - *r as f32) / total_txt)
375                    .unwrap_or(0.0);
376                vector_weight * vs + text_weight * ts
377            }
378        };
379        fused.insert(*node, score);
380    }
381
382    // Seed the expansion from the highest-scored fused hits first: the
383    // multi-source BFS keeps only the first `max_nodes` seeds, so an arbitrary
384    // `AHashMap` iteration order would drop top-scored seeds nondeterministically.
385    // Sort by fused score descending, breaking ties by node id for a stable,
386    // reproducible seed set.
387    let mut ranked: Vec<(NodeId, f32)> = fused.iter().map(|(n, s)| (*n, *s)).collect();
388    ranked.sort_by(|a, b| {
389        b.1.partial_cmp(&a.1)
390            .unwrap_or(std::cmp::Ordering::Equal)
391            .then(a.0.cmp(&b.0))
392    });
393    let seeds: Vec<NodeId> = ranked.into_iter().map(|(n, _)| n).collect();
394
395    if seeds.is_empty() {
396        return Ok(Subgraph {
397            nodes: Vec::new(),
398            edges: Vec::new(),
399            scores: HashMap::new(),
400            truncated: false,
401        });
402    }
403
404    // ---- BFS expansion -----------------------------------------------------
405    let (node_list, truncated) =
406        bfs_multi_source_undirected(graph, &seeds, opts.hops, opts.max_nodes)?;
407    let node_set: AHashSet<NodeId> = node_list.into_iter().collect();
408
409    let mut scores: AHashMap<NodeId, f32> = fused;
410    scores.retain(|n, _| node_set.contains(n));
411
412    let edges = induced_edges(graph, &node_set)?;
413
414    Ok(Subgraph {
415        nodes: node_set.into_iter().collect(),
416        edges,
417        scores: scores.into_iter().collect(),
418        truncated,
419    })
420}
421
422#[cfg(test)]
423mod tests {
424    use serde_json::json;
425    use tempfile::TempDir;
426
427    use super::*;
428
429    fn open_tmp() -> (TempDir, Graph) {
430        let dir = TempDir::new().unwrap();
431        let g = Graph::open(dir.path(), 1).unwrap();
432        (dir, g)
433    }
434
435    #[test]
436    fn retrieve_empty_vector_index_is_an_error() {
437        let (_dir, g) = open_tmp();
438        let err = retrieve(&g, &[1.0f32, 0.0], 5, 2).unwrap_err();
439        assert!(
440            matches!(
441                err,
442                RetrievalError::Vector(issundb_vector::VectorError::EmptyIndex)
443            ),
444            "got {err:?}"
445        );
446    }
447
448    #[test]
449    fn hybrid_retrieve_without_any_query_is_an_error() {
450        let (_dir, g) = open_tmp();
451        let err = retrieve_hybrid(&g, &[], "", &HybridRetrieveOptions::default()).unwrap_err();
452        assert!(matches!(err, RetrievalError::NoQuery), "got {err:?}");
453
454        // Disabling both modalities is the same misuse as omitting both inputs.
455        let opts = HybridRetrieveOptions {
456            vector_k: 0,
457            text_k: 0,
458            ..Default::default()
459        };
460        let err = retrieve_hybrid(&g, &[1.0f32, 0.0], "cassava", &opts).unwrap_err();
461        assert!(matches!(err, RetrievalError::NoQuery), "got {err:?}");
462    }
463
464    #[test]
465    fn retrieve_hops_zero_returns_only_seed_nodes() {
466        let (_dir, g) = open_tmp();
467        let a = g.add_node("N", &json!({})).unwrap();
468        let b = g.add_node("N", &json!({})).unwrap();
469        let c = g.add_node("N", &json!({})).unwrap();
470        g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
471        g.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
472        g.add_edge(a, c, "E", &json!({})).unwrap();
473
474        // hops=0: no BFS expansion; c is only reachable via a's out-edge.
475        let sub = retrieve(&g, &[1.0f32, 0.0, 0.0], 1, 0).unwrap();
476        assert_eq!(sub.nodes.len(), 1);
477        assert_eq!(sub.nodes[0], a);
478        assert!(!sub.nodes.contains(&c));
479    }
480
481    #[test]
482    fn retrieve_expands_bfs_to_correct_depth() {
483        let (_dir, g) = open_tmp();
484        // Chain: a to b to c to d; only a has a vector.
485        let a = g.add_node("N", &json!({})).unwrap();
486        let b = g.add_node("N", &json!({})).unwrap();
487        let c = g.add_node("N", &json!({})).unwrap();
488        let d = g.add_node("N", &json!({})).unwrap();
489        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
490        g.add_edge(a, b, "E", &json!({})).unwrap();
491        g.add_edge(b, c, "E", &json!({})).unwrap();
492        g.add_edge(c, d, "E", &json!({})).unwrap();
493
494        let sub1 = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
495        let sub2 = retrieve(&g, &[1.0f32, 0.0], 1, 2).unwrap();
496
497        let mut n1 = sub1.nodes.clone();
498        n1.sort_unstable();
499        assert_eq!(n1, vec![a, b]);
500
501        let mut n2 = sub2.nodes.clone();
502        n2.sort_unstable();
503        assert_eq!(n2, vec![a, b, c]);
504    }
505
506    #[test]
507    fn retrieve_subgraph_edges_connect_only_nodes_in_set() {
508        let (_dir, g) = open_tmp();
509        // a to b to c; only a and b are in the subgraph (hops=1 from a).
510        let a = g.add_node("N", &json!({})).unwrap();
511        let b = g.add_node("N", &json!({})).unwrap();
512        let c = g.add_node("N", &json!({})).unwrap();
513        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
514        let e_ab = g.add_edge(a, b, "E", &json!({})).unwrap();
515        let _e_bc = g.add_edge(b, c, "E", &json!({})).unwrap();
516
517        let sub = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
518        assert!(sub.edges.contains(&e_ab));
519        // b to c edge must NOT appear: c is outside the 1-hop subgraph.
520        assert_eq!(sub.edges.len(), 1);
521    }
522
523    #[test]
524    fn retrieve_scores_map_contains_seed_distances() {
525        let (_dir, g) = open_tmp();
526        let a = g.add_node("N", &json!({})).unwrap();
527        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
528
529        let sub = retrieve(&g, &[1.0f32, 0.0], 1, 0).unwrap();
530        assert!(sub.scores.contains_key(&a));
531        assert!(sub.scores[&a] < 1e-5);
532    }
533
534    #[test]
535    fn retrieve_with_max_distance_filters_far_seeds() {
536        let (_dir, g) = open_tmp();
537        let a = g.add_node("N", &json!({})).unwrap();
538        let b = g.add_node("N", &json!({})).unwrap();
539        // a is at distance ~0 from the query; b is orthogonal (distance ~1).
540        g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
541        g.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
542
543        let sub = retrieve_with(
544            &g,
545            &[1.0f32, 0.0, 0.0],
546            &RetrieveOptions {
547                k: 2,
548                hops: 0,
549                max_distance: 0.1,
550                max_nodes: None,
551            },
552        )
553        .unwrap();
554
555        // Only a is within 0.1 cosine distance of the query.
556        assert_eq!(sub.nodes.len(), 1);
557        assert_eq!(sub.nodes[0], a);
558    }
559
560    #[test]
561    fn retrieve_with_max_nodes_caps_subgraph() {
562        let (_dir, g) = open_tmp();
563        // Star: a to b, c, d, e
564        let a = g.add_node("N", &json!({})).unwrap();
565        let b = g.add_node("N", &json!({})).unwrap();
566        let c = g.add_node("N", &json!({})).unwrap();
567        let d = g.add_node("N", &json!({})).unwrap();
568        let e = g.add_node("N", &json!({})).unwrap();
569        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
570        g.add_edge(a, b, "E", &json!({})).unwrap();
571        g.add_edge(a, c, "E", &json!({})).unwrap();
572        g.add_edge(a, d, "E", &json!({})).unwrap();
573        g.add_edge(a, e, "E", &json!({})).unwrap();
574
575        let sub = retrieve_with(
576            &g,
577            &[1.0f32, 0.0],
578            &RetrieveOptions {
579                k: 1,
580                hops: 1,
581                max_distance: f32::MAX,
582                max_nodes: Some(3),
583            },
584        )
585        .unwrap();
586
587        assert!(sub.nodes.len() <= 3);
588        assert!(
589            sub.truncated,
590            "the cap dropped reachable nodes, so the subgraph must say so"
591        );
592    }
593
594    #[test]
595    fn retrieve_without_a_cap_is_not_truncated() {
596        let (_dir, g) = open_tmp();
597        let a = g.add_node("N", &json!({})).unwrap();
598        let b = g.add_node("N", &json!({})).unwrap();
599        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
600        g.add_edge(a, b, "E", &json!({})).unwrap();
601
602        let sub = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
603        assert!(!sub.truncated);
604    }
605
606    #[test]
607    fn retrieve_with_multiple_seeds_each_expand_independently() {
608        let (_dir, g) = open_tmp();
609        // Two disconnected chains: a to b to c; d to e to f
610        // Both a and d have vectors and qualify as seeds.
611        // With hops=1 the subgraph must include {a, b, d, e} but not {c, f}.
612        // With hops=2 it must include all six nodes.
613        let a = g.add_node("N", &json!({})).unwrap();
614        let b = g.add_node("N", &json!({})).unwrap();
615        let c = g.add_node("N", &json!({})).unwrap();
616        let d = g.add_node("N", &json!({})).unwrap();
617        let e = g.add_node("N", &json!({})).unwrap();
618        let f = g.add_node("N", &json!({})).unwrap();
619        g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
620        g.upsert_vector(d, &[0.0f32, 1.0, 0.0]).unwrap();
621        g.add_edge(a, b, "E", &json!({})).unwrap();
622        g.add_edge(b, c, "E", &json!({})).unwrap();
623        g.add_edge(d, e, "E", &json!({})).unwrap();
624        g.add_edge(e, f, "E", &json!({})).unwrap();
625
626        let sub1 = retrieve_with(
627            &g,
628            &[1.0f32, 0.0, 0.0],
629            &RetrieveOptions {
630                k: 2,
631                hops: 1,
632                max_distance: f32::MAX,
633                max_nodes: None,
634            },
635        )
636        .unwrap();
637        let mut n1 = sub1.nodes.clone();
638        n1.sort_unstable();
639        assert!(n1.contains(&a), "seed a must be present at hops=1");
640        assert!(n1.contains(&b), "b is 1 hop from seed a");
641        assert!(n1.contains(&d), "seed d must be present at hops=1");
642        assert!(n1.contains(&e), "e is 1 hop from seed d");
643        assert!(!n1.contains(&c), "c is 2 hops from a, out of range");
644        assert!(!n1.contains(&f), "f is 2 hops from d, out of range");
645        assert_eq!(n1.len(), 4);
646
647        let sub2 = retrieve_with(
648            &g,
649            &[1.0f32, 0.0, 0.0],
650            &RetrieveOptions {
651                k: 2,
652                hops: 2,
653                max_distance: f32::MAX,
654                max_nodes: None,
655            },
656        )
657        .unwrap();
658        assert_eq!(sub2.nodes.len(), 6, "all six nodes reachable within 2 hops");
659        assert!(sub2.scores.contains_key(&a));
660        assert!(sub2.scores.contains_key(&d));
661    }
662
663    // --- retrieve_with ---
664    //
665    // Each test calls `rebuild_csr()` after graph mutations so the
666    // CSR snapshot is current before retrieve_with is invoked.
667
668    #[test]
669    fn retrieve_k_hop_expansion() {
670        let (_dir, g) = open_tmp();
671        let a = g.add_node("N", &json!({})).unwrap();
672        let b = g.add_node("N", &json!({})).unwrap();
673        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
674        g.add_edge(a, b, "E", &json!({})).unwrap();
675        g.rebuild_csr().unwrap();
676
677        let sub = retrieve_with(
678            &g,
679            &[1.0f32, 0.0],
680            &RetrieveOptions {
681                k: 1,
682                hops: 1,
683                max_distance: f32::MAX,
684                max_nodes: None,
685            },
686        )
687        .unwrap();
688
689        assert_eq!(sub.nodes.len(), 2);
690        assert!(sub.nodes.contains(&a));
691        assert!(sub.nodes.contains(&b));
692    }
693
694    #[test]
695    fn retrieve_hops_zero_returns_only_seed() {
696        let (_dir, g) = open_tmp();
697        let a = g.add_node("N", &json!({})).unwrap();
698        let b = g.add_node("N", &json!({})).unwrap();
699        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
700        g.add_edge(a, b, "E", &json!({})).unwrap();
701        g.rebuild_csr().unwrap();
702
703        let sub = retrieve_with(
704            &g,
705            &[1.0f32, 0.0],
706            &RetrieveOptions {
707                k: 1,
708                hops: 0,
709                max_distance: f32::MAX,
710                max_nodes: None,
711            },
712        )
713        .unwrap();
714
715        assert_eq!(sub.nodes, vec![a]);
716        assert!(sub.edges.is_empty(), "no edges when hops=0");
717    }
718
719    #[test]
720    fn retrieve_scores_keys_are_subset_of_nodes() {
721        let (_dir, g) = open_tmp();
722        let a = g.add_node("N", &json!({})).unwrap();
723        let b = g.add_node("N", &json!({})).unwrap();
724        let c = g.add_node("N", &json!({})).unwrap();
725        g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
726        g.upsert_vector(b, &[0.9f32, 0.1, 0.0]).unwrap();
727        g.add_edge(a, c, "E", &json!({})).unwrap();
728        g.rebuild_csr().unwrap();
729
730        let sub = retrieve_with(
731            &g,
732            &[1.0f32, 0.0, 0.0],
733            &RetrieveOptions {
734                k: 2,
735                hops: 1,
736                max_distance: f32::MAX,
737                max_nodes: None,
738            },
739        )
740        .unwrap();
741
742        // Every key in scores must be present in nodes.
743        for node_id in sub.scores.keys() {
744            assert!(
745                sub.nodes.contains(node_id),
746                "scores key {node_id:?} is absent from nodes"
747            );
748        }
749    }
750
751    #[test]
752    fn retrieve_edges_connect_only_nodes_in_subgraph() {
753        let (_dir, g) = open_tmp();
754        // Chain: a to b to c to d; seed is a (hops=1 includes {a, b}).
755        let a = g.add_node("N", &json!({})).unwrap();
756        let b = g.add_node("N", &json!({})).unwrap();
757        let c = g.add_node("N", &json!({})).unwrap();
758        let d = g.add_node("N", &json!({})).unwrap();
759        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
760        let e_ab = g.add_edge(a, b, "E", &json!({})).unwrap();
761        let _e_bc = g.add_edge(b, c, "E", &json!({})).unwrap();
762        g.add_edge(c, d, "E", &json!({})).unwrap();
763        g.rebuild_csr().unwrap();
764
765        let sub = retrieve_with(
766            &g,
767            &[1.0f32, 0.0],
768            &RetrieveOptions {
769                k: 1,
770                hops: 1,
771                max_distance: f32::MAX,
772                max_nodes: None,
773            },
774        )
775        .unwrap();
776
777        assert!(sub.nodes.contains(&a));
778        assert!(sub.nodes.contains(&b));
779        assert!(!sub.nodes.contains(&c));
780        assert!(sub.edges.contains(&e_ab), "edge a to b must be in subgraph");
781        assert_eq!(
782            sub.edges.len(),
783            1,
784            "only a to b is within the 1-hop subgraph"
785        );
786    }
787
788    #[test]
789    fn retrieve_max_distance_filters_far_seeds() {
790        let (_dir, g) = open_tmp();
791        let a = g.add_node("N", &json!({})).unwrap();
792        let b = g.add_node("N", &json!({})).unwrap();
793        // a is close to query; b is orthogonal (distance ~1).
794        g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
795        g.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
796        g.rebuild_csr().unwrap();
797
798        let sub = retrieve_with(
799            &g,
800            &[1.0f32, 0.0, 0.0],
801            &RetrieveOptions {
802                k: 2,
803                hops: 0,
804                max_distance: 0.1,
805                max_nodes: None,
806            },
807        )
808        .unwrap();
809
810        assert_eq!(sub.nodes.len(), 1);
811        assert_eq!(sub.nodes[0], a);
812        assert!(sub.scores.contains_key(&a));
813        assert!(!sub.scores.contains_key(&b));
814    }
815
816    #[test]
817    fn retrieve_max_nodes_caps_subgraph() {
818        let (_dir, g) = open_tmp();
819        // Star: a to b, c, d, e
820        let a = g.add_node("N", &json!({})).unwrap();
821        let b = g.add_node("N", &json!({})).unwrap();
822        let c = g.add_node("N", &json!({})).unwrap();
823        let d = g.add_node("N", &json!({})).unwrap();
824        let e = g.add_node("N", &json!({})).unwrap();
825        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
826        g.add_edge(a, b, "E", &json!({})).unwrap();
827        g.add_edge(a, c, "E", &json!({})).unwrap();
828        g.add_edge(a, d, "E", &json!({})).unwrap();
829        g.add_edge(a, e, "E", &json!({})).unwrap();
830        g.rebuild_csr().unwrap();
831
832        let sub = retrieve_with(
833            &g,
834            &[1.0f32, 0.0],
835            &RetrieveOptions {
836                k: 1,
837                hops: 1,
838                max_distance: f32::MAX,
839                max_nodes: Some(3),
840            },
841        )
842        .unwrap();
843
844        assert!(
845            sub.nodes.len() <= 3,
846            "expected at most 3 nodes, got {}",
847            sub.nodes.len()
848        );
849        assert!(sub.truncated, "the cap dropped reachable nodes");
850    }
851
852    #[test]
853    fn retrieve_scores_contain_seed_distances() {
854        let (_dir, g) = open_tmp();
855        let a = g.add_node("N", &json!({})).unwrap();
856        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
857        g.rebuild_csr().unwrap();
858
859        let sub = retrieve_with(
860            &g,
861            &[1.0f32, 0.0],
862            &RetrieveOptions {
863                k: 1,
864                hops: 0,
865                max_distance: f32::MAX,
866                max_nodes: None,
867            },
868        )
869        .unwrap();
870
871        assert!(sub.scores.contains_key(&a));
872        assert!(
873            sub.scores[&a] < 1e-5,
874            "distance to identical vector must be ~0"
875        );
876    }
877
878    #[test]
879    fn retrieve_with_over_an_empty_vector_index_is_an_error() {
880        let (_dir, g) = open_tmp();
881        g.rebuild_csr().unwrap();
882
883        let err = retrieve_with(&g, &[1.0f32, 0.0], &RetrieveOptions::default()).unwrap_err();
884        assert!(
885            matches!(
886                err,
887                RetrievalError::Vector(issundb_vector::VectorError::EmptyIndex)
888            ),
889            "got {err:?}"
890        );
891    }
892
893    #[test]
894    fn retrieve_multiple_seeds_each_expand_independently() {
895        let (_dir, g) = open_tmp();
896        // Two disconnected chains
897        // a to b to c; d to e to f, with vectors on a and d.
898        let a = g.add_node("N", &json!({})).unwrap();
899        let b = g.add_node("N", &json!({})).unwrap();
900        let c = g.add_node("N", &json!({})).unwrap();
901        let d = g.add_node("N", &json!({})).unwrap();
902        let e = g.add_node("N", &json!({})).unwrap();
903        let f = g.add_node("N", &json!({})).unwrap();
904        g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
905        g.upsert_vector(d, &[0.0f32, 1.0, 0.0]).unwrap();
906        g.add_edge(a, b, "E", &json!({})).unwrap();
907        g.add_edge(b, c, "E", &json!({})).unwrap();
908        g.add_edge(d, e, "E", &json!({})).unwrap();
909        g.add_edge(e, f, "E", &json!({})).unwrap();
910        g.rebuild_csr().unwrap();
911
912        let sub1 = retrieve_with(
913            &g,
914            &[1.0f32, 0.0, 0.0],
915            &RetrieveOptions {
916                k: 2,
917                hops: 1,
918                max_distance: f32::MAX,
919                max_nodes: None,
920            },
921        )
922        .unwrap();
923        assert!(sub1.nodes.contains(&a), "seed a must be present at hops=1");
924        assert!(sub1.nodes.contains(&b), "b is 1 hop from seed a");
925        assert!(sub1.nodes.contains(&d), "seed d must be present at hops=1");
926        assert!(sub1.nodes.contains(&e), "e is 1 hop from seed d");
927        assert!(!sub1.nodes.contains(&c), "c is 2 hops from a, out of range");
928        assert!(!sub1.nodes.contains(&f), "f is 2 hops from d, out of range");
929        assert_eq!(sub1.nodes.len(), 4);
930
931        let sub2 = retrieve_with(
932            &g,
933            &[1.0f32, 0.0, 0.0],
934            &RetrieveOptions {
935                k: 2,
936                hops: 2,
937                max_distance: f32::MAX,
938                max_nodes: None,
939            },
940        )
941        .unwrap();
942        assert_eq!(sub2.nodes.len(), 6, "all six nodes reachable within 2 hops");
943        assert!(sub2.scores.contains_key(&a));
944        assert!(sub2.scores.contains_key(&d));
945    }
946
947    #[test]
948    fn hybrid_retrieve_vector_only_matches_pure_vector_search() {
949        let (_dir, g) = open_tmp();
950        let a = g.add_node("N", &json!({})).unwrap();
951        let b = g.add_node("N", &json!({})).unwrap();
952        g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
953        g.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
954        g.rebuild_csr().unwrap();
955
956        let sub = retrieve_hybrid(
957            &g,
958            &[1.0f32, 0.0, 0.0],
959            "",
960            &HybridRetrieveOptions {
961                vector_k: 1,
962                text_k: 0,
963                hops: 0,
964                ..Default::default()
965            },
966        )
967        .unwrap();
968        assert_eq!(sub.nodes.len(), 1);
969        assert_eq!(sub.nodes[0], a);
970    }
971
972    /// `retrieve_hybrid` must keep the highest-scored seeds when `max_nodes`
973    /// caps the result, not an arbitrary subset. Regression for score-blind,
974    /// nondeterministic seed truncation.
975    #[test]
976    fn hybrid_retrieve_keeps_top_scored_seeds_under_max_nodes() {
977        let (_dir, g) = open_tmp();
978        let a = g.add_node("N", &json!({})).unwrap();
979        let b = g.add_node("N", &json!({})).unwrap();
980        let c = g.add_node("N", &json!({})).unwrap();
981        let d = g.add_node("N", &json!({})).unwrap();
982        g.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap(); // closest to the query
983        g.upsert_vector(b, &[0.9f32, 0.1, 0.0]).unwrap(); // second closest
984        g.upsert_vector(c, &[0.2f32, 1.0, 0.0]).unwrap(); // far
985        g.upsert_vector(d, &[0.0f32, 0.0, 1.0]).unwrap(); // far
986        g.rebuild_csr().unwrap();
987
988        let opts = HybridRetrieveOptions {
989            vector_k: 4,
990            text_k: 0,
991            hops: 0,
992            max_distance: 2.0, // admit all four as seeds so truncation is exercised
993            max_nodes: Some(2),
994            ..Default::default()
995        };
996        let sub = retrieve_hybrid(&g, &[1.0f32, 0.0, 0.0], "", &opts).unwrap();
997        let mut nodes = sub.nodes.clone();
998        nodes.sort_unstable();
999        let mut expected = vec![a, b];
1000        expected.sort_unstable();
1001        assert_eq!(
1002            nodes, expected,
1003            "the two highest-scored seeds must survive the max_nodes cap"
1004        );
1005        assert!(sub.truncated, "the cap dropped two of the four seeds");
1006    }
1007
1008    #[test]
1009    fn hybrid_retrieve_fuses_both_sources() {
1010        let (_dir, g) = open_tmp();
1011        let a = g
1012            .add_node("Doc", &json!({"body": "rust graph database storage"}))
1013            .unwrap();
1014        let b = g
1015            .add_node("Doc", &json!({"body": "vector search nearest neighbor"}))
1016            .unwrap();
1017        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1018        g.upsert_vector(b, &[0.0f32, 1.0]).unwrap();
1019        g.update(|txn| txn.create_node_text_index("Doc", "body"))
1020            .unwrap();
1021        g.rebuild_csr().unwrap();
1022
1023        // b has text match for "vector"; a has vector match for [1, 0].
1024        let sub = retrieve_hybrid(
1025            &g,
1026            &[1.0f32, 0.0],
1027            "vector",
1028            &HybridRetrieveOptions {
1029                vector_k: 1,
1030                text_k: 1,
1031                text_label: Some("Doc".into()),
1032                text_property: Some("body".into()),
1033                hops: 0,
1034                ..Default::default()
1035            },
1036        )
1037        .unwrap();
1038        assert!(sub.nodes.contains(&a), "vector hit a must be present");
1039        assert!(sub.nodes.contains(&b), "text hit b must be present");
1040    }
1041
1042    #[test]
1043    fn hybrid_retrieve_weighted_sum_produces_correct_scores() {
1044        let (_dir, g) = open_tmp();
1045        let a = g.add_node("Doc", &json!({"body": "alpha bravo"})).unwrap();
1046        let b = g
1047            .add_node("Doc", &json!({"body": "charlie delta"}))
1048            .unwrap();
1049        g.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1050        g.upsert_vector(b, &[0.0f32, 1.0]).unwrap();
1051        g.update(|txn| txn.create_node_text_index("Doc", "body"))
1052            .unwrap();
1053        g.rebuild_csr().unwrap();
1054
1055        // a is the top vector hit (rank 0) and b is the top text hit (rank 0).
1056        // vector_k=1, text_k=1, so normalized rank score = (k - rank) / k = 1.0.
1057        // WeightedSum: score = 0.7 * vec_norm + 0.3 * text_norm.
1058        // a: vec_norm = 1.0, text_norm = 0.0 => 0.7
1059        // b: vec_norm = 0.0, text_norm = 1.0 => 0.3
1060        let sub = retrieve_hybrid(
1061            &g,
1062            &[1.0f32, 0.0],
1063            "charlie",
1064            &HybridRetrieveOptions {
1065                vector_k: 1,
1066                text_k: 1,
1067                text_label: Some("Doc".into()),
1068                text_property: Some("body".into()),
1069                hops: 0,
1070                fusion: FusionStrategy::WeightedSum {
1071                    vector_weight: 0.7,
1072                    text_weight: 0.3,
1073                },
1074                ..Default::default()
1075            },
1076        )
1077        .unwrap();
1078
1079        assert!(
1080            sub.scores.contains_key(&a),
1081            "vector seed a must have a score"
1082        );
1083        assert!(sub.scores.contains_key(&b), "text seed b must have a score");
1084        assert!(
1085            (sub.scores[&a] - 0.7).abs() < 1e-5,
1086            "a score should be 0.7, got {}",
1087            sub.scores[&a]
1088        );
1089        assert!(
1090            (sub.scores[&b] - 0.3).abs() < 1e-5,
1091            "b score should be 0.3, got {}",
1092            sub.scores[&b]
1093        );
1094    }
1095
1096    /// A graph with a text index but no embeddings can still serve the text
1097    /// arm: an empty vector index contributes zero vector seeds instead of
1098    /// failing the whole call, because the contract reserves an error for a
1099    /// call where neither modality would run.
1100    #[test]
1101    fn hybrid_retrieve_with_text_active_survives_an_empty_vector_index() {
1102        let (_dir, g) = open_tmp();
1103        let a = g
1104            .add_node("Doc", &json!({"body": "quantum computing research"}))
1105            .unwrap();
1106        let _b = g
1107            .add_node("Doc", &json!({"body": "classical music orchestra"}))
1108            .unwrap();
1109        g.update(|txn| txn.create_node_text_index("Doc", "body"))
1110            .unwrap();
1111
1112        let sub = retrieve_hybrid(
1113            &g,
1114            &[1.0f32, 0.0],
1115            "quantum",
1116            &HybridRetrieveOptions {
1117                vector_k: 5,
1118                text_k: 5,
1119                text_label: Some("Doc".into()),
1120                text_property: Some("body".into()),
1121                hops: 0,
1122                ..Default::default()
1123            },
1124        )
1125        .unwrap();
1126
1127        assert_eq!(sub.nodes, vec![a], "the text seed alone forms the subgraph");
1128        assert!(sub.scores.contains_key(&a));
1129    }
1130
1131    /// When the vector arm is the only active modality, an empty index is
1132    /// still an error: there is no other arm to serve the call.
1133    #[test]
1134    fn hybrid_retrieve_vector_only_over_an_empty_index_still_errors() {
1135        let (_dir, g) = open_tmp();
1136        g.add_node("Doc", &json!({"body": "quantum"})).unwrap();
1137        g.update(|txn| txn.create_node_text_index("Doc", "body"))
1138            .unwrap();
1139
1140        let err = retrieve_hybrid(
1141            &g,
1142            &[1.0f32, 0.0],
1143            "",
1144            &HybridRetrieveOptions {
1145                vector_k: 5,
1146                text_k: 5,
1147                hops: 0,
1148                ..Default::default()
1149            },
1150        )
1151        .unwrap_err();
1152        assert!(
1153            matches!(
1154                err,
1155                RetrievalError::Vector(issundb_vector::VectorError::EmptyIndex)
1156            ),
1157            "got {err:?}"
1158        );
1159    }
1160
1161    /// The GraphRAG shape: `(:Chunk)-[:MENTIONS]->(:Entity)`, seeded on the
1162    /// entity. Expansion is undirected, so the chunk one incoming hop away is
1163    /// part of the subgraph, and so is the edge into the seed.
1164    #[test]
1165    fn retrieve_expands_over_incoming_edges() {
1166        let (_dir, g) = open_tmp();
1167        let entity = g.add_node("Entity", &json!({})).unwrap();
1168        let chunk = g.add_node("Chunk", &json!({})).unwrap();
1169        let e = g.add_edge(chunk, entity, "MENTIONS", &json!({})).unwrap();
1170        g.upsert_vector(entity, &[1.0f32, 0.0]).unwrap();
1171
1172        let sub = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
1173        let mut nodes = sub.nodes.clone();
1174        nodes.sort_unstable();
1175        assert_eq!(nodes, vec![entity, chunk]);
1176        assert_eq!(sub.edges, vec![e], "the edge into the seed is collected");
1177        assert!(!sub.truncated);
1178        assert!(sub.scores.contains_key(&entity));
1179        assert!(
1180            !sub.scores.contains_key(&chunk),
1181            "expansion-only nodes carry no score"
1182        );
1183    }
1184
1185    /// Hop depth counts undirected steps: on the chain a to b to c, a seed on
1186    /// c reaches b at one hop and a at two.
1187    #[test]
1188    fn retrieve_expands_incoming_chain_to_depth() {
1189        let (_dir, g) = open_tmp();
1190        let a = g.add_node("N", &json!({})).unwrap();
1191        let b = g.add_node("N", &json!({})).unwrap();
1192        let c = g.add_node("N", &json!({})).unwrap();
1193        g.add_edge(a, b, "E", &json!({})).unwrap();
1194        g.add_edge(b, c, "E", &json!({})).unwrap();
1195        g.upsert_vector(c, &[1.0f32, 0.0]).unwrap();
1196
1197        let sub1 = retrieve(&g, &[1.0f32, 0.0], 1, 1).unwrap();
1198        let mut n1 = sub1.nodes.clone();
1199        n1.sort_unstable();
1200        assert_eq!(n1, vec![b, c]);
1201
1202        let sub2 = retrieve(&g, &[1.0f32, 0.0], 1, 2).unwrap();
1203        let mut n2 = sub2.nodes.clone();
1204        n2.sort_unstable();
1205        assert_eq!(n2, vec![a, b, c]);
1206    }
1207
1208    /// The `max_nodes` cap and the `truncated` flag hold on the undirected
1209    /// path: an incoming star larger than the cap is cut off and says so.
1210    #[test]
1211    fn retrieve_undirected_expansion_caps_and_reports_truncation() {
1212        let (_dir, g) = open_tmp();
1213        let hub = g.add_node("N", &json!({})).unwrap();
1214        for _ in 0..4 {
1215            let leaf = g.add_node("N", &json!({})).unwrap();
1216            g.add_edge(leaf, hub, "E", &json!({})).unwrap();
1217        }
1218        g.upsert_vector(hub, &[1.0f32, 0.0]).unwrap();
1219
1220        let sub = retrieve_with(
1221            &g,
1222            &[1.0f32, 0.0],
1223            &RetrieveOptions {
1224                k: 1,
1225                hops: 1,
1226                max_distance: f32::MAX,
1227                max_nodes: Some(3),
1228            },
1229        )
1230        .unwrap();
1231
1232        assert!(sub.nodes.len() <= 3);
1233        assert!(sub.nodes.contains(&hub), "the seed survives the cap");
1234        assert!(
1235            sub.truncated,
1236            "the cap dropped reachable incoming neighbors"
1237        );
1238    }
1239
1240    /// `retrieve_hybrid` expands over both directions too: a text-seeded
1241    /// entity pulls in the chunk that mentions it.
1242    #[test]
1243    fn hybrid_retrieve_expands_over_incoming_edges() {
1244        let (_dir, g) = open_tmp();
1245        let entity = g
1246            .add_node("Entity", &json!({"name": "cassava root"}))
1247            .unwrap();
1248        let chunk = g.add_node("Chunk", &json!({})).unwrap();
1249        let e = g.add_edge(chunk, entity, "MENTIONS", &json!({})).unwrap();
1250        g.update(|txn| txn.create_node_text_index("Entity", "name"))
1251            .unwrap();
1252
1253        let sub = retrieve_hybrid(
1254            &g,
1255            &[],
1256            "cassava",
1257            &HybridRetrieveOptions {
1258                vector_k: 0,
1259                text_k: 5,
1260                hops: 1,
1261                ..Default::default()
1262            },
1263        )
1264        .unwrap();
1265
1266        let mut nodes = sub.nodes.clone();
1267        nodes.sort_unstable();
1268        assert_eq!(nodes, vec![entity, chunk]);
1269        assert_eq!(sub.edges, vec![e]);
1270    }
1271
1272    #[test]
1273    fn hybrid_retrieve_text_only_returns_text_seeds() {
1274        let (_dir, g) = open_tmp();
1275        let a = g
1276            .add_node("Doc", &json!({"body": "quantum computing research"}))
1277            .unwrap();
1278        let b = g
1279            .add_node("Doc", &json!({"body": "classical music orchestra"}))
1280            .unwrap();
1281        g.update(|txn| txn.create_node_text_index("Doc", "body"))
1282            .unwrap();
1283        g.rebuild_csr().unwrap();
1284
1285        // vector_k=0 disables vector search; only text seeds are used.
1286        let sub = retrieve_hybrid(
1287            &g,
1288            &[],
1289            "quantum",
1290            &HybridRetrieveOptions {
1291                vector_k: 0,
1292                text_k: 5,
1293                text_label: Some("Doc".into()),
1294                text_property: Some("body".into()),
1295                hops: 0,
1296                ..Default::default()
1297            },
1298        )
1299        .unwrap();
1300
1301        assert_eq!(
1302            sub.nodes.len(),
1303            1,
1304            "only the text-matching node should appear"
1305        );
1306        assert_eq!(sub.nodes[0], a);
1307        assert!(sub.scores.contains_key(&a));
1308        assert!(!sub.nodes.contains(&b), "non-matching node must be absent");
1309    }
1310}