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 (flag / XDG 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)]
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 short = 'o',
179 long,
180 value_name = "PATH",
181 help = "Atomic JSON output path (atomwrite algorithm; short -o)"
182 )]
183 pub output: Option<std::path::PathBuf>,
184}
185
186#[derive(Serialize)]
187struct SubQuery {
188 id: usize,
189 text: String,
190 source: &'static str,
191}
192
193#[derive(Serialize)]
194struct DeepResult {
195 name: String,
196 score: f64,
197 source: String,
198 sub_query_ids: Vec<usize>,
199 snippet: String,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 body: Option<String>,
202 hop_distance: Option<usize>,
203}
204
205#[derive(Serialize, Clone)]
207struct EvidenceNode {
208 entity: String,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 relation: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 weight: Option<f64>,
213}
214
215#[derive(Serialize)]
224struct EvidenceChain {
225 from: String,
226 to: String,
227 path: Vec<EvidenceNode>,
228 total_weight: f64,
229 depth: usize,
230 sub_query_ids: Vec<usize>,
231}
232
233#[derive(Serialize)]
234struct ResearchStats {
235 sub_queries_total: usize,
236 sub_queries_completed: usize,
237 sub_queries_failed: usize,
238 sub_queries_timed_out: usize,
239 unique_memories_found: usize,
240 evidence_chains_found: usize,
241 elapsed_ms: u64,
242 vec_degraded: bool,
243}
244
245#[derive(Serialize)]
246struct GraphContextEntity {
247 name: String,
248 entity_type: String,
249 degree: u32,
250}
251
252#[derive(Serialize)]
253struct GraphContextRel {
254 from: String,
255 to: String,
256 relation: String,
257 weight: f64,
258}
259
260#[derive(Serialize)]
261struct GraphContext {
262 entities: Vec<GraphContextEntity>,
263 relationships: Vec<GraphContextRel>,
264}
265
266#[derive(Serialize)]
267struct DeepResearchResponse {
268 query: String,
269 sub_queries: Vec<SubQuery>,
270 results: Vec<DeepResult>,
271 evidence_chains: Vec<EvidenceChain>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 graph_context: Option<GraphContext>,
274 stats: ResearchStats,
275}
276
277type MergedHit = (f64, String, String, String, Option<usize>, Vec<usize>);
279
280struct SubQueryResult {
282 sub_query_id: usize,
283 hits: Vec<(i64, f64, String, String, String, Option<usize>)>,
285 chains: Vec<EvidenceChain>,
287}
288
289#[tracing::instrument(skip_all, level = "debug", name = "deep_research")]
291pub fn run(
292 args: DeepResearchArgs,
293 llm_backend: crate::cli::LlmBackendChoice,
294 embedding_backend: crate::cli::EmbeddingBackendChoice,
295) -> Result<(), AppError> {
296 tracing::debug!(target: "deep_research", query = %args.query, k = args.k, "starting deep research");
297
298 let paths = AppPaths::resolve(args.db.as_deref())?;
306 crate::storage::connection::ensure_db_ready(&paths)?;
307 let sub_query_plan = resolve_sub_queries(&args)?;
309 let sub_query_texts: Vec<String> = sub_query_plan.iter().map(|s| s.text.clone()).collect();
310 let (sub_embeddings, vec_degraded) =
311 compute_sub_embeddings(&paths, &sub_query_texts, embedding_backend, llm_backend);
312
313 let rt = tokio::runtime::Builder::new_multi_thread()
314 .worker_threads(2)
315 .enable_all()
316 .build()
317 .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build tokio runtime: {e}")))?;
318 rt.block_on(run_async(
319 args,
320 llm_backend,
321 embedding_backend,
322 sub_query_plan,
323 sub_embeddings,
324 vec_degraded,
325 ))
326}
327
328fn compute_sub_embeddings(
337 paths: &crate::paths::AppPaths,
338 sub_query_texts: &[String],
339 embedding_backend: crate::cli::EmbeddingBackendChoice,
340 llm_backend: crate::cli::LlmBackendChoice,
341) -> (Vec<Option<Arc<Vec<f32>>>>, bool) {
342 output::emit_progress_i18n(
343 "Computing per-sub-query embeddings...",
344 "Calculando embeddings por sub-consulta...",
345 );
346 let mut sub_embeddings: Vec<Option<Arc<Vec<f32>>>> = Vec::with_capacity(sub_query_texts.len());
347 let mut vec_degraded = false;
348 for sq_text in sub_query_texts {
349 match crate::embedder::try_embed_query_with_embedding_choice(
350 &paths.models,
351 sq_text,
352 embedding_backend,
353 llm_backend,
354 ) {
355 Ok((v, _backend)) => sub_embeddings.push(Some(Arc::new(v))),
356 Err(reason) => {
357 tracing::warn!(target: "deep_research", fallback_reason = %reason, reason_code = %reason.reason_code(), "embedding failed for sub-query; falling back to FTS5");
358 sub_embeddings.push(None);
359 vec_degraded = true;
360 }
361 }
362 }
363 (sub_embeddings, vec_degraded)
364}
365
366async fn run_async(
374 args: DeepResearchArgs,
375 _llm_backend: crate::cli::LlmBackendChoice,
376 _embedding_backend: crate::cli::EmbeddingBackendChoice,
377 sub_queries: Vec<SubQuery>,
378 sub_embeddings: Vec<Option<Arc<Vec<f32>>>>,
379 vec_degraded: bool,
380) -> Result<(), AppError> {
381 let start = std::time::Instant::now();
382
383 if args.query.trim().is_empty() {
384 return Err(AppError::Validation(crate::i18n::validation::empty_query()));
385 }
386
387 if args.max_cost_usd.is_some() && args.mode == "none" {
388 tracing::warn!(target: "deep_research", "--max-cost-usd has no effect without --mode claude-code/codex");
389 }
390
391 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
392 let paths = AppPaths::resolve(args.db.as_deref())?;
393 crate::storage::connection::ensure_db_ready(&paths)?;
394
395 let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
397
398 if vec_degraded {
403 tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
404 }
405
406 let cpu_count = std::thread::available_parallelism()
408 .map(|n| n.get())
409 .unwrap_or(4);
410 let permits = args
411 .max_concurrency
412 .unwrap_or_else(|| cpu_count.min(8))
413 .min(sub_queries.len())
414 .max(1);
415 let semaphore = Arc::new(Semaphore::new(permits));
416 let timeout_dur = std::time::Duration::from_secs(args.timeout);
417
418 let mut join_set: JoinSet<Result<SubQueryResult, (usize, String)>> = JoinSet::new();
419
420 for (idx, sq_text) in sub_query_texts.iter().enumerate() {
421 let sem = Arc::clone(&semaphore);
422 let emb = sub_embeddings[idx].clone();
424 let ns = namespace.clone();
425 let db_path = paths.db.clone();
426 let query_text = sq_text.clone();
427 let k = args.k;
428 let max_hops = args.max_hops;
429 let min_weight = args.min_weight;
430 let rrf_k = args.rrf_k;
431 let graph_decay = args.graph_decay;
432 let graph_min_score = args.graph_min_score;
433 let max_neighbors_per_hop = args.max_neighbors_per_hop;
434
435 join_set.spawn(async move {
436 let _permit = sem
437 .acquire_owned()
438 .await
439 .map_err(|e| (idx, format!("semaphore closed: {e}")))?;
440
441 let result = tokio::time::timeout(timeout_dur, async move {
443 execute_sub_query(
444 idx,
445 &query_text,
446 emb.as_ref().map(|v| v.as_slice()),
447 &ns,
448 &db_path,
449 k,
450 max_hops,
451 min_weight,
452 rrf_k,
453 graph_decay,
454 graph_min_score,
455 max_neighbors_per_hop,
456 )
457 })
458 .await;
459
460 match result {
461 Ok(inner) => inner.map_err(|e| (idx, e)),
462 Err(_) => Err((idx, "timeout".to_string())),
463 }
464 });
465 }
466
467 let mut sub_query_results: Vec<SubQueryResult> = Vec::with_capacity(sub_queries.len());
469 let mut failed_count = 0usize;
470 let mut timed_out_count = 0usize;
471
472 while let Some(join_result) = join_set.join_next().await {
473 match join_result {
474 Ok(Ok(sqr)) => sub_query_results.push(sqr),
475 Ok(Err((_idx, reason))) => {
476 if reason == "timeout" {
477 timed_out_count += 1;
478 } else {
479 failed_count += 1;
480 }
481 tracing::warn!(target: "deep_research", sub_query_id = _idx, reason = %reason, "sub-query failed");
482 }
483 Err(join_err) => {
484 failed_count += 1;
485 if join_err.is_panic() {
486 tracing::error!(target: "deep_research", error = %join_err, "sub-query task panicked");
487 } else {
488 tracing::warn!(target: "deep_research", error = %join_err, "sub-query task cancelled");
489 }
490 }
491 }
492 }
493
494 let mut merged: crate::hash::AHashMap<i64, MergedHit> =
497 crate::hash::AHashMap::with_capacity_and_hasher(
498 sub_query_results.len() * args.k,
499 Default::default(),
500 );
501
502 for sqr in &sub_query_results {
503 for (mem_id, score, source, snippet, body, hop) in &sqr.hits {
504 let entry = merged.entry(*mem_id).or_insert_with(|| {
505 (
506 *score,
507 source.clone(),
508 snippet.clone(),
509 body.clone(),
510 *hop,
511 Vec::new(),
512 )
513 });
514 if *score > entry.0 {
516 entry.0 = *score;
517 entry.1 = source.clone();
518 entry.2 = snippet.clone();
519 entry.3 = body.clone();
520 entry.4 = *hop;
521 }
522 if !entry.5.contains(&sqr.sub_query_id) {
523 entry.5.push(sqr.sub_query_id);
524 }
525 }
526 }
527
528 let conn = open_ro(&paths.db)?;
530 let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
531
532 let mut ranked: Vec<(i64, MergedHit)> = merged.into_iter().collect();
534 ranked.sort_by(|a, b| {
535 b.1 .0
536 .partial_cmp(&a.1 .0)
537 .unwrap_or(std::cmp::Ordering::Equal)
538 });
539 ranked.truncate(args.max_results);
540
541 for (mem_id, (score, source, snippet, body, hop, sq_ids)) in ranked {
542 let name = match memories::read_full(&conn, mem_id)? {
543 Some(row) => row.name,
544 None => continue,
545 };
546 results.push(DeepResult {
547 name,
548 score,
549 source,
550 sub_query_ids: sq_ids,
551 snippet,
552 body: if args.with_bodies { Some(body) } else { None },
553 hop_distance: hop,
554 });
555 }
556
557 let completed_count = sub_query_results.len();
561 let mut evidence_chains: Vec<EvidenceChain> = Vec::with_capacity(completed_count * 2);
562 let mut seen_chain_keys: HashSet<String> = HashSet::with_capacity(completed_count * 2);
563
564 for sqr in sub_query_results {
565 for chain in sqr.chains {
566 let key = format!("{}->{}", chain.from, chain.to);
568 if seen_chain_keys.insert(key) {
569 evidence_chains.push(chain);
570 }
571 }
572 }
573
574 evidence_chains.retain(|c| c.depth >= 2);
576 evidence_chains.sort_by(|a, b| {
577 b.total_weight
578 .partial_cmp(&a.total_weight)
579 .unwrap_or(std::cmp::Ordering::Equal)
580 });
581
582 let unique_memories = results.len();
583 let evidence_count = evidence_chains.len();
584
585 let graph_context = if !results.is_empty() {
587 let result_names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
588 let mut ctx_entities: Vec<GraphContextEntity> = Vec::with_capacity(results.len());
589 let mut ctx_rels: Vec<GraphContextRel> = Vec::with_capacity(results.len() * 2);
590 let mut seen_entity_ids: crate::hash::AHashSet<i64> =
591 crate::hash::AHashSet::with_capacity_and_hasher(results.len(), Default::default());
592
593 for name in &result_names {
594 if let Ok(Some(eid)) = entities::find_entity_id(&conn, &namespace, name) {
595 if seen_entity_ids.insert(eid) {
596 let etype: String = conn
597 .query_row(
598 "SELECT COALESCE(type,'concept') FROM entities WHERE id = ?1",
599 rusqlite::params![eid],
600 |r| r.get(0),
601 )
602 .unwrap_or_else(|_| "concept".to_string());
603 let degree: u32 = conn
604 .query_row(
605 "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 OR target_id = ?1",
606 rusqlite::params![eid],
607 |r| r.get(0),
608 )
609 .unwrap_or(0);
610 ctx_entities.push(GraphContextEntity {
611 name: name.to_string(),
612 entity_type: etype,
613 degree,
614 });
615 }
616 }
617 }
618
619 let entity_ids: Vec<i64> = seen_entity_ids.iter().copied().collect();
620 if entity_ids.len() >= 2 {
621 let placeholders: String = entity_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
622 let sql = format!(
623 "SELECT s.name, t.name, r.relation, r.weight \
624 FROM relationships r \
625 JOIN entities s ON s.id = r.source_id \
626 JOIN entities t ON t.id = r.target_id \
627 WHERE r.source_id IN ({placeholders}) AND r.target_id IN ({placeholders}) \
628 LIMIT 50"
629 );
630 if let Ok(mut stmt) = conn.prepare(&sql) {
631 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
632 Vec::with_capacity(entity_ids.len() * 2);
633 for id in &entity_ids {
634 params.push(Box::new(*id));
635 }
636 for id in &entity_ids {
637 params.push(Box::new(*id));
638 }
639 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
640 params.iter().map(|p| p.as_ref()).collect();
641 if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |r| {
642 Ok((
643 r.get::<_, String>(0)?,
644 r.get::<_, String>(1)?,
645 r.get::<_, String>(2)?,
646 r.get::<_, f64>(3)?,
647 ))
648 }) {
649 for row in rows.flatten() {
650 ctx_rels.push(GraphContextRel {
651 from: row.0,
652 to: row.1,
653 relation: row.2,
654 weight: row.3,
655 });
656 }
657 }
658 }
659 }
660
661 if ctx_entities.is_empty() {
662 None
663 } else {
664 Some(GraphContext {
665 entities: ctx_entities,
666 relationships: ctx_rels,
667 })
668 }
669 } else {
670 None
671 };
672
673 tracing::debug!(target: "deep_research",
674 total_results = results.len(),
675 total_chains = evidence_chains.len(),
676 "assembly complete"
677 );
678
679 let response = DeepResearchResponse {
681 query: args.query,
682 sub_queries,
683 results,
684 evidence_chains,
685 graph_context,
686 stats: ResearchStats {
687 sub_queries_total: sub_query_texts.len(),
688 sub_queries_completed: completed_count,
689 sub_queries_failed: failed_count,
690 sub_queries_timed_out: timed_out_count,
691 unique_memories_found: unique_memories,
692 evidence_chains_found: evidence_count,
693 elapsed_ms: start.elapsed().as_millis() as u64,
694 vec_degraded,
695 },
696 };
697
698 if let Some(path) = args.output.as_ref() {
699 crate::atomic_io::write_json_atomic(path, &response)?;
705 if !path.exists() {
706 return Err(AppError::Validation(format!(
707 "deep-research --output failed: path does not exist after atomic write: {}",
708 path.display()
709 )));
710 }
711 let meta = std::fs::metadata(path).map_err(AppError::Io)?;
712 if meta.len() == 0 {
713 return Err(AppError::Validation(format!(
714 "deep-research --output failed: written file is empty (0 bytes): {}",
715 path.display()
716 )));
717 }
718 let file_bytes = std::fs::read(path).map_err(AppError::Io)?;
719 let digest = blake3::hash(&file_bytes).to_hex().to_string();
720 #[derive(Serialize)]
721 struct WrittenAck {
722 written: String,
723 bytes: u64,
724 blake3: String,
725 sub_queries_total: usize,
726 unique_memories_found: usize,
727 elapsed_ms: u64,
728 }
729 output::emit_json(&WrittenAck {
730 written: path.display().to_string(),
731 bytes: meta.len(),
732 blake3: digest,
733 sub_queries_total: response.stats.sub_queries_total,
734 unique_memories_found: response.stats.unique_memories_found,
735 elapsed_ms: response.stats.elapsed_ms,
736 })?;
737 } else {
738 output::emit_json(&response)?;
739 }
740
741 Ok(())
742}
743
744fn resolve_sub_queries(args: &DeepResearchArgs) -> Result<Vec<SubQuery>, AppError> {
746 if args.query.trim().is_empty() {
747 return Err(AppError::Validation(crate::i18n::validation::empty_query()));
748 }
749 match args.sub_query_strategy.as_str() {
750 "manual" => {
751 let path = args.sub_queries_file.as_ref().ok_or_else(|| {
752 AppError::Validation(
753 "--sub-query-strategy manual requires --sub-queries-file PATH".to_string(),
754 )
755 })?;
756 let raw = std::fs::read_to_string(path).map_err(AppError::Io)?;
757 let mut texts: Vec<String> = raw
758 .lines()
759 .map(str::trim)
760 .filter(|l| !l.is_empty() && !l.starts_with('#'))
761 .map(str::to_string)
762 .collect();
763 if texts.is_empty() {
764 return Err(AppError::Validation(format!(
765 "sub-queries file '{}' has no usable lines",
766 path.display()
767 )));
768 }
769 texts.truncate(args.max_sub_queries);
770 Ok(texts
771 .into_iter()
772 .enumerate()
773 .map(|(i, text)| SubQuery {
774 id: i,
775 text,
776 source: "manual",
777 })
778 .collect())
779 }
780 _ => {
781 let planned = decompose_query_with_sources(&args.query, args.max_sub_queries);
782 Ok(planned
783 .into_iter()
784 .enumerate()
785 .map(|(i, (text, source))| SubQuery {
786 id: i,
787 text,
788 source,
789 })
790 .collect())
791 }
792 }
793}
794
795const SINGLE_TOKEN_ASPECTS: &[&str] = &[
801 "patrimonio",
802 "stack",
803 "tecnologia",
804 "stakeholders",
805 "pessoas",
806 "projeto",
807 "decisao",
808 "relacionamento",
809 "contexto",
810 "architecture",
811 "history",
812];
813
814fn decompose_query_with_sources(query: &str, max: usize) -> Vec<(String, &'static str)> {
820 if query.is_empty() {
821 return vec![(query.to_string(), "original")];
822 }
823
824 let mut parts: Vec<(String, &'static str)> = Vec::with_capacity(max);
825
826 let relational = [
828 " that caused ",
829 " depending on ",
830 " related to ",
831 " connected to ",
832 " linked to ",
833 " caused by ",
834 " followed by ",
835 ];
836 let mut text = query.to_string();
837 let mut did_relational_split = false;
838 for phrase in &relational {
839 if text.to_lowercase().contains(phrase) {
840 let lower = text.to_lowercase();
841 if let Some(pos) = lower.find(phrase) {
842 let left = text[..pos].trim().to_string();
843 let right = text[pos + phrase.len()..].trim().to_string();
844 if !left.is_empty() {
845 parts.push((left, "decomposed"));
846 }
847 if !right.is_empty() {
848 text = right;
849 }
850 did_relational_split = true;
851 }
852 }
853 }
854 if did_relational_split && !text.is_empty() {
855 parts.push((text.clone(), "decomposed"));
856 }
857
858 if parts.is_empty() {
860 let semi_parts: Vec<&str> = query.split(';').collect();
861 if semi_parts.len() > 1 {
862 for p in &semi_parts {
863 let trimmed = p.trim();
864 if !trimmed.is_empty() {
865 parts.push((trimmed.to_string(), "decomposed"));
866 }
867 }
868 } else {
869 let normalized = query
870 .replace(" and ", ", ")
871 .replace(" AND ", ", ")
872 .replace(" e ", ", ")
873 .replace(" E ", ", ");
874 let comma_parts: Vec<&str> = normalized.split(',').collect();
875 if comma_parts.len() > 1 {
876 for p in &comma_parts {
877 let trimmed = p.trim();
878 if !trimmed.is_empty() {
879 parts.push((trimmed.to_string(), "decomposed"));
880 }
881 }
882 }
883 }
884 }
885
886 if parts.is_empty() {
888 let words: Vec<&str> = query.split_whitespace().filter(|w| w.len() > 2).collect();
889 if words.len() >= 3 {
890 parts.push((query.to_string(), "original"));
891 parts.push((format!("{} {}", words[0], words[1]), "decomposed"));
892 parts.push((
893 format!("{} {}", words[words.len() - 2], words[words.len() - 1]),
894 "decomposed",
895 ));
896 }
897 }
898
899 if parts.is_empty() {
901 let token_count = query.split_whitespace().filter(|w| !w.is_empty()).count();
902 if token_count == 1 {
903 let token = query.trim();
904 parts.push((token.to_string(), "original"));
905 for aspect in SINGLE_TOKEN_ASPECTS {
906 if parts.len() >= max {
907 break;
908 }
909 parts.push((format!("{token} {aspect}"), "aspect"));
910 }
911 } else {
912 return vec![(query.to_string(), "original")];
913 }
914 }
915
916 parts.truncate(max);
917 parts
918}
919
920#[cfg(test)]
922fn decompose_query(query: &str, max: usize) -> Vec<String> {
923 decompose_query_with_sources(query, max)
924 .into_iter()
925 .map(|(t, _)| t)
926 .collect()
927}
928
929fn reconstruct_path(
933 target_id: i64,
934 seed_entity_ids: &HashSet<i64>,
935 predecessor: &PredecessorMap,
936 entity_names: &crate::hash::AHashMap<i64, String>,
937) -> Option<(Vec<EvidenceNode>, f64)> {
938 let mut path_ids: Vec<(i64, Option<String>, Option<f64>)> = Vec::with_capacity(8);
939 let mut total_weight = 1.0_f64;
940 let mut current = target_id;
941
942 loop {
943 if seed_entity_ids.contains(¤t) {
944 break;
945 }
946 let (parent, relation, weight) = predecessor.get(¤t)?;
947 total_weight *= weight;
948 path_ids.push((current, Some(relation.clone()), Some(*weight)));
949 current = *parent;
950 }
951 path_ids.push((current, None, None));
953
954 path_ids.reverse();
956
957 let nodes: Vec<EvidenceNode> = path_ids
958 .into_iter()
959 .map(|(id, relation, weight)| EvidenceNode {
960 entity: entity_names
961 .get(&id)
962 .cloned()
963 .unwrap_or_else(|| format!("entity-{id}")),
964 relation,
965 weight,
966 })
967 .collect();
968
969 Some((nodes, total_weight))
970}
971
972#[allow(clippy::too_many_arguments)]
982fn execute_sub_query(
983 sub_query_id: usize,
984 query_text: &str,
985 embedding: Option<&[f32]>,
986 namespace: &str,
987 db_path: &std::path::Path,
988 k: usize,
989 max_hops: usize,
990 min_weight: f64,
991 rrf_k: f64,
992 graph_decay: f64,
993 graph_min_score: f64,
994 max_neighbors_per_hop: Option<usize>,
995) -> Result<SubQueryResult, String> {
996 let conn = open_ro(db_path).map_err(|e| format!("failed to open db: {e}"))?;
997
998 let mut hits: Vec<(i64, f64, String, String, String, Option<usize>)> =
999 Vec::with_capacity(k * 2);
1000 let mut seen_ids: crate::hash::AHashSet<i64> =
1001 crate::hash::AHashSet::with_capacity_and_hasher(k * 2, Default::default());
1002
1003 let (knn_ids, knn_distance_map) = if let Some(emb) = embedding {
1007 let knn_results = memories::knn_search(&conn, emb, &[namespace.to_string()], None, k)
1008 .map_err(|e| format!("knn_search failed: {e}"))?;
1009 let ids: Vec<i64> = knn_results.iter().map(|(id, _)| *id).collect();
1010 tracing::debug!(target: "deep_research", sub_query_id, knn_count = ids.len(), "KNN complete");
1011 let dist_map: crate::hash::AHashMap<i64, f64> = knn_results
1012 .iter()
1013 .map(|(id, dist)| (*id, *dist as f64))
1014 .collect();
1015 (ids, dist_map)
1016 } else {
1017 tracing::debug!(target: "deep_research", sub_query_id, "KNN skipped (no embedding); FTS5-only");
1018 (vec![], crate::hash::AHashMap::default())
1019 };
1020
1021 let fts_results = match memories::fts_search(&conn, query_text, namespace, None, k) {
1023 Ok(rows) => rows,
1024 Err(e) => {
1025 tracing::warn!(target: "deep_research",
1026 sub_query_id,
1027 "FTS5 search failed, continuing with KNN only: {e}"
1028 );
1029 vec![]
1030 }
1031 };
1032 let fts_ids: Vec<i64> = fts_results.iter().map(|r| r.id).collect();
1033 tracing::debug!(target: "deep_research", sub_query_id, fts_count = fts_ids.len(), "FTS complete");
1034
1035 let rrf_scores = rrf_fuse(&[(1.0, &knn_ids), (1.0, &fts_ids)], rrf_k);
1037 let max_possible = rrf_max_possible(&[1.0, 1.0], rrf_k);
1038
1039 let mut fused: Vec<(i64, f64)> = rrf_scores.into_iter().collect();
1041 fused.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1042 fused.truncate(k * 2);
1043 tracing::debug!(target: "deep_research",
1044 sub_query_id,
1045 fused_count = fused.len(),
1046 "RRF fusion complete"
1047 );
1048
1049 if fused.is_empty() && !knn_ids.is_empty() {
1050 tracing::warn!(target: "deep_research", sub_query_id, knn_count = knn_ids.len(), fts_count = fts_ids.len(),
1051 "RRF fusion returned 0 results despite KNN/FTS hits; consider lowering --graph-min-score");
1052 }
1053
1054 for (memory_id, combined_score) in &fused {
1055 if seen_ids.insert(*memory_id) {
1056 let normalized = if max_possible > 0.0 {
1057 combined_score / max_possible
1058 } else {
1059 0.0
1060 };
1061 let score = normalized.clamp(0.0, 1.0);
1062 let in_knn = knn_distance_map.contains_key(memory_id);
1063 let in_fts = fts_ids.contains(memory_id);
1064 let source = match (in_knn, in_fts) {
1065 (true, true) => "hybrid",
1066 (true, false) => "knn",
1067 (false, true) => "fts",
1068 (false, false) => "graph",
1069 };
1070 if let Ok(Some(row)) = memories::read_full(&conn, *memory_id) {
1071 let snippet: String = row.body.chars().take(300).collect();
1072 hits.push((
1073 *memory_id,
1074 score,
1075 source.to_string(),
1076 snippet,
1077 row.body,
1078 None,
1079 ));
1080 }
1081 }
1082 }
1083
1084 let memory_ids: Vec<i64> = hits.iter().map(|(id, ..)| *id).collect();
1087 let mut chains: Vec<EvidenceChain> = Vec::with_capacity(memory_ids.len());
1088
1089 if !memory_ids.is_empty() && max_hops > 0 {
1090 let entity_ids: Vec<i64> = if let Some(emb) = embedding {
1092 entities::knn_search(&conn, emb, namespace, 5)
1093 .inspect_err(|e| tracing::warn!(target: "deep_research", error = %e, "entity KNN search failed, skipping graph seed"))
1094 .unwrap_or_default()
1095 .iter()
1096 .map(|(id, _)| *id)
1097 .collect()
1098 } else {
1099 vec![]
1100 };
1101
1102 let top_seed_count = 5.min(memory_ids.len());
1105 let top_memory_ids = &memory_ids[..top_seed_count];
1106 let mut seed_entity_ids: Vec<i64> = entity_ids.clone();
1107 for &mem_id in top_memory_ids {
1108 let mut stmt = conn
1109 .prepare_cached("SELECT entity_id FROM memory_entities WHERE memory_id = ?1")
1110 .map_err(|e| format!("prepare failed: {e}"))?;
1111 let ids: Vec<i64> = stmt
1112 .query_map(rusqlite::params![mem_id], |r| r.get(0))
1113 .map_err(|e| format!("query failed: {e}"))?
1114 .filter_map(|r| r.ok())
1115 .collect();
1116 seed_entity_ids.extend(ids);
1117 }
1118 seed_entity_ids.sort_unstable();
1119 seed_entity_ids.dedup();
1120 tracing::debug!(target: "deep_research",
1121 sub_query_id,
1122 seed_count = seed_entity_ids.len(),
1123 "seed entities collected"
1124 );
1125
1126 let all_seed_ids: Vec<i64> = memory_ids
1127 .iter()
1128 .chain(entity_ids.iter())
1129 .copied()
1130 .collect();
1131
1132 if let Ok(graph_results) = traverse_from_memories_with_hops_capped(
1134 &conn,
1135 &all_seed_ids,
1136 namespace,
1137 min_weight,
1138 max_hops as u32,
1139 max_neighbors_per_hop,
1140 ) {
1141 let seed_score_map: crate::hash::AHashMap<i64, f64> = fused
1143 .iter()
1144 .map(|(id, s)| {
1145 let normalized = if max_possible > 0.0 {
1146 s / max_possible
1147 } else {
1148 0.0
1149 };
1150 (*id, normalized.clamp(0.0, 1.0))
1151 })
1152 .collect();
1153
1154 for (graph_mem_id, hop) in graph_results {
1155 if seen_ids.insert(graph_mem_id) {
1156 let avg_seed_score: f64 = if seed_score_map.is_empty() {
1161 0.5
1162 } else {
1163 let sum: f64 = seed_score_map.values().sum();
1164 sum / seed_score_map.len() as f64
1165 };
1166 let graph_score =
1167 (avg_seed_score * graph_decay.powi(hop as i32)).clamp(0.0, 1.0);
1168
1169 if graph_score < graph_min_score {
1170 continue;
1171 }
1172
1173 if let Ok(Some(row)) = memories::read_full(&conn, graph_mem_id) {
1174 let snippet: String = row.body.chars().take(300).collect();
1175 hits.push((
1176 graph_mem_id,
1177 graph_score,
1178 "graph".to_string(),
1179 snippet,
1180 row.body,
1181 Some(hop as usize),
1182 ));
1183 }
1184 }
1185 }
1186 }
1187
1188 if !seed_entity_ids.is_empty() {
1191 let (entity_depth, predecessor) = bfs_with_predecessors(
1192 &conn,
1193 &seed_entity_ids,
1194 namespace,
1195 min_weight,
1196 max_hops as u32,
1197 max_neighbors_per_hop,
1198 )
1199 .unwrap_or_default();
1200
1201 tracing::debug!(target: "deep_research",
1202 sub_query_id,
1203 bfs_nodes = entity_depth.len(),
1204 predecessors = predecessor.len(),
1205 "BFS complete"
1206 );
1207
1208 let seed_entity_set: HashSet<i64> = seed_entity_ids.iter().copied().collect();
1209
1210 let all_entity_ids: Vec<i64> = entity_depth.keys().copied().collect();
1212 let mut entity_names: crate::hash::AHashMap<i64, String> =
1213 crate::hash::AHashMap::with_capacity_and_hasher(
1214 all_entity_ids.len(),
1215 ahash::RandomState::default(),
1216 );
1217 for &eid in &all_entity_ids {
1218 let name_res: rusqlite::Result<String> = conn.query_row(
1219 "SELECT name FROM entities WHERE id = ?1",
1220 rusqlite::params![eid],
1221 |r| r.get(0),
1222 );
1223 if let Ok(name) = name_res {
1224 entity_names.insert(eid, name);
1225 }
1226 }
1227
1228 for (&target_id, &_hop) in &entity_depth {
1230 if seed_entity_set.contains(&target_id) {
1231 continue;
1232 }
1233 if !predecessor.contains_key(&target_id) {
1234 continue;
1235 }
1236 if let Some((path_nodes, total_weight)) =
1237 reconstruct_path(target_id, &seed_entity_set, &predecessor, &entity_names)
1238 {
1239 if path_nodes.len() < 2 {
1240 continue;
1241 }
1242 let from = path_nodes
1243 .first()
1244 .map(|n| n.entity.clone())
1245 .unwrap_or_default();
1246 let to = path_nodes
1247 .last()
1248 .map(|n| n.entity.clone())
1249 .unwrap_or_default();
1250 let depth = path_nodes.len();
1251 chains.push(EvidenceChain {
1252 from,
1253 to,
1254 path: path_nodes,
1255 total_weight,
1256 depth,
1257 sub_query_ids: vec![sub_query_id],
1258 });
1259 }
1260 }
1261
1262 chains.sort_by(|a, b| {
1264 b.total_weight
1265 .partial_cmp(&a.total_weight)
1266 .unwrap_or(std::cmp::Ordering::Equal)
1267 });
1268 chains.truncate(20);
1269 tracing::debug!(target: "deep_research",
1270 sub_query_id,
1271 chains_count = chains.len(),
1272 "evidence chains built"
1273 );
1274 }
1275 }
1276
1277 Ok(SubQueryResult {
1278 sub_query_id,
1279 hits,
1280 chains,
1281 })
1282}
1283
1284#[cfg(test)]
1290mod tests {
1291 use super::*;
1292
1293 #[test]
1294 fn test_decompose_and_conjunction() {
1295 let result = decompose_query("A and B", 7);
1296 assert_eq!(result, vec!["A", "B"]);
1297 }
1298
1299 #[test]
1300 fn test_decompose_no_split_multiword_stays_single() {
1301 let result = decompose_query("simple query", 7);
1304 assert_eq!(result, vec!["simple query"]);
1305 }
1306
1307 #[test]
1309 fn test_decompose_single_token_danilo_fans_out() {
1310 let result = decompose_query("danilo", 7);
1311 assert!(
1312 result.len() > 1,
1313 "expected aspect fan-out for single token, got {result:?}"
1314 );
1315 assert_eq!(result[0], "danilo");
1316 assert!(
1317 result
1318 .iter()
1319 .any(|s| s.contains("stack") || s.contains("patrimonio")),
1320 "expected aspect facets in {result:?}"
1321 );
1322 let with_src = decompose_query_with_sources("danilo", 7);
1323 assert_eq!(with_src[0].1, "original");
1324 assert!(with_src.iter().skip(1).all(|(_, s)| *s == "aspect"));
1325 }
1326
1327 #[test]
1328 fn test_decompose_three_parts() {
1329 let result = decompose_query("A, B and C", 7);
1330 assert_eq!(result, vec!["A", "B", "C"]);
1331 }
1332
1333 #[test]
1334 fn test_decompose_portuguese_conjunctions() {
1335 let result = decompose_query("A e B", 7);
1336 assert_eq!(result, vec!["A", "B"]);
1337 }
1338
1339 #[test]
1340 fn test_decompose_max_cap() {
1341 let parts: Vec<String> = (0..10).map(|i| format!("part{i}")).collect();
1342 let query = parts.join(", ");
1343 let result = decompose_query(&query, 7);
1344 assert!(
1345 result.len() <= 7,
1346 "expected at most 7 sub-queries, got {}",
1347 result.len()
1348 );
1349 }
1350
1351 #[test]
1352 fn test_decompose_empty_preserves_original() {
1353 let result = decompose_query("", 7);
1354 assert_eq!(result, vec![""]);
1355 }
1356
1357 #[test]
1358 fn test_decompose_semicolons() {
1359 let result = decompose_query("auth design; deployment config; logging", 7);
1360 assert_eq!(result, vec!["auth design", "deployment config", "logging"]);
1361 }
1362
1363 #[test]
1364 fn test_decompose_relational_phrase() {
1365 let result = decompose_query("auth that caused deployment failure", 7);
1366 assert_eq!(result, vec!["auth", "deployment failure"]);
1367 }
1368
1369 #[test]
1370 fn test_sub_query_serialization() {
1371 let sq = SubQuery {
1372 id: 0,
1373 text: "test query".to_string(),
1374 source: "original",
1375 };
1376 let json = serde_json::to_value(&sq).expect("serialization failed");
1377 assert_eq!(json["id"], 0);
1378 assert_eq!(json["text"], "test query");
1379 assert_eq!(json["source"], "original");
1380 }
1381
1382 #[test]
1383 fn test_deep_result_omits_body_when_none() {
1384 let result = DeepResult {
1385 name: "test".to_string(),
1386 score: 0.9,
1387 source: "knn".to_string(),
1388 sub_query_ids: vec![0],
1389 snippet: "snippet".to_string(),
1390 body: None,
1391 hop_distance: None,
1392 };
1393 let json = serde_json::to_string(&result).expect("serialization failed");
1394 assert!(!json.contains("\"body\""), "body must be omitted when None");
1395 }
1396
1397 #[test]
1398 fn test_deep_result_includes_body_when_some() {
1399 let result = DeepResult {
1400 name: "test".to_string(),
1401 score: 0.9,
1402 source: "knn".to_string(),
1403 sub_query_ids: vec![0, 1],
1404 snippet: "snippet".to_string(),
1405 body: Some("full body content".to_string()),
1406 hop_distance: Some(2),
1407 };
1408 let json = serde_json::to_string(&result).expect("serialization failed");
1409 assert!(json.contains("\"body\""), "body must be present when Some");
1410 assert!(json.contains("full body content"));
1411 }
1412
1413 #[test]
1414 fn test_evidence_node_omits_none_fields() {
1415 let node = EvidenceNode {
1416 entity: "auth-module".to_string(),
1417 relation: None,
1418 weight: None,
1419 };
1420 let json = serde_json::to_string(&node).expect("serialization failed");
1421 assert!(
1422 !json.contains("\"relation\""),
1423 "relation must be omitted when None"
1424 );
1425 assert!(
1426 !json.contains("\"weight\""),
1427 "weight must be omitted when None"
1428 );
1429 }
1430
1431 #[test]
1432 fn test_research_stats_serialization() {
1433 let stats = ResearchStats {
1434 sub_queries_total: 3,
1435 sub_queries_completed: 2,
1436 sub_queries_failed: 1,
1437 sub_queries_timed_out: 0,
1438 unique_memories_found: 10,
1439 evidence_chains_found: 2,
1440 elapsed_ms: 1234,
1441 vec_degraded: false,
1442 };
1443 let json = serde_json::to_value(&stats).expect("serialization failed");
1444 assert_eq!(json["sub_queries_total"], 3);
1445 assert_eq!(json["sub_queries_completed"], 2);
1446 assert_eq!(json["sub_queries_failed"], 1);
1447 assert_eq!(json["elapsed_ms"], 1234);
1448 }
1449
1450 #[test]
1451 fn test_deep_research_response_serialization() {
1452 let resp = DeepResearchResponse {
1453 query: "test query".to_string(),
1454 sub_queries: vec![SubQuery {
1455 id: 0,
1456 text: "test query".to_string(),
1457 source: "original",
1458 }],
1459 results: vec![],
1460 evidence_chains: vec![],
1461 graph_context: None,
1462 stats: ResearchStats {
1463 sub_queries_total: 1,
1464 sub_queries_completed: 1,
1465 sub_queries_failed: 0,
1466 sub_queries_timed_out: 0,
1467 unique_memories_found: 0,
1468 evidence_chains_found: 0,
1469 elapsed_ms: 42,
1470 vec_degraded: false,
1471 },
1472 };
1473 let json = serde_json::to_value(&resp).expect("serialization failed");
1474 assert_eq!(json["query"], "test query");
1475 assert!(json["sub_queries"].is_array());
1476 assert!(json["results"].is_array());
1477 assert!(json["evidence_chains"].is_array());
1478 assert_eq!(json["stats"]["elapsed_ms"], 42);
1479 }
1480
1481 #[test]
1485 fn test_distinct_sub_queries_produce_distinct_texts() {
1486 let queries = [
1487 "authentication design decisions",
1488 "deployment configuration and infrastructure",
1489 ];
1490 assert_ne!(queries[0], queries[1]);
1492
1493 let decomposed = decompose_query(
1495 "authentication design decisions; deployment configuration and infrastructure",
1496 7,
1497 );
1498 assert_eq!(decomposed.len(), 2);
1499 assert_ne!(decomposed[0], decomposed[1]);
1500 }
1501
1502 #[test]
1504 fn test_rrf_fuse_via_fusion_module() {
1505 use crate::storage::fusion::rrf_fuse;
1506
1507 let knn_ids: Vec<i64> = vec![1, 2, 3];
1508 let fts_ids: Vec<i64> = vec![2, 1, 4];
1509 let scores = rrf_fuse(&[(1.0, &knn_ids), (1.0, &fts_ids)], 60.0);
1510
1511 let score_1 = scores[&1];
1513 let score_2 = scores[&2];
1514 let score_3 = scores[&3]; let score_4 = scores[&4]; assert!(
1518 score_1 > score_3,
1519 "id 1 (both lists) must beat id 3 (knn-only rank 3)"
1520 );
1521 assert!(
1522 score_2 > score_4,
1523 "id 2 (both lists) must beat id 4 (fts-only rank 3)"
1524 );
1525 }
1526
1527 #[test]
1529 fn test_evidence_chain_has_from_to_and_path() {
1530 let chain = EvidenceChain {
1531 from: "auth-module".to_string(),
1532 to: "jwt-service".to_string(),
1533 path: vec![
1534 EvidenceNode {
1535 entity: "auth-module".to_string(),
1536 relation: None,
1537 weight: None,
1538 },
1539 EvidenceNode {
1540 entity: "token-validator".to_string(),
1541 relation: Some("depends-on".to_string()),
1542 weight: Some(0.9),
1543 },
1544 EvidenceNode {
1545 entity: "jwt-service".to_string(),
1546 relation: Some("uses".to_string()),
1547 weight: Some(0.8),
1548 },
1549 ],
1550 total_weight: 0.72,
1551 depth: 3,
1552 sub_query_ids: vec![0],
1553 };
1554
1555 let json = serde_json::to_value(&chain).expect("serialization failed");
1556 assert!(
1557 json["from"].is_string(),
1558 "evidence chain must have 'from' field"
1559 );
1560 assert!(
1561 json["to"].is_string(),
1562 "evidence chain must have 'to' field"
1563 );
1564 assert!(
1565 json["path"].is_array(),
1566 "evidence chain must have 'path' array"
1567 );
1568 assert_eq!(json["path"].as_array().unwrap().len(), 3);
1569 assert!(json["total_weight"].is_number(), "must have total_weight");
1570 assert_eq!(json["depth"], 3);
1571 }
1572
1573 #[test]
1575 fn test_reconstruct_path_root_to_target_order() {
1576 let seed_set: HashSet<i64> = [10i64].into_iter().collect();
1578 let mut predecessor: PredecessorMap = std::collections::HashMap::new();
1579 predecessor.insert(20, (10, "depends-on".to_string(), 0.9));
1580 predecessor.insert(30, (20, "uses".to_string(), 0.8));
1581 let mut entity_names: crate::hash::AHashMap<i64, String> = crate::hash::AHashMap::default();
1582 entity_names.insert(10, "seed-entity".to_string());
1583 entity_names.insert(20, "middle-entity".to_string());
1584 entity_names.insert(30, "target-entity".to_string());
1585
1586 let result = reconstruct_path(30, &seed_set, &predecessor, &entity_names);
1587 assert!(result.is_some(), "path must be reconstructed");
1588 let (nodes, weight) = result.unwrap();
1589 assert_eq!(nodes.len(), 3);
1591 assert_eq!(nodes[0].entity, "seed-entity");
1592 assert_eq!(nodes[1].entity, "middle-entity");
1593 assert_eq!(nodes[2].entity, "target-entity");
1594 assert!((weight - 0.72).abs() < 1e-6);
1596 }
1597
1598 #[test]
1600 fn test_evidence_chains_single_hop_filtered_out() {
1601 let chain = EvidenceChain {
1603 from: "a".to_string(),
1604 to: "a".to_string(),
1605 path: vec![EvidenceNode {
1606 entity: "a".to_string(),
1607 relation: None,
1608 weight: None,
1609 }],
1610 total_weight: 1.0,
1611 depth: 1,
1612 sub_query_ids: vec![0],
1613 };
1614 let chains = vec![chain];
1616 let retained: Vec<_> = chains.into_iter().filter(|c| c.depth >= 2).collect();
1617 assert!(retained.is_empty(), "depth-1 chains must be filtered out");
1618 }
1619
1620 #[test]
1622 fn test_bfs_with_predecessors_respects_neighbor_cap() {
1623 use crate::graph::bfs_with_predecessors;
1624 use rusqlite::Connection;
1625
1626 let conn = Connection::open_in_memory().unwrap();
1627 conn.execute_batch(
1628 "CREATE TABLE relationships (
1629 source_id INTEGER NOT NULL,
1630 target_id INTEGER NOT NULL,
1631 weight REAL NOT NULL,
1632 namespace TEXT NOT NULL,
1633 relation TEXT NOT NULL DEFAULT 'related'
1634 );",
1635 )
1636 .unwrap();
1637
1638 for target in 2i64..=6 {
1640 conn.execute(
1641 "INSERT INTO relationships (source_id, target_id, weight, namespace) VALUES (?1, ?2, ?3, 'ns')",
1642 rusqlite::params![1i64, target, 1.0f64],
1643 )
1644 .unwrap();
1645 }
1646
1647 let (depth_uncapped, _) = bfs_with_predecessors(&conn, &[1], "ns", 0.0, 1, None).unwrap();
1649 assert_eq!(
1650 depth_uncapped.len() - 1,
1651 5,
1652 "uncapped must discover all 5 neighbours (plus seed)"
1653 );
1654
1655 let (depth_capped, _) = bfs_with_predecessors(&conn, &[1], "ns", 0.0, 1, Some(2)).unwrap();
1657 assert_eq!(
1659 depth_capped.len(),
1660 3,
1661 "capped to 2 must yield seed + 2 neighbours"
1662 );
1663 }
1664
1665 #[test]
1668 fn output_path_materializes_file_with_ack_fields() {
1669 use crate::atomic_io::write_json_atomic;
1670 use serde_json::json;
1671 use std::time::{SystemTime, UNIX_EPOCH};
1672
1673 let nanos = SystemTime::now()
1674 .duration_since(UNIX_EPOCH)
1675 .unwrap()
1676 .as_nanos();
1677 let path = std::env::temp_dir().join(format!(
1678 "sqlite-graphrag-dr02-{}-{}.json",
1679 std::process::id(),
1680 nanos
1681 ));
1682 let envelope = json!({
1683 "query": "auth",
1684 "hits": [{"memory": "m1"}],
1685 "stats": {
1686 "sub_queries_total": 2,
1687 "unique_memories_found": 1,
1688 "elapsed_ms": 12
1689 }
1690 });
1691 write_json_atomic(&path, &envelope).expect("atomic write must succeed");
1692 assert!(path.exists(), "GAP-CLI-DR-02: -o/--output must materialize a file");
1693 let meta = std::fs::metadata(&path).expect("metadata");
1694 assert!(meta.len() > 0, "GAP-CLI-DR-02: written file must be non-empty");
1695 let file_bytes = std::fs::read(&path).expect("read");
1696 let digest = blake3::hash(&file_bytes).to_hex().to_string();
1697 assert_eq!(digest.len(), 64, "blake3 hex digest length");
1698 let ack = json!({
1700 "written": path.display().to_string(),
1701 "bytes": meta.len(),
1702 "blake3": digest,
1703 });
1704 assert!(ack["written"].as_str().unwrap().ends_with(".json"));
1705 assert!(ack["bytes"].as_u64().unwrap() > 0);
1706 assert_eq!(ack["blake3"].as_str().unwrap().len(), 64);
1707 let _ = std::fs::remove_file(&path);
1708 }
1709}