Skip to main content

agentstatedeveloper_cli/commands/
search.rs

1//! `asd search <query>` — ranked concept search over indexed symbols.
2//!
3//! Primary path: BM25 via FTS5 table populated at `asd index` time.
4//! Hybrid reranking: FTS BM25 score + ledger-text token boost.
5//! Fallback: in-memory O(N) scoring when FTS table is empty or absent.
6
7use anyhow::Result;
8use clap::Args;
9
10use agentstatedeveloper_core::{
11    AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, EffectStore, Engine,
12    FeedbackMetrics, FeedbackStore, FeedbackVerdict, FtsFilters, IndexStore, LedgerStore,
13    SearchDocsDb, SearchFtsDb, apply_feedback_adjustments, apply_file_scope_feedback,
14    build_feedback_state_from_entries, classify_layer_sym, compute_trust_score,
15    compute_uncertainty, confidence_reason, confidence_scores, detect_ambiguous_tokens,
16    detect_confidence_warnings, detect_possible_misses, effect_detail_reason, estimate_tokens,
17    explain_feedback_impacts, explain_match, extract_summary, gather_recency, hybrid_boost,
18    intent_focus, kind_str, load_layer_overrides, matches_any_path_glob, parse_intent, parse_query,
19    resolve_scope, result_bucket, stale_warning, suggest_better_queries, suggest_scoped_queries,
20    symbol_tier, trim_for_agent,
21};
22
23use crate::config::Config;
24
25#[derive(Debug, Args)]
26pub struct SearchArgs {
27    /// Concept or keyword(s) to search for. Scored across symbol name,
28    /// signature, doc comment, file path, and ledger entry summaries.
29    pub query: String,
30
31    /// Filter by symbol kind: module, function, method, class, variable.
32    #[arg(long)]
33    pub kind: Option<String>,
34
35    /// Filter by language (e.g. "swift", "python", "typescript", "rust").
36    #[arg(long)]
37    pub language: Option<String>,
38
39    /// Maximum results to show (default: 20).
40    #[arg(long, default_value = "20")]
41    pub limit: usize,
42
43    /// Include symbols from test files in results. By default tests are
44    /// excluded so production entry points rank first.
45    #[arg(long)]
46    pub include_tests: bool,
47
48    /// Restrict results to test symbols only. Overrides --include-tests.
49    /// Use when classifying coverage, finding fixture usage, or auditing
50    /// test layout.
51    #[arg(long)]
52    pub tests_only: bool,
53
54    /// Suppress the stale-index warning.
55    #[arg(long)]
56    pub quiet: bool,
57
58    /// Adjust guidance context for a specific intent.
59    /// Values: bugfix, feature, refactor, test, architecture, ui.
60    #[arg(long)]
61    pub intent: Option<String>,
62
63    /// Emit token-budgeted JSON for LLM consumption. Trims bodies and
64    /// collapses low-signal fields; adds token_estimate.
65    #[arg(long)]
66    pub agent: bool,
67
68    /// Token budget when --agent is set (default: 8000).
69    #[arg(long, default_value = "8000")]
70    pub agent_budget: usize,
71
72    /// Comma-separated terms to exclude. Candidates whose qname, file, doc,
73    /// or signature contain any term are dropped. Also supports inline
74    /// minus-prefix syntax in the query, e.g. "drift playhead -sample".
75    #[arg(long)]
76    pub exclude: Option<String>,
77
78    /// Comma-separated glob patterns to restrict results to specific paths,
79    /// e.g. --paths "App/**/DriftPad*,Packages/SequencerCore/**".
80    #[arg(long)]
81    pub paths: Option<String>,
82
83    /// Named scope alias from .asd/scopes.toml, e.g. --scope drift-pad.
84    /// Expanded to the path globs defined in the scopes file.
85    #[arg(long)]
86    pub scope: Option<String>,
87
88    /// Plan J t-011: drop results whose file matches any of these glob
89    /// patterns. Comma-separated, e.g. `--exclude-path 'tests/**,**/generated/**'`.
90    /// Patterns use `*` (within a segment) and `**` (any segments) —
91    /// same syntax as `--paths`.
92    #[arg(long)]
93    pub exclude_path: Option<String>,
94
95    /// Plan J t-011: drop results whose symbol language is in this list.
96    /// Comma-separated, e.g. `--exclude-lang 'swift,kotlin'`. Useful in
97    /// polyglot monorepos for queries like "auth flow" where the same
98    /// concept lives in multiple stacks and only one is in scope.
99    #[arg(long)]
100    pub exclude_lang: Option<String>,
101
102    /// Plan J t-011: named exclude set from `.asd/scopes.toml`
103    /// `[exclude_sets]` table, e.g. `--exclude-set tests`. Expanded to
104    /// the path globs defined there and merged into `--exclude-path`.
105    #[arg(long)]
106    pub exclude_set: Option<String>,
107
108    /// Print match reasons for each result (which token matched which field,
109    /// ledger involvement). Implied by --agent; this shows it in terminal output.
110    #[arg(long)]
111    pub explain: bool,
112
113    /// Restrict results to semantic symbols only (skip document index).
114    #[arg(long)]
115    pub symbols_only: bool,
116
117    /// Restrict results to document/resource hits only (skip symbol index).
118    #[arg(long)]
119    pub docs_only: bool,
120
121    /// Print per-result score breakdown: BM25, hybrid boost, ledger boost,
122    /// SOT boost, state-holder penalty, and domain-overlap count.
123    #[arg(long)]
124    pub debug_boosts: bool,
125}
126
127pub fn run(cfg: &Config, args: SearchArgs) -> Result<()> {
128    if !args.quiet {
129        if let Some(warn) = stale_warning(&cfg.db_path, 3600) {
130            eprintln!("{warn}");
131        }
132    }
133    let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
134    if !intent.is_empty() {
135        eprintln!("intent: {}", intent_focus(intent));
136    }
137    let layer_overrides = load_layer_overrides(&cfg.db_path);
138    let engine = Engine::open_sqlite(&cfg.db_path)?;
139    let ledger_store = AsgLedgerStore::from_engine(&engine);
140    let effect_store = AsgEffectStore::from_engine(&engine);
141    // Hoist trust/data-quality state — used by uncertainty model and avoid
142    // re-opening the DB or sidecar inside the hot search path.
143    let dq_state_str: String = {
144        let trust = compute_trust_score(&cfg.db_path);
145        trust.data_quality.state.clone()
146    };
147    // Hoist all feedback entries — previously called 2-3× per search (once for
148    // score adjustment, once for result annotation, once for display badges).
149    let all_feedback: Vec<agentstatedeveloper_core::FeedbackEntry> = {
150        let fb_store = AsgFeedbackStore::from_engine(&engine);
151        // Plan J t-014: drop expired verdicts on the ranking path.
152        // Storage is untouched — `asd feedback list` still shows
153        // expired entries so users can audit / clean up. Only the
154        // search/ranking pipeline ignores them.
155        fb_store
156            .list_all(&engine.ref_name)
157            .unwrap_or_default()
158            .into_iter()
159            .filter(|e| !e.is_expired())
160            .collect()
161    };
162
163    let (tokens_from_query, mut inline_exclusions) = parse_query(&args.query);
164    if let Some(ref excl) = args.exclude {
165        for term in excl
166            .split(',')
167            .map(|t| t.trim().to_lowercase())
168            .filter(|t| !t.is_empty())
169        {
170            inline_exclusions.push(term);
171        }
172    }
173    let mut paths_filter: Vec<String> = Vec::new();
174    if let Some(ref scope) = args.scope {
175        paths_filter.extend(resolve_scope(scope, &cfg.db_path));
176    }
177    if let Some(ref paths) = args.paths {
178        paths_filter.extend(
179            paths
180                .split(',')
181                .map(|p| p.trim().to_string())
182                .filter(|p| !p.is_empty()),
183        );
184    }
185    // Plan J t-011: negative-glob, language-exclude, exclude-set
186    // resolution. All three are additive: --exclude-set expands into
187    // --exclude-path globs, then both stack with --exclude-lang.
188    let mut exclude_paths: Vec<String> = Vec::new();
189    if let Some(ref s) = args.exclude_set {
190        exclude_paths.extend(agentstatedeveloper_core::resolve_exclude_set(
191            s,
192            &cfg.db_path,
193        ));
194    }
195    if let Some(ref s) = args.exclude_path {
196        exclude_paths.extend(
197            s.split(',')
198                .map(|p| p.trim().to_string())
199                .filter(|p| !p.is_empty()),
200        );
201    }
202    let exclude_languages: Vec<String> = args
203        .exclude_lang
204        .as_deref()
205        .map(|s| {
206            s.split(',')
207                .map(|p| p.trim().to_string())
208                .filter(|p| !p.is_empty())
209                .collect()
210        })
211        .unwrap_or_default();
212
213    let filters = FtsFilters {
214        kind: args.kind.as_deref().map(|k| k.to_lowercase()),
215        language: args.language.as_deref().map(|l| l.to_lowercase()),
216        include_tests: args.include_tests,
217        tests_only: args.tests_only,
218        exclude_terms: inline_exclusions,
219        paths_filter,
220        exclude_paths,
221        exclude_languages,
222    };
223
224    // --- Document hits (broad corpus) ---
225    let doc_hits = if !args.symbols_only {
226        SearchDocsDb::open(&cfg.db_path)
227            .ok()
228            .filter(|db| !db.is_empty())
229            .and_then(|db| db.search(&tokens_from_query, args.limit, None).ok())
230            .unwrap_or_default()
231    } else {
232        vec![]
233    };
234
235    // --- FTS path ---
236    let fts_result = if args.docs_only {
237        None
238    } else {
239        SearchFtsDb::open(&cfg.db_path)
240            .ok()
241            .filter(|fts| fts.has_data())
242            // Fetch limit*2 candidates (was *4). The extra factor is needed for
243            // reranking after ledger/SOT boosts; *2 keeps accuracy well enough for
244            // the golden probe suite while cutting ledger-read count in half vs *4.
245            // Bump back to *3/*4 if a ranking regression is observed.
246            .and_then(|fts| fts.search(&args.query, &filters, args.limit * 2).ok())
247    };
248
249    if let Some(mut hits) = fts_result {
250        // Plan J t-017: filter the FTS result set by paths /
251        // exclude_paths / exclude_languages BEFORE the expensive
252        // hybrid reranking + ledger lookups run. These filters were
253        // previously a no-op in the search hot path — `--paths`,
254        // `--exclude-path`, `--exclude-lang`, `--scope`,
255        // `--exclude-set` populated FtsFilters and gated the
256        // `scope_narrowed` advisory flag, but the actual result
257        // pruning lived in core::candidates::apply_*_filter, which
258        // only `find_candidates` (used by prepare_change, investigate,
259        // context_for, etc.) called. Search bypassed all of it.
260        //
261        // We filter on FtsHit directly rather than calling the core
262        // helpers because FtsHit already carries `file` and
263        // `language` — zero extra reads. The helpers' value is when
264        // you only have a qname and need to resolve back to a file;
265        // search has the full hit.
266        //
267        // Order: positive (paths) → negative (exclude_paths) →
268        // language exclude. Each is a no-op when its filter list is
269        // empty, so the cost on unfiltered queries is one
270        // `is_empty()` check per axis.
271        if !filters.paths_filter.is_empty() {
272            hits.retain(|h| matches_any_path_glob(&filters.paths_filter, &h.file));
273        }
274        if !filters.exclude_paths.is_empty() {
275            hits.retain(|h| !matches_any_path_glob(&filters.exclude_paths, &h.file));
276        }
277        if !filters.exclude_languages.is_empty() {
278            let lower: Vec<String> = filters
279                .exclude_languages
280                .iter()
281                .map(|s| s.to_lowercase())
282                .collect();
283            hits.retain(|h| {
284                let lang = h.language.to_lowercase();
285                !lower.iter().any(|excl| excl == &lang)
286            });
287        }
288
289        let tokens = tokens_from_query.clone();
290
291        // Hybrid reranking: BM25 + path/name boost + ledger boost + ownership anchor boost.
292        // Also track which symbols received a SOT boost (for boosted-but-outranked report).
293        let mut sot_boosted_qnames: std::collections::HashSet<String> =
294            std::collections::HashSet::new();
295        // Per-result boost breakdown (populated when --debug-boosts is set).
296        let mut boost_debug: std::collections::HashMap<String, serde_json::Value> =
297            std::collections::HashMap::new();
298        const GENERIC_BOOST_SKIP: &[&str] = &[
299            "state",
300            "update",
301            "position",
302            "value",
303            "cursor",
304            "progress",
305            "indicator",
306            "status",
307            "mode",
308            "flag",
309            "current",
310            "local",
311            "playhead",
312            "tick",
313            "item",
314            "data",
315            "info",
316            "manager",
317        ];
318        // M59: ledger_cache is now lazily populated at display time only (top ~20 results).
319        // Scoring uses denormalized ledger_text/ledger_flags from FTS rows — no list_entries
320        // calls during the hot scoring loop (was N=80 calls, now 0).
321        let mut ledger_cache: std::collections::HashMap<
322            String,
323            Vec<agentstatedeveloper_core::LedgerEntry>,
324        > = std::collections::HashMap::new();
325        // has_ledger_ids: symbol_ids with any ledger entries — populated from FTS fields.
326        let mut has_ledger_ids: std::collections::HashSet<String> =
327            std::collections::HashSet::new();
328        let mut scored: Vec<(f64, _)> = {
329            let mut tmp = Vec::with_capacity(hits.len());
330            for hit in hits {
331                let hybrid = hybrid_boost(&hit, &tokens);
332                // M59: use denormalized fields — no list_entries call.
333                let ledger_boost = if hit.ledger_text.is_empty() {
334                    0.0
335                } else {
336                    tokens
337                        .iter()
338                        .filter(|t| hit.ledger_text.contains(t.as_str()))
339                        .count() as f64
340                };
341                let haystack = format!("{} {}", hit.qname.to_lowercase(), hit.file.to_lowercase());
342                let domain_overlap = tokens
343                    .iter()
344                    .filter(|t| !GENERIC_BOOST_SKIP.contains(&t.as_str()))
345                    .filter(|t| haystack.contains(t.as_str()))
346                    .count();
347                let has_ownership = hit.has_ownership();
348                let has_invariant = hit.has_invariant();
349                if hit.has_ledger() {
350                    has_ledger_ids.insert(hit.symbol_id.clone());
351                }
352                let is_state_holder =
353                    matches!(hit.kind.as_str(), "class" | "struct" | "type" | "enum")
354                        && !has_ownership
355                        && !has_invariant
356                        && !tokens.iter().any(|t| {
357                            matches!(
358                                t.as_str(),
359                                "state" | "model" | "type" | "class" | "struct" | "enum" | "schema"
360                            )
361                        });
362                let state_penalty = if is_state_holder { -0.8 } else { 0.0 };
363                let sot_boost = if has_ownership && domain_overlap >= 2 {
364                    5.0
365                } else if has_ownership && domain_overlap >= 1 {
366                    3.5
367                } else if has_ownership {
368                    2.0
369                } else if has_invariant && domain_overlap >= 1 {
370                    1.5
371                } else {
372                    0.0
373                };
374                let total = hit.bm25_score + hybrid + ledger_boost + sot_boost + state_penalty;
375                if args.debug_boosts {
376                    boost_debug.insert(
377                        hit.qname.clone(),
378                        serde_json::json!({
379                            "bm25": hit.bm25_score,
380                            "hybrid_boost": hybrid,
381                            "ledger_boost": ledger_boost,
382                            "sot_boost": sot_boost,
383                            "state_penalty": state_penalty,
384                            "domain_overlap": domain_overlap,
385                            "total": total,
386                        }),
387                    );
388                }
389                tmp.push((total, hit));
390            }
391            tmp
392        };
393
394        // Record SOT-boosted symbols before truncation for outranked reporting.
395        // Uses has_ledger_ids populated from FTS fields — no extra git reads.
396        for (_, hit) in &scored {
397            if has_ledger_ids.contains(&hit.symbol_id) && hit.has_ownership() {
398                sot_boosted_qnames.insert(hit.qname.clone());
399            }
400        }
401
402        // Apply durable feedback: suppress noisy/wrong-layer, boost useful.
403        // Uses the hoisted all_feedback — no extra list_all() call here.
404        let mut feedback_metrics = FeedbackMetrics::default();
405        let mut feedback_suppressed_detail: Vec<String> = Vec::new();
406        {
407            let idx = AsgIndexStore::from_engine(&engine);
408            if !all_feedback.is_empty() {
409                // Plan J t-016: tuple gained created_at for age-decay.
410                let fb_tuples: Vec<_> = all_feedback
411                    .iter()
412                    .filter(|e| e.file_scope.is_none())
413                    .map(|e| {
414                        (
415                            e.symbol_id.clone(),
416                            e.query.clone(),
417                            e.verdict,
418                            e.created_at,
419                        )
420                    })
421                    .collect();
422                let fs_tuples: Vec<_> = all_feedback
423                    .iter()
424                    .filter_map(|e| {
425                        e.file_scope
426                            .as_ref()
427                            .map(|g| (g.clone(), e.verdict, e.query.clone(), e.created_at))
428                    })
429                    .collect();
430                let mut adj: Vec<(f64, String)> =
431                    scored.iter().map(|(s, h)| (*s, h.qname.clone())).collect();
432                feedback_metrics =
433                    apply_feedback_adjustments(&engine, &idx, &args.query, &mut adj, &fb_tuples);
434                apply_file_scope_feedback(&engine, &idx, &args.query, &mut adj, &fs_tuples);
435                // Collect suppressed qnames before consuming adj.
436                let surviving: std::collections::HashSet<&str> =
437                    adj.iter().map(|(_, q)| q.as_str()).collect();
438                feedback_suppressed_detail = scored
439                    .iter()
440                    .map(|(_, h)| h.qname.clone())
441                    .filter(|q| !surviving.contains(q.as_str()))
442                    .collect();
443                let adj_map: std::collections::HashMap<String, f64> =
444                    adj.into_iter().map(|(s, q)| (q, s)).collect();
445                scored.retain(|(_, h)| adj_map.contains_key(&h.qname));
446                for (score, h) in scored.iter_mut() {
447                    if let Some(&new_s) = adj_map.get(&h.qname) {
448                        *score = new_s;
449                    }
450                }
451            }
452        }
453
454        scored.sort_by(|a, b| {
455            b.0.partial_cmp(&a.0)
456                .unwrap_or(std::cmp::Ordering::Equal)
457                .then_with(|| a.1.qname.cmp(&b.1.qname))
458        });
459        scored.truncate(args.limit);
460
461        if scored.is_empty() {
462            // Field-eval (2026-06-04): users piping `asd search ...
463            // --agent | jq` got "Invalid numeric literal" when the
464            // query returned 0 hits because the empty-results path
465            // bypassed JSON and printed a plain "No results for ..."
466            // line. `--agent` MUST always emit JSON so downstream
467            // consumers don't have to special-case the empty case.
468            if args.agent {
469                let empty = serde_json::json!({
470                    "query": args.query,
471                    "intent": if intent.is_empty() {
472                        serde_json::Value::Null
473                    } else {
474                        serde_json::json!(intent)
475                    },
476                    "results": [],
477                    "document_hits": [],
478                    "broadened_search": serde_json::Value::Null,
479                    "note": format!("No results for {:?}", args.query),
480                });
481                // Token economy: compact JSON in agent mode.
482                println!("{}", serde_json::to_string(&empty)?);
483            } else {
484                println!("No results for {:?}", args.query);
485            }
486            return Ok(());
487        }
488
489        // One git pass to annotate with recency (hot = changed in last 14 days).
490        let recency = gather_recency(200, 14.0);
491
492        let index_store = AsgIndexStore::from_engine(&engine);
493        // Uncertainty: compute confidence scores across the result set.
494        let raw_scores: Vec<f64> = scored.iter().map(|(s, _)| *s).collect();
495        let confidences = confidence_scores(&raw_scores);
496        // Uncertainty: detect ambiguous query tokens.
497        let ambiguous_terms = detect_ambiguous_tokens(&tokens, engine.fts.as_ref(), &filters);
498        if args.agent {
499            // Pre-compute feedback impacts for ALL result qnames at once using the
500            // hoisted all_feedback — no extra list_all() call here.
501            // explain_feedback_impacts does a full symbol-tree scan when noisy symbols exist;
502            // calling it per-result meant 20 full tree scans. One batch call eliminates that.
503            let all_result_qnames: Vec<String> =
504                scored.iter().map(|(_, h)| h.qname.clone()).collect();
505            let all_feedback_impacts = explain_feedback_impacts(
506                &engine,
507                &AsgIndexStore::from_engine(&engine),
508                &args.query,
509                &all_result_qnames,
510                &all_feedback,
511            );
512
513            let mut layers_present: std::collections::HashSet<&str> =
514                std::collections::HashSet::new();
515            let results: Vec<serde_json::Value> = scored
516                .iter()
517                .zip(confidences.iter())
518                .map(|((score, hit), conf)| {
519                    let rec = recency.get(&hit.file);
520                    let is_hot = rec.map(|r| r.hot).unwrap_or(false);
521                    let tier = symbol_tier(&hit.file);
522                    let layer = classify_layer_sym(&hit.file, &hit.qname, tier, &layer_overrides);
523                    layers_present.insert(Box::leak(layer.to_string().into_boxed_str()));
524                    // M59: lazy-load ledger entries at display time (top N only).
525                    // Scoring no longer populates ledger_cache; we do it here on demand.
526                    if !ledger_cache.contains_key(&hit.symbol_id) {
527                        if let Ok(entries) =
528                            ledger_store.list_entries(&engine.ref_name, &hit.symbol_id)
529                        {
530                            ledger_cache.insert(hit.symbol_id.clone(), entries);
531                        }
532                    }
533                    let ledger_entries = ledger_cache
534                        .get(&hit.symbol_id)
535                        .cloned()
536                        .unwrap_or_default();
537                    // Use has_ledger_ids (from FTS fields) as authoritative source;
538                    // fall back to cache for completeness.
539                    let has_ledger =
540                        has_ledger_ids.contains(&hit.symbol_id) || !ledger_entries.is_empty();
541                    let match_reasons = if let Ok(Some(sym)) =
542                        index_store.get_symbol_by_qname(&engine.ref_name, &hit.qname)
543                    {
544                        explain_match(&sym, &tokens, &ledger_entries, is_hot)
545                    } else {
546                        vec![]
547                    };
548                    let bucket = result_bucket(&hit.file, &match_reasons, has_ledger, is_hot);
549                    let conf_reason = confidence_reason(&match_reasons, has_ledger, is_hot);
550                    // Check for an active useful feedback verdict (these survive filtering).
551                    // Uses the hoisted all_feedback — no extra git read per result.
552                    let fb_status = {
553                        let q = args.query.to_lowercase();
554                        all_feedback
555                            .iter()
556                            .find(|e| {
557                                e.symbol_id == hit.symbol_id
558                                    && (e.query.is_empty()
559                                        || q.contains(e.query.as_str())
560                                        || e.query.contains(q.as_str()))
561                            })
562                            .map(|e| e.verdict.as_str().to_string())
563                    };
564                    // feedback_rule: look up from the pre-computed impact map (no extra git reads).
565                    let feedback_rule: Option<serde_json::Value> =
566                        all_feedback_impacts.get(&hit.qname).map(|imp| {
567                            serde_json::json!({
568                                "verdict": imp.verdict,
569                                "matched_query": imp.matched_query,
570                                "author": imp.author,
571                            })
572                        });
573                    // effect_detail: one-line reason for effect verification state.
574                    let effect_detail = {
575                        let decl = effect_store
576                            .get_effects(&engine.ref_name, &hit.symbol_id)
577                            .ok()
578                            .flatten();
579                        effect_detail_reason(decl.as_ref())
580                    };
581                    let mut result_val = serde_json::json!({
582                        "score": score, "confidence": conf, "bucket": bucket,
583                        "confidence_reason": conf_reason,
584                        "qname": hit.qname, "kind": hit.kind,
585                        "file": hit.file, "line": hit.line, "layer": layer,
586                        "summary": extract_summary(hit.doc.as_deref(), hit.signature.as_deref()),
587                        "last_touched_days": rec.and_then(|r| r.last_touched_days),
588                        "hot": is_hot,
589                        "match_reasons": match_reasons,
590                        "feedback_status": fb_status,
591                        "feedback_rule": feedback_rule,
592                        "effect_detail": effect_detail,
593                    });
594                    if args.debug_boosts {
595                        if let Some(dbg) = boost_debug.get(&hit.qname) {
596                            result_val["boost_debug"] = dbg.clone();
597                        }
598                    }
599                    result_val
600                })
601                .collect();
602            let scope_narrowed =
603                !filters.paths_filter.is_empty() || !filters.exclude_terms.is_empty();
604            let possible_misses = if scope_narrowed {
605                vec![]
606            } else {
607                detect_possible_misses(&args.query, &layers_present, results.len())
608            };
609            // t-003: typed confidence warnings (ambiguous vs sparse).
610            let confidence_warnings = detect_confidence_warnings(
611                &tokens,
612                results.len(),
613                &ambiguous_terms,
614                engine.fts.as_ref(),
615            );
616            // t-004/t-005: query improvement suggestions.
617            let query_suggestions = if scope_narrowed {
618                vec![]
619            } else {
620                suggest_better_queries(&tokens, &args.query)
621            };
622            // t-004: scoped query suggestions using co-occurring tokens from top results.
623            let top_qnames: Vec<String> = results
624                .iter()
625                .take(5)
626                .filter_map(|r| r["qname"].as_str().map(|s| s.to_string()))
627                .collect();
628            let scoped_suggestions = if scope_narrowed || ambiguous_terms.is_empty() {
629                vec![]
630            } else {
631                suggest_scoped_queries(&tokens, &ambiguous_terms, &top_qnames)
632            };
633            let doc_results: Vec<serde_json::Value> = doc_hits
634                .iter()
635                .map(|h| {
636                    serde_json::json!({
637                        "source": "document",
638                        "score": h.bm25_score,
639                        "kind": h.kind,
640                        "path": h.path,
641                        "line": h.span_start,
642                        "title": h.title,
643                        "preview": h.preview,
644                        "owner_symbol_id": h.owner_symbol_id,
645                    })
646                })
647                .collect();
648            // boosted_outranked: SOT symbols that got a boost but ranked below position 5
649            // or didn't make results at all. Useful for diagnosing cases where an SOT
650            // symbol should have surfaced higher — the probe harness can assert these
651            // are reported when a known-good symbol slips past the top cut.
652            const OUTRANKED_THRESHOLD: usize = 5;
653            let result_positions: std::collections::HashMap<&str, usize> = results
654                .iter()
655                .enumerate()
656                .filter_map(|(i, r)| r["qname"].as_str().map(|q| (q, i + 1)))
657                .collect();
658            let boosted_outranked: Vec<&str> = sot_boosted_qnames
659                .iter()
660                .map(|s| s.as_str())
661                .filter(|q| match result_positions.get(*q) {
662                    Some(&rank) => rank > OUTRANKED_THRESHOLD,
663                    None => true, // not in results at all
664                })
665                .collect();
666
667            // Use hoisted dq_state_str — avoids re-opening sidecar/DB for trust data.
668            let uncertainty = compute_uncertainty(
669                &tokens,
670                &ambiguous_terms,
671                &possible_misses,
672                results.len(),
673                &scoped_suggestions,
674                engine.fts.as_ref(),
675                Some(dq_state_str.as_str()),
676            );
677            // Use hoisted all_feedback — avoids a second list_all() call.
678            let feedback_state = build_feedback_state_from_entries(
679                &all_feedback,
680                &args.query,
681                feedback_metrics.entries_applied,
682            );
683            // Plan J t-004: broad-search miss diagnosis. When the
684            // primary pass returns fewer than BROADEN_THRESHOLD hits
685            // AND at least one narrowing filter is active (paths,
686            // exclude_paths, exclude_languages, language), retry once
687            // with those filters cleared. Report whatever NEW qnames
688            // surface as `extra_hits` plus a `dropped_filters` array
689            // so the agent can see what was holding back the original
690            // pass without having to guess.
691            const BROADEN_THRESHOLD: usize = 3;
692            let broadened_search = compute_broadened_search(
693                BROADEN_THRESHOLD,
694                &results,
695                &filters,
696                &args.query,
697                args.limit,
698                &cfg.db_path,
699            );
700
701            let raw = serde_json::json!({
702                "query": args.query,
703                "intent": if intent.is_empty() { serde_json::Value::Null } else { serde_json::json!(intent) },
704                "uncertainty": uncertainty.to_json(),
705                "ambiguous_terms": ambiguous_terms,
706                "possible_misses": possible_misses,
707                "confidence_warnings": confidence_warnings,
708                "query_suggestions": query_suggestions,
709                "scoped_suggestions": scoped_suggestions,
710                "scope_narrowed": scope_narrowed,
711                "broadened_search": broadened_search,
712                "feedback_suppressed": feedback_metrics.suppressed,
713                "feedback_suppressed_detail": feedback_suppressed_detail,
714                "boosted_outranked": boosted_outranked,
715                "feedback_state": feedback_state.to_json(),
716                "feedback_summary": {
717                    "entries_applied": feedback_metrics.entries_applied,
718                    "suppressed": feedback_metrics.suppressed,
719                    "preserved_useful_siblings": feedback_metrics.preserved_useful_siblings,
720                    "boosted": feedback_metrics.boosted,
721                    "recurring_fp_suppressed": feedback_metrics.recurring_fp_suppressed,
722                    "boosted_outranked": boosted_outranked.len(),
723                    "rules_applied": feedback_metrics.rules_applied,
724                    "entries_total": feedback_state.entries_total,
725                    "query_matches": feedback_state.query_matches,
726                    "coverage": if !feedback_state.available { "none" }
727                                else if feedback_state.query_matches == 0 { "none" }
728                                else if feedback_metrics.entries_applied > 0 { "applied" }
729                                else { "partial" },
730                },
731                "results": results,
732                "document_hits": doc_results,
733            });
734            let max_list = (args.agent_budget / 500).max(3).min(20);
735            let mut trimmed = trim_for_agent(&raw, max_list);
736            // 1.0.79: drop input echo (the agent has the query).
737            if let Some(obj) = trimmed.as_object_mut() {
738                obj.remove("query");
739            }
740            let trimmed = agentstatedeveloper_core::drop_empty_top_level(trimmed);
741            let compact = serde_json::to_string(&trimmed)?;
742            let token_est = estimate_tokens(&compact);
743            let mut out = trimmed.clone();
744            if let Some(obj) = out.as_object_mut() {
745                obj.insert("token_estimate".into(), serde_json::json!(token_est));
746            }
747            println!("{}", serde_json::to_string(&out)?);
748        } else {
749            // t-005: show query suggestions before results.
750            let scope_narrowed_term =
751                !filters.paths_filter.is_empty() || !filters.exclude_terms.is_empty();
752            let q_suggestions = if scope_narrowed_term {
753                vec![]
754            } else {
755                suggest_better_queries(&tokens, &args.query)
756            };
757            for s in &q_suggestions {
758                eprintln!("asd: {}", s);
759            }
760
761            // Use hoisted all_feedback for display badges — no extra list_all() call.
762            let fb_for_display = &all_feedback;
763            let query_norm = args.query.to_lowercase();
764            for (idx, (score, hit)) in scored.iter().enumerate() {
765                let rec = recency.get(&hit.file);
766                let is_hot = rec.map(|r| r.hot).unwrap_or(false);
767                let hot_tag = if is_hot { " [hot]" } else { "" };
768                let age_tag = rec
769                    .and_then(|r| r.last_touched_days)
770                    .map(|d| format!(" ~{:.0}d ago", d))
771                    .unwrap_or_default();
772                // Feedback badge: show [useful] when a matching verdict boosted this result.
773                let fb_tag = if !fb_for_display.is_empty() {
774                    let q = query_norm.as_str();
775                    let has_useful = fb_for_display.iter().any(|e| {
776                        e.symbol_id == hit.symbol_id
777                            && (e.query.is_empty()
778                                || q.contains(e.query.as_str())
779                                || e.query.contains(q))
780                            && matches!(e.verdict, FeedbackVerdict::Useful)
781                    });
782                    if has_useful { " [useful]" } else { "" }
783                } else {
784                    ""
785                };
786                // t-002: confidence bucket + reason label in terminal output.
787                // M59: lazy-load ledger entries for display (only called for top N shown results).
788                let conf = confidences.get(idx).copied().unwrap_or(0.5);
789                if !ledger_cache.contains_key(&hit.symbol_id) {
790                    if let Ok(entries) = ledger_store.list_entries(&engine.ref_name, &hit.symbol_id)
791                    {
792                        ledger_cache.insert(hit.symbol_id.clone(), entries);
793                    }
794                }
795                let cached_entries = ledger_cache
796                    .get(&hit.symbol_id)
797                    .cloned()
798                    .unwrap_or_default();
799                let (bucket, conf_reason_str, match_reasons_disp) = if let Ok(Some(sym)) =
800                    index_store.get_symbol_by_qname(&engine.ref_name, &hit.qname)
801                {
802                    let has_ledger =
803                        has_ledger_ids.contains(&hit.symbol_id) || !cached_entries.is_empty();
804                    let reasons = explain_match(&sym, &tokens, &cached_entries, is_hot);
805                    let b = result_bucket(&hit.file, &reasons, has_ledger, is_hot);
806                    let cr = confidence_reason(&reasons, has_ledger, is_hot);
807                    (b, cr, reasons)
808                } else {
809                    (
810                        "noisy",
811                        "weak: low-signal indirect match".to_string(),
812                        vec![],
813                    )
814                };
815                // Cap confidence display when query is all-generic (avoids "[relevant 100%]"
816                // on matches that only hit generic tokens with no domain anchor).
817                let all_generic_query = !tokens.is_empty()
818                    && !ambiguous_terms.is_empty()
819                    && tokens.iter().all(|t| ambiguous_terms.contains(t));
820                let (display_conf, display_bucket) = if all_generic_query && conf > 0.5 {
821                    (0.5_f64, "peripheral")
822                } else {
823                    (conf, bucket)
824                };
825                let conf_tag = format!(" [{} {:.0}%]", display_bucket, display_conf * 100.0);
826                println!(
827                    "[{:.1}] {} {}{}{}{}{} ({}:{})",
828                    score,
829                    hit.kind,
830                    hit.qname,
831                    hot_tag,
832                    fb_tag,
833                    conf_tag,
834                    age_tag,
835                    hit.file,
836                    hit.line
837                );
838                if let Some(sig) = &hit.signature {
839                    if !sig.is_empty() {
840                        println!("       sig: {}", sig);
841                    }
842                }
843                let summary = extract_summary(hit.doc.as_deref(), hit.signature.as_deref());
844                if !summary.is_empty() {
845                    println!("       {}", summary);
846                }
847                if args.explain {
848                    println!(
849                        "       confidence: {:.0}%  {} ({})",
850                        conf * 100.0,
851                        conf_reason_str,
852                        bucket
853                    );
854                    if !match_reasons_disp.is_empty() {
855                        println!("       signals: {}", match_reasons_disp.join(", "));
856                    }
857                }
858                if args.debug_boosts {
859                    if let Some(dbg) = boost_debug.get(&hit.qname) {
860                        println!(
861                            "       boosts: bm25={:.2} hybrid={:.2} ledger={:.2} sot={:.2} state_pen={:.2} domain_overlap={}  => total={:.2}",
862                            dbg["bm25"].as_f64().unwrap_or(0.0),
863                            dbg["hybrid_boost"].as_f64().unwrap_or(0.0),
864                            dbg["ledger_boost"].as_f64().unwrap_or(0.0),
865                            dbg["sot_boost"].as_f64().unwrap_or(0.0),
866                            dbg["state_penalty"].as_f64().unwrap_or(0.0),
867                            dbg["domain_overlap"].as_u64().unwrap_or(0),
868                            dbg["total"].as_f64().unwrap_or(0.0),
869                        );
870                    }
871                }
872            }
873            if feedback_metrics.suppressed > 0 {
874                eprintln!(
875                    "asd: {} result(s) suppressed by feedback (use `asd feedback list` to review)",
876                    feedback_metrics.suppressed
877                );
878            }
879            // Try-narrowing suggestion: when ambiguous terms dominate, suggest scoped queries.
880            if !ambiguous_terms.is_empty() {
881                let top_qnames: Vec<String> = scored
882                    .iter()
883                    .take(5)
884                    .map(|(_, h)| h.qname.clone())
885                    .collect();
886                let narrowing = suggest_scoped_queries(&tokens, &ambiguous_terms, &top_qnames);
887                if !narrowing.is_empty() {
888                    eprintln!("asd: try narrowing with: {}", narrowing.join(", "));
889                }
890            }
891            // Print document hits below symbol hits.
892            if !doc_hits.is_empty() {
893                println!("\n-- document hits --");
894                for h in &doc_hits {
895                    let line_tag = h.span_start.map(|l| format!(":{l}")).unwrap_or_default();
896                    println!("[{:.1}] {} {}{}", h.bm25_score, h.kind, h.path, line_tag);
897                    if !h.title.is_empty() {
898                        println!("       {}", h.title);
899                    }
900                    if !h.preview.is_empty() {
901                        println!(
902                            "       {}",
903                            &h.preview.chars().take(120).collect::<String>()
904                        );
905                    }
906                }
907            }
908        }
909        return Ok(());
910    }
911
912    // --- Fallback: in-memory O(N) scoring ---
913    eprintln!(
914        "asd: FTS index not populated — falling back to in-memory search (run `asd index` to enable fast search)"
915    );
916
917    let tokens = tokens_from_query;
918    if tokens.is_empty() {
919        println!("[]");
920        return Ok(());
921    }
922
923    let kind_filter = args.kind.as_deref().map(|k| k.to_lowercase());
924    let lang_filter = args.language.as_deref();
925
926    // Shared core implementation: one by-qname tree read + one ledger
927    // walk (was a per-qname symbol fetch + per-symbol ledger read —
928    // minutes at 10k symbols on a cold FTS cache; Plan T scale fix).
929    let mut scored: Vec<(u32, agentstatedeveloper_core::Symbol)> =
930        agentstatedeveloper_core::in_memory_scored_symbols(
931            &engine,
932            &tokens,
933            kind_filter.as_deref(),
934            lang_filter,
935        );
936
937    // Apply durable feedback on fallback path too.
938    // Reuse hoisted all_feedback — no extra list_all() call.
939    {
940        let idx = AsgIndexStore::from_engine(&engine);
941        let fb = &all_feedback;
942        if !fb.is_empty() {
943            // Plan J t-016: tuple gained created_at for age-decay.
944            let fb_tuples: Vec<_> = fb
945                .iter()
946                .filter(|e| e.file_scope.is_none())
947                .map(|e| {
948                    (
949                        e.symbol_id.clone(),
950                        e.query.clone(),
951                        e.verdict,
952                        e.created_at,
953                    )
954                })
955                .collect();
956            let fs_tuples: Vec<_> = fb
957                .iter()
958                .filter_map(|e| {
959                    e.file_scope
960                        .as_ref()
961                        .map(|g| (g.clone(), e.verdict, e.query.clone(), e.created_at))
962                })
963                .collect();
964            let mut adj: Vec<(f64, String)> = scored
965                .iter()
966                .map(|(s, sym)| (*s as f64, sym.qname.clone()))
967                .collect();
968            let _ = apply_feedback_adjustments(&engine, &idx, &args.query, &mut adj, &fb_tuples);
969            apply_file_scope_feedback(&engine, &idx, &args.query, &mut adj, &fs_tuples);
970            let surviving: std::collections::HashSet<String> =
971                adj.into_iter().map(|(_, q)| q).collect();
972            scored.retain(|(_, sym)| surviving.contains(&sym.qname));
973        }
974    }
975
976    scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.qname.cmp(&b.1.qname)));
977    scored.truncate(args.limit);
978
979    if scored.is_empty() {
980        // Same fix as the FTS hot path above: --agent must always
981        // emit JSON so pipe consumers can run `jq` unconditionally.
982        if args.agent {
983            let empty = serde_json::json!({
984                "query": args.query,
985                "intent": if intent.is_empty() {
986                    serde_json::Value::Null
987                } else {
988                    serde_json::json!(intent)
989                },
990                "results": [],
991                "document_hits": [],
992                "broadened_search": serde_json::Value::Null,
993                "note": format!("No results for {:?} (in-memory fallback)", args.query),
994            });
995            // Token economy: compact + drop empties. Matches the
996            // FTS-path empty branch.
997            let empty = agentstatedeveloper_core::drop_empty_top_level(empty);
998            println!("{}", serde_json::to_string(&empty)?);
999        } else {
1000            println!("No results for {:?}", args.query);
1001        }
1002        return Ok(());
1003    }
1004
1005    for (score, sym) in &scored {
1006        let kind = kind_str(&sym.kind);
1007        println!("[{:3}] {} {} ({})", score, kind, sym.qname, sym.file);
1008        if let Some(sig) = sym.signature.as_deref() {
1009            if !sig.is_empty() {
1010                println!("       sig: {}", sig);
1011            }
1012        }
1013        let summary = extract_summary(sym.doc.as_deref(), sym.signature.as_deref());
1014        if !summary.is_empty() {
1015            println!("       {}", summary);
1016        }
1017        if args.explain {
1018            let entries = ledger_store
1019                .list_entries(&engine.ref_name, &sym.symbol_id)
1020                .unwrap_or_default();
1021            let reasons = explain_match(sym, &tokens, &entries, false);
1022            if !reasons.is_empty() {
1023                println!("       why: {}", reasons.join(", "));
1024            }
1025        }
1026    }
1027
1028    Ok(())
1029}
1030
1031// ---------------------------------------------------------------------------
1032// Shared helpers
1033// ---------------------------------------------------------------------------
1034
1035/// Plan J t-004: when the primary search returned fewer than
1036/// `threshold` hits AND any of the path/language narrowing filters
1037/// is active, re-run the FTS query without those filters and report
1038/// the new qnames that surface plus a list of which filters were
1039/// dropped. Returns `Value::Null` when not triggered so callers can
1040/// emit it unconditionally in the response without padding the
1041/// shape on hot queries.
1042///
1043/// What gets dropped:
1044///   - `paths_filter` (positive glob list)
1045///   - `exclude_paths` (negative globs)
1046///   - `exclude_languages`
1047///   - `language` (single-language filter)
1048///
1049/// Deliberately KEPT in the broadening pass:
1050///   - `kind` (explicit type filter; the agent meant it)
1051///   - `include_tests` / `tests_only` (test-tier filter)
1052///   - `exclude_terms` (user typed `-foo` in the query body)
1053///
1054/// The fallback uses the same `args.limit` cap, so a query that's
1055/// over-filtered down to 1 hit might return up to `limit` extra hits.
1056fn compute_broadened_search(
1057    threshold: usize,
1058    primary_results: &[serde_json::Value],
1059    filters: &agentstatedeveloper_core::FtsFilters,
1060    query: &str,
1061    limit: usize,
1062    db_path: &std::path::Path,
1063) -> serde_json::Value {
1064    if primary_results.len() >= threshold {
1065        return serde_json::Value::Null;
1066    }
1067
1068    // Identify which narrowing filters are active.
1069    let mut dropped: Vec<String> = Vec::new();
1070    if !filters.paths_filter.is_empty() {
1071        dropped.push(format!(
1072            "paths ({} pattern{})",
1073            filters.paths_filter.len(),
1074            if filters.paths_filter.len() == 1 {
1075                ""
1076            } else {
1077                "s"
1078            }
1079        ));
1080    }
1081    if !filters.exclude_paths.is_empty() {
1082        dropped.push(format!(
1083            "exclude-paths ({} pattern{})",
1084            filters.exclude_paths.len(),
1085            if filters.exclude_paths.len() == 1 {
1086                ""
1087            } else {
1088                "s"
1089            }
1090        ));
1091    }
1092    if !filters.exclude_languages.is_empty() {
1093        dropped.push(format!(
1094            "exclude-lang ({})",
1095            filters.exclude_languages.join(", ")
1096        ));
1097    }
1098    if let Some(ref lang) = filters.language {
1099        dropped.push(format!("language ({lang})"));
1100    }
1101    if dropped.is_empty() {
1102        // Low hit count but no narrowing filter to blame — the
1103        // query is just genuinely sparse. Don't run a redundant
1104        // search; callers already get `confidence_warnings` /
1105        // `possible_misses` for this case.
1106        return serde_json::Value::Null;
1107    }
1108
1109    // Build a broadened FtsFilters: clear the path/language axes,
1110    // preserve everything else (kind, include_tests, tests_only,
1111    // exclude_terms, the user's explicit choices).
1112    let broadened_filters = agentstatedeveloper_core::FtsFilters {
1113        kind: filters.kind.clone(),
1114        language: None,
1115        include_tests: filters.include_tests,
1116        tests_only: filters.tests_only,
1117        exclude_terms: filters.exclude_terms.clone(),
1118        paths_filter: Vec::new(),
1119        exclude_paths: Vec::new(),
1120        exclude_languages: Vec::new(),
1121    };
1122
1123    let broadened_hits = match SearchFtsDb::open(db_path).ok() {
1124        Some(fts) if fts.has_data() => fts
1125            .search(query, &broadened_filters, limit)
1126            .ok()
1127            .unwrap_or_default(),
1128        _ => return serde_json::Value::Null,
1129    };
1130
1131    // Surface only qnames that DID NOT appear in the primary
1132    // results — that's the actual signal an agent needs to see.
1133    let primary_qnames: std::collections::HashSet<&str> = primary_results
1134        .iter()
1135        .filter_map(|r| r["qname"].as_str())
1136        .collect();
1137    let extra_hits: Vec<serde_json::Value> = broadened_hits
1138        .iter()
1139        .filter(|h| !primary_qnames.contains(h.qname.as_str()))
1140        .take(limit)
1141        .map(|h| {
1142            serde_json::json!({
1143                "qname": h.qname,
1144                "file": h.file,
1145                "score": h.bm25_score,
1146            })
1147        })
1148        .collect();
1149
1150    if extra_hits.is_empty() {
1151        // The filters weren't actually narrowing — broadening
1152        // surfaces the same set. Tell the agent so they know
1153        // it's not a filter problem.
1154        return serde_json::json!({
1155            "triggered": true,
1156            "primary_hits": primary_results.len(),
1157            "threshold": threshold,
1158            "dropped_filters": dropped,
1159            "extra_hits": [],
1160            "note": "Broadening produced no additional results — the low hit count is likely a query sparsity issue, not a filter narrowing one.",
1161        });
1162    }
1163
1164    serde_json::json!({
1165        "triggered": true,
1166        "primary_hits": primary_results.len(),
1167        "threshold": threshold,
1168        "dropped_filters": dropped,
1169        "extra_hits": extra_hits,
1170        "note": format!(
1171            "Primary pass returned {} hit{} below threshold {}. Broadened by dropping {} and recovered {} extra qname{}.",
1172            primary_results.len(),
1173            if primary_results.len() == 1 { "" } else { "s" },
1174            threshold,
1175            dropped.join(" + "),
1176            extra_hits.len(),
1177            if extra_hits.len() == 1 { "" } else { "s" },
1178        ),
1179    })
1180}