Skip to main content

sqlite_graphrag/commands/deep_research/
mod.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::output;
9use crate::paths::AppPaths;
10use crate::storage::connection::open_ro;
11use crate::storage::{entities, memories};
12
13use serde::Serialize;
14use std::collections::HashSet;
15use std::sync::Arc;
16use tokio::sync::Semaphore;
17use tokio::task::JoinSet;
18
19mod pipeline;
20use pipeline::{compute_sub_embeddings, execute_sub_query, resolve_sub_queries};
21
22#[cfg(test)]
23use pipeline::{decompose_query, decompose_query_with_sources};
24
25/// Arguments for the `deep-research` subcommand.
26#[derive(clap::Args)]
27#[command(
28    about = "Deep parallel multi-hop GraphRAG research via query decomposition",
29    after_long_help = "CONTRACT:\n  \
30        stdout = pretty JSON envelope only (machine-readable).\n  \
31        stderr = tracing / progress / diagnostics only.\n  \
32        Never redirect with `&>` or `2>&1` into the same file as stdout — that\n  \
33        contaminates the JSON and breaks jaq/jq. Prefer:\n  \
34        sqlite-graphrag deep-research \"q\" > out.json 2>/dev/null\n  \
35        or --output out.json (atomic write via atomwrite algorithm).\n\n\
36EXAMPLES:\n  \
37        # Basic deep research (single-token queries auto-expand into aspects)\n  \
38        sqlite-graphrag deep-research \"danilo\"\n\n  \
39        # With custom parameters\n  \
40        sqlite-graphrag deep-research \"auth\" --k 20 --max-hops 3 --max-sub-queries 7\n\n  \
41        # Include full memory bodies in output\n  \
42        sqlite-graphrag deep-research \"auth\" --with-bodies\n\n  \
43        # Manual sub-queries (one query per line)\n  \
44        sqlite-graphrag deep-research \"danilo\" --sub-query-strategy manual \\\n  \
45          --sub-queries-file aspects.txt\n\n  \
46        # Atomic JSON file (crash-safe; preferred for large --with-bodies runs)\n  \
47        sqlite-graphrag deep-research \"auth\" --output /tmp/dr.json\n\n  \
48        # Tune RRF and graph scoring\n  \
49        sqlite-graphrag deep-research \"auth and deployment\" --rrf-k 60 --graph-decay 0.7"
50)]
51pub struct DeepResearchArgs {
52    /// Research query to decompose and search.
53    #[arg(
54        value_name = "QUERY",
55        allow_hyphen_values = true,
56        help = "Research query to decompose and search"
57    )]
58    pub query: String,
59    /// Results per sub-query (Recall@20 captures 95%+ relevant hits).
60    #[arg(
61        long,
62        short,
63        aliases = ["limit", "top-k"],
64        default_value_t = 20,
65        help = "Results per sub-query (Recall@20 captures 95%+ relevant hits)"
66    )]
67    pub k: usize,
68    /// Maximum sub-queries from decomposition (covers complex multi-hop queries).
69    #[arg(
70        long,
71        default_value_t = 7,
72        help = "Maximum sub-queries (covers complex multi-hop queries)"
73    )]
74    pub max_sub_queries: usize,
75    /// Multi-hop graph traversal depth (sweet spot: 2-3 hops).
76    #[arg(
77        long,
78        default_value_t = 3,
79        help = "Multi-hop graph traversal depth (sweet spot: 2-3 hops)"
80    )]
81    pub max_hops: usize,
82    /// Minimum edge weight for graph traversal.
83    #[arg(
84        long,
85        default_value_t = 0.3,
86        help = "Minimum edge weight for graph traversal"
87    )]
88    pub min_weight: f64,
89    /// Maximum concurrent sub-queries (default: min(cpus, 8)).
90    #[arg(long, help = "Maximum concurrent sub-queries (default: min(cpus, 8))")]
91    pub max_concurrency: Option<usize>,
92    /// Timeout per sub-query in seconds.
93    #[arg(long, default_value_t = 30, help = "Timeout per sub-query in seconds")]
94    pub timeout: u64,
95    /// Include full memory bodies in results.
96    #[arg(
97        long,
98        default_value_t = false,
99        help = "Include full memory bodies in results"
100    )]
101    pub with_bodies: bool,
102    /// Maximum results after deduplication.
103    #[arg(
104        long,
105        default_value_t = 50,
106        help = "Maximum results after deduplication"
107    )]
108    pub max_results: usize,
109    /// RRF k parameter controlling score smoothing (higher = less weight on top ranks).
110    #[arg(
111        long,
112        default_value_t = 60.0,
113        help = "RRF k parameter (higher = less weight on top ranks)"
114    )]
115    pub rrf_k: f64,
116    /// Decay factor applied to graph scores per hop (score = seed_score * decay^hop).
117    #[arg(
118        long,
119        default_value_t = 0.7,
120        help = "Graph score decay factor per hop (0.0-1.0)"
121    )]
122    pub graph_decay: f64,
123    /// Minimum score threshold for graph-expanded results (filters noise).
124    #[arg(
125        long,
126        default_value_t = 0.05,
127        help = "Minimum score threshold for graph-expanded results"
128    )]
129    pub graph_min_score: f64,
130    /// Limit top-k neighbours followed per entity per hop (None = unlimited).
131    #[arg(
132        long,
133        help = "Limit neighbours per entity per hop for graph traversal (default: unlimited)"
134    )]
135    pub max_neighbors_per_hop: Option<usize>,
136    /// Namespace (flag / XDG namespace.default / global).
137    #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
138    pub namespace: Option<String>,
139    /// Research mode: `none` (local heuristic, default), `claude-code`, `codex` (v1.1.0).
140    #[arg(long, default_value = "none", value_parser = ["none"], hide = true)]
141    pub mode: String,
142    /// Maximum LLM cost in USD (effective with --mode claude-code/codex, reserved for v1.1.0).
143    #[arg(
144        long,
145        value_name = "USD",
146        help = "Max LLM cost in USD (effective with --mode claude-code/codex)"
147    )]
148    pub max_cost_usd: Option<f64>,
149    /// JSON output (always on, kept for consistency).
150    #[arg(long, hide = true)]
151    pub json: bool,
152    /// Database path.
153    #[arg(long)]
154    pub db: Option<String>,
155    /// Sub-query strategy: `heuristic` (default, syntactic + single-token aspects)
156    /// or `manual` (requires `--sub-queries-file`).
157    #[arg(
158        long,
159        default_value = "heuristic",
160        value_parser = ["heuristic", "manual"],
161        help = "Sub-query strategy: heuristic (default) or manual"
162    )]
163    pub sub_query_strategy: String,
164    /// Path to a UTF-8 text file with one sub-query per line (required when
165    /// `--sub-query-strategy manual`). Empty lines and `#` comments are ignored.
166    #[arg(
167        long,
168        value_name = "PATH",
169        help = "File with one sub-query per line (manual strategy)"
170    )]
171    pub sub_queries_file: Option<std::path::PathBuf>,
172    /// Write the JSON envelope atomically to this path (tempfile→fsync→rename).
173    /// When set, stdout receives a short confirmation JSON
174    /// `{ "written": "<path>", "bytes": N, "blake3": "..." }` instead of the full
175    /// envelope — preventing shell redirect truncation of multi-MB payloads.
176    #[arg(
177        short = 'o',
178        long,
179        value_name = "PATH",
180        help = "Atomic JSON output path (atomwrite algorithm; short -o)"
181    )]
182    pub output: Option<std::path::PathBuf>,
183}
184
185#[derive(Serialize)]
186pub(super) struct SubQuery {
187    pub(super) id: usize,
188    pub(super) text: String,
189    pub(super) source: &'static str,
190}
191
192#[derive(Serialize)]
193struct DeepResult {
194    name: String,
195    score: f64,
196    source: String,
197    sub_query_ids: Vec<usize>,
198    snippet: String,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    body: Option<String>,
201    hop_distance: Option<usize>,
202}
203
204/// A node in a reconstructed evidence path.
205#[derive(Serialize, Clone)]
206pub(super) struct EvidenceNode {
207    pub(super) entity: String,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub(super) relation: Option<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub(super) weight: Option<f64>,
212}
213
214/// A directed evidence chain reconstructed from BFS predecessors.
215///
216/// Fields:
217/// - `from`: name of the seed (source) entity.
218/// - `to`: name of the terminal (target) entity.
219/// - `path`: ordered list of intermediate nodes from `from` to `to`.
220/// - `total_weight`: product of edge weights along the path.
221/// - `sub_query_ids`: which sub-queries produced this chain.
222#[derive(Serialize)]
223pub(super) struct EvidenceChain {
224    pub(super) from: String,
225    pub(super) to: String,
226    pub(super) path: Vec<EvidenceNode>,
227    pub(super) total_weight: f64,
228    pub(super) depth: usize,
229    pub(super) sub_query_ids: Vec<usize>,
230}
231
232#[derive(Serialize)]
233struct ResearchStats {
234    sub_queries_total: usize,
235    sub_queries_completed: usize,
236    sub_queries_failed: usize,
237    sub_queries_timed_out: usize,
238    unique_memories_found: usize,
239    evidence_chains_found: usize,
240    elapsed_ms: u64,
241    vec_degraded: bool,
242}
243
244#[derive(Serialize)]
245struct GraphContextEntity {
246    name: String,
247    entity_type: String,
248    degree: u32,
249}
250
251#[derive(Serialize)]
252struct GraphContextRel {
253    from: String,
254    to: String,
255    relation: String,
256    weight: f64,
257}
258
259#[derive(Serialize)]
260struct GraphContext {
261    entities: Vec<GraphContextEntity>,
262    relationships: Vec<GraphContextRel>,
263}
264
265#[derive(Serialize)]
266struct DeepResearchResponse {
267    query: String,
268    sub_queries: Vec<SubQuery>,
269    results: Vec<DeepResult>,
270    evidence_chains: Vec<EvidenceChain>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    graph_context: Option<GraphContext>,
273    stats: ResearchStats,
274}
275
276/// Aggregated hit data: (score, source_label, snippet, body, hop_distance, sub_query_ids).
277type MergedHit = (f64, String, String, String, Option<usize>, Vec<usize>);
278
279/// Intermediate result from a single sub-query execution.
280pub(super) struct SubQueryResult {
281    pub(super) sub_query_id: usize,
282    /// (memory_id, score, source_label, snippet, body, hop_distance)
283    pub(super) hits: Vec<(i64, f64, String, String, String, Option<usize>)>,
284    /// Evidence chains reconstructed from BFS.
285    pub(super) chains: Vec<EvidenceChain>,
286}
287
288/// Sync entry point — builds a tokio runtime for the async fan-out.
289#[tracing::instrument(skip_all, level = "debug", name = "deep_research")]
290pub fn run(
291    args: DeepResearchArgs,
292    llm_backend: crate::cli::LlmBackendChoice,
293    embedding_backend: crate::cli::EmbeddingBackendChoice,
294) -> Result<(), AppError> {
295    tracing::debug!(target: "deep_research", query = %args.query, k = args.k, "starting deep research");
296
297    // GAP-001 (v1.1.04): resolve embeddings for every sub-query BEFORE the
298    // multi-thread runtime is built. `compute_sub_embeddings` calls the
299    // OpenRouter REST path, which internally does
300    // `shared_runtime()?.block_on(...)`; running that inside the worker
301    // threads of the runtime created below panics with
302    // "Cannot start a runtime from within a runtime". Doing the work
303    // synchronously here removes the nesting entirely.
304    let paths = AppPaths::resolve(args.db.as_deref())?;
305    crate::storage::connection::ensure_db_ready(&paths)?;
306    // Resolve sub-queries once (shared by embedding precompute + fan-out).
307    let sub_query_plan = resolve_sub_queries(&args)?;
308    let sub_query_texts: Vec<String> = sub_query_plan.iter().map(|s| s.text.clone()).collect();
309    let (sub_embeddings, vec_degraded) =
310        compute_sub_embeddings(&paths, &sub_query_texts, embedding_backend, llm_backend);
311
312    let rt = tokio::runtime::Builder::new_multi_thread()
313        .worker_threads(2)
314        .enable_all()
315        .build()
316        .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build tokio runtime: {e}")))?;
317    rt.block_on(run_async(
318        args,
319        llm_backend,
320        embedding_backend,
321        sub_query_plan,
322        sub_embeddings,
323        vec_degraded,
324    ))
325}
326
327/// Main async logic: decompose, fan-out, assemble, emit JSON.
328///
329/// `sub_embeddings` and `vec_degraded` are computed synchronously in
330/// [`run`] before the tokio runtime is built (GAP-001, v1.1.04) to avoid
331/// a nested-runtime panic on the OpenRouter embedding path.
332/// `sub_queries` is also resolved in [`run`] so embedding precompute and
333/// fan-out share one plan (v1.1.05).
334async fn run_async(
335    args: DeepResearchArgs,
336    _llm_backend: crate::cli::LlmBackendChoice,
337    _embedding_backend: crate::cli::EmbeddingBackendChoice,
338    sub_queries: Vec<SubQuery>,
339    sub_embeddings: Vec<Option<Arc<Vec<f32>>>>,
340    vec_degraded: bool,
341) -> Result<(), AppError> {
342    let start = std::time::Instant::now();
343
344    if args.query.trim().is_empty() {
345        return Err(AppError::Validation(crate::i18n::validation::empty_query()));
346    }
347
348    if args.max_cost_usd.is_some() && args.mode == "none" {
349        tracing::warn!(target: "deep_research", "--max-cost-usd has no effect without --mode claude-code/codex");
350    }
351
352    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
353    let paths = AppPaths::resolve(args.db.as_deref())?;
354    crate::storage::connection::ensure_db_ready(&paths)?;
355
356    // Phase 1: sub-queries already resolved in `run` (heuristic / manual / aspects).
357    let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
358
359    // GAP-001 (v1.1.04): sub-query embeddings were already resolved in
360    // `run` before the tokio runtime was built. Using them here keeps the
361    // OpenRouter REST path out of the worker threads (nested-runtime panic).
362    // `vec_degraded` reflects per-sub-query FTS5 fallback (GAP-DEEPRESEARCH-001).
363    if vec_degraded {
364        tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
365    }
366
367    // Phase 2: Fan-out — parallel sub-query execution.
368    let cpu_count = std::thread::available_parallelism()
369        .map(|n| n.get())
370        .unwrap_or(4);
371    let permits = args
372        .max_concurrency
373        .unwrap_or_else(|| cpu_count.min(8))
374        .min(sub_queries.len())
375        .max(1);
376    let semaphore = Arc::new(Semaphore::new(permits));
377    let timeout_dur = std::time::Duration::from_secs(args.timeout);
378
379    let mut join_set: JoinSet<Result<SubQueryResult, (usize, String)>> = JoinSet::new();
380
381    for (idx, sq_text) in sub_query_texts.iter().enumerate() {
382        let sem = Arc::clone(&semaphore);
383        // GAP-DEEPRESEARCH-001 FIX: pass Optional embedding (None = FTS5-only).
384        let emb = sub_embeddings[idx].clone();
385        let ns = namespace.clone();
386        let db_path = paths.db.clone();
387        let query_text = sq_text.clone();
388        let k = args.k;
389        let max_hops = args.max_hops;
390        let min_weight = args.min_weight;
391        let rrf_k = args.rrf_k;
392        let graph_decay = args.graph_decay;
393        let graph_min_score = args.graph_min_score;
394        let max_neighbors_per_hop = args.max_neighbors_per_hop;
395
396        join_set.spawn(async move {
397            let _permit = sem
398                .acquire_owned()
399                .await
400                .map_err(|e| (idx, format!("semaphore closed: {e}")))?;
401
402            // Dereference the Arc to obtain a &[f32] slice for the sync function.
403            let result = tokio::time::timeout(timeout_dur, async move {
404                execute_sub_query(
405                    idx,
406                    &query_text,
407                    emb.as_ref().map(|v| v.as_slice()),
408                    &ns,
409                    &db_path,
410                    k,
411                    max_hops,
412                    min_weight,
413                    rrf_k,
414                    graph_decay,
415                    graph_min_score,
416                    max_neighbors_per_hop,
417                )
418            })
419            .await;
420
421            match result {
422                Ok(inner) => inner.map_err(|e| (idx, e)),
423                Err(_) => Err((idx, "timeout".to_string())),
424            }
425        });
426    }
427
428    // Collect results incrementally.
429    let mut sub_query_results: Vec<SubQueryResult> = Vec::with_capacity(sub_queries.len());
430    let mut failed_count = 0usize;
431    let mut timed_out_count = 0usize;
432
433    while let Some(join_result) = join_set.join_next().await {
434        match join_result {
435            Ok(Ok(sqr)) => sub_query_results.push(sqr),
436            Ok(Err((_idx, reason))) => {
437                if reason == "timeout" {
438                    timed_out_count += 1;
439                } else {
440                    failed_count += 1;
441                }
442                tracing::warn!(target: "deep_research", sub_query_id = _idx, reason = %reason, "sub-query failed");
443            }
444            Err(join_err) => {
445                failed_count += 1;
446                if join_err.is_panic() {
447                    tracing::error!(target: "deep_research", error = %join_err, "sub-query task panicked");
448                } else {
449                    tracing::warn!(target: "deep_research", error = %join_err, "sub-query task cancelled");
450                }
451            }
452        }
453    }
454
455    // Phase 3: Evidence assembly — merge, dedup, rank.
456    // Aggregate hits: memory_id -> (best_score, source, snippet, body, hop_distance, sub_query_ids)
457    let mut merged: crate::hash::AHashMap<i64, MergedHit> =
458        crate::hash::AHashMap::with_capacity_and_hasher(
459            sub_query_results.len() * args.k,
460            Default::default(),
461        );
462
463    for sqr in &sub_query_results {
464        for (mem_id, score, source, snippet, body, hop) in &sqr.hits {
465            let entry = merged.entry(*mem_id).or_insert_with(|| {
466                (
467                    *score,
468                    source.clone(),
469                    snippet.clone(),
470                    body.clone(),
471                    *hop,
472                    Vec::new(),
473                )
474            });
475            // Keep best score.
476            if *score > entry.0 {
477                entry.0 = *score;
478                entry.1 = source.clone();
479                entry.2 = snippet.clone();
480                entry.3 = body.clone();
481                entry.4 = *hop;
482            }
483            if !entry.5.contains(&sqr.sub_query_id) {
484                entry.5.push(sqr.sub_query_id);
485            }
486        }
487    }
488
489    // Resolve memory names for merged results.
490    let conn = open_ro(&paths.db)?;
491    let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
492
493    // Sort by score descending.
494    let mut ranked: Vec<(i64, MergedHit)> = merged.into_iter().collect();
495    ranked.sort_by(|a, b| {
496        b.1 .0
497            .partial_cmp(&a.1 .0)
498            .unwrap_or(std::cmp::Ordering::Equal)
499    });
500    ranked.truncate(args.max_results);
501
502    for (mem_id, (score, source, snippet, body, hop, sq_ids)) in ranked {
503        let name = match memories::read_full(&conn, mem_id)? {
504            Some(row) => row.name,
505            None => continue,
506        };
507        results.push(DeepResult {
508            name,
509            score,
510            source,
511            sub_query_ids: sq_ids,
512            snippet,
513            body: if args.with_bodies { Some(body) } else { None },
514            hop_distance: hop,
515        });
516    }
517
518    // GAP-09/10 FIX: Collect evidence chains from reconstructed BFS paths.
519    // The old code appended flat node pairs from a global SELECT; now each
520    // sub-query returns directed EvidenceChain structs (from, to, path).
521    let completed_count = sub_query_results.len();
522    let mut evidence_chains: Vec<EvidenceChain> = Vec::with_capacity(completed_count * 2);
523    let mut seen_chain_keys: HashSet<String> = HashSet::with_capacity(completed_count * 2);
524
525    for sqr in sub_query_results {
526        for chain in sqr.chains {
527            // Deduplicate chains by (from, to) pair.
528            let key = format!("{}->{}", chain.from, chain.to);
529            if seen_chain_keys.insert(key) {
530                evidence_chains.push(chain);
531            }
532        }
533    }
534
535    // Sort evidence chains by total_weight descending, discard single-hop trivial chains.
536    evidence_chains.retain(|c| c.depth >= 2);
537    evidence_chains.sort_by(|a, b| {
538        b.total_weight
539            .partial_cmp(&a.total_weight)
540            .unwrap_or(std::cmp::Ordering::Equal)
541    });
542
543    let unique_memories = results.len();
544    let evidence_count = evidence_chains.len();
545
546    // MEDIUM-01b: Build graph_context with entities and relationships from result memories.
547    let graph_context = if !results.is_empty() {
548        let result_names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
549        let mut ctx_entities: Vec<GraphContextEntity> = Vec::with_capacity(results.len());
550        let mut ctx_rels: Vec<GraphContextRel> = Vec::with_capacity(results.len() * 2);
551        let mut seen_entity_ids: crate::hash::AHashSet<i64> =
552            crate::hash::AHashSet::with_capacity_and_hasher(results.len(), Default::default());
553
554        for name in &result_names {
555            if let Ok(Some(eid)) = entities::find_entity_id(&conn, &namespace, name) {
556                if seen_entity_ids.insert(eid) {
557                    let etype: String = conn
558                        .query_row(
559                            "SELECT COALESCE(type,'concept') FROM entities WHERE id = ?1",
560                            rusqlite::params![eid],
561                            |r| r.get(0),
562                        )
563                        .unwrap_or_else(|_| "concept".to_string());
564                    let degree: u32 = conn
565                        .query_row(
566                            "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 OR target_id = ?1",
567                            rusqlite::params![eid],
568                            |r| r.get(0),
569                        )
570                        .unwrap_or(0);
571                    ctx_entities.push(GraphContextEntity {
572                        name: name.to_string(),
573                        entity_type: etype,
574                        degree,
575                    });
576                }
577            }
578        }
579
580        let entity_ids: Vec<i64> = seen_entity_ids.iter().copied().collect();
581        if entity_ids.len() >= 2 {
582            let placeholders: String = entity_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
583            let sql = format!(
584                "SELECT s.name, t.name, r.relation, r.weight \
585                 FROM relationships r \
586                 JOIN entities s ON s.id = r.source_id \
587                 JOIN entities t ON t.id = r.target_id \
588                 WHERE r.source_id IN ({placeholders}) AND r.target_id IN ({placeholders}) \
589                 LIMIT 50"
590            );
591            if let Ok(mut stmt) = conn.prepare(&sql) {
592                let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
593                    Vec::with_capacity(entity_ids.len() * 2);
594                for id in &entity_ids {
595                    params.push(Box::new(*id));
596                }
597                for id in &entity_ids {
598                    params.push(Box::new(*id));
599                }
600                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
601                    params.iter().map(|p| p.as_ref()).collect();
602                if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |r| {
603                    Ok((
604                        r.get::<_, String>(0)?,
605                        r.get::<_, String>(1)?,
606                        r.get::<_, String>(2)?,
607                        r.get::<_, f64>(3)?,
608                    ))
609                }) {
610                    for row in rows.flatten() {
611                        ctx_rels.push(GraphContextRel {
612                            from: row.0,
613                            to: row.1,
614                            relation: row.2,
615                            weight: row.3,
616                        });
617                    }
618                }
619            }
620        }
621
622        if ctx_entities.is_empty() {
623            None
624        } else {
625            Some(GraphContext {
626                entities: ctx_entities,
627                relationships: ctx_rels,
628            })
629        }
630    } else {
631        None
632    };
633
634    tracing::debug!(target: "deep_research",
635        total_results = results.len(),
636        total_chains = evidence_chains.len(),
637        "assembly complete"
638    );
639
640    // Phase 4: JSON output (stdout and/or atomic --output).
641    let response = DeepResearchResponse {
642        query: args.query,
643        sub_queries,
644        results,
645        evidence_chains,
646        graph_context,
647        stats: ResearchStats {
648            sub_queries_total: sub_query_texts.len(),
649            sub_queries_completed: completed_count,
650            sub_queries_failed: failed_count,
651            sub_queries_timed_out: timed_out_count,
652            unique_memories_found: unique_memories,
653            evidence_chains_found: evidence_count,
654            elapsed_ms: start.elapsed().as_millis() as u64,
655            vec_degraded,
656        },
657    };
658
659    if let Some(path) = args.output.as_ref() {
660        // v1.1.05 Bug 2: atomic write avoids truncated envelopes under SIGTERM /
661        // shell redirect races. Full envelope goes to the file; stdout gets a
662        // small confirmation so pipelines can still check exit 0 + path.
663        // v1.1.8 GAP-CLI-DR-01..03: short `-o` is registered; fail-fast if the
664        // path was requested and the final file is missing or empty.
665        crate::atomic_io::write_json_atomic(path, &response)?;
666        if !path.exists() {
667            return Err(AppError::Validation(
668                crate::i18n::validation::deep_research_output_missing(&path.display().to_string()),
669            ));
670        }
671        let meta = std::fs::metadata(path).map_err(AppError::Io)?;
672        if meta.len() == 0 {
673            return Err(AppError::Validation(
674                crate::i18n::validation::deep_research_output_empty(&path.display().to_string()),
675            ));
676        }
677        let file_bytes = std::fs::read(path).map_err(AppError::Io)?;
678        let digest = blake3::hash(&file_bytes).to_hex().to_string();
679        #[derive(Serialize)]
680        struct WrittenAck {
681            written: String,
682            bytes: u64,
683            blake3: String,
684            sub_queries_total: usize,
685            unique_memories_found: usize,
686            elapsed_ms: u64,
687        }
688        output::emit_json(&WrittenAck {
689            written: path.display().to_string(),
690            bytes: meta.len(),
691            blake3: digest,
692            sub_queries_total: response.stats.sub_queries_total,
693            unique_memories_found: response.stats.unique_memories_found,
694            elapsed_ms: response.stats.elapsed_ms,
695        })?;
696    } else {
697        output::emit_json(&response)?;
698    }
699
700    Ok(())
701}
702
703// Re-export sub_query_results field initialisation for the stats counter.
704// The field is moved out of run_async after the join loop; we need to shadow it.
705// ────────────────────────────────────────────────────────────────────────────
706#[cfg(test)]
707mod tests;