1use crate::errors::AppError;
8use crate::graph::{
9 bfs_with_predecessors, traverse_from_memories_with_hops_capped, PredecessorMap,
10};
11use crate::output;
12use crate::paths::AppPaths;
13use crate::storage::connection::open_ro;
14use crate::storage::fusion::{rrf_fuse, rrf_max_possible};
15use crate::storage::{entities, memories};
16
17use serde::Serialize;
18use std::collections::HashSet;
19use std::sync::Arc;
20use tokio::sync::Semaphore;
21use tokio::task::JoinSet;
22
23#[derive(clap::Args)]
25#[command(
26 about = "Deep parallel multi-hop GraphRAG research via query decomposition",
27 after_long_help = "CONTRACT:\n \
28 stdout = pretty JSON envelope only (machine-readable).\n \
29 stderr = tracing / progress / diagnostics only.\n \
30 Never redirect with `&>` or `2>&1` into the same file as stdout — that\n \
31 contaminates the JSON and breaks jaq/jq. Prefer:\n \
32 sqlite-graphrag deep-research \"q\" > out.json 2>/dev/null\n \
33 or --output out.json (atomic write via atomwrite algorithm).\n\n\
34EXAMPLES:\n \
35 # Basic deep research (single-token queries auto-expand into aspects)\n \
36 sqlite-graphrag deep-research \"danilo\"\n\n \
37 # With custom parameters\n \
38 sqlite-graphrag deep-research \"auth\" --k 20 --max-hops 3 --max-sub-queries 7\n\n \
39 # Include full memory bodies in output\n \
40 sqlite-graphrag deep-research \"auth\" --with-bodies\n\n \
41 # Manual sub-queries (one query per line)\n \
42 sqlite-graphrag deep-research \"danilo\" --sub-query-strategy manual \\\n \
43 --sub-queries-file aspects.txt\n\n \
44 # Atomic JSON file (crash-safe; preferred for large --with-bodies runs)\n \
45 sqlite-graphrag deep-research \"auth\" --output /tmp/dr.json\n\n \
46 # Tune RRF and graph scoring\n \
47 sqlite-graphrag deep-research \"auth and deployment\" --rrf-k 60 --graph-decay 0.7"
48)]
49pub struct DeepResearchArgs {
50 #[arg(
52 value_name = "QUERY",
53 allow_hyphen_values = true,
54 help = "Research query to decompose and search"
55 )]
56 pub query: String,
57 #[arg(
59 long,
60 short,
61 aliases = ["limit", "top-k"],
62 default_value_t = 20,
63 help = "Results per sub-query (Recall@20 captures 95%+ relevant hits)"
64 )]
65 pub k: usize,
66 #[arg(
68 long,
69 default_value_t = 7,
70 help = "Maximum sub-queries (covers complex multi-hop queries)"
71 )]
72 pub max_sub_queries: usize,
73 #[arg(
75 long,
76 default_value_t = 3,
77 help = "Multi-hop graph traversal depth (sweet spot: 2-3 hops)"
78 )]
79 pub max_hops: usize,
80 #[arg(
82 long,
83 default_value_t = 0.3,
84 help = "Minimum edge weight for graph traversal"
85 )]
86 pub min_weight: f64,
87 #[arg(long, help = "Maximum concurrent sub-queries (default: min(cpus, 8))")]
89 pub max_concurrency: Option<usize>,
90 #[arg(long, default_value_t = 30, help = "Timeout per sub-query in seconds")]
92 pub timeout: u64,
93 #[arg(
95 long,
96 default_value_t = false,
97 help = "Include full memory bodies in results"
98 )]
99 pub with_bodies: bool,
100 #[arg(
102 long,
103 default_value_t = 50,
104 help = "Maximum results after deduplication"
105 )]
106 pub max_results: usize,
107 #[arg(
109 long,
110 default_value_t = 60.0,
111 help = "RRF k parameter (higher = less weight on top ranks)"
112 )]
113 pub rrf_k: f64,
114 #[arg(
116 long,
117 default_value_t = 0.7,
118 help = "Graph score decay factor per hop (0.0-1.0)"
119 )]
120 pub graph_decay: f64,
121 #[arg(
123 long,
124 default_value_t = 0.05,
125 help = "Minimum score threshold for graph-expanded results"
126 )]
127 pub graph_min_score: f64,
128 #[arg(
130 long,
131 help = "Limit neighbours per entity per hop for graph traversal (default: unlimited)"
132 )]
133 pub max_neighbors_per_hop: Option<usize>,
134 #[arg(
136 long,
137 help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
138 )]
139 pub namespace: Option<String>,
140 #[arg(long, default_value = "none", value_parser = ["none"], hide = true)]
142 pub mode: String,
143 #[arg(
145 long,
146 value_name = "USD",
147 help = "Max LLM cost in USD (effective with --mode claude-code/codex)"
148 )]
149 pub max_cost_usd: Option<f64>,
150 #[arg(long, hide = true)]
152 pub json: bool,
153 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
155 pub db: Option<String>,
156 #[arg(
159 long,
160 default_value = "heuristic",
161 value_parser = ["heuristic", "manual"],
162 help = "Sub-query strategy: heuristic (default) or manual"
163 )]
164 pub sub_query_strategy: String,
165 #[arg(
168 long,
169 value_name = "PATH",
170 help = "File with one sub-query per line (manual strategy)"
171 )]
172 pub sub_queries_file: Option<std::path::PathBuf>,
173 #[arg(
178 long,
179 value_name = "PATH",
180 help = "Atomic JSON output path (atomwrite algorithm)"
181 )]
182 pub output: Option<std::path::PathBuf>,
183}
184
185#[derive(Serialize)]
186struct SubQuery {
187 id: usize,
188 text: String,
189 source: &'static str,
190}
191
192#[derive(Serialize)]
193struct DeepResult {
194 name: String,
195 score: f64,
196 source: String,
197 sub_query_ids: Vec<usize>,
198 snippet: String,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 body: Option<String>,
201 hop_distance: Option<usize>,
202}
203
204#[derive(Serialize, Clone)]
206struct EvidenceNode {
207 entity: String,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 relation: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 weight: Option<f64>,
212}
213
214#[derive(Serialize)]
223struct EvidenceChain {
224 from: String,
225 to: String,
226 path: Vec<EvidenceNode>,
227 total_weight: f64,
228 depth: usize,
229 sub_query_ids: Vec<usize>,
230}
231
232#[derive(Serialize)]
233struct ResearchStats {
234 sub_queries_total: usize,
235 sub_queries_completed: usize,
236 sub_queries_failed: usize,
237 sub_queries_timed_out: usize,
238 unique_memories_found: usize,
239 evidence_chains_found: usize,
240 elapsed_ms: u64,
241 vec_degraded: bool,
242}
243
244#[derive(Serialize)]
245struct GraphContextEntity {
246 name: String,
247 entity_type: String,
248 degree: u32,
249}
250
251#[derive(Serialize)]
252struct GraphContextRel {
253 from: String,
254 to: String,
255 relation: String,
256 weight: f64,
257}
258
259#[derive(Serialize)]
260struct GraphContext {
261 entities: Vec<GraphContextEntity>,
262 relationships: Vec<GraphContextRel>,
263}
264
265#[derive(Serialize)]
266struct DeepResearchResponse {
267 query: String,
268 sub_queries: Vec<SubQuery>,
269 results: Vec<DeepResult>,
270 evidence_chains: Vec<EvidenceChain>,
271 #[serde(skip_serializing_if = "Option::is_none")]
272 graph_context: Option<GraphContext>,
273 stats: ResearchStats,
274}
275
276type MergedHit = (f64, String, String, String, Option<usize>, Vec<usize>);
278
279struct SubQueryResult {
281 sub_query_id: usize,
282 hits: Vec<(i64, f64, String, String, String, Option<usize>)>,
284 chains: Vec<EvidenceChain>,
286}
287
288#[tracing::instrument(skip_all, level = "debug", name = "deep_research")]
290pub fn run(
291 args: DeepResearchArgs,
292 llm_backend: crate::cli::LlmBackendChoice,
293 embedding_backend: crate::cli::EmbeddingBackendChoice,
294) -> Result<(), AppError> {
295 tracing::debug!(target: "deep_research", query = %args.query, k = args.k, "starting deep research");
296
297 let paths = AppPaths::resolve(args.db.as_deref())?;
305 crate::storage::connection::ensure_db_ready(&paths)?;
306 let sub_query_plan = resolve_sub_queries(&args)?;
308 let sub_query_texts: Vec<String> = sub_query_plan.iter().map(|s| s.text.clone()).collect();
309 let (sub_embeddings, vec_degraded) =
310 compute_sub_embeddings(&paths, &sub_query_texts, embedding_backend, llm_backend);
311
312 let rt = tokio::runtime::Builder::new_multi_thread()
313 .worker_threads(2)
314 .enable_all()
315 .build()
316 .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build tokio runtime: {e}")))?;
317 rt.block_on(run_async(
318 args,
319 llm_backend,
320 embedding_backend,
321 sub_query_plan,
322 sub_embeddings,
323 vec_degraded,
324 ))
325}
326
327fn compute_sub_embeddings(
336 paths: &crate::paths::AppPaths,
337 sub_query_texts: &[String],
338 embedding_backend: crate::cli::EmbeddingBackendChoice,
339 llm_backend: crate::cli::LlmBackendChoice,
340) -> (Vec<Option<Arc<Vec<f32>>>>, bool) {
341 output::emit_progress_i18n(
342 "Computing per-sub-query embeddings...",
343 "Calculando embeddings por sub-consulta...",
344 );
345 let mut sub_embeddings: Vec<Option<Arc<Vec<f32>>>> = Vec::with_capacity(sub_query_texts.len());
346 let mut vec_degraded = false;
347 for sq_text in sub_query_texts {
348 match crate::embedder::try_embed_query_with_embedding_choice(
349 &paths.models,
350 sq_text,
351 embedding_backend,
352 llm_backend,
353 ) {
354 Ok((v, _backend)) => sub_embeddings.push(Some(Arc::new(v))),
355 Err(reason) => {
356 tracing::warn!(target: "deep_research", fallback_reason = %reason, reason_code = %reason.reason_code(), "embedding failed for sub-query; falling back to FTS5");
357 sub_embeddings.push(None);
358 vec_degraded = true;
359 }
360 }
361 }
362 (sub_embeddings, vec_degraded)
363}
364
365async fn run_async(
373 args: DeepResearchArgs,
374 _llm_backend: crate::cli::LlmBackendChoice,
375 _embedding_backend: crate::cli::EmbeddingBackendChoice,
376 sub_queries: Vec<SubQuery>,
377 sub_embeddings: Vec<Option<Arc<Vec<f32>>>>,
378 vec_degraded: bool,
379) -> Result<(), AppError> {
380 let start = std::time::Instant::now();
381
382 if args.query.trim().is_empty() {
383 return Err(AppError::Validation(crate::i18n::validation::empty_query()));
384 }
385
386 if args.max_cost_usd.is_some() && args.mode == "none" {
387 tracing::warn!(target: "deep_research", "--max-cost-usd has no effect without --mode claude-code/codex");
388 }
389
390 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
391 let paths = AppPaths::resolve(args.db.as_deref())?;
392 crate::storage::connection::ensure_db_ready(&paths)?;
393
394 let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
396
397 if vec_degraded {
402 tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
403 }
404
405 let cpu_count = std::thread::available_parallelism()
407 .map(|n| n.get())
408 .unwrap_or(4);
409 let permits = args
410 .max_concurrency
411 .unwrap_or_else(|| cpu_count.min(8))
412 .min(sub_queries.len())
413 .max(1);
414 let semaphore = Arc::new(Semaphore::new(permits));
415 let timeout_dur = std::time::Duration::from_secs(args.timeout);
416
417 let mut join_set: JoinSet<Result<SubQueryResult, (usize, String)>> = JoinSet::new();
418
419 for (idx, sq_text) in sub_query_texts.iter().enumerate() {
420 let sem = Arc::clone(&semaphore);
421 let emb = sub_embeddings[idx].clone();
423 let ns = namespace.clone();
424 let db_path = paths.db.clone();
425 let query_text = sq_text.clone();
426 let k = args.k;
427 let max_hops = args.max_hops;
428 let min_weight = args.min_weight;
429 let rrf_k = args.rrf_k;
430 let graph_decay = args.graph_decay;
431 let graph_min_score = args.graph_min_score;
432 let max_neighbors_per_hop = args.max_neighbors_per_hop;
433
434 join_set.spawn(async move {
435 let _permit = sem
436 .acquire_owned()
437 .await
438 .map_err(|e| (idx, format!("semaphore closed: {e}")))?;
439
440 let result = tokio::time::timeout(timeout_dur, async move {
442 execute_sub_query(
443 idx,
444 &query_text,
445 emb.as_ref().map(|v| v.as_slice()),
446 &ns,
447 &db_path,
448 k,
449 max_hops,
450 min_weight,
451 rrf_k,
452 graph_decay,
453 graph_min_score,
454 max_neighbors_per_hop,
455 )
456 })
457 .await;
458
459 match result {
460 Ok(inner) => inner.map_err(|e| (idx, e)),
461 Err(_) => Err((idx, "timeout".to_string())),
462 }
463 });
464 }
465
466 let mut sub_query_results: Vec<SubQueryResult> = Vec::with_capacity(sub_queries.len());
468 let mut failed_count = 0usize;
469 let mut timed_out_count = 0usize;
470
471 while let Some(join_result) = join_set.join_next().await {
472 match join_result {
473 Ok(Ok(sqr)) => sub_query_results.push(sqr),
474 Ok(Err((_idx, reason))) => {
475 if reason == "timeout" {
476 timed_out_count += 1;
477 } else {
478 failed_count += 1;
479 }
480 tracing::warn!(target: "deep_research", sub_query_id = _idx, reason = %reason, "sub-query failed");
481 }
482 Err(join_err) => {
483 failed_count += 1;
484 if join_err.is_panic() {
485 tracing::error!(target: "deep_research", error = %join_err, "sub-query task panicked");
486 } else {
487 tracing::warn!(target: "deep_research", error = %join_err, "sub-query task cancelled");
488 }
489 }
490 }
491 }
492
493 let mut merged: crate::hash::AHashMap<i64, MergedHit> =
496 crate::hash::AHashMap::with_capacity_and_hasher(
497 sub_query_results.len() * args.k,
498 Default::default(),
499 );
500
501 for sqr in &sub_query_results {
502 for (mem_id, score, source, snippet, body, hop) in &sqr.hits {
503 let entry = merged.entry(*mem_id).or_insert_with(|| {
504 (
505 *score,
506 source.clone(),
507 snippet.clone(),
508 body.clone(),
509 *hop,
510 Vec::new(),
511 )
512 });
513 if *score > entry.0 {
515 entry.0 = *score;
516 entry.1 = source.clone();
517 entry.2 = snippet.clone();
518 entry.3 = body.clone();
519 entry.4 = *hop;
520 }
521 if !entry.5.contains(&sqr.sub_query_id) {
522 entry.5.push(sqr.sub_query_id);
523 }
524 }
525 }
526
527 let conn = open_ro(&paths.db)?;
529 let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
530
531 let mut ranked: Vec<(i64, MergedHit)> = merged.into_iter().collect();
533 ranked.sort_by(|a, b| {
534 b.1 .0
535 .partial_cmp(&a.1 .0)
536 .unwrap_or(std::cmp::Ordering::Equal)
537 });
538 ranked.truncate(args.max_results);
539
540 for (mem_id, (score, source, snippet, body, hop, sq_ids)) in ranked {
541 let name = match memories::read_full(&conn, mem_id)? {
542 Some(row) => row.name,
543 None => continue,
544 };
545 results.push(DeepResult {
546 name,
547 score,
548 source,
549 sub_query_ids: sq_ids,
550 snippet,
551 body: if args.with_bodies { Some(body) } else { None },
552 hop_distance: hop,
553 });
554 }
555
556 let completed_count = sub_query_results.len();
560 let mut evidence_chains: Vec<EvidenceChain> = Vec::with_capacity(completed_count * 2);
561 let mut seen_chain_keys: HashSet<String> = HashSet::with_capacity(completed_count * 2);
562
563 for sqr in sub_query_results {
564 for chain in sqr.chains {
565 let key = format!("{}->{}", chain.from, chain.to);
567 if seen_chain_keys.insert(key) {
568 evidence_chains.push(chain);
569 }
570 }
571 }
572
573 evidence_chains.retain(|c| c.depth >= 2);
575 evidence_chains.sort_by(|a, b| {
576 b.total_weight
577 .partial_cmp(&a.total_weight)
578 .unwrap_or(std::cmp::Ordering::Equal)
579 });
580
581 let unique_memories = results.len();
582 let evidence_count = evidence_chains.len();
583
584 let graph_context = if !results.is_empty() {
586 let result_names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
587 let mut ctx_entities: Vec<GraphContextEntity> = Vec::with_capacity(results.len());
588 let mut ctx_rels: Vec<GraphContextRel> = Vec::with_capacity(results.len() * 2);
589 let mut seen_entity_ids: crate::hash::AHashSet<i64> =
590 crate::hash::AHashSet::with_capacity_and_hasher(results.len(), Default::default());
591
592 for name in &result_names {
593 if let Ok(Some(eid)) = entities::find_entity_id(&conn, &namespace, name) {
594 if seen_entity_ids.insert(eid) {
595 let etype: String = conn
596 .query_row(
597 "SELECT COALESCE(type,'concept') FROM entities WHERE id = ?1",
598 rusqlite::params![eid],
599 |r| r.get(0),
600 )
601 .unwrap_or_else(|_| "concept".to_string());
602 let degree: u32 = conn
603 .query_row(
604 "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 OR target_id = ?1",
605 rusqlite::params![eid],
606 |r| r.get(0),
607 )
608 .unwrap_or(0);
609 ctx_entities.push(GraphContextEntity {
610 name: name.to_string(),
611 entity_type: etype,
612 degree,
613 });
614 }
615 }
616 }
617
618 let entity_ids: Vec<i64> = seen_entity_ids.iter().copied().collect();
619 if entity_ids.len() >= 2 {
620 let placeholders: String = entity_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
621 let sql = format!(
622 "SELECT s.name, t.name, r.relation, r.weight \
623 FROM relationships r \
624 JOIN entities s ON s.id = r.source_id \
625 JOIN entities t ON t.id = r.target_id \
626 WHERE r.source_id IN ({placeholders}) AND r.target_id IN ({placeholders}) \
627 LIMIT 50"
628 );
629 if let Ok(mut stmt) = conn.prepare(&sql) {
630 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
631 Vec::with_capacity(entity_ids.len() * 2);
632 for id in &entity_ids {
633 params.push(Box::new(*id));
634 }
635 for id in &entity_ids {
636 params.push(Box::new(*id));
637 }
638 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
639 params.iter().map(|p| p.as_ref()).collect();
640 if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |r| {
641 Ok((
642 r.get::<_, String>(0)?,
643 r.get::<_, String>(1)?,
644 r.get::<_, String>(2)?,
645 r.get::<_, f64>(3)?,
646 ))
647 }) {
648 for row in rows.flatten() {
649 ctx_rels.push(GraphContextRel {
650 from: row.0,
651 to: row.1,
652 relation: row.2,
653 weight: row.3,
654 });
655 }
656 }
657 }
658 }
659
660 if ctx_entities.is_empty() {
661 None
662 } else {
663 Some(GraphContext {
664 entities: ctx_entities,
665 relationships: ctx_rels,
666 })
667 }
668 } else {
669 None
670 };
671
672 tracing::debug!(target: "deep_research",
673 total_results = results.len(),
674 total_chains = evidence_chains.len(),
675 "assembly complete"
676 );
677
678 let response = DeepResearchResponse {
680 query: args.query,
681 sub_queries,
682 results,
683 evidence_chains,
684 graph_context,
685 stats: ResearchStats {
686 sub_queries_total: sub_query_texts.len(),
687 sub_queries_completed: completed_count,
688 sub_queries_failed: failed_count,
689 sub_queries_timed_out: timed_out_count,
690 unique_memories_found: unique_memories,
691 evidence_chains_found: evidence_count,
692 elapsed_ms: start.elapsed().as_millis() as u64,
693 vec_degraded,
694 },
695 };
696
697 if let Some(path) = args.output.as_ref() {
698 crate::atomic_io::write_json_atomic(path, &response)?;
702 let meta = std::fs::metadata(path).map_err(AppError::Io)?;
703 let file_bytes = std::fs::read(path).map_err(AppError::Io)?;
704 let digest = blake3::hash(&file_bytes).to_hex().to_string();
705 #[derive(Serialize)]
706 struct WrittenAck {
707 written: String,
708 bytes: u64,
709 blake3: String,
710 sub_queries_total: usize,
711 unique_memories_found: usize,
712 elapsed_ms: u64,
713 }
714 output::emit_json(&WrittenAck {
715 written: path.display().to_string(),
716 bytes: meta.len(),
717 blake3: digest,
718 sub_queries_total: response.stats.sub_queries_total,
719 unique_memories_found: response.stats.unique_memories_found,
720 elapsed_ms: response.stats.elapsed_ms,
721 })?;
722 } else {
723 output::emit_json(&response)?;
724 }
725
726 Ok(())
727}
728
729fn resolve_sub_queries(args: &DeepResearchArgs) -> Result<Vec<SubQuery>, AppError> {
731 if args.query.trim().is_empty() {
732 return Err(AppError::Validation(crate::i18n::validation::empty_query()));
733 }
734 match args.sub_query_strategy.as_str() {
735 "manual" => {
736 let path = args.sub_queries_file.as_ref().ok_or_else(|| {
737 AppError::Validation(
738 "--sub-query-strategy manual requires --sub-queries-file PATH".to_string(),
739 )
740 })?;
741 let raw = std::fs::read_to_string(path).map_err(AppError::Io)?;
742 let mut texts: Vec<String> = raw
743 .lines()
744 .map(str::trim)
745 .filter(|l| !l.is_empty() && !l.starts_with('#'))
746 .map(str::to_string)
747 .collect();
748 if texts.is_empty() {
749 return Err(AppError::Validation(format!(
750 "sub-queries file '{}' has no usable lines",
751 path.display()
752 )));
753 }
754 texts.truncate(args.max_sub_queries);
755 Ok(texts
756 .into_iter()
757 .enumerate()
758 .map(|(i, text)| SubQuery {
759 id: i,
760 text,
761 source: "manual",
762 })
763 .collect())
764 }
765 _ => {
766 let planned = decompose_query_with_sources(&args.query, args.max_sub_queries);
767 Ok(planned
768 .into_iter()
769 .enumerate()
770 .map(|(i, (text, source))| SubQuery {
771 id: i,
772 text,
773 source,
774 })
775 .collect())
776 }
777 }
778}
779
780const SINGLE_TOKEN_ASPECTS: &[&str] = &[
786 "patrimonio",
787 "stack",
788 "tecnologia",
789 "stakeholders",
790 "pessoas",
791 "projeto",
792 "decisao",
793 "relacionamento",
794 "contexto",
795 "architecture",
796 "history",
797];
798
799fn decompose_query_with_sources(query: &str, max: usize) -> Vec<(String, &'static str)> {
805 if query.is_empty() {
806 return vec![(query.to_string(), "original")];
807 }
808
809 let mut parts: Vec<(String, &'static str)> = Vec::with_capacity(max);
810
811 let relational = [
813 " that caused ",
814 " depending on ",
815 " related to ",
816 " connected to ",
817 " linked to ",
818 " caused by ",
819 " followed by ",
820 ];
821 let mut text = query.to_string();
822 let mut did_relational_split = false;
823 for phrase in &relational {
824 if text.to_lowercase().contains(phrase) {
825 let lower = text.to_lowercase();
826 if let Some(pos) = lower.find(phrase) {
827 let left = text[..pos].trim().to_string();
828 let right = text[pos + phrase.len()..].trim().to_string();
829 if !left.is_empty() {
830 parts.push((left, "decomposed"));
831 }
832 if !right.is_empty() {
833 text = right;
834 }
835 did_relational_split = true;
836 }
837 }
838 }
839 if did_relational_split && !text.is_empty() {
840 parts.push((text.clone(), "decomposed"));
841 }
842
843 if parts.is_empty() {
845 let semi_parts: Vec<&str> = query.split(';').collect();
846 if semi_parts.len() > 1 {
847 for p in &semi_parts {
848 let trimmed = p.trim();
849 if !trimmed.is_empty() {
850 parts.push((trimmed.to_string(), "decomposed"));
851 }
852 }
853 } else {
854 let normalized = query
855 .replace(" and ", ", ")
856 .replace(" AND ", ", ")
857 .replace(" e ", ", ")
858 .replace(" E ", ", ");
859 let comma_parts: Vec<&str> = normalized.split(',').collect();
860 if comma_parts.len() > 1 {
861 for p in &comma_parts {
862 let trimmed = p.trim();
863 if !trimmed.is_empty() {
864 parts.push((trimmed.to_string(), "decomposed"));
865 }
866 }
867 }
868 }
869 }
870
871 if parts.is_empty() {
873 let words: Vec<&str> = query.split_whitespace().filter(|w| w.len() > 2).collect();
874 if words.len() >= 3 {
875 parts.push((query.to_string(), "original"));
876 parts.push((format!("{} {}", words[0], words[1]), "decomposed"));
877 parts.push((
878 format!("{} {}", words[words.len() - 2], words[words.len() - 1]),
879 "decomposed",
880 ));
881 }
882 }
883
884 if parts.is_empty() {
886 let token_count = query.split_whitespace().filter(|w| !w.is_empty()).count();
887 if token_count == 1 {
888 let token = query.trim();
889 parts.push((token.to_string(), "original"));
890 for aspect in SINGLE_TOKEN_ASPECTS {
891 if parts.len() >= max {
892 break;
893 }
894 parts.push((format!("{token} {aspect}"), "aspect"));
895 }
896 } else {
897 return vec![(query.to_string(), "original")];
898 }
899 }
900
901 parts.truncate(max);
902 parts
903}
904
905#[cfg(test)]
907fn decompose_query(query: &str, max: usize) -> Vec<String> {
908 decompose_query_with_sources(query, max)
909 .into_iter()
910 .map(|(t, _)| t)
911 .collect()
912}
913
914fn reconstruct_path(
918 target_id: i64,
919 seed_entity_ids: &HashSet<i64>,
920 predecessor: &PredecessorMap,
921 entity_names: &crate::hash::AHashMap<i64, String>,
922) -> Option<(Vec<EvidenceNode>, f64)> {
923 let mut path_ids: Vec<(i64, Option<String>, Option<f64>)> = Vec::with_capacity(8);
924 let mut total_weight = 1.0_f64;
925 let mut current = target_id;
926
927 loop {
928 if seed_entity_ids.contains(¤t) {
929 break;
930 }
931 let (parent, relation, weight) = predecessor.get(¤t)?;
932 total_weight *= weight;
933 path_ids.push((current, Some(relation.clone()), Some(*weight)));
934 current = *parent;
935 }
936 path_ids.push((current, None, None));
938
939 path_ids.reverse();
941
942 let nodes: Vec<EvidenceNode> = path_ids
943 .into_iter()
944 .map(|(id, relation, weight)| EvidenceNode {
945 entity: entity_names
946 .get(&id)
947 .cloned()
948 .unwrap_or_else(|| format!("entity-{id}")),
949 relation,
950 weight,
951 })
952 .collect();
953
954 Some((nodes, total_weight))
955}
956
957#[allow(clippy::too_many_arguments)]
967fn execute_sub_query(
968 sub_query_id: usize,
969 query_text: &str,
970 embedding: Option<&[f32]>,
971 namespace: &str,
972 db_path: &std::path::Path,
973 k: usize,
974 max_hops: usize,
975 min_weight: f64,
976 rrf_k: f64,
977 graph_decay: f64,
978 graph_min_score: f64,
979 max_neighbors_per_hop: Option<usize>,
980) -> Result<SubQueryResult, String> {
981 let conn = open_ro(db_path).map_err(|e| format!("failed to open db: {e}"))?;
982
983 let mut hits: Vec<(i64, f64, String, String, String, Option<usize>)> =
984 Vec::with_capacity(k * 2);
985 let mut seen_ids: crate::hash::AHashSet<i64> =
986 crate::hash::AHashSet::with_capacity_and_hasher(k * 2, Default::default());
987
988 let (knn_ids, knn_distance_map) = if let Some(emb) = embedding {
992 let knn_results = memories::knn_search(&conn, emb, &[namespace.to_string()], None, k)
993 .map_err(|e| format!("knn_search failed: {e}"))?;
994 let ids: Vec<i64> = knn_results.iter().map(|(id, _)| *id).collect();
995 tracing::debug!(target: "deep_research", sub_query_id, knn_count = ids.len(), "KNN complete");
996 let dist_map: crate::hash::AHashMap<i64, f64> = knn_results
997 .iter()
998 .map(|(id, dist)| (*id, *dist as f64))
999 .collect();
1000 (ids, dist_map)
1001 } else {
1002 tracing::debug!(target: "deep_research", sub_query_id, "KNN skipped (no embedding); FTS5-only");
1003 (vec![], crate::hash::AHashMap::default())
1004 };
1005
1006 let fts_results = match memories::fts_search(&conn, query_text, namespace, None, k) {
1008 Ok(rows) => rows,
1009 Err(e) => {
1010 tracing::warn!(target: "deep_research",
1011 sub_query_id,
1012 "FTS5 search failed, continuing with KNN only: {e}"
1013 );
1014 vec![]
1015 }
1016 };
1017 let fts_ids: Vec<i64> = fts_results.iter().map(|r| r.id).collect();
1018 tracing::debug!(target: "deep_research", sub_query_id, fts_count = fts_ids.len(), "FTS complete");
1019
1020 let rrf_scores = rrf_fuse(&[(1.0, &knn_ids), (1.0, &fts_ids)], rrf_k);
1022 let max_possible = rrf_max_possible(&[1.0, 1.0], rrf_k);
1023
1024 let mut fused: Vec<(i64, f64)> = rrf_scores.into_iter().collect();
1026 fused.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1027 fused.truncate(k * 2);
1028 tracing::debug!(target: "deep_research",
1029 sub_query_id,
1030 fused_count = fused.len(),
1031 "RRF fusion complete"
1032 );
1033
1034 if fused.is_empty() && !knn_ids.is_empty() {
1035 tracing::warn!(target: "deep_research", sub_query_id, knn_count = knn_ids.len(), fts_count = fts_ids.len(),
1036 "RRF fusion returned 0 results despite KNN/FTS hits; consider lowering --graph-min-score");
1037 }
1038
1039 for (memory_id, combined_score) in &fused {
1040 if seen_ids.insert(*memory_id) {
1041 let normalized = if max_possible > 0.0 {
1042 combined_score / max_possible
1043 } else {
1044 0.0
1045 };
1046 let score = normalized.clamp(0.0, 1.0);
1047 let in_knn = knn_distance_map.contains_key(memory_id);
1048 let in_fts = fts_ids.contains(memory_id);
1049 let source = match (in_knn, in_fts) {
1050 (true, true) => "hybrid",
1051 (true, false) => "knn",
1052 (false, true) => "fts",
1053 (false, false) => "graph",
1054 };
1055 if let Ok(Some(row)) = memories::read_full(&conn, *memory_id) {
1056 let snippet: String = row.body.chars().take(300).collect();
1057 hits.push((
1058 *memory_id,
1059 score,
1060 source.to_string(),
1061 snippet,
1062 row.body,
1063 None,
1064 ));
1065 }
1066 }
1067 }
1068
1069 let memory_ids: Vec<i64> = hits.iter().map(|(id, ..)| *id).collect();
1072 let mut chains: Vec<EvidenceChain> = Vec::with_capacity(memory_ids.len());
1073
1074 if !memory_ids.is_empty() && max_hops > 0 {
1075 let entity_ids: Vec<i64> = if let Some(emb) = embedding {
1077 entities::knn_search(&conn, emb, namespace, 5)
1078 .inspect_err(|e| tracing::warn!(target: "deep_research", error = %e, "entity KNN search failed, skipping graph seed"))
1079 .unwrap_or_default()
1080 .iter()
1081 .map(|(id, _)| *id)
1082 .collect()
1083 } else {
1084 vec![]
1085 };
1086
1087 let top_seed_count = 5.min(memory_ids.len());
1090 let top_memory_ids = &memory_ids[..top_seed_count];
1091 let mut seed_entity_ids: Vec<i64> = entity_ids.clone();
1092 for &mem_id in top_memory_ids {
1093 let mut stmt = conn
1094 .prepare_cached("SELECT entity_id FROM memory_entities WHERE memory_id = ?1")
1095 .map_err(|e| format!("prepare failed: {e}"))?;
1096 let ids: Vec<i64> = stmt
1097 .query_map(rusqlite::params![mem_id], |r| r.get(0))
1098 .map_err(|e| format!("query failed: {e}"))?
1099 .filter_map(|r| r.ok())
1100 .collect();
1101 seed_entity_ids.extend(ids);
1102 }
1103 seed_entity_ids.sort_unstable();
1104 seed_entity_ids.dedup();
1105 tracing::debug!(target: "deep_research",
1106 sub_query_id,
1107 seed_count = seed_entity_ids.len(),
1108 "seed entities collected"
1109 );
1110
1111 let all_seed_ids: Vec<i64> = memory_ids
1112 .iter()
1113 .chain(entity_ids.iter())
1114 .copied()
1115 .collect();
1116
1117 if let Ok(graph_results) = traverse_from_memories_with_hops_capped(
1119 &conn,
1120 &all_seed_ids,
1121 namespace,
1122 min_weight,
1123 max_hops as u32,
1124 max_neighbors_per_hop,
1125 ) {
1126 let seed_score_map: crate::hash::AHashMap<i64, f64> = fused
1128 .iter()
1129 .map(|(id, s)| {
1130 let normalized = if max_possible > 0.0 {
1131 s / max_possible
1132 } else {
1133 0.0
1134 };
1135 (*id, normalized.clamp(0.0, 1.0))
1136 })
1137 .collect();
1138
1139 for (graph_mem_id, hop) in graph_results {
1140 if seen_ids.insert(graph_mem_id) {
1141 let avg_seed_score: f64 = if seed_score_map.is_empty() {
1146 0.5
1147 } else {
1148 let sum: f64 = seed_score_map.values().sum();
1149 sum / seed_score_map.len() as f64
1150 };
1151 let graph_score =
1152 (avg_seed_score * graph_decay.powi(hop as i32)).clamp(0.0, 1.0);
1153
1154 if graph_score < graph_min_score {
1155 continue;
1156 }
1157
1158 if let Ok(Some(row)) = memories::read_full(&conn, graph_mem_id) {
1159 let snippet: String = row.body.chars().take(300).collect();
1160 hits.push((
1161 graph_mem_id,
1162 graph_score,
1163 "graph".to_string(),
1164 snippet,
1165 row.body,
1166 Some(hop as usize),
1167 ));
1168 }
1169 }
1170 }
1171 }
1172
1173 if !seed_entity_ids.is_empty() {
1176 let (entity_depth, predecessor) = bfs_with_predecessors(
1177 &conn,
1178 &seed_entity_ids,
1179 namespace,
1180 min_weight,
1181 max_hops as u32,
1182 max_neighbors_per_hop,
1183 )
1184 .unwrap_or_default();
1185
1186 tracing::debug!(target: "deep_research",
1187 sub_query_id,
1188 bfs_nodes = entity_depth.len(),
1189 predecessors = predecessor.len(),
1190 "BFS complete"
1191 );
1192
1193 let seed_entity_set: HashSet<i64> = seed_entity_ids.iter().copied().collect();
1194
1195 let all_entity_ids: Vec<i64> = entity_depth.keys().copied().collect();
1197 let mut entity_names: crate::hash::AHashMap<i64, String> =
1198 crate::hash::AHashMap::with_capacity_and_hasher(
1199 all_entity_ids.len(),
1200 ahash::RandomState::default(),
1201 );
1202 for &eid in &all_entity_ids {
1203 let name_res: rusqlite::Result<String> = conn.query_row(
1204 "SELECT name FROM entities WHERE id = ?1",
1205 rusqlite::params![eid],
1206 |r| r.get(0),
1207 );
1208 if let Ok(name) = name_res {
1209 entity_names.insert(eid, name);
1210 }
1211 }
1212
1213 for (&target_id, &_hop) in &entity_depth {
1215 if seed_entity_set.contains(&target_id) {
1216 continue;
1217 }
1218 if !predecessor.contains_key(&target_id) {
1219 continue;
1220 }
1221 if let Some((path_nodes, total_weight)) =
1222 reconstruct_path(target_id, &seed_entity_set, &predecessor, &entity_names)
1223 {
1224 if path_nodes.len() < 2 {
1225 continue;
1226 }
1227 let from = path_nodes
1228 .first()
1229 .map(|n| n.entity.clone())
1230 .unwrap_or_default();
1231 let to = path_nodes
1232 .last()
1233 .map(|n| n.entity.clone())
1234 .unwrap_or_default();
1235 let depth = path_nodes.len();
1236 chains.push(EvidenceChain {
1237 from,
1238 to,
1239 path: path_nodes,
1240 total_weight,
1241 depth,
1242 sub_query_ids: vec![sub_query_id],
1243 });
1244 }
1245 }
1246
1247 chains.sort_by(|a, b| {
1249 b.total_weight
1250 .partial_cmp(&a.total_weight)
1251 .unwrap_or(std::cmp::Ordering::Equal)
1252 });
1253 chains.truncate(20);
1254 tracing::debug!(target: "deep_research",
1255 sub_query_id,
1256 chains_count = chains.len(),
1257 "evidence chains built"
1258 );
1259 }
1260 }
1261
1262 Ok(SubQueryResult {
1263 sub_query_id,
1264 hits,
1265 chains,
1266 })
1267}
1268
1269#[cfg(test)]
1275mod tests {
1276 use super::*;
1277
1278 #[test]
1279 fn test_decompose_and_conjunction() {
1280 let result = decompose_query("A and B", 7);
1281 assert_eq!(result, vec!["A", "B"]);
1282 }
1283
1284 #[test]
1285 fn test_decompose_no_split_multiword_stays_single() {
1286 let result = decompose_query("simple query", 7);
1289 assert_eq!(result, vec!["simple query"]);
1290 }
1291
1292 #[test]
1294 fn test_decompose_single_token_danilo_fans_out() {
1295 let result = decompose_query("danilo", 7);
1296 assert!(
1297 result.len() > 1,
1298 "expected aspect fan-out for single token, got {result:?}"
1299 );
1300 assert_eq!(result[0], "danilo");
1301 assert!(
1302 result
1303 .iter()
1304 .any(|s| s.contains("stack") || s.contains("patrimonio")),
1305 "expected aspect facets in {result:?}"
1306 );
1307 let with_src = decompose_query_with_sources("danilo", 7);
1308 assert_eq!(with_src[0].1, "original");
1309 assert!(with_src.iter().skip(1).all(|(_, s)| *s == "aspect"));
1310 }
1311
1312 #[test]
1313 fn test_decompose_three_parts() {
1314 let result = decompose_query("A, B and C", 7);
1315 assert_eq!(result, vec!["A", "B", "C"]);
1316 }
1317
1318 #[test]
1319 fn test_decompose_portuguese_conjunctions() {
1320 let result = decompose_query("A e B", 7);
1321 assert_eq!(result, vec!["A", "B"]);
1322 }
1323
1324 #[test]
1325 fn test_decompose_max_cap() {
1326 let parts: Vec<String> = (0..10).map(|i| format!("part{i}")).collect();
1327 let query = parts.join(", ");
1328 let result = decompose_query(&query, 7);
1329 assert!(
1330 result.len() <= 7,
1331 "expected at most 7 sub-queries, got {}",
1332 result.len()
1333 );
1334 }
1335
1336 #[test]
1337 fn test_decompose_empty_preserves_original() {
1338 let result = decompose_query("", 7);
1339 assert_eq!(result, vec![""]);
1340 }
1341
1342 #[test]
1343 fn test_decompose_semicolons() {
1344 let result = decompose_query("auth design; deployment config; logging", 7);
1345 assert_eq!(result, vec!["auth design", "deployment config", "logging"]);
1346 }
1347
1348 #[test]
1349 fn test_decompose_relational_phrase() {
1350 let result = decompose_query("auth that caused deployment failure", 7);
1351 assert_eq!(result, vec!["auth", "deployment failure"]);
1352 }
1353
1354 #[test]
1355 fn test_sub_query_serialization() {
1356 let sq = SubQuery {
1357 id: 0,
1358 text: "test query".to_string(),
1359 source: "original",
1360 };
1361 let json = serde_json::to_value(&sq).expect("serialization failed");
1362 assert_eq!(json["id"], 0);
1363 assert_eq!(json["text"], "test query");
1364 assert_eq!(json["source"], "original");
1365 }
1366
1367 #[test]
1368 fn test_deep_result_omits_body_when_none() {
1369 let result = DeepResult {
1370 name: "test".to_string(),
1371 score: 0.9,
1372 source: "knn".to_string(),
1373 sub_query_ids: vec![0],
1374 snippet: "snippet".to_string(),
1375 body: None,
1376 hop_distance: None,
1377 };
1378 let json = serde_json::to_string(&result).expect("serialization failed");
1379 assert!(!json.contains("\"body\""), "body must be omitted when None");
1380 }
1381
1382 #[test]
1383 fn test_deep_result_includes_body_when_some() {
1384 let result = DeepResult {
1385 name: "test".to_string(),
1386 score: 0.9,
1387 source: "knn".to_string(),
1388 sub_query_ids: vec![0, 1],
1389 snippet: "snippet".to_string(),
1390 body: Some("full body content".to_string()),
1391 hop_distance: Some(2),
1392 };
1393 let json = serde_json::to_string(&result).expect("serialization failed");
1394 assert!(json.contains("\"body\""), "body must be present when Some");
1395 assert!(json.contains("full body content"));
1396 }
1397
1398 #[test]
1399 fn test_evidence_node_omits_none_fields() {
1400 let node = EvidenceNode {
1401 entity: "auth-module".to_string(),
1402 relation: None,
1403 weight: None,
1404 };
1405 let json = serde_json::to_string(&node).expect("serialization failed");
1406 assert!(
1407 !json.contains("\"relation\""),
1408 "relation must be omitted when None"
1409 );
1410 assert!(
1411 !json.contains("\"weight\""),
1412 "weight must be omitted when None"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_research_stats_serialization() {
1418 let stats = ResearchStats {
1419 sub_queries_total: 3,
1420 sub_queries_completed: 2,
1421 sub_queries_failed: 1,
1422 sub_queries_timed_out: 0,
1423 unique_memories_found: 10,
1424 evidence_chains_found: 2,
1425 elapsed_ms: 1234,
1426 vec_degraded: false,
1427 };
1428 let json = serde_json::to_value(&stats).expect("serialization failed");
1429 assert_eq!(json["sub_queries_total"], 3);
1430 assert_eq!(json["sub_queries_completed"], 2);
1431 assert_eq!(json["sub_queries_failed"], 1);
1432 assert_eq!(json["elapsed_ms"], 1234);
1433 }
1434
1435 #[test]
1436 fn test_deep_research_response_serialization() {
1437 let resp = DeepResearchResponse {
1438 query: "test query".to_string(),
1439 sub_queries: vec![SubQuery {
1440 id: 0,
1441 text: "test query".to_string(),
1442 source: "original",
1443 }],
1444 results: vec![],
1445 evidence_chains: vec![],
1446 graph_context: None,
1447 stats: ResearchStats {
1448 sub_queries_total: 1,
1449 sub_queries_completed: 1,
1450 sub_queries_failed: 0,
1451 sub_queries_timed_out: 0,
1452 unique_memories_found: 0,
1453 evidence_chains_found: 0,
1454 elapsed_ms: 42,
1455 vec_degraded: false,
1456 },
1457 };
1458 let json = serde_json::to_value(&resp).expect("serialization failed");
1459 assert_eq!(json["query"], "test query");
1460 assert!(json["sub_queries"].is_array());
1461 assert!(json["results"].is_array());
1462 assert!(json["evidence_chains"].is_array());
1463 assert_eq!(json["stats"]["elapsed_ms"], 42);
1464 }
1465
1466 #[test]
1470 fn test_distinct_sub_queries_produce_distinct_texts() {
1471 let queries = [
1472 "authentication design decisions",
1473 "deployment configuration and infrastructure",
1474 ];
1475 assert_ne!(queries[0], queries[1]);
1477
1478 let decomposed = decompose_query(
1480 "authentication design decisions; deployment configuration and infrastructure",
1481 7,
1482 );
1483 assert_eq!(decomposed.len(), 2);
1484 assert_ne!(decomposed[0], decomposed[1]);
1485 }
1486
1487 #[test]
1489 fn test_rrf_fuse_via_fusion_module() {
1490 use crate::storage::fusion::rrf_fuse;
1491
1492 let knn_ids: Vec<i64> = vec![1, 2, 3];
1493 let fts_ids: Vec<i64> = vec![2, 1, 4];
1494 let scores = rrf_fuse(&[(1.0, &knn_ids), (1.0, &fts_ids)], 60.0);
1495
1496 let score_1 = scores[&1];
1498 let score_2 = scores[&2];
1499 let score_3 = scores[&3]; let score_4 = scores[&4]; assert!(
1503 score_1 > score_3,
1504 "id 1 (both lists) must beat id 3 (knn-only rank 3)"
1505 );
1506 assert!(
1507 score_2 > score_4,
1508 "id 2 (both lists) must beat id 4 (fts-only rank 3)"
1509 );
1510 }
1511
1512 #[test]
1514 fn test_evidence_chain_has_from_to_and_path() {
1515 let chain = EvidenceChain {
1516 from: "auth-module".to_string(),
1517 to: "jwt-service".to_string(),
1518 path: vec![
1519 EvidenceNode {
1520 entity: "auth-module".to_string(),
1521 relation: None,
1522 weight: None,
1523 },
1524 EvidenceNode {
1525 entity: "token-validator".to_string(),
1526 relation: Some("depends-on".to_string()),
1527 weight: Some(0.9),
1528 },
1529 EvidenceNode {
1530 entity: "jwt-service".to_string(),
1531 relation: Some("uses".to_string()),
1532 weight: Some(0.8),
1533 },
1534 ],
1535 total_weight: 0.72,
1536 depth: 3,
1537 sub_query_ids: vec![0],
1538 };
1539
1540 let json = serde_json::to_value(&chain).expect("serialization failed");
1541 assert!(
1542 json["from"].is_string(),
1543 "evidence chain must have 'from' field"
1544 );
1545 assert!(
1546 json["to"].is_string(),
1547 "evidence chain must have 'to' field"
1548 );
1549 assert!(
1550 json["path"].is_array(),
1551 "evidence chain must have 'path' array"
1552 );
1553 assert_eq!(json["path"].as_array().unwrap().len(), 3);
1554 assert!(json["total_weight"].is_number(), "must have total_weight");
1555 assert_eq!(json["depth"], 3);
1556 }
1557
1558 #[test]
1560 fn test_reconstruct_path_root_to_target_order() {
1561 let seed_set: HashSet<i64> = [10i64].into_iter().collect();
1563 let mut predecessor: PredecessorMap = std::collections::HashMap::new();
1564 predecessor.insert(20, (10, "depends-on".to_string(), 0.9));
1565 predecessor.insert(30, (20, "uses".to_string(), 0.8));
1566 let mut entity_names: crate::hash::AHashMap<i64, String> = crate::hash::AHashMap::default();
1567 entity_names.insert(10, "seed-entity".to_string());
1568 entity_names.insert(20, "middle-entity".to_string());
1569 entity_names.insert(30, "target-entity".to_string());
1570
1571 let result = reconstruct_path(30, &seed_set, &predecessor, &entity_names);
1572 assert!(result.is_some(), "path must be reconstructed");
1573 let (nodes, weight) = result.unwrap();
1574 assert_eq!(nodes.len(), 3);
1576 assert_eq!(nodes[0].entity, "seed-entity");
1577 assert_eq!(nodes[1].entity, "middle-entity");
1578 assert_eq!(nodes[2].entity, "target-entity");
1579 assert!((weight - 0.72).abs() < 1e-6);
1581 }
1582
1583 #[test]
1585 fn test_evidence_chains_single_hop_filtered_out() {
1586 let chain = EvidenceChain {
1588 from: "a".to_string(),
1589 to: "a".to_string(),
1590 path: vec![EvidenceNode {
1591 entity: "a".to_string(),
1592 relation: None,
1593 weight: None,
1594 }],
1595 total_weight: 1.0,
1596 depth: 1,
1597 sub_query_ids: vec![0],
1598 };
1599 let chains = vec![chain];
1601 let retained: Vec<_> = chains.into_iter().filter(|c| c.depth >= 2).collect();
1602 assert!(retained.is_empty(), "depth-1 chains must be filtered out");
1603 }
1604
1605 #[test]
1607 fn test_bfs_with_predecessors_respects_neighbor_cap() {
1608 use crate::graph::bfs_with_predecessors;
1609 use rusqlite::Connection;
1610
1611 let conn = Connection::open_in_memory().unwrap();
1612 conn.execute_batch(
1613 "CREATE TABLE relationships (
1614 source_id INTEGER NOT NULL,
1615 target_id INTEGER NOT NULL,
1616 weight REAL NOT NULL,
1617 namespace TEXT NOT NULL,
1618 relation TEXT NOT NULL DEFAULT 'related'
1619 );",
1620 )
1621 .unwrap();
1622
1623 for target in 2i64..=6 {
1625 conn.execute(
1626 "INSERT INTO relationships (source_id, target_id, weight, namespace) VALUES (?1, ?2, ?3, 'ns')",
1627 rusqlite::params![1i64, target, 1.0f64],
1628 )
1629 .unwrap();
1630 }
1631
1632 let (depth_uncapped, _) = bfs_with_predecessors(&conn, &[1], "ns", 0.0, 1, None).unwrap();
1634 assert_eq!(
1635 depth_uncapped.len() - 1,
1636 5,
1637 "uncapped must discover all 5 neighbours (plus seed)"
1638 );
1639
1640 let (depth_capped, _) = bfs_with_predecessors(&conn, &[1], "ns", 0.0, 1, Some(2)).unwrap();
1642 assert_eq!(
1644 depth_capped.len(),
1645 3,
1646 "capped to 2 must yield seed + 2 neighbours"
1647 );
1648 }
1649}