1use 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 pub query: String,
30
31 #[arg(long)]
33 pub kind: Option<String>,
34
35 #[arg(long)]
37 pub language: Option<String>,
38
39 #[arg(long, default_value = "20")]
41 pub limit: usize,
42
43 #[arg(long)]
46 pub include_tests: bool,
47
48 #[arg(long)]
52 pub tests_only: bool,
53
54 #[arg(long)]
56 pub quiet: bool,
57
58 #[arg(long)]
61 pub intent: Option<String>,
62
63 #[arg(long)]
66 pub agent: bool,
67
68 #[arg(long, default_value = "8000")]
70 pub agent_budget: usize,
71
72 #[arg(long)]
76 pub exclude: Option<String>,
77
78 #[arg(long)]
81 pub paths: Option<String>,
82
83 #[arg(long)]
86 pub scope: Option<String>,
87
88 #[arg(long)]
93 pub exclude_path: Option<String>,
94
95 #[arg(long)]
100 pub exclude_lang: Option<String>,
101
102 #[arg(long)]
106 pub exclude_set: Option<String>,
107
108 #[arg(long)]
111 pub explain: bool,
112
113 #[arg(long)]
115 pub symbols_only: bool,
116
117 #[arg(long)]
119 pub docs_only: bool,
120
121 #[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 let dq_state_str: String = {
144 let trust = compute_trust_score(&cfg.db_path);
145 trust.data_quality.state.clone()
146 };
147 let all_feedback: Vec<agentstatedeveloper_core::FeedbackEntry> = {
150 let fb_store = AsgFeedbackStore::from_engine(&engine);
151 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 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 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 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 .and_then(|fts| fts.search(&args.query, &filters, args.limit * 2).ok())
247 };
248
249 if let Some(mut hits) = fts_result {
250 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 let mut sot_boosted_qnames: std::collections::HashSet<String> =
294 std::collections::HashSet::new();
295 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 let mut ledger_cache: std::collections::HashMap<
322 String,
323 Vec<agentstatedeveloper_core::LedgerEntry>,
324 > = std::collections::HashMap::new();
325 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 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 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 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 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 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 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 println!("{}", serde_json::to_string(&empty)?);
483 } else {
484 println!("No results for {:?}", args.query);
485 }
486 return Ok(());
487 }
488
489 let recency = gather_recency(200, 14.0);
491
492 let index_store = AsgIndexStore::from_engine(&engine);
493 let raw_scores: Vec<f64> = scored.iter().map(|(s, _)| *s).collect();
495 let confidences = confidence_scores(&raw_scores);
496 let ambiguous_terms = detect_ambiguous_tokens(&tokens, engine.fts.as_ref(), &filters);
498 if args.agent {
499 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 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 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 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 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 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 let confidence_warnings = detect_confidence_warnings(
611 &tokens,
612 results.len(),
613 &ambiguous_terms,
614 engine.fts.as_ref(),
615 );
616 let query_suggestions = if scope_narrowed {
618 vec![]
619 } else {
620 suggest_better_queries(&tokens, &args.query)
621 };
622 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 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, })
665 .collect();
666
667 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 let feedback_state = build_feedback_state_from_entries(
679 &all_feedback,
680 &args.query,
681 feedback_metrics.entries_applied,
682 );
683 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 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 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 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 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 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 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 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 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 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 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 {
940 let idx = AsgIndexStore::from_engine(&engine);
941 let fb = &all_feedback;
942 if !fb.is_empty() {
943 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 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 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
1031fn 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 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 return serde_json::Value::Null;
1107 }
1108
1109 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 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 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}