Skip to main content

sqlite_graphrag/commands/
deep_research.rs

1//! Handler for the `deep-research` CLI subcommand.
2//!
3//! Orchestrates parallel multi-hop GraphRAG search via query decomposition.
4//! The workload is I/O-bound (SQLite WAL reads), so tokio is used instead of
5//! rayon. Each sub-query opens its own read-only connection.
6
7use 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/// Arguments for the `deep-research` subcommand.
24#[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    /// Research query to decompose and search.
51    #[arg(
52        value_name = "QUERY",
53        allow_hyphen_values = true,
54        help = "Research query to decompose and search"
55    )]
56    pub query: String,
57    /// Results per sub-query (Recall@20 captures 95%+ relevant hits).
58    #[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    /// Maximum sub-queries from decomposition (covers complex multi-hop queries).
67    #[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    /// Multi-hop graph traversal depth (sweet spot: 2-3 hops).
74    #[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    /// Minimum edge weight for graph traversal.
81    #[arg(
82        long,
83        default_value_t = 0.3,
84        help = "Minimum edge weight for graph traversal"
85    )]
86    pub min_weight: f64,
87    /// Maximum concurrent sub-queries (default: min(cpus, 8)).
88    #[arg(long, help = "Maximum concurrent sub-queries (default: min(cpus, 8))")]
89    pub max_concurrency: Option<usize>,
90    /// Timeout per sub-query in seconds.
91    #[arg(long, default_value_t = 30, help = "Timeout per sub-query in seconds")]
92    pub timeout: u64,
93    /// Include full memory bodies in results.
94    #[arg(
95        long,
96        default_value_t = false,
97        help = "Include full memory bodies in results"
98    )]
99    pub with_bodies: bool,
100    /// Maximum results after deduplication.
101    #[arg(
102        long,
103        default_value_t = 50,
104        help = "Maximum results after deduplication"
105    )]
106    pub max_results: usize,
107    /// RRF k parameter controlling score smoothing (higher = less weight on top ranks).
108    #[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    /// Decay factor applied to graph scores per hop (score = seed_score * decay^hop).
115    #[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    /// Minimum score threshold for graph-expanded results (filters noise).
122    #[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    /// Limit top-k neighbours followed per entity per hop (None = unlimited).
129    #[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    /// Namespace (flag / XDG namespace.default / global).
135    #[arg(
136        long,
137        help = "Namespace (flag / XDG namespace.default / global)"
138    )]
139    pub namespace: Option<String>,
140    /// Research mode: `none` (local heuristic, default), `claude-code`, `codex` (v1.1.0).
141    #[arg(long, default_value = "none", value_parser = ["none"], hide = true)]
142    pub mode: String,
143    /// Maximum LLM cost in USD (effective with --mode claude-code/codex, reserved for v1.1.0).
144    #[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    /// JSON output (always on, kept for consistency).
151    #[arg(long, hide = true)]
152    pub json: bool,
153    /// Database path.
154    #[arg(long)]
155    pub db: Option<String>,
156    /// Sub-query strategy: `heuristic` (default, syntactic + single-token aspects)
157    /// or `manual` (requires `--sub-queries-file`).
158    #[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    /// Path to a UTF-8 text file with one sub-query per line (required when
166    /// `--sub-query-strategy manual`). Empty lines and `#` comments are ignored.
167    #[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    /// Write the JSON envelope atomically to this path (tempfile→fsync→rename).
174    /// When set, stdout receives a short confirmation JSON
175    /// `{ "written": "<path>", "bytes": N, "blake3": "..." }` instead of the full
176    /// envelope — preventing shell redirect truncation of multi-MB payloads.
177    #[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/// A node in a reconstructed evidence path.
206#[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/// A directed evidence chain reconstructed from BFS predecessors.
216///
217/// Fields:
218/// - `from`: name of the seed (source) entity.
219/// - `to`: name of the terminal (target) entity.
220/// - `path`: ordered list of intermediate nodes from `from` to `to`.
221/// - `total_weight`: product of edge weights along the path.
222/// - `sub_query_ids`: which sub-queries produced this chain.
223#[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
277/// Aggregated hit data: (score, source_label, snippet, body, hop_distance, sub_query_ids).
278type MergedHit = (f64, String, String, String, Option<usize>, Vec<usize>);
279
280/// Intermediate result from a single sub-query execution.
281struct SubQueryResult {
282    sub_query_id: usize,
283    /// (memory_id, score, source_label, snippet, body, hop_distance)
284    hits: Vec<(i64, f64, String, String, String, Option<usize>)>,
285    /// Evidence chains reconstructed from BFS.
286    chains: Vec<EvidenceChain>,
287}
288
289/// Sync entry point — builds a tokio runtime for the async fan-out.
290#[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    // GAP-001 (v1.1.04): resolve embeddings for every sub-query BEFORE the
299    // multi-thread runtime is built. `compute_sub_embeddings` calls the
300    // OpenRouter REST path, which internally does
301    // `shared_runtime()?.block_on(...)`; running that inside the worker
302    // threads of the runtime created below panics with
303    // "Cannot start a runtime from within a runtime". Doing the work
304    // synchronously here removes the nesting entirely.
305    let paths = AppPaths::resolve(args.db.as_deref())?;
306    crate::storage::connection::ensure_db_ready(&paths)?;
307    // Resolve sub-queries once (shared by embedding precompute + fan-out).
308    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
328/// GAP-001 (v1.1.04): computes per-sub-query embeddings OUTSIDE the tokio
329/// runtime. `try_embed_query_with_embedding_choice` (OpenRouter path) calls
330/// `shared_runtime()?.block_on(...)` internally; running it inside the
331/// multi-thread runtime built in `run` triggers
332/// "Cannot start a runtime from within a runtime" because the nested
333/// `block_on` happens on a worker thread already driven by the outer
334/// runtime. Resolving embeddings synchronously before the runtime is built
335/// removes the nesting entirely.
336fn 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
366/// Main async logic: decompose, fan-out, assemble, emit JSON.
367///
368/// `sub_embeddings` and `vec_degraded` are computed synchronously in
369/// [`run`] before the tokio runtime is built (GAP-001, v1.1.04) to avoid
370/// a nested-runtime panic on the OpenRouter embedding path.
371/// `sub_queries` is also resolved in [`run`] so embedding precompute and
372/// fan-out share one plan (v1.1.05).
373async 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    // Phase 1: sub-queries already resolved in `run` (heuristic / manual / aspects).
396    let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
397
398    // GAP-001 (v1.1.04): sub-query embeddings were already resolved in
399    // `run` before the tokio runtime was built. Using them here keeps the
400    // OpenRouter REST path out of the worker threads (nested-runtime panic).
401    // `vec_degraded` reflects per-sub-query FTS5 fallback (GAP-DEEPRESEARCH-001).
402    if vec_degraded {
403        tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
404    }
405
406    // Phase 2: Fan-out — parallel sub-query execution.
407    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        // GAP-DEEPRESEARCH-001 FIX: pass Optional embedding (None = FTS5-only).
423        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            // Dereference the Arc to obtain a &[f32] slice for the sync function.
442            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    // Collect results incrementally.
468    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    // Phase 3: Evidence assembly — merge, dedup, rank.
495    // Aggregate hits: memory_id -> (best_score, source, snippet, body, hop_distance, sub_query_ids)
496    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            // Keep best score.
515            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    // Resolve memory names for merged results.
529    let conn = open_ro(&paths.db)?;
530    let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
531
532    // Sort by score descending.
533    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    // GAP-09/10 FIX: Collect evidence chains from reconstructed BFS paths.
558    // The old code appended flat node pairs from a global SELECT; now each
559    // sub-query returns directed EvidenceChain structs (from, to, path).
560    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            // Deduplicate chains by (from, to) pair.
567            let key = format!("{}->{}", chain.from, chain.to);
568            if seen_chain_keys.insert(key) {
569                evidence_chains.push(chain);
570            }
571        }
572    }
573
574    // Sort evidence chains by total_weight descending, discard single-hop trivial chains.
575    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    // MEDIUM-01b: Build graph_context with entities and relationships from result memories.
586    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    // Phase 4: JSON output (stdout and/or atomic --output).
680    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        // v1.1.05 Bug 2: atomic write avoids truncated envelopes under SIGTERM /
700        // shell redirect races. Full envelope goes to the file; stdout gets a
701        // small confirmation so pipelines can still check exit 0 + path.
702        // v1.1.8 GAP-CLI-DR-01..03: short `-o` is registered; fail-fast if the
703        // path was requested and the final file is missing or empty.
704        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
744/// Build the sub-query plan from CLI strategy (heuristic or manual file).
745fn 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
795/// Aspect facets applied when a single-token query cannot be split syntactically.
796///
797/// Covers the angles operators expect for person/org subjects (patrimony, stack,
798/// stakeholders, projects, decisions, relationships, context) in EN and PT so
799/// FTS/hybrid retrieval fans out beyond the literal token (v1.1.05 Bug 1).
800const 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
814/// Heuristic query decomposition with per-sub-query source labels.
815///
816/// Splits by conjunctions, commas, semicolons, relational phrases, word-pairs
817/// for multi-word queries, and **single-token aspect expansion** when none of
818/// the syntactic branches fire (v1.1.05 Bug 1).
819fn 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    // Split by relational phrases first (most specific).
827    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 no relational split, try conjunctions and delimiters.
859    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 still no split, try word-pair decomposition for multi-word queries.
887    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    // v1.1.05 Bug 1: single-token (or unsplittable) queries get aspect fan-out.
900    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/// Heuristic query decomposition (text-only; unit tests).
921#[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
929/// Reconstruct a directed path from `target_entity_id` back to a seed using the
930/// predecessor map built by BFS.  Returns the path nodes from root to target
931/// plus the accumulated edge weights.
932fn 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(&current) {
944            break;
945        }
946        let (parent, relation, weight) = predecessor.get(&current)?;
947        total_weight *= weight;
948        path_ids.push((current, Some(relation.clone()), Some(*weight)));
949        current = *parent;
950    }
951    // Push the seed entity (root).
952    path_ids.push((current, None, None));
953
954    // Reverse so path goes from seed → target.
955    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/// Execute a single sub-query: hybrid search (KNN + FTS fused via RRF) + graph traversal.
973///
974/// GAP-07 fix: receives the embedding for THIS sub-query (not the shared original).
975/// GAP-08/11 fix: uses rrf_fuse() for proper score fusion instead of hardcoded 0.5.
976/// GAP-09/10 fix: builds directed evidence chains filtered to discovered entities.
977/// GAP-17: respects max_neighbors_per_hop cap in BFS.
978///
979/// Runs synchronously on a blocking thread (called from a tokio spawn context).
980/// Each call opens its own read-only SQLite connection to leverage WAL concurrency.
981#[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    // --- GAP-08/11 FIX: Use RRF fusion for KNN + FTS instead of hardcoded 0.5 ---
1004
1005    // 1. KNN vector search — collect ranked IDs (skipped when embedding unavailable).
1006    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    // 2. FTS5 search — collect ranked IDs.
1022    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    // 3. Fuse via RRF.
1036    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    // 4. Sort fused results and build hits.
1040    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    // 5. Graph traversal from discovered memories.
1085    // GAP-09/10 FIX: entity KNN also uses this sub-query's embedding.
1086    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        // Seed entities from KNN on entity vectors (skipped when embedding unavailable).
1091        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        // HIGH-01 FIX: limit seeds to top-5 memories by score to prevent
1103        // BFS from starting at every node when k >= total memories.
1104        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        // Graph traversal with hop scores.
1133        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            // Build seed score map from RRF-fused scores for graph decay computation.
1142            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                    // GAP-08/11 FIX: graph score = seed_score * decay^hop * edge_weight.
1157                    // For the seed score, use the best score among the seed memories that
1158                    // transitively reached this graph memory (approximate with the average
1159                    // seed score since we don't track the exact path yet).
1160                    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        // GAP-09/10 FIX: Build directed evidence chains using BFS with predecessor map,
1189        // filtered to entities discovered in this sub-query.
1190        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            // Collect entity IDs we need names for.
1211            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            // Reconstruct a path for each non-seed entity that has a predecessor.
1229            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            // Sort chains by total_weight descending and cap to avoid huge output.
1263            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// ────────────────────────────────────────────────────────────────────────────
1285// Re-export sub_query_results field initialisation for the stats counter.
1286// The field is moved out of run_async after the join loop; we need to shadow it.
1287// ────────────────────────────────────────────────────────────────────────────
1288
1289#[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        // Two-word queries without delimiters stay as one sub-query (not
1302        // single-token aspect expansion).
1303        let result = decompose_query("simple query", 7);
1304        assert_eq!(result, vec!["simple query"]);
1305    }
1306
1307    /// v1.1.05 Bug 1: single-token queries must fan out into aspects.
1308    #[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    // ---- GAP-07 regression: different sub-queries produce distinct embeddings ----
1482    // We test decompose_query returns texts that *would* produce distinct embeddings
1483    // (different text inputs → different embedding inputs → different search results).
1484    #[test]
1485    fn test_distinct_sub_queries_produce_distinct_texts() {
1486        let queries = [
1487            "authentication design decisions",
1488            "deployment configuration and infrastructure",
1489        ];
1490        // These two texts must be different strings (prerequisite for distinct embeddings).
1491        assert_ne!(queries[0], queries[1]);
1492
1493        // decompose_query with semicolons must preserve distinct texts.
1494        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    // ---- GAP-08/11 regression: rrf_fuse integration via fusion module ----
1503    #[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        // Items appearing in both lists must score higher than items in only one list.
1512        let score_1 = scores[&1];
1513        let score_2 = scores[&2];
1514        let score_3 = scores[&3]; // knn only, rank 3
1515        let score_4 = scores[&4]; // fts only, rank 3
1516
1517        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    // ---- GAP-09/10 regression: evidence chains must be directed paths ----
1528    #[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    // ---- GAP-10 regression: reconstruct_path returns correct node order ----
1574    #[test]
1575    fn test_reconstruct_path_root_to_target_order() {
1576        // Build a simple chain: entity 10 (seed) -> entity 20 -> entity 30 (target)
1577        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        // Path must be [seed, middle, target]
1590        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        // total_weight = 0.9 * 0.8
1595        assert!((weight - 0.72).abs() < 1e-6);
1596    }
1597
1598    // ---- GAP-09 regression: evidence chains must NOT be present for 1-hop trivial pairs ----
1599    #[test]
1600    fn test_evidence_chains_single_hop_filtered_out() {
1601        // A chain of depth 1 (only root node) should be discarded.
1602        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        // Simulate the filter: retain chains with depth >= 2.
1615        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    // ---- GAP-17 regression: bfs_with_predecessors honours max_neighbors_per_hop ----
1621    #[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        // Seed entity 1 has 5 neighbours.
1639        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        // Without cap: all 5 neighbours reached.
1648        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        // With cap=2: only top-2 neighbours (by weight; all equal here so first 2 returned).
1656        let (depth_capped, _) = bfs_with_predecessors(&conn, &[1], "ns", 0.0, 1, Some(2)).unwrap();
1657        // seed + 2 neighbours = 3 entries.
1658        assert_eq!(
1659            depth_capped.len(),
1660            3,
1661            "capped to 2 must yield seed + 2 neighbours"
1662        );
1663    }
1664
1665    /// GAP-CLI-DR-02: materialize path writes non-empty JSON and yields
1666    /// `{written,bytes,blake3}` semantics (mirrors --output / -o post-write checks).
1667    #[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        // Ack shape contract (fields consumers read after deep-research -o)
1699        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}