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 (env: SQLITE_GRAPHRAG_NAMESPACE, default: global).
135    #[arg(
136        long,
137        help = "Namespace (env: SQLITE_GRAPHRAG_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, env = "SQLITE_GRAPHRAG_DB_PATH")]
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        long,
179        value_name = "PATH",
180        help = "Atomic JSON output path (atomwrite algorithm)"
181    )]
182    pub output: Option<std::path::PathBuf>,
183}
184
185#[derive(Serialize)]
186struct SubQuery {
187    id: usize,
188    text: String,
189    source: &'static str,
190}
191
192#[derive(Serialize)]
193struct DeepResult {
194    name: String,
195    score: f64,
196    source: String,
197    sub_query_ids: Vec<usize>,
198    snippet: String,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    body: Option<String>,
201    hop_distance: Option<usize>,
202}
203
204/// A node in a reconstructed evidence path.
205#[derive(Serialize, Clone)]
206struct EvidenceNode {
207    entity: String,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    relation: Option<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    weight: Option<f64>,
212}
213
214/// 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)]
223struct EvidenceChain {
224    from: String,
225    to: String,
226    path: Vec<EvidenceNode>,
227    total_weight: f64,
228    depth: usize,
229    sub_query_ids: Vec<usize>,
230}
231
232#[derive(Serialize)]
233struct ResearchStats {
234    sub_queries_total: usize,
235    sub_queries_completed: usize,
236    sub_queries_failed: usize,
237    sub_queries_timed_out: usize,
238    unique_memories_found: usize,
239    evidence_chains_found: usize,
240    elapsed_ms: u64,
241    vec_degraded: bool,
242}
243
244#[derive(Serialize)]
245struct GraphContextEntity {
246    name: String,
247    entity_type: String,
248    degree: u32,
249}
250
251#[derive(Serialize)]
252struct GraphContextRel {
253    from: String,
254    to: String,
255    relation: String,
256    weight: f64,
257}
258
259#[derive(Serialize)]
260struct GraphContext {
261    entities: Vec<GraphContextEntity>,
262    relationships: Vec<GraphContextRel>,
263}
264
265#[derive(Serialize)]
266struct DeepResearchResponse {
267    query: String,
268    sub_queries: Vec<SubQuery>,
269    results: Vec<DeepResult>,
270    evidence_chains: Vec<EvidenceChain>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    graph_context: Option<GraphContext>,
273    stats: ResearchStats,
274}
275
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.
280struct SubQueryResult {
281    sub_query_id: usize,
282    /// (memory_id, score, source_label, snippet, body, hop_distance)
283    hits: Vec<(i64, f64, String, String, String, Option<usize>)>,
284    /// Evidence chains reconstructed from BFS.
285    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/// GAP-001 (v1.1.04): computes per-sub-query embeddings OUTSIDE the tokio
328/// runtime. `try_embed_query_with_embedding_choice` (OpenRouter path) calls
329/// `shared_runtime()?.block_on(...)` internally; running it inside the
330/// multi-thread runtime built in `run` triggers
331/// "Cannot start a runtime from within a runtime" because the nested
332/// `block_on` happens on a worker thread already driven by the outer
333/// runtime. Resolving embeddings synchronously before the runtime is built
334/// removes the nesting entirely.
335fn compute_sub_embeddings(
336    paths: &crate::paths::AppPaths,
337    sub_query_texts: &[String],
338    embedding_backend: crate::cli::EmbeddingBackendChoice,
339    llm_backend: crate::cli::LlmBackendChoice,
340) -> (Vec<Option<Arc<Vec<f32>>>>, bool) {
341    output::emit_progress_i18n(
342        "Computing per-sub-query embeddings...",
343        "Calculando embeddings por sub-consulta...",
344    );
345    let mut sub_embeddings: Vec<Option<Arc<Vec<f32>>>> = Vec::with_capacity(sub_query_texts.len());
346    let mut vec_degraded = false;
347    for sq_text in sub_query_texts {
348        match crate::embedder::try_embed_query_with_embedding_choice(
349            &paths.models,
350            sq_text,
351            embedding_backend,
352            llm_backend,
353        ) {
354            Ok((v, _backend)) => sub_embeddings.push(Some(Arc::new(v))),
355            Err(reason) => {
356                tracing::warn!(target: "deep_research", fallback_reason = %reason, reason_code = %reason.reason_code(), "embedding failed for sub-query; falling back to FTS5");
357                sub_embeddings.push(None);
358                vec_degraded = true;
359            }
360        }
361    }
362    (sub_embeddings, vec_degraded)
363}
364
365/// Main async logic: decompose, fan-out, assemble, emit JSON.
366///
367/// `sub_embeddings` and `vec_degraded` are computed synchronously in
368/// [`run`] before the tokio runtime is built (GAP-001, v1.1.04) to avoid
369/// a nested-runtime panic on the OpenRouter embedding path.
370/// `sub_queries` is also resolved in [`run`] so embedding precompute and
371/// fan-out share one plan (v1.1.05).
372async fn run_async(
373    args: DeepResearchArgs,
374    _llm_backend: crate::cli::LlmBackendChoice,
375    _embedding_backend: crate::cli::EmbeddingBackendChoice,
376    sub_queries: Vec<SubQuery>,
377    sub_embeddings: Vec<Option<Arc<Vec<f32>>>>,
378    vec_degraded: bool,
379) -> Result<(), AppError> {
380    let start = std::time::Instant::now();
381
382    if args.query.trim().is_empty() {
383        return Err(AppError::Validation(crate::i18n::validation::empty_query()));
384    }
385
386    if args.max_cost_usd.is_some() && args.mode == "none" {
387        tracing::warn!(target: "deep_research", "--max-cost-usd has no effect without --mode claude-code/codex");
388    }
389
390    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
391    let paths = AppPaths::resolve(args.db.as_deref())?;
392    crate::storage::connection::ensure_db_ready(&paths)?;
393
394    // Phase 1: sub-queries already resolved in `run` (heuristic / manual / aspects).
395    let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
396
397    // GAP-001 (v1.1.04): sub-query embeddings were already resolved in
398    // `run` before the tokio runtime was built. Using them here keeps the
399    // OpenRouter REST path out of the worker threads (nested-runtime panic).
400    // `vec_degraded` reflects per-sub-query FTS5 fallback (GAP-DEEPRESEARCH-001).
401    if vec_degraded {
402        tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
403    }
404
405    // Phase 2: Fan-out — parallel sub-query execution.
406    let cpu_count = std::thread::available_parallelism()
407        .map(|n| n.get())
408        .unwrap_or(4);
409    let permits = args
410        .max_concurrency
411        .unwrap_or_else(|| cpu_count.min(8))
412        .min(sub_queries.len())
413        .max(1);
414    let semaphore = Arc::new(Semaphore::new(permits));
415    let timeout_dur = std::time::Duration::from_secs(args.timeout);
416
417    let mut join_set: JoinSet<Result<SubQueryResult, (usize, String)>> = JoinSet::new();
418
419    for (idx, sq_text) in sub_query_texts.iter().enumerate() {
420        let sem = Arc::clone(&semaphore);
421        // GAP-DEEPRESEARCH-001 FIX: pass Optional embedding (None = FTS5-only).
422        let emb = sub_embeddings[idx].clone();
423        let ns = namespace.clone();
424        let db_path = paths.db.clone();
425        let query_text = sq_text.clone();
426        let k = args.k;
427        let max_hops = args.max_hops;
428        let min_weight = args.min_weight;
429        let rrf_k = args.rrf_k;
430        let graph_decay = args.graph_decay;
431        let graph_min_score = args.graph_min_score;
432        let max_neighbors_per_hop = args.max_neighbors_per_hop;
433
434        join_set.spawn(async move {
435            let _permit = sem
436                .acquire_owned()
437                .await
438                .map_err(|e| (idx, format!("semaphore closed: {e}")))?;
439
440            // Dereference the Arc to obtain a &[f32] slice for the sync function.
441            let result = tokio::time::timeout(timeout_dur, async move {
442                execute_sub_query(
443                    idx,
444                    &query_text,
445                    emb.as_ref().map(|v| v.as_slice()),
446                    &ns,
447                    &db_path,
448                    k,
449                    max_hops,
450                    min_weight,
451                    rrf_k,
452                    graph_decay,
453                    graph_min_score,
454                    max_neighbors_per_hop,
455                )
456            })
457            .await;
458
459            match result {
460                Ok(inner) => inner.map_err(|e| (idx, e)),
461                Err(_) => Err((idx, "timeout".to_string())),
462            }
463        });
464    }
465
466    // Collect results incrementally.
467    let mut sub_query_results: Vec<SubQueryResult> = Vec::with_capacity(sub_queries.len());
468    let mut failed_count = 0usize;
469    let mut timed_out_count = 0usize;
470
471    while let Some(join_result) = join_set.join_next().await {
472        match join_result {
473            Ok(Ok(sqr)) => sub_query_results.push(sqr),
474            Ok(Err((_idx, reason))) => {
475                if reason == "timeout" {
476                    timed_out_count += 1;
477                } else {
478                    failed_count += 1;
479                }
480                tracing::warn!(target: "deep_research", sub_query_id = _idx, reason = %reason, "sub-query failed");
481            }
482            Err(join_err) => {
483                failed_count += 1;
484                if join_err.is_panic() {
485                    tracing::error!(target: "deep_research", error = %join_err, "sub-query task panicked");
486                } else {
487                    tracing::warn!(target: "deep_research", error = %join_err, "sub-query task cancelled");
488                }
489            }
490        }
491    }
492
493    // Phase 3: Evidence assembly — merge, dedup, rank.
494    // Aggregate hits: memory_id -> (best_score, source, snippet, body, hop_distance, sub_query_ids)
495    let mut merged: crate::hash::AHashMap<i64, MergedHit> =
496        crate::hash::AHashMap::with_capacity_and_hasher(
497            sub_query_results.len() * args.k,
498            Default::default(),
499        );
500
501    for sqr in &sub_query_results {
502        for (mem_id, score, source, snippet, body, hop) in &sqr.hits {
503            let entry = merged.entry(*mem_id).or_insert_with(|| {
504                (
505                    *score,
506                    source.clone(),
507                    snippet.clone(),
508                    body.clone(),
509                    *hop,
510                    Vec::new(),
511                )
512            });
513            // Keep best score.
514            if *score > entry.0 {
515                entry.0 = *score;
516                entry.1 = source.clone();
517                entry.2 = snippet.clone();
518                entry.3 = body.clone();
519                entry.4 = *hop;
520            }
521            if !entry.5.contains(&sqr.sub_query_id) {
522                entry.5.push(sqr.sub_query_id);
523            }
524        }
525    }
526
527    // Resolve memory names for merged results.
528    let conn = open_ro(&paths.db)?;
529    let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
530
531    // Sort by score descending.
532    let mut ranked: Vec<(i64, MergedHit)> = merged.into_iter().collect();
533    ranked.sort_by(|a, b| {
534        b.1 .0
535            .partial_cmp(&a.1 .0)
536            .unwrap_or(std::cmp::Ordering::Equal)
537    });
538    ranked.truncate(args.max_results);
539
540    for (mem_id, (score, source, snippet, body, hop, sq_ids)) in ranked {
541        let name = match memories::read_full(&conn, mem_id)? {
542            Some(row) => row.name,
543            None => continue,
544        };
545        results.push(DeepResult {
546            name,
547            score,
548            source,
549            sub_query_ids: sq_ids,
550            snippet,
551            body: if args.with_bodies { Some(body) } else { None },
552            hop_distance: hop,
553        });
554    }
555
556    // GAP-09/10 FIX: Collect evidence chains from reconstructed BFS paths.
557    // The old code appended flat node pairs from a global SELECT; now each
558    // sub-query returns directed EvidenceChain structs (from, to, path).
559    let completed_count = sub_query_results.len();
560    let mut evidence_chains: Vec<EvidenceChain> = Vec::with_capacity(completed_count * 2);
561    let mut seen_chain_keys: HashSet<String> = HashSet::with_capacity(completed_count * 2);
562
563    for sqr in sub_query_results {
564        for chain in sqr.chains {
565            // Deduplicate chains by (from, to) pair.
566            let key = format!("{}->{}", chain.from, chain.to);
567            if seen_chain_keys.insert(key) {
568                evidence_chains.push(chain);
569            }
570        }
571    }
572
573    // Sort evidence chains by total_weight descending, discard single-hop trivial chains.
574    evidence_chains.retain(|c| c.depth >= 2);
575    evidence_chains.sort_by(|a, b| {
576        b.total_weight
577            .partial_cmp(&a.total_weight)
578            .unwrap_or(std::cmp::Ordering::Equal)
579    });
580
581    let unique_memories = results.len();
582    let evidence_count = evidence_chains.len();
583
584    // MEDIUM-01b: Build graph_context with entities and relationships from result memories.
585    let graph_context = if !results.is_empty() {
586        let result_names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
587        let mut ctx_entities: Vec<GraphContextEntity> = Vec::with_capacity(results.len());
588        let mut ctx_rels: Vec<GraphContextRel> = Vec::with_capacity(results.len() * 2);
589        let mut seen_entity_ids: crate::hash::AHashSet<i64> =
590            crate::hash::AHashSet::with_capacity_and_hasher(results.len(), Default::default());
591
592        for name in &result_names {
593            if let Ok(Some(eid)) = entities::find_entity_id(&conn, &namespace, name) {
594                if seen_entity_ids.insert(eid) {
595                    let etype: String = conn
596                        .query_row(
597                            "SELECT COALESCE(type,'concept') FROM entities WHERE id = ?1",
598                            rusqlite::params![eid],
599                            |r| r.get(0),
600                        )
601                        .unwrap_or_else(|_| "concept".to_string());
602                    let degree: u32 = conn
603                        .query_row(
604                            "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 OR target_id = ?1",
605                            rusqlite::params![eid],
606                            |r| r.get(0),
607                        )
608                        .unwrap_or(0);
609                    ctx_entities.push(GraphContextEntity {
610                        name: name.to_string(),
611                        entity_type: etype,
612                        degree,
613                    });
614                }
615            }
616        }
617
618        let entity_ids: Vec<i64> = seen_entity_ids.iter().copied().collect();
619        if entity_ids.len() >= 2 {
620            let placeholders: String = entity_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
621            let sql = format!(
622                "SELECT s.name, t.name, r.relation, r.weight \
623                 FROM relationships r \
624                 JOIN entities s ON s.id = r.source_id \
625                 JOIN entities t ON t.id = r.target_id \
626                 WHERE r.source_id IN ({placeholders}) AND r.target_id IN ({placeholders}) \
627                 LIMIT 50"
628            );
629            if let Ok(mut stmt) = conn.prepare(&sql) {
630                let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
631                    Vec::with_capacity(entity_ids.len() * 2);
632                for id in &entity_ids {
633                    params.push(Box::new(*id));
634                }
635                for id in &entity_ids {
636                    params.push(Box::new(*id));
637                }
638                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
639                    params.iter().map(|p| p.as_ref()).collect();
640                if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |r| {
641                    Ok((
642                        r.get::<_, String>(0)?,
643                        r.get::<_, String>(1)?,
644                        r.get::<_, String>(2)?,
645                        r.get::<_, f64>(3)?,
646                    ))
647                }) {
648                    for row in rows.flatten() {
649                        ctx_rels.push(GraphContextRel {
650                            from: row.0,
651                            to: row.1,
652                            relation: row.2,
653                            weight: row.3,
654                        });
655                    }
656                }
657            }
658        }
659
660        if ctx_entities.is_empty() {
661            None
662        } else {
663            Some(GraphContext {
664                entities: ctx_entities,
665                relationships: ctx_rels,
666            })
667        }
668    } else {
669        None
670    };
671
672    tracing::debug!(target: "deep_research",
673        total_results = results.len(),
674        total_chains = evidence_chains.len(),
675        "assembly complete"
676    );
677
678    // Phase 4: JSON output (stdout and/or atomic --output).
679    let response = DeepResearchResponse {
680        query: args.query,
681        sub_queries,
682        results,
683        evidence_chains,
684        graph_context,
685        stats: ResearchStats {
686            sub_queries_total: sub_query_texts.len(),
687            sub_queries_completed: completed_count,
688            sub_queries_failed: failed_count,
689            sub_queries_timed_out: timed_out_count,
690            unique_memories_found: unique_memories,
691            evidence_chains_found: evidence_count,
692            elapsed_ms: start.elapsed().as_millis() as u64,
693            vec_degraded,
694        },
695    };
696
697    if let Some(path) = args.output.as_ref() {
698        // v1.1.05 Bug 2: atomic write avoids truncated envelopes under SIGTERM /
699        // shell redirect races. Full envelope goes to the file; stdout gets a
700        // small confirmation so pipelines can still check exit 0 + path.
701        crate::atomic_io::write_json_atomic(path, &response)?;
702        let meta = std::fs::metadata(path).map_err(AppError::Io)?;
703        let file_bytes = std::fs::read(path).map_err(AppError::Io)?;
704        let digest = blake3::hash(&file_bytes).to_hex().to_string();
705        #[derive(Serialize)]
706        struct WrittenAck {
707            written: String,
708            bytes: u64,
709            blake3: String,
710            sub_queries_total: usize,
711            unique_memories_found: usize,
712            elapsed_ms: u64,
713        }
714        output::emit_json(&WrittenAck {
715            written: path.display().to_string(),
716            bytes: meta.len(),
717            blake3: digest,
718            sub_queries_total: response.stats.sub_queries_total,
719            unique_memories_found: response.stats.unique_memories_found,
720            elapsed_ms: response.stats.elapsed_ms,
721        })?;
722    } else {
723        output::emit_json(&response)?;
724    }
725
726    Ok(())
727}
728
729/// Build the sub-query plan from CLI strategy (heuristic or manual file).
730fn resolve_sub_queries(args: &DeepResearchArgs) -> Result<Vec<SubQuery>, AppError> {
731    if args.query.trim().is_empty() {
732        return Err(AppError::Validation(crate::i18n::validation::empty_query()));
733    }
734    match args.sub_query_strategy.as_str() {
735        "manual" => {
736            let path = args.sub_queries_file.as_ref().ok_or_else(|| {
737                AppError::Validation(
738                    "--sub-query-strategy manual requires --sub-queries-file PATH".to_string(),
739                )
740            })?;
741            let raw = std::fs::read_to_string(path).map_err(AppError::Io)?;
742            let mut texts: Vec<String> = raw
743                .lines()
744                .map(str::trim)
745                .filter(|l| !l.is_empty() && !l.starts_with('#'))
746                .map(str::to_string)
747                .collect();
748            if texts.is_empty() {
749                return Err(AppError::Validation(format!(
750                    "sub-queries file '{}' has no usable lines",
751                    path.display()
752                )));
753            }
754            texts.truncate(args.max_sub_queries);
755            Ok(texts
756                .into_iter()
757                .enumerate()
758                .map(|(i, text)| SubQuery {
759                    id: i,
760                    text,
761                    source: "manual",
762                })
763                .collect())
764        }
765        _ => {
766            let planned = decompose_query_with_sources(&args.query, args.max_sub_queries);
767            Ok(planned
768                .into_iter()
769                .enumerate()
770                .map(|(i, (text, source))| SubQuery {
771                    id: i,
772                    text,
773                    source,
774                })
775                .collect())
776        }
777    }
778}
779
780/// Aspect facets applied when a single-token query cannot be split syntactically.
781///
782/// Covers the angles operators expect for person/org subjects (patrimony, stack,
783/// stakeholders, projects, decisions, relationships, context) in EN and PT so
784/// FTS/hybrid retrieval fans out beyond the literal token (v1.1.05 Bug 1).
785const SINGLE_TOKEN_ASPECTS: &[&str] = &[
786    "patrimonio",
787    "stack",
788    "tecnologia",
789    "stakeholders",
790    "pessoas",
791    "projeto",
792    "decisao",
793    "relacionamento",
794    "contexto",
795    "architecture",
796    "history",
797];
798
799/// Heuristic query decomposition with per-sub-query source labels.
800///
801/// Splits by conjunctions, commas, semicolons, relational phrases, word-pairs
802/// for multi-word queries, and **single-token aspect expansion** when none of
803/// the syntactic branches fire (v1.1.05 Bug 1).
804fn decompose_query_with_sources(query: &str, max: usize) -> Vec<(String, &'static str)> {
805    if query.is_empty() {
806        return vec![(query.to_string(), "original")];
807    }
808
809    let mut parts: Vec<(String, &'static str)> = Vec::with_capacity(max);
810
811    // Split by relational phrases first (most specific).
812    let relational = [
813        " that caused ",
814        " depending on ",
815        " related to ",
816        " connected to ",
817        " linked to ",
818        " caused by ",
819        " followed by ",
820    ];
821    let mut text = query.to_string();
822    let mut did_relational_split = false;
823    for phrase in &relational {
824        if text.to_lowercase().contains(phrase) {
825            let lower = text.to_lowercase();
826            if let Some(pos) = lower.find(phrase) {
827                let left = text[..pos].trim().to_string();
828                let right = text[pos + phrase.len()..].trim().to_string();
829                if !left.is_empty() {
830                    parts.push((left, "decomposed"));
831                }
832                if !right.is_empty() {
833                    text = right;
834                }
835                did_relational_split = true;
836            }
837        }
838    }
839    if did_relational_split && !text.is_empty() {
840        parts.push((text.clone(), "decomposed"));
841    }
842
843    // If no relational split, try conjunctions and delimiters.
844    if parts.is_empty() {
845        let semi_parts: Vec<&str> = query.split(';').collect();
846        if semi_parts.len() > 1 {
847            for p in &semi_parts {
848                let trimmed = p.trim();
849                if !trimmed.is_empty() {
850                    parts.push((trimmed.to_string(), "decomposed"));
851                }
852            }
853        } else {
854            let normalized = query
855                .replace(" and ", ", ")
856                .replace(" AND ", ", ")
857                .replace(" e ", ", ")
858                .replace(" E ", ", ");
859            let comma_parts: Vec<&str> = normalized.split(',').collect();
860            if comma_parts.len() > 1 {
861                for p in &comma_parts {
862                    let trimmed = p.trim();
863                    if !trimmed.is_empty() {
864                        parts.push((trimmed.to_string(), "decomposed"));
865                    }
866                }
867            }
868        }
869    }
870
871    // If still no split, try word-pair decomposition for multi-word queries.
872    if parts.is_empty() {
873        let words: Vec<&str> = query.split_whitespace().filter(|w| w.len() > 2).collect();
874        if words.len() >= 3 {
875            parts.push((query.to_string(), "original"));
876            parts.push((format!("{} {}", words[0], words[1]), "decomposed"));
877            parts.push((
878                format!("{} {}", words[words.len() - 2], words[words.len() - 1]),
879                "decomposed",
880            ));
881        }
882    }
883
884    // v1.1.05 Bug 1: single-token (or unsplittable) queries get aspect fan-out.
885    if parts.is_empty() {
886        let token_count = query.split_whitespace().filter(|w| !w.is_empty()).count();
887        if token_count == 1 {
888            let token = query.trim();
889            parts.push((token.to_string(), "original"));
890            for aspect in SINGLE_TOKEN_ASPECTS {
891                if parts.len() >= max {
892                    break;
893                }
894                parts.push((format!("{token} {aspect}"), "aspect"));
895            }
896        } else {
897            return vec![(query.to_string(), "original")];
898        }
899    }
900
901    parts.truncate(max);
902    parts
903}
904
905/// Heuristic query decomposition (text-only; unit tests).
906#[cfg(test)]
907fn decompose_query(query: &str, max: usize) -> Vec<String> {
908    decompose_query_with_sources(query, max)
909        .into_iter()
910        .map(|(t, _)| t)
911        .collect()
912}
913
914/// Reconstruct a directed path from `target_entity_id` back to a seed using the
915/// predecessor map built by BFS.  Returns the path nodes from root to target
916/// plus the accumulated edge weights.
917fn reconstruct_path(
918    target_id: i64,
919    seed_entity_ids: &HashSet<i64>,
920    predecessor: &PredecessorMap,
921    entity_names: &crate::hash::AHashMap<i64, String>,
922) -> Option<(Vec<EvidenceNode>, f64)> {
923    let mut path_ids: Vec<(i64, Option<String>, Option<f64>)> = Vec::with_capacity(8);
924    let mut total_weight = 1.0_f64;
925    let mut current = target_id;
926
927    loop {
928        if seed_entity_ids.contains(&current) {
929            break;
930        }
931        let (parent, relation, weight) = predecessor.get(&current)?;
932        total_weight *= weight;
933        path_ids.push((current, Some(relation.clone()), Some(*weight)));
934        current = *parent;
935    }
936    // Push the seed entity (root).
937    path_ids.push((current, None, None));
938
939    // Reverse so path goes from seed → target.
940    path_ids.reverse();
941
942    let nodes: Vec<EvidenceNode> = path_ids
943        .into_iter()
944        .map(|(id, relation, weight)| EvidenceNode {
945            entity: entity_names
946                .get(&id)
947                .cloned()
948                .unwrap_or_else(|| format!("entity-{id}")),
949            relation,
950            weight,
951        })
952        .collect();
953
954    Some((nodes, total_weight))
955}
956
957/// Execute a single sub-query: hybrid search (KNN + FTS fused via RRF) + graph traversal.
958///
959/// GAP-07 fix: receives the embedding for THIS sub-query (not the shared original).
960/// GAP-08/11 fix: uses rrf_fuse() for proper score fusion instead of hardcoded 0.5.
961/// GAP-09/10 fix: builds directed evidence chains filtered to discovered entities.
962/// GAP-17: respects max_neighbors_per_hop cap in BFS.
963///
964/// Runs synchronously on a blocking thread (called from a tokio spawn context).
965/// Each call opens its own read-only SQLite connection to leverage WAL concurrency.
966#[allow(clippy::too_many_arguments)]
967fn execute_sub_query(
968    sub_query_id: usize,
969    query_text: &str,
970    embedding: Option<&[f32]>,
971    namespace: &str,
972    db_path: &std::path::Path,
973    k: usize,
974    max_hops: usize,
975    min_weight: f64,
976    rrf_k: f64,
977    graph_decay: f64,
978    graph_min_score: f64,
979    max_neighbors_per_hop: Option<usize>,
980) -> Result<SubQueryResult, String> {
981    let conn = open_ro(db_path).map_err(|e| format!("failed to open db: {e}"))?;
982
983    let mut hits: Vec<(i64, f64, String, String, String, Option<usize>)> =
984        Vec::with_capacity(k * 2);
985    let mut seen_ids: crate::hash::AHashSet<i64> =
986        crate::hash::AHashSet::with_capacity_and_hasher(k * 2, Default::default());
987
988    // --- GAP-08/11 FIX: Use RRF fusion for KNN + FTS instead of hardcoded 0.5 ---
989
990    // 1. KNN vector search — collect ranked IDs (skipped when embedding unavailable).
991    let (knn_ids, knn_distance_map) = if let Some(emb) = embedding {
992        let knn_results = memories::knn_search(&conn, emb, &[namespace.to_string()], None, k)
993            .map_err(|e| format!("knn_search failed: {e}"))?;
994        let ids: Vec<i64> = knn_results.iter().map(|(id, _)| *id).collect();
995        tracing::debug!(target: "deep_research", sub_query_id, knn_count = ids.len(), "KNN complete");
996        let dist_map: crate::hash::AHashMap<i64, f64> = knn_results
997            .iter()
998            .map(|(id, dist)| (*id, *dist as f64))
999            .collect();
1000        (ids, dist_map)
1001    } else {
1002        tracing::debug!(target: "deep_research", sub_query_id, "KNN skipped (no embedding); FTS5-only");
1003        (vec![], crate::hash::AHashMap::default())
1004    };
1005
1006    // 2. FTS5 search — collect ranked IDs.
1007    let fts_results = match memories::fts_search(&conn, query_text, namespace, None, k) {
1008        Ok(rows) => rows,
1009        Err(e) => {
1010            tracing::warn!(target: "deep_research",
1011                sub_query_id,
1012                "FTS5 search failed, continuing with KNN only: {e}"
1013            );
1014            vec![]
1015        }
1016    };
1017    let fts_ids: Vec<i64> = fts_results.iter().map(|r| r.id).collect();
1018    tracing::debug!(target: "deep_research", sub_query_id, fts_count = fts_ids.len(), "FTS complete");
1019
1020    // 3. Fuse via RRF.
1021    let rrf_scores = rrf_fuse(&[(1.0, &knn_ids), (1.0, &fts_ids)], rrf_k);
1022    let max_possible = rrf_max_possible(&[1.0, 1.0], rrf_k);
1023
1024    // 4. Sort fused results and build hits.
1025    let mut fused: Vec<(i64, f64)> = rrf_scores.into_iter().collect();
1026    fused.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1027    fused.truncate(k * 2);
1028    tracing::debug!(target: "deep_research",
1029        sub_query_id,
1030        fused_count = fused.len(),
1031        "RRF fusion complete"
1032    );
1033
1034    if fused.is_empty() && !knn_ids.is_empty() {
1035        tracing::warn!(target: "deep_research", sub_query_id, knn_count = knn_ids.len(), fts_count = fts_ids.len(),
1036            "RRF fusion returned 0 results despite KNN/FTS hits; consider lowering --graph-min-score");
1037    }
1038
1039    for (memory_id, combined_score) in &fused {
1040        if seen_ids.insert(*memory_id) {
1041            let normalized = if max_possible > 0.0 {
1042                combined_score / max_possible
1043            } else {
1044                0.0
1045            };
1046            let score = normalized.clamp(0.0, 1.0);
1047            let in_knn = knn_distance_map.contains_key(memory_id);
1048            let in_fts = fts_ids.contains(memory_id);
1049            let source = match (in_knn, in_fts) {
1050                (true, true) => "hybrid",
1051                (true, false) => "knn",
1052                (false, true) => "fts",
1053                (false, false) => "graph",
1054            };
1055            if let Ok(Some(row)) = memories::read_full(&conn, *memory_id) {
1056                let snippet: String = row.body.chars().take(300).collect();
1057                hits.push((
1058                    *memory_id,
1059                    score,
1060                    source.to_string(),
1061                    snippet,
1062                    row.body,
1063                    None,
1064                ));
1065            }
1066        }
1067    }
1068
1069    // 5. Graph traversal from discovered memories.
1070    // GAP-09/10 FIX: entity KNN also uses this sub-query's embedding.
1071    let memory_ids: Vec<i64> = hits.iter().map(|(id, ..)| *id).collect();
1072    let mut chains: Vec<EvidenceChain> = Vec::with_capacity(memory_ids.len());
1073
1074    if !memory_ids.is_empty() && max_hops > 0 {
1075        // Seed entities from KNN on entity vectors (skipped when embedding unavailable).
1076        let entity_ids: Vec<i64> = if let Some(emb) = embedding {
1077            entities::knn_search(&conn, emb, namespace, 5)
1078                .inspect_err(|e| tracing::warn!(target: "deep_research", error = %e, "entity KNN search failed, skipping graph seed"))
1079                .unwrap_or_default()
1080                .iter()
1081                .map(|(id, _)| *id)
1082                .collect()
1083        } else {
1084            vec![]
1085        };
1086
1087        // HIGH-01 FIX: limit seeds to top-5 memories by score to prevent
1088        // BFS from starting at every node when k >= total memories.
1089        let top_seed_count = 5.min(memory_ids.len());
1090        let top_memory_ids = &memory_ids[..top_seed_count];
1091        let mut seed_entity_ids: Vec<i64> = entity_ids.clone();
1092        for &mem_id in top_memory_ids {
1093            let mut stmt = conn
1094                .prepare_cached("SELECT entity_id FROM memory_entities WHERE memory_id = ?1")
1095                .map_err(|e| format!("prepare failed: {e}"))?;
1096            let ids: Vec<i64> = stmt
1097                .query_map(rusqlite::params![mem_id], |r| r.get(0))
1098                .map_err(|e| format!("query failed: {e}"))?
1099                .filter_map(|r| r.ok())
1100                .collect();
1101            seed_entity_ids.extend(ids);
1102        }
1103        seed_entity_ids.sort_unstable();
1104        seed_entity_ids.dedup();
1105        tracing::debug!(target: "deep_research",
1106            sub_query_id,
1107            seed_count = seed_entity_ids.len(),
1108            "seed entities collected"
1109        );
1110
1111        let all_seed_ids: Vec<i64> = memory_ids
1112            .iter()
1113            .chain(entity_ids.iter())
1114            .copied()
1115            .collect();
1116
1117        // Graph traversal with hop scores.
1118        if let Ok(graph_results) = traverse_from_memories_with_hops_capped(
1119            &conn,
1120            &all_seed_ids,
1121            namespace,
1122            min_weight,
1123            max_hops as u32,
1124            max_neighbors_per_hop,
1125        ) {
1126            // Build seed score map from RRF-fused scores for graph decay computation.
1127            let seed_score_map: crate::hash::AHashMap<i64, f64> = fused
1128                .iter()
1129                .map(|(id, s)| {
1130                    let normalized = if max_possible > 0.0 {
1131                        s / max_possible
1132                    } else {
1133                        0.0
1134                    };
1135                    (*id, normalized.clamp(0.0, 1.0))
1136                })
1137                .collect();
1138
1139            for (graph_mem_id, hop) in graph_results {
1140                if seen_ids.insert(graph_mem_id) {
1141                    // GAP-08/11 FIX: graph score = seed_score * decay^hop * edge_weight.
1142                    // For the seed score, use the best score among the seed memories that
1143                    // transitively reached this graph memory (approximate with the average
1144                    // seed score since we don't track the exact path yet).
1145                    let avg_seed_score: f64 = if seed_score_map.is_empty() {
1146                        0.5
1147                    } else {
1148                        let sum: f64 = seed_score_map.values().sum();
1149                        sum / seed_score_map.len() as f64
1150                    };
1151                    let graph_score =
1152                        (avg_seed_score * graph_decay.powi(hop as i32)).clamp(0.0, 1.0);
1153
1154                    if graph_score < graph_min_score {
1155                        continue;
1156                    }
1157
1158                    if let Ok(Some(row)) = memories::read_full(&conn, graph_mem_id) {
1159                        let snippet: String = row.body.chars().take(300).collect();
1160                        hits.push((
1161                            graph_mem_id,
1162                            graph_score,
1163                            "graph".to_string(),
1164                            snippet,
1165                            row.body,
1166                            Some(hop as usize),
1167                        ));
1168                    }
1169                }
1170            }
1171        }
1172
1173        // GAP-09/10 FIX: Build directed evidence chains using BFS with predecessor map,
1174        // filtered to entities discovered in this sub-query.
1175        if !seed_entity_ids.is_empty() {
1176            let (entity_depth, predecessor) = bfs_with_predecessors(
1177                &conn,
1178                &seed_entity_ids,
1179                namespace,
1180                min_weight,
1181                max_hops as u32,
1182                max_neighbors_per_hop,
1183            )
1184            .unwrap_or_default();
1185
1186            tracing::debug!(target: "deep_research",
1187                sub_query_id,
1188                bfs_nodes = entity_depth.len(),
1189                predecessors = predecessor.len(),
1190                "BFS complete"
1191            );
1192
1193            let seed_entity_set: HashSet<i64> = seed_entity_ids.iter().copied().collect();
1194
1195            // Collect entity IDs we need names for.
1196            let all_entity_ids: Vec<i64> = entity_depth.keys().copied().collect();
1197            let mut entity_names: crate::hash::AHashMap<i64, String> =
1198                crate::hash::AHashMap::with_capacity_and_hasher(
1199                    all_entity_ids.len(),
1200                    ahash::RandomState::default(),
1201                );
1202            for &eid in &all_entity_ids {
1203                let name_res: rusqlite::Result<String> = conn.query_row(
1204                    "SELECT name FROM entities WHERE id = ?1",
1205                    rusqlite::params![eid],
1206                    |r| r.get(0),
1207                );
1208                if let Ok(name) = name_res {
1209                    entity_names.insert(eid, name);
1210                }
1211            }
1212
1213            // Reconstruct a path for each non-seed entity that has a predecessor.
1214            for (&target_id, &_hop) in &entity_depth {
1215                if seed_entity_set.contains(&target_id) {
1216                    continue;
1217                }
1218                if !predecessor.contains_key(&target_id) {
1219                    continue;
1220                }
1221                if let Some((path_nodes, total_weight)) =
1222                    reconstruct_path(target_id, &seed_entity_set, &predecessor, &entity_names)
1223                {
1224                    if path_nodes.len() < 2 {
1225                        continue;
1226                    }
1227                    let from = path_nodes
1228                        .first()
1229                        .map(|n| n.entity.clone())
1230                        .unwrap_or_default();
1231                    let to = path_nodes
1232                        .last()
1233                        .map(|n| n.entity.clone())
1234                        .unwrap_or_default();
1235                    let depth = path_nodes.len();
1236                    chains.push(EvidenceChain {
1237                        from,
1238                        to,
1239                        path: path_nodes,
1240                        total_weight,
1241                        depth,
1242                        sub_query_ids: vec![sub_query_id],
1243                    });
1244                }
1245            }
1246
1247            // Sort chains by total_weight descending and cap to avoid huge output.
1248            chains.sort_by(|a, b| {
1249                b.total_weight
1250                    .partial_cmp(&a.total_weight)
1251                    .unwrap_or(std::cmp::Ordering::Equal)
1252            });
1253            chains.truncate(20);
1254            tracing::debug!(target: "deep_research",
1255                sub_query_id,
1256                chains_count = chains.len(),
1257                "evidence chains built"
1258            );
1259        }
1260    }
1261
1262    Ok(SubQueryResult {
1263        sub_query_id,
1264        hits,
1265        chains,
1266    })
1267}
1268
1269// ────────────────────────────────────────────────────────────────────────────
1270// Re-export sub_query_results field initialisation for the stats counter.
1271// The field is moved out of run_async after the join loop; we need to shadow it.
1272// ────────────────────────────────────────────────────────────────────────────
1273
1274#[cfg(test)]
1275mod tests {
1276    use super::*;
1277
1278    #[test]
1279    fn test_decompose_and_conjunction() {
1280        let result = decompose_query("A and B", 7);
1281        assert_eq!(result, vec!["A", "B"]);
1282    }
1283
1284    #[test]
1285    fn test_decompose_no_split_multiword_stays_single() {
1286        // Two-word queries without delimiters stay as one sub-query (not
1287        // single-token aspect expansion).
1288        let result = decompose_query("simple query", 7);
1289        assert_eq!(result, vec!["simple query"]);
1290    }
1291
1292    /// v1.1.05 Bug 1: single-token queries must fan out into aspects.
1293    #[test]
1294    fn test_decompose_single_token_danilo_fans_out() {
1295        let result = decompose_query("danilo", 7);
1296        assert!(
1297            result.len() > 1,
1298            "expected aspect fan-out for single token, got {result:?}"
1299        );
1300        assert_eq!(result[0], "danilo");
1301        assert!(
1302            result
1303                .iter()
1304                .any(|s| s.contains("stack") || s.contains("patrimonio")),
1305            "expected aspect facets in {result:?}"
1306        );
1307        let with_src = decompose_query_with_sources("danilo", 7);
1308        assert_eq!(with_src[0].1, "original");
1309        assert!(with_src.iter().skip(1).all(|(_, s)| *s == "aspect"));
1310    }
1311
1312    #[test]
1313    fn test_decompose_three_parts() {
1314        let result = decompose_query("A, B and C", 7);
1315        assert_eq!(result, vec!["A", "B", "C"]);
1316    }
1317
1318    #[test]
1319    fn test_decompose_portuguese_conjunctions() {
1320        let result = decompose_query("A e B", 7);
1321        assert_eq!(result, vec!["A", "B"]);
1322    }
1323
1324    #[test]
1325    fn test_decompose_max_cap() {
1326        let parts: Vec<String> = (0..10).map(|i| format!("part{i}")).collect();
1327        let query = parts.join(", ");
1328        let result = decompose_query(&query, 7);
1329        assert!(
1330            result.len() <= 7,
1331            "expected at most 7 sub-queries, got {}",
1332            result.len()
1333        );
1334    }
1335
1336    #[test]
1337    fn test_decompose_empty_preserves_original() {
1338        let result = decompose_query("", 7);
1339        assert_eq!(result, vec![""]);
1340    }
1341
1342    #[test]
1343    fn test_decompose_semicolons() {
1344        let result = decompose_query("auth design; deployment config; logging", 7);
1345        assert_eq!(result, vec!["auth design", "deployment config", "logging"]);
1346    }
1347
1348    #[test]
1349    fn test_decompose_relational_phrase() {
1350        let result = decompose_query("auth that caused deployment failure", 7);
1351        assert_eq!(result, vec!["auth", "deployment failure"]);
1352    }
1353
1354    #[test]
1355    fn test_sub_query_serialization() {
1356        let sq = SubQuery {
1357            id: 0,
1358            text: "test query".to_string(),
1359            source: "original",
1360        };
1361        let json = serde_json::to_value(&sq).expect("serialization failed");
1362        assert_eq!(json["id"], 0);
1363        assert_eq!(json["text"], "test query");
1364        assert_eq!(json["source"], "original");
1365    }
1366
1367    #[test]
1368    fn test_deep_result_omits_body_when_none() {
1369        let result = DeepResult {
1370            name: "test".to_string(),
1371            score: 0.9,
1372            source: "knn".to_string(),
1373            sub_query_ids: vec![0],
1374            snippet: "snippet".to_string(),
1375            body: None,
1376            hop_distance: None,
1377        };
1378        let json = serde_json::to_string(&result).expect("serialization failed");
1379        assert!(!json.contains("\"body\""), "body must be omitted when None");
1380    }
1381
1382    #[test]
1383    fn test_deep_result_includes_body_when_some() {
1384        let result = DeepResult {
1385            name: "test".to_string(),
1386            score: 0.9,
1387            source: "knn".to_string(),
1388            sub_query_ids: vec![0, 1],
1389            snippet: "snippet".to_string(),
1390            body: Some("full body content".to_string()),
1391            hop_distance: Some(2),
1392        };
1393        let json = serde_json::to_string(&result).expect("serialization failed");
1394        assert!(json.contains("\"body\""), "body must be present when Some");
1395        assert!(json.contains("full body content"));
1396    }
1397
1398    #[test]
1399    fn test_evidence_node_omits_none_fields() {
1400        let node = EvidenceNode {
1401            entity: "auth-module".to_string(),
1402            relation: None,
1403            weight: None,
1404        };
1405        let json = serde_json::to_string(&node).expect("serialization failed");
1406        assert!(
1407            !json.contains("\"relation\""),
1408            "relation must be omitted when None"
1409        );
1410        assert!(
1411            !json.contains("\"weight\""),
1412            "weight must be omitted when None"
1413        );
1414    }
1415
1416    #[test]
1417    fn test_research_stats_serialization() {
1418        let stats = ResearchStats {
1419            sub_queries_total: 3,
1420            sub_queries_completed: 2,
1421            sub_queries_failed: 1,
1422            sub_queries_timed_out: 0,
1423            unique_memories_found: 10,
1424            evidence_chains_found: 2,
1425            elapsed_ms: 1234,
1426            vec_degraded: false,
1427        };
1428        let json = serde_json::to_value(&stats).expect("serialization failed");
1429        assert_eq!(json["sub_queries_total"], 3);
1430        assert_eq!(json["sub_queries_completed"], 2);
1431        assert_eq!(json["sub_queries_failed"], 1);
1432        assert_eq!(json["elapsed_ms"], 1234);
1433    }
1434
1435    #[test]
1436    fn test_deep_research_response_serialization() {
1437        let resp = DeepResearchResponse {
1438            query: "test query".to_string(),
1439            sub_queries: vec![SubQuery {
1440                id: 0,
1441                text: "test query".to_string(),
1442                source: "original",
1443            }],
1444            results: vec![],
1445            evidence_chains: vec![],
1446            graph_context: None,
1447            stats: ResearchStats {
1448                sub_queries_total: 1,
1449                sub_queries_completed: 1,
1450                sub_queries_failed: 0,
1451                sub_queries_timed_out: 0,
1452                unique_memories_found: 0,
1453                evidence_chains_found: 0,
1454                elapsed_ms: 42,
1455                vec_degraded: false,
1456            },
1457        };
1458        let json = serde_json::to_value(&resp).expect("serialization failed");
1459        assert_eq!(json["query"], "test query");
1460        assert!(json["sub_queries"].is_array());
1461        assert!(json["results"].is_array());
1462        assert!(json["evidence_chains"].is_array());
1463        assert_eq!(json["stats"]["elapsed_ms"], 42);
1464    }
1465
1466    // ---- GAP-07 regression: different sub-queries produce distinct embeddings ----
1467    // We test decompose_query returns texts that *would* produce distinct embeddings
1468    // (different text inputs → different embedding inputs → different search results).
1469    #[test]
1470    fn test_distinct_sub_queries_produce_distinct_texts() {
1471        let queries = [
1472            "authentication design decisions",
1473            "deployment configuration and infrastructure",
1474        ];
1475        // These two texts must be different strings (prerequisite for distinct embeddings).
1476        assert_ne!(queries[0], queries[1]);
1477
1478        // decompose_query with semicolons must preserve distinct texts.
1479        let decomposed = decompose_query(
1480            "authentication design decisions; deployment configuration and infrastructure",
1481            7,
1482        );
1483        assert_eq!(decomposed.len(), 2);
1484        assert_ne!(decomposed[0], decomposed[1]);
1485    }
1486
1487    // ---- GAP-08/11 regression: rrf_fuse integration via fusion module ----
1488    #[test]
1489    fn test_rrf_fuse_via_fusion_module() {
1490        use crate::storage::fusion::rrf_fuse;
1491
1492        let knn_ids: Vec<i64> = vec![1, 2, 3];
1493        let fts_ids: Vec<i64> = vec![2, 1, 4];
1494        let scores = rrf_fuse(&[(1.0, &knn_ids), (1.0, &fts_ids)], 60.0);
1495
1496        // Items appearing in both lists must score higher than items in only one list.
1497        let score_1 = scores[&1];
1498        let score_2 = scores[&2];
1499        let score_3 = scores[&3]; // knn only, rank 3
1500        let score_4 = scores[&4]; // fts only, rank 3
1501
1502        assert!(
1503            score_1 > score_3,
1504            "id 1 (both lists) must beat id 3 (knn-only rank 3)"
1505        );
1506        assert!(
1507            score_2 > score_4,
1508            "id 2 (both lists) must beat id 4 (fts-only rank 3)"
1509        );
1510    }
1511
1512    // ---- GAP-09/10 regression: evidence chains must be directed paths ----
1513    #[test]
1514    fn test_evidence_chain_has_from_to_and_path() {
1515        let chain = EvidenceChain {
1516            from: "auth-module".to_string(),
1517            to: "jwt-service".to_string(),
1518            path: vec![
1519                EvidenceNode {
1520                    entity: "auth-module".to_string(),
1521                    relation: None,
1522                    weight: None,
1523                },
1524                EvidenceNode {
1525                    entity: "token-validator".to_string(),
1526                    relation: Some("depends-on".to_string()),
1527                    weight: Some(0.9),
1528                },
1529                EvidenceNode {
1530                    entity: "jwt-service".to_string(),
1531                    relation: Some("uses".to_string()),
1532                    weight: Some(0.8),
1533                },
1534            ],
1535            total_weight: 0.72,
1536            depth: 3,
1537            sub_query_ids: vec![0],
1538        };
1539
1540        let json = serde_json::to_value(&chain).expect("serialization failed");
1541        assert!(
1542            json["from"].is_string(),
1543            "evidence chain must have 'from' field"
1544        );
1545        assert!(
1546            json["to"].is_string(),
1547            "evidence chain must have 'to' field"
1548        );
1549        assert!(
1550            json["path"].is_array(),
1551            "evidence chain must have 'path' array"
1552        );
1553        assert_eq!(json["path"].as_array().unwrap().len(), 3);
1554        assert!(json["total_weight"].is_number(), "must have total_weight");
1555        assert_eq!(json["depth"], 3);
1556    }
1557
1558    // ---- GAP-10 regression: reconstruct_path returns correct node order ----
1559    #[test]
1560    fn test_reconstruct_path_root_to_target_order() {
1561        // Build a simple chain: entity 10 (seed) -> entity 20 -> entity 30 (target)
1562        let seed_set: HashSet<i64> = [10i64].into_iter().collect();
1563        let mut predecessor: PredecessorMap = std::collections::HashMap::new();
1564        predecessor.insert(20, (10, "depends-on".to_string(), 0.9));
1565        predecessor.insert(30, (20, "uses".to_string(), 0.8));
1566        let mut entity_names: crate::hash::AHashMap<i64, String> = crate::hash::AHashMap::default();
1567        entity_names.insert(10, "seed-entity".to_string());
1568        entity_names.insert(20, "middle-entity".to_string());
1569        entity_names.insert(30, "target-entity".to_string());
1570
1571        let result = reconstruct_path(30, &seed_set, &predecessor, &entity_names);
1572        assert!(result.is_some(), "path must be reconstructed");
1573        let (nodes, weight) = result.unwrap();
1574        // Path must be [seed, middle, target]
1575        assert_eq!(nodes.len(), 3);
1576        assert_eq!(nodes[0].entity, "seed-entity");
1577        assert_eq!(nodes[1].entity, "middle-entity");
1578        assert_eq!(nodes[2].entity, "target-entity");
1579        // total_weight = 0.9 * 0.8
1580        assert!((weight - 0.72).abs() < 1e-6);
1581    }
1582
1583    // ---- GAP-09 regression: evidence chains must NOT be present for 1-hop trivial pairs ----
1584    #[test]
1585    fn test_evidence_chains_single_hop_filtered_out() {
1586        // A chain of depth 1 (only root node) should be discarded.
1587        let chain = EvidenceChain {
1588            from: "a".to_string(),
1589            to: "a".to_string(),
1590            path: vec![EvidenceNode {
1591                entity: "a".to_string(),
1592                relation: None,
1593                weight: None,
1594            }],
1595            total_weight: 1.0,
1596            depth: 1,
1597            sub_query_ids: vec![0],
1598        };
1599        // Simulate the filter: retain chains with depth >= 2.
1600        let chains = vec![chain];
1601        let retained: Vec<_> = chains.into_iter().filter(|c| c.depth >= 2).collect();
1602        assert!(retained.is_empty(), "depth-1 chains must be filtered out");
1603    }
1604
1605    // ---- GAP-17 regression: bfs_with_predecessors honours max_neighbors_per_hop ----
1606    #[test]
1607    fn test_bfs_with_predecessors_respects_neighbor_cap() {
1608        use crate::graph::bfs_with_predecessors;
1609        use rusqlite::Connection;
1610
1611        let conn = Connection::open_in_memory().unwrap();
1612        conn.execute_batch(
1613            "CREATE TABLE relationships (
1614                source_id INTEGER NOT NULL,
1615                target_id INTEGER NOT NULL,
1616                weight REAL NOT NULL,
1617                namespace TEXT NOT NULL,
1618                relation TEXT NOT NULL DEFAULT 'related'
1619             );",
1620        )
1621        .unwrap();
1622
1623        // Seed entity 1 has 5 neighbours.
1624        for target in 2i64..=6 {
1625            conn.execute(
1626                "INSERT INTO relationships (source_id, target_id, weight, namespace) VALUES (?1, ?2, ?3, 'ns')",
1627                rusqlite::params![1i64, target, 1.0f64],
1628            )
1629            .unwrap();
1630        }
1631
1632        // Without cap: all 5 neighbours reached.
1633        let (depth_uncapped, _) = bfs_with_predecessors(&conn, &[1], "ns", 0.0, 1, None).unwrap();
1634        assert_eq!(
1635            depth_uncapped.len() - 1,
1636            5,
1637            "uncapped must discover all 5 neighbours (plus seed)"
1638        );
1639
1640        // With cap=2: only top-2 neighbours (by weight; all equal here so first 2 returned).
1641        let (depth_capped, _) = bfs_with_predecessors(&conn, &[1], "ns", 0.0, 1, Some(2)).unwrap();
1642        // seed + 2 neighbours = 3 entries.
1643        assert_eq!(
1644            depth_capped.len(),
1645            3,
1646            "capped to 2 must yield seed + 2 neighbours"
1647        );
1648    }
1649}