1use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13use crate::engine::Engine;
14use crate::index::{AsgIndexStore, IndexStore};
15use crate::ledger::{AsgLedgerStore, LedgerStore};
16use crate::schema::{LedgerEntry, LedgerKind, Symbol, SymbolKind};
17use crate::search_fts::{
18 FtsFilters, SearchFtsDb, classify_file_role, classify_layer_sym, hybrid_boost, is_stopword,
19};
20
21pub fn query_tokens(query: &str) -> Vec<String> {
27 query
28 .split(|c: char| c.is_whitespace() || c == '_' || c == '-' || c == '.')
29 .map(|t| t.to_lowercase())
30 .filter(|t| t.len() >= 2 && !is_stopword(t))
31 .collect()
32}
33
34pub fn parse_query(query: &str) -> (Vec<String>, Vec<String>) {
42 let mut positive_words: Vec<&str> = Vec::new();
43 let mut exclusions: Vec<String> = Vec::new();
44
45 for word in query.split_whitespace() {
46 if let Some(excl) = word.strip_prefix('-') {
47 let term = excl.to_lowercase();
48 if term.len() >= 2 {
49 exclusions.push(term);
50 }
51 } else {
52 positive_words.push(word);
53 }
54 }
55
56 let tokens = positive_words
57 .join(" ")
58 .split(|c: char| c.is_whitespace() || c == '_' || c == '-' || c == '.')
59 .map(|t| t.to_lowercase())
60 .filter(|t| t.len() >= 2 && !is_stopword(t))
61 .collect();
62
63 (tokens, exclusions)
64}
65
66pub fn view_layer_matches(layer: &str, file: &str) -> bool {
84 if layer == "ui" || layer == "viewmodel" {
85 return true;
86 }
87 let role = classify_file_role(file);
88 role == "view" || role == "viewmodel"
89}
90
91pub fn kind_str(kind: &SymbolKind) -> &'static str {
92 match kind {
93 SymbolKind::Module => "module",
94 SymbolKind::Function => "function",
95 SymbolKind::Method => "method",
96 SymbolKind::Class => "class",
97 SymbolKind::Variable => "variable",
98 }
99}
100
101pub fn in_memory_score(
108 sym: &crate::Symbol,
109 tokens: &[String],
110 ledger_store: &AsgLedgerStore,
111 engine: &Engine,
112) -> u32 {
113 let ledger_text: String = ledger_store
114 .list_entries(&engine.ref_name, &sym.symbol_id)
115 .unwrap_or_default()
116 .iter()
117 .map(|e| e.summary.to_lowercase())
118 .collect::<Vec<_>>()
119 .join(" ");
120 in_memory_score_with_text(sym, tokens, &ledger_text)
121}
122
123pub fn in_memory_score_with_text(sym: &crate::Symbol, tokens: &[String], ledger_text: &str) -> u32 {
128 let qname_lower = sym.qname.to_lowercase();
129 let sig_lower = sym.signature.as_deref().unwrap_or("").to_lowercase();
130 let doc_lower = sym.doc.as_deref().unwrap_or("").to_lowercase();
131 let file_lower = sym.file.to_lowercase();
132
133 let mut score: u32 = 0;
134 for token in tokens {
135 if qname_lower.contains(token.as_str()) {
136 score += 4;
137 }
138 if !sig_lower.is_empty() && sig_lower.contains(token.as_str()) {
139 score += 3;
140 }
141 if !doc_lower.is_empty() && doc_lower.contains(token.as_str()) {
142 score += 3;
143 }
144 if !ledger_text.is_empty() && ledger_text.contains(token.as_str()) {
145 score += 2;
146 }
147 if file_lower.contains(token.as_str()) {
148 score += 1;
149 }
150 }
151 score
152}
153
154pub fn glob_match(pattern: &str, path: &str) -> bool {
165 glob_match_parts(
166 &pattern.split('/').collect::<Vec<_>>(),
167 &path.split('/').collect::<Vec<_>>(),
168 )
169}
170
171fn glob_match_parts(pat: &[&str], path: &[&str]) -> bool {
172 match (pat.first(), path.first()) {
173 (None, None) => true,
174 (None, _) | (_, None) => {
175 pat.is_empty() && path.is_empty()
178 || pat.iter().all(|p| *p == "**") && path.is_empty()
179 || pat.is_empty() && path.is_empty()
180 }
181 (Some(&"**"), _) => {
182 glob_match_parts(&pat[1..], path) || glob_match_parts(pat, &path[1..])
184 }
185 (Some(p), Some(s)) => segment_match(p, s) && glob_match_parts(&pat[1..], &path[1..]),
186 }
187}
188
189fn segment_match(pat: &str, seg: &str) -> bool {
191 let parts: Vec<&str> = pat.split('*').collect();
192 if parts.len() == 1 {
193 return pat == seg;
194 }
195 let mut remaining = seg;
196 for (i, part) in parts.iter().enumerate() {
197 if i == 0 {
198 if !remaining.starts_with(part) {
199 return false;
200 }
201 remaining = &remaining[part.len()..];
202 } else if i == parts.len() - 1 {
203 if !remaining.ends_with(part) {
204 return false;
205 }
206 } else {
207 match remaining.find(part) {
208 Some(pos) => remaining = &remaining[pos + part.len()..],
209 None => return false,
210 }
211 }
212 }
213 true
214}
215
216pub fn matches_any_path_glob(globs: &[String], file: &str) -> bool {
218 globs.iter().any(|g| glob_match(g.as_str(), file))
219}
220
221pub fn load_scope_aliases(db_path: &Path) -> HashMap<String, Vec<String>> {
235 let scopes_path = db_path
236 .parent()
237 .unwrap_or(Path::new("."))
238 .join("scopes.toml");
239 let contents = match std::fs::read_to_string(&scopes_path) {
240 Ok(c) => c,
241 Err(_) => return HashMap::new(),
242 };
243 let table: toml::Table = match toml::from_str(&contents) {
244 Ok(t) => t,
245 Err(_) => return HashMap::new(),
246 };
247 table
248 .into_iter()
249 .filter_map(|(k, v)| {
250 let globs = match v {
251 toml::Value::Array(arr) => arr
252 .into_iter()
253 .filter_map(|v| {
254 if let toml::Value::String(s) = v {
255 Some(s)
256 } else {
257 None
258 }
259 })
260 .collect(),
261 toml::Value::String(s) => vec![s],
262 _ => return None,
263 };
264 Some((k, globs))
265 })
266 .collect()
267}
268
269pub fn resolve_scope(scope: &str, db_path: &Path) -> Vec<String> {
272 let aliases = load_scope_aliases(db_path);
273 aliases
274 .get(scope)
275 .cloned()
276 .unwrap_or_else(|| vec![scope.to_string()])
277}
278
279pub fn load_exclude_sets(db_path: &Path) -> HashMap<String, Vec<String>> {
296 let scopes_path = db_path
297 .parent()
298 .unwrap_or(Path::new("."))
299 .join("scopes.toml");
300 let contents = match std::fs::read_to_string(&scopes_path) {
301 Ok(c) => c,
302 Err(_) => return HashMap::new(),
303 };
304 let table: toml::Table = match toml::from_str(&contents) {
305 Ok(t) => t,
306 Err(_) => return HashMap::new(),
307 };
308 let excl = match table.get("exclude_sets") {
309 Some(toml::Value::Table(t)) => t.clone(),
310 _ => return HashMap::new(),
311 };
312 excl.into_iter()
313 .filter_map(|(k, v)| {
314 let globs = match v {
315 toml::Value::Array(arr) => arr
316 .into_iter()
317 .filter_map(|v| {
318 if let toml::Value::String(s) = v {
319 Some(s)
320 } else {
321 None
322 }
323 })
324 .collect(),
325 toml::Value::String(s) => vec![s],
326 _ => return None,
327 };
328 Some((k, globs))
329 })
330 .collect()
331}
332
333pub fn resolve_exclude_set(name: &str, db_path: &Path) -> Vec<String> {
337 load_exclude_sets(db_path).remove(name).unwrap_or_default()
338}
339
340fn apply_paths_filter(
347 engine: &Engine,
348 index_store: &AsgIndexStore,
349 fts: Option<&SearchFtsDb>,
350 paths_filter: &[String],
351 scored: &mut Vec<(f64, String)>,
352) {
353 if paths_filter.is_empty() {
354 return;
355 }
356 let qname_strs: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
358 let resolved = fts
359 .map(|f| f.resolve_qnames_bulk(&qname_strs))
360 .unwrap_or_default();
361 scored.retain(|(_, qname)| {
362 if let Some(rsym) = resolved.get(qname.as_str()) {
363 matches_any_path_glob(paths_filter, &rsym.file)
364 } else {
365 match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
366 Ok(Some(sym)) => matches_any_path_glob(paths_filter, &sym.file),
367 _ => true,
368 }
369 }
370 });
371}
372
373fn apply_exclude_paths_filter(
380 engine: &Engine,
381 index_store: &AsgIndexStore,
382 fts: Option<&SearchFtsDb>,
383 exclude_paths: &[String],
384 scored: &mut Vec<(f64, String)>,
385) {
386 if exclude_paths.is_empty() {
387 return;
388 }
389 let qname_strs: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
390 let resolved = fts
391 .map(|f| f.resolve_qnames_bulk(&qname_strs))
392 .unwrap_or_default();
393 scored.retain(|(_, qname)| {
394 let file = if let Some(rsym) = resolved.get(qname.as_str()) {
395 rsym.file.clone()
396 } else {
397 match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
398 Ok(Some(sym)) => sym.file,
399 _ => return true, }
401 };
402 !matches_any_path_glob(exclude_paths, &file)
403 });
404}
405
406fn apply_exclude_languages_filter(
420 engine: &Engine,
421 index_store: &AsgIndexStore,
422 _fts: Option<&SearchFtsDb>,
423 exclude_languages: &[String],
424 scored: &mut Vec<(f64, String)>,
425) {
426 if exclude_languages.is_empty() {
427 return;
428 }
429 let lower: Vec<String> = exclude_languages.iter().map(|s| s.to_lowercase()).collect();
430 scored.retain(|(_, qname)| {
431 let lang = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
432 Ok(Some(sym)) => sym.language.to_lowercase(),
433 _ => return true, };
435 !lower.iter().any(|excl| excl == &lang)
436 });
437}
438
439fn apply_exclusions(
446 engine: &Engine,
447 index_store: &AsgIndexStore,
448 fts: Option<&SearchFtsDb>,
449 exclude_terms: &[String],
450 scored: &mut Vec<(f64, String)>,
451) {
452 if exclude_terms.is_empty() {
453 return;
454 }
455 let qname_strs: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
457 let resolved = fts
458 .map(|f| f.resolve_qnames_bulk(&qname_strs))
459 .unwrap_or_default();
460 scored.retain(|(_, qname)| {
461 let qname_lower = qname.to_lowercase();
462 let (file_lower, doc_lower, sig_lower) = if let Some(rsym) = resolved.get(qname.as_str()) {
463 (
464 rsym.file.to_lowercase(),
465 rsym.doc.as_deref().unwrap_or("").to_lowercase(),
466 rsym.signature.as_deref().unwrap_or("").to_lowercase(),
467 )
468 } else {
469 match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
470 Ok(Some(s)) => (
471 s.file.to_lowercase(),
472 s.doc.as_deref().unwrap_or("").to_lowercase(),
473 s.signature.as_deref().unwrap_or("").to_lowercase(),
474 ),
475 _ => return true,
476 }
477 };
478 !exclude_terms.iter().any(|excl| {
479 qname_lower.contains(excl.as_str())
480 || file_lower.contains(excl.as_str())
481 || doc_lower.contains(excl.as_str())
482 || sig_lower.contains(excl.as_str())
483 })
484 });
485}
486
487const ANCHOR_SCORE: f64 = 0.5;
494
495const MAX_ANCHORS: usize = 5;
497
498fn ledger_anchor_pass(
507 engine: &Engine,
508 fts: Option<&SearchFtsDb>,
509 tokens: &[String],
510 candidates: &mut Vec<(f64, String)>,
511) {
512 if tokens.is_empty() {
513 return;
514 }
515
516 let existing_qnames: HashSet<String> = candidates.iter().map(|(_, q)| q.clone()).collect();
517
518 if let Some(fts) = fts {
520 if fts.has_data() {
521 let hits = fts.anchor_candidates(tokens, &existing_qnames, MAX_ANCHORS);
522 for (qname, _sym_id) in hits {
523 candidates.push((ANCHOR_SCORE, qname));
524 }
525 return;
526 }
527 }
528
529 let ledger_prefix = format!("{}/ledger", crate::paths::ASD_ROOT);
531 let ledger_tree = match engine.repo.get_tree(&engine.ref_name, &ledger_prefix) {
532 Ok(v) => v,
533 Err(_) => return,
534 };
535
536 let mut matching_sym_ids: Vec<String> = Vec::new();
537 if let serde_json::Value::Object(by_symbol) = ledger_tree {
538 for (sym_id, per_symbol) in by_symbol {
539 if let serde_json::Value::Object(entries_map) = per_symbol {
540 for (_entry_id, v) in entries_map {
541 if let Ok(entry) = serde_json::from_value::<LedgerEntry>(v) {
542 if !matches!(entry.kind, LedgerKind::Invariant | LedgerKind::Hazard) {
543 continue;
544 }
545 let summary_lower = entry.summary.to_lowercase();
546 if tokens.iter().any(|t| summary_lower.contains(t.as_str())) {
547 matching_sym_ids.push(sym_id.clone());
548 break;
549 }
550 }
551 }
552 }
553 }
554 }
555
556 if matching_sym_ids.is_empty() {
557 return;
558 }
559
560 let qname_prefix = format!("{}/index/by-qname", crate::paths::ASD_ROOT);
561 let id_map: HashMap<String, Symbol> =
562 match engine.repo.get_tree(&engine.ref_name, &qname_prefix) {
563 Ok(serde_json::Value::Object(map)) => map
564 .values()
565 .filter_map(|v| serde_json::from_value::<Symbol>(v.clone()).ok())
566 .map(|s| (s.symbol_id.clone(), s))
567 .collect(),
568 _ => HashMap::new(),
569 };
570
571 let mut anchors = 0usize;
572 for sym_id in matching_sym_ids {
573 if anchors >= MAX_ANCHORS {
574 break;
575 }
576 if let Some(sym) = id_map.get(&sym_id) {
577 if !existing_qnames.contains(&sym.qname) {
578 candidates.push((ANCHOR_SCORE, sym.qname.clone()));
579 anchors += 1;
580 }
581 }
582 }
583}
584
585const ASD_PATH_PREFIX: &str = "/asd/v1";
590
591pub fn find_candidates(
603 engine: &Engine,
604 query: &str,
605 tokens: &[String],
606 filters: &FtsFilters,
607 _ledger_store: &AsgLedgerStore,
611 index_store: &AsgIndexStore,
612 depth: usize,
613) -> Vec<(f64, String)> {
614 let fts: Option<&SearchFtsDb> = engine.fts.as_ref();
619 let fts_result = fts
620 .filter(|f| f.has_data())
621 .and_then(|f| f.search(query, filters, depth * 2).ok());
622
623 if let Some(hits) = fts_result {
624 let ledger_cache: HashMap<String, Vec<crate::schema::LedgerEntry>> =
627 HashMap::with_capacity(hits.len());
628 let mut qname_to_sym: HashMap<String, (String, String)> =
630 HashMap::with_capacity(hits.len());
631
632 let mut has_ledger_ids: HashSet<String> = HashSet::new();
635
636 let mut scored: Vec<(f64, String)> = {
637 let mut tmp = Vec::with_capacity(hits.len());
638 for hit in hits {
639 let boost = hybrid_boost(&hit, tokens);
640 let (ledger_boost, ownership_struct_boost) = {
644 let sot = if hit.has_ownership() { 2.0_f64 } else { 0.0 };
645 let text_boost = if hit.ledger_text.is_empty() {
647 0.0
648 } else {
649 let matches = tokens
650 .iter()
651 .filter(|t| hit.ledger_text.contains(t.as_str()))
652 .count() as f64;
653 if matches == 0.0 {
654 0.0
655 } else {
656 let weight = if hit.has_ownership() {
658 3.0
659 } else if hit.has_invariant() {
660 1.5
661 } else if hit.has_hazard() {
662 1.0
663 } else {
664 0.5
665 };
666 matches * weight
667 }
668 };
669 if hit.has_ledger() {
670 has_ledger_ids.insert(hit.symbol_id.clone());
671 }
672 (text_boost, sot)
673 };
674 qname_to_sym.insert(hit.qname.clone(), (hit.symbol_id.clone(), hit.file.clone()));
675 tmp.push((
676 hit.bm25_score + boost + ledger_boost + ownership_struct_boost,
677 hit.qname,
678 ));
679 }
680 tmp
681 };
682 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
683
684 let covered_files: HashSet<String> = scored
691 .iter()
692 .take(depth)
693 .filter_map(|(_, qname)| {
694 let (sym_id, file) = qname_to_sym.get(qname)?;
695 let has_led = has_ledger_ids.contains(sym_id)
698 || ledger_cache.get(sym_id).map_or(false, |e| !e.is_empty());
699 if has_led { Some(file.clone()) } else { None }
700 })
701 .collect();
702
703 const VIEW_STEM_HINTS: &[&str] = &[
704 "view", "ui", "render", "display", "screen", "layout", "widget", "cell", "button",
705 "pad",
706 ];
707 let query_lower = query.to_lowercase();
708 let is_view_query = VIEW_STEM_HINTS.iter().any(|h| query_lower.contains(h));
709 if let Some(fts) = fts {
711 let limit = depth * 2 * tokens.len().max(1);
712 if let Ok(stem_hits) = fts.file_stem_candidates_batch(tokens, filters, limit) {
713 for hit in stem_hits {
714 if !covered_files.contains(&hit.file) {
715 let boost = hybrid_boost(&hit, tokens);
716 let tier = hit.tier;
717 let layer = classify_layer_sym(&hit.file, &hit.qname, tier, &[]);
718 let view_boost = if is_view_query && view_layer_matches(layer, &hit.file) {
735 2.0
736 } else {
737 0.0
738 };
739 qname_to_sym
742 .entry(hit.qname.clone())
743 .or_insert_with(|| (hit.symbol_id.clone(), hit.file.clone()));
744 if hit.has_ledger() {
746 has_ledger_ids.insert(hit.symbol_id.clone());
747 }
748 scored.push((1.0 + boost + view_boost, hit.qname));
749 }
750 }
751 }
752 }
753
754 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
755 scored.dedup_by(|a, b| a.1 == b.1);
756
757 let has_ledger: HashSet<String> = scored
764 .iter()
765 .filter_map(|(_, qname)| {
766 let (sym_id, _) = qname_to_sym.get(qname.as_str())?;
767 let has = has_ledger_ids.contains(sym_id)
769 || ledger_cache.get(sym_id).map_or(false, |e| !e.is_empty());
770 if has { Some(qname.clone()) } else { None }
771 })
772 .collect();
773 scored.sort_by(|a, b| {
774 let a_ledger = has_ledger.contains(&a.1);
775 let b_ledger = has_ledger.contains(&b.1);
776 b_ledger
777 .cmp(&a_ledger)
778 .then_with(|| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal))
779 });
780 let mut seen_files: HashSet<String> = HashSet::new();
781 scored.retain(|(_, qname)| {
782 match qname_to_sym.get(qname.as_str()) {
783 Some((_, file)) => seen_files.insert(file.clone()),
784 None => true, }
786 });
787 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
789 scored.truncate(depth);
790
791 apply_paths_filter(engine, index_store, fts, &filters.paths_filter, &mut scored);
792 apply_exclusions(
793 engine,
794 index_store,
795 fts,
796 &filters.exclude_terms,
797 &mut scored,
798 );
799 apply_exclude_paths_filter(
801 engine,
802 index_store,
803 fts,
804 &filters.exclude_paths,
805 &mut scored,
806 );
807 apply_exclude_languages_filter(
808 engine,
809 index_store,
810 fts,
811 &filters.exclude_languages,
812 &mut scored,
813 );
814
815 ledger_anchor_pass(engine, fts, tokens, &mut scored);
819
820 return scored;
821 }
822
823 fallback_in_memory_search(engine, tokens, filters, index_store, fts, depth)
825}
826
827pub fn in_memory_scored_symbols(
840 engine: &Engine,
841 tokens: &[String],
842 kind_filter: Option<&str>,
843 lang_filter: Option<&str>,
844) -> Vec<(u32, Symbol)> {
845 let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
846 let symbols: Vec<Symbol> = match engine.repo.get_tree(&engine.ref_name, &prefix) {
847 Ok(serde_json::Value::Object(map)) => map
848 .into_iter()
849 .filter_map(|(_, v)| serde_json::from_value(v).ok())
850 .collect(),
851 _ => Vec::new(),
852 };
853
854 let ledger_text: HashMap<String, String> = crate::thinking::all_ledger_buckets(engine)
857 .into_iter()
858 .map(|(symbol_id, entries)| {
859 let text = crate::thinking::live_entries(entries)
860 .iter()
861 .map(|e| e.summary.to_lowercase())
862 .collect::<Vec<_>>()
863 .join(" ");
864 (symbol_id, text)
865 })
866 .collect();
867
868 let mut scored: Vec<(u32, Symbol)> = Vec::new();
869 for sym in symbols {
870 if let Some(k) = kind_filter {
871 if kind_str(&sym.kind) != k {
872 continue;
873 }
874 }
875 if let Some(lang) = lang_filter {
876 if sym.language != lang {
877 continue;
878 }
879 }
880 let text = ledger_text
881 .get(&sym.symbol_id)
882 .map(String::as_str)
883 .unwrap_or("");
884 let s = in_memory_score_with_text(&sym, tokens, text);
885 if s > 0 {
886 scored.push((s, sym));
887 }
888 }
889 scored
890}
891
892fn fallback_in_memory_search(
897 engine: &Engine,
898 tokens: &[String],
899 filters: &FtsFilters,
900 index_store: &AsgIndexStore,
901 fts: Option<&SearchFtsDb>,
902 depth: usize,
903) -> Vec<(f64, String)> {
904 eprintln!("asd: FTS index not populated — falling back to in-memory search");
905
906 let kind_filter = filters.kind.as_deref().map(|k| k.to_lowercase());
907 let lang_filter = filters.language.as_deref();
908
909 let mut scored: Vec<(f64, String)> =
910 in_memory_scored_symbols(engine, tokens, kind_filter.as_deref(), lang_filter)
911 .into_iter()
912 .map(|(s, sym)| (s as f64, sym.qname))
913 .collect();
914 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
915 scored.truncate(depth);
916 apply_paths_filter(engine, index_store, fts, &filters.paths_filter, &mut scored);
917 apply_exclusions(
918 engine,
919 index_store,
920 fts,
921 &filters.exclude_terms,
922 &mut scored,
923 );
924 ledger_anchor_pass(engine, fts, tokens, &mut scored);
925 scored
926}
927
928pub fn explain_match(
934 sym: &Symbol,
935 tokens: &[String],
936 ledger_entries: &[LedgerEntry],
937 is_hot: bool,
938) -> Vec<String> {
939 let qname_lower = sym.qname.to_lowercase();
940 let file_lower = sym.file.to_lowercase();
941 let sig_lower = sym.signature.as_deref().unwrap_or("").to_lowercase();
942 let doc_lower = sym.doc.as_deref().unwrap_or("").to_lowercase();
943
944 let mut reasons: Vec<String> = Vec::new();
945 for token in tokens {
946 let t = token.as_str();
947 if qname_lower.contains(t) {
948 reasons.push(format!("name:{}", token));
949 } else if file_lower.contains(t) {
950 reasons.push(format!("file:{}", token));
951 } else if sig_lower.contains(t) {
952 reasons.push(format!("sig:{}", token));
953 } else if doc_lower.contains(t) {
954 reasons.push(format!("doc:{}", token));
955 }
956 }
957
958 let inv_count = ledger_entries
959 .iter()
960 .filter(|e| matches!(e.kind, LedgerKind::Invariant))
961 .count();
962 let haz_count = ledger_entries
963 .iter()
964 .filter(|e| matches!(e.kind, LedgerKind::Hazard))
965 .count();
966 if inv_count > 0 {
967 reasons.push(format!("invariant-attached:{}", inv_count));
968 }
969 if haz_count > 0 {
970 reasons.push(format!(
971 "ledger:{} hazard{}",
972 haz_count,
973 if haz_count == 1 { "" } else { "s" }
974 ));
975 }
976 let owns: Vec<&str> = ledger_entries
979 .iter()
980 .filter(|e| e.kind == LedgerKind::Ownership)
981 .map(|e| e.summary.as_str())
982 .filter(|s| tokens.iter().any(|t| s.to_lowercase().contains(t.as_str())))
983 .collect();
984 if !owns.is_empty() {
985 reasons.push(format!(
986 "ownership:{}",
987 owns[0]
988 .split_whitespace()
989 .take(4)
990 .collect::<Vec<_>>()
991 .join("-")
992 ));
993 }
994 if is_hot {
995 reasons.push("recent-edit".to_string());
996 }
997
998 reasons
999}
1000
1001pub fn confidence_scores(scores: &[f64]) -> Vec<f64> {
1008 if scores.is_empty() {
1009 return vec![];
1010 }
1011 if scores.len() == 1 {
1012 return vec![1.0];
1013 }
1014 let max = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1015 let min = scores.iter().cloned().fold(f64::INFINITY, f64::min);
1016 let range = max - min;
1017 if range < 1e-9 {
1018 return vec![1.0; scores.len()];
1019 }
1020 scores
1021 .iter()
1022 .map(|&s| 0.1 + 0.9 * (s - min) / range)
1023 .collect()
1024}
1025
1026pub fn result_bucket(
1034 file: &str,
1035 match_reasons: &[String],
1036 has_ledger: bool,
1037 is_hot: bool,
1038) -> &'static str {
1039 use crate::search_fts::symbol_tier;
1040 if symbol_tier(file) == 2 {
1041 return "test-only";
1042 }
1043 let has_name = match_reasons.iter().any(|r| r.starts_with("name:"));
1044 let has_doc_or_file = match_reasons
1045 .iter()
1046 .any(|r| r.starts_with("doc:") || r.starts_with("file:"));
1047 if has_ledger && (has_name || is_hot) {
1048 return "core";
1049 }
1050 if has_ledger || has_name {
1051 return "relevant";
1052 }
1053 if is_hot || has_doc_or_file {
1054 return "peripheral";
1055 }
1056 "noisy"
1057}
1058
1059pub fn confidence_reason(match_reasons: &[String], has_ledger: bool, is_hot: bool) -> String {
1063 let has_name = match_reasons.iter().any(|r| r.starts_with("name:"));
1064 let has_inv = match_reasons
1065 .iter()
1066 .any(|r| r.starts_with("invariant-attached:"));
1067 let has_own = match_reasons.iter().any(|r| r.starts_with("ownership:"));
1068 let has_haz = match_reasons.iter().any(|r| r.starts_with("ledger:"));
1069 let has_sig = match_reasons.iter().any(|r| r.starts_with("sig:"));
1070 let has_doc = match_reasons.iter().any(|r| r.starts_with("doc:"));
1071 let has_file = match_reasons.iter().any(|r| r.starts_with("file:"));
1072
1073 if has_name && has_inv {
1074 "strong: name match + invariant-attached".to_string()
1075 } else if has_name && has_own {
1076 "strong: name match + ownership declaration".to_string()
1077 } else if has_name && has_haz {
1078 "strong: name match + hazard ledger entry".to_string()
1079 } else if has_name && has_ledger {
1080 "strong: name match + ledger entry".to_string()
1081 } else if has_name && is_hot {
1082 "moderate: name match + recently edited".to_string()
1083 } else if has_name {
1084 "moderate: name match only".to_string()
1085 } else if has_ledger && has_sig {
1086 "moderate: signature match + ledger entry".to_string()
1087 } else if has_ledger {
1088 "moderate: ledger entry (indirect match)".to_string()
1089 } else if has_sig {
1090 "weak: signature match only".to_string()
1091 } else if has_doc {
1092 "weak: doc-only match".to_string()
1093 } else if has_file {
1094 "weak: file path match only".to_string()
1095 } else {
1096 "weak: low-signal indirect match".to_string()
1097 }
1098}
1099
1100pub fn detect_ambiguous_tokens(
1108 tokens: &[String],
1109 fts: Option<&SearchFtsDb>,
1110 filters: &FtsFilters,
1111) -> Vec<String> {
1112 const THRESHOLD: usize = 25;
1113 let fts = match fts {
1114 Some(f) if f.has_data() => f,
1115 _ => return vec![],
1116 };
1117 let candidates: Vec<&str> = tokens
1118 .iter()
1119 .filter(|t| !is_stopword(t))
1120 .map(|t| t.as_str())
1121 .collect();
1122 if candidates.is_empty() {
1123 return vec![];
1124 }
1125
1126 let counts = fts
1127 .count_distinct_files_per_token(&candidates, filters.include_tests)
1128 .unwrap_or_default();
1129
1130 counts
1131 .into_iter()
1132 .filter(|(_, cnt)| *cnt > THRESHOLD)
1133 .map(|(tok, _)| tok)
1134 .collect()
1135}
1136
1137pub fn detect_possible_misses(
1144 query: &str,
1145 layers_present: &HashSet<&str>,
1146 result_count: usize,
1147) -> Vec<String> {
1148 let ql = query.to_lowercase();
1149 let mut warnings = Vec::new();
1150
1151 const VIEW_HINTS: &[&str] = &[
1153 "view", " ui", "render", "display", "screen", "layout", "widget", "cell", "button", "pad",
1154 ];
1155 const VIEW_LAYERS: &[&str] = &["view", "ui", "presentation"];
1156 if VIEW_HINTS.iter().any(|h| ql.contains(h))
1157 && !VIEW_LAYERS.iter().any(|l| layers_present.contains(l))
1158 && result_count > 0
1159 {
1160 warnings.push(
1161 "no view-layer symbols found — query suggests UI involvement; \
1162 check --scope or path coverage"
1163 .to_string(),
1164 );
1165 }
1166
1167 const SERVICE_HINTS: &[&str] = &[
1169 "service",
1170 "manager",
1171 "coordinator",
1172 "handler",
1173 "processor",
1174 "store",
1175 "repository",
1176 "repo",
1177 ];
1178 const SERVICE_LAYERS: &[&str] = &["service", "domain", "core", "infrastructure"];
1179 if SERVICE_HINTS.iter().any(|h| ql.contains(h))
1180 && result_count > 0
1181 && SERVICE_LAYERS.iter().all(|l| !layers_present.contains(l))
1182 && (layers_present.contains("view") || layers_present.contains("ui"))
1183 {
1184 warnings.push(
1185 "results are all UI-layer — query suggests service/domain involvement; \
1186 service layer symbols may not be indexed"
1187 .to_string(),
1188 );
1189 }
1190
1191 const DATA_HINTS: &[&str] = &[
1193 "model",
1194 "entity",
1195 "persist",
1196 "database",
1197 "migration",
1198 "schema",
1199 "table",
1200 ];
1201 const DATA_LAYERS: &[&str] = &["persistence", "data", "infrastructure", "domain"];
1202 if DATA_HINTS.iter().any(|h| ql.contains(h))
1203 && result_count > 0
1204 && DATA_LAYERS.iter().all(|l| !layers_present.contains(l))
1205 {
1206 warnings.push(
1207 "no persistence/data-layer symbols found — query suggests data model involvement"
1208 .to_string(),
1209 );
1210 }
1211
1212 const SCHEDULER_HINTS: &[&str] = &[
1214 "scheduler",
1215 "schedule",
1216 "clock",
1217 "timer",
1218 "tick",
1219 "dispatch",
1220 "queue",
1221 "async",
1222 ];
1223 if SCHEDULER_HINTS.iter().any(|h| ql.contains(h))
1224 && result_count > 0
1225 && !layers_present.contains("scheduler")
1226 {
1227 warnings.push(
1228 "scheduler layer absent from results — query implies timing/dispatch involvement; \
1229 check that scheduler symbols are indexed"
1230 .to_string(),
1231 );
1232 }
1233
1234 const ENGINE_HINTS: &[&str] = &[
1235 "engine",
1236 "audio",
1237 "video",
1238 "render",
1239 "pipeline",
1240 "runtime",
1241 "processor",
1242 ];
1243 if ENGINE_HINTS.iter().any(|h| ql.contains(h))
1244 && result_count > 0
1245 && !layers_present.contains("engine")
1246 && !layers_present.contains("core_model")
1247 {
1248 warnings.push(
1249 "engine/core layer absent from results — query implies runtime/processing involvement; \
1250 consider broadening scope or adding the engine layer to the index"
1251 .to_string(),
1252 );
1253 }
1254
1255 const NETWORK_HINTS: &[&str] = &[
1256 "network", "api", "http", "request", "response", "endpoint", "fetch", "upload", "download",
1257 ];
1258 if NETWORK_HINTS.iter().any(|h| ql.contains(h))
1259 && result_count > 0
1260 && !layers_present.contains("network")
1261 && !layers_present.contains("infrastructure")
1262 {
1263 warnings.push(
1264 "network layer absent from results — query implies API/network involvement; \
1265 check that network client symbols are indexed"
1266 .to_string(),
1267 );
1268 }
1269
1270 if result_count > 0 && result_count < 3 && ql.split_whitespace().count() >= 3 {
1272 warnings.push(format!(
1273 "only {} result{} for a multi-term query — symbols may not be indexed yet",
1274 result_count,
1275 if result_count == 1 { "" } else { "s" }
1276 ));
1277 }
1278
1279 warnings
1280}
1281
1282pub fn suggest_scoped_queries(
1287 original_tokens: &[String],
1288 ambiguous_terms: &[String],
1289 top_qnames: &[String],
1290) -> Vec<String> {
1291 if ambiguous_terms.is_empty() || top_qnames.is_empty() {
1292 return vec![];
1293 }
1294
1295 let amb_set: HashSet<&str> = ambiguous_terms.iter().map(|s| s.as_str()).collect();
1296 let specific_tokens: Vec<&str> = original_tokens
1297 .iter()
1298 .filter(|t| !amb_set.contains(t.as_str()))
1299 .map(|s| s.as_str())
1300 .collect();
1301
1302 let mut cooccur: HashMap<String, usize> = HashMap::new();
1304 for qname in top_qnames {
1305 let last = qname
1306 .rsplit(|c: char| c == '.' || c == ':' || c == '/')
1307 .next()
1308 .unwrap_or(qname);
1309 let mut cur = String::new();
1311 let mut words: Vec<String> = Vec::new();
1312 for ch in last.chars() {
1313 if ch.is_uppercase() && !cur.is_empty() {
1314 words.push(cur.clone());
1315 cur.clear();
1316 }
1317 cur.push(ch.to_lowercase().next().unwrap_or(ch));
1318 }
1319 if !cur.is_empty() {
1320 words.push(cur);
1321 }
1322 let words: Vec<String> = words
1324 .iter()
1325 .flat_map(|w| w.split('_').map(|s| s.to_string()).collect::<Vec<_>>())
1326 .filter(|w| w.len() > 2 && !is_stopword(w) && !amb_set.contains(w.as_str()))
1327 .collect();
1328 for w in words {
1329 if !original_tokens.iter().any(|t| t == &w) {
1330 *cooccur.entry(w).or_default() += 1;
1331 }
1332 }
1333 }
1334
1335 let mut ranked: Vec<(usize, String)> = cooccur.into_iter().map(|(w, c)| (c, w)).collect();
1337 ranked.sort_by(|a, b| b.0.cmp(&a.0));
1338
1339 let mut suggestions = Vec::new();
1340 for (_, narrowing_term) in ranked.iter().take(3) {
1341 let mut parts: Vec<&str> = specific_tokens.clone();
1342 parts.push(narrowing_term.as_str());
1343 let query = parts.join(" ");
1344 if !suggestions.contains(&query) {
1345 suggestions.push(query);
1346 }
1347 }
1348 suggestions
1349}
1350
1351pub fn detect_confidence_warnings(
1356 tokens: &[String],
1357 result_count: usize,
1358 ambiguous_terms: &[String],
1359 fts: Option<&SearchFtsDb>,
1360) -> Vec<serde_json::Value> {
1361 use serde_json::json;
1362 let mut warnings = Vec::new();
1363
1364 for term in ambiguous_terms {
1366 warnings.push(json!({
1367 "kind": "ambiguous_term",
1368 "term": term,
1369 "message": format!(
1370 "\"{}\" matches too many unrelated files — add more specific terms to narrow results",
1371 term
1372 ),
1373 }));
1374 }
1375
1376 if result_count < 3 && ambiguous_terms.is_empty() && !tokens.is_empty() {
1378 let total = fts
1380 .filter(|f| f.has_data())
1381 .and_then(|f| f.search("", &FtsFilters::default(), 1).ok())
1382 .is_some();
1383 if total {
1384 warnings.push(json!({
1385 "kind": "sparse_index",
1386 "message": "very few results — the index may not cover this area yet; \
1387 try `asd index` or broaden the query",
1388 }));
1389 }
1390 }
1391
1392 warnings
1393}
1394
1395pub fn suggest_better_queries(tokens: &[String], query: &str) -> Vec<String> {
1399 if tokens.is_empty() {
1400 return vec![];
1401 }
1402 let mut suggestions = Vec::new();
1403
1404 let stopword_count = tokens.iter().filter(|t| is_stopword(t)).count();
1406 let meaningful = tokens.len() - stopword_count;
1407
1408 if meaningful <= 1 {
1409 let tok = tokens
1410 .iter()
1411 .find(|t| !is_stopword(t))
1412 .map(|s| s.as_str())
1413 .unwrap_or(query);
1414 suggestions.push(format!(
1415 "try a more specific phrase, e.g. \"{} <subsystem>\" or \"{} <action>\"",
1416 tok, tok
1417 ));
1418 }
1419
1420 if tokens.len() >= 3 && stopword_count * 2 > tokens.len() {
1422 suggestions.push(
1423 "query contains many common words — focus on domain nouns and verbs for better precision"
1424 .to_string(),
1425 );
1426 }
1427
1428 const BROAD_TERMS: &[&str] = &[
1430 "update", "get", "set", "handle", "process", "run", "execute", "init", "start", "stop",
1431 "load", "save", "create", "delete", "state", "data", "model", "manager", "service", "util",
1432 "helper", "config",
1433 ];
1434 for tok in tokens {
1435 if BROAD_TERMS.contains(&tok.as_str()) && tokens.len() == 1 {
1436 suggestions.push(format!(
1437 "\"{}\" is very broad — combine with a domain term, e.g. \"{} playhead\" or \"{} session\"",
1438 tok, tok, tok
1439 ));
1440 }
1441 }
1442
1443 suggestions.dedup();
1444 suggestions
1445}
1446
1447fn fb_query_tokens(q: &str) -> std::collections::HashSet<String> {
1449 q.split(|c: char| !c.is_alphabetic())
1450 .filter(|t| t.len() > 2)
1451 .map(|t| t.to_lowercase())
1452 .filter(|t| !is_stopword(t))
1453 .collect()
1454}
1455
1456fn name_tokens_from_qname(qname: &str) -> HashSet<String> {
1460 let last = qname
1461 .rsplit(|c| c == ':' || c == '.')
1462 .next()
1463 .unwrap_or(qname);
1464 let mut tokens: Vec<String> = Vec::new();
1466 let mut cur = String::new();
1467 for ch in last.chars() {
1468 if ch.is_uppercase() && !cur.is_empty() {
1469 tokens.push(std::mem::take(&mut cur));
1470 }
1471 cur.push(ch.to_lowercase().next().unwrap_or(ch));
1472 }
1473 if !cur.is_empty() {
1474 tokens.push(cur);
1475 }
1476 tokens
1478 .iter()
1479 .flat_map(|t| t.split('_'))
1480 .filter(|t| t.len() > 2 && !is_stopword(t))
1481 .map(|t| t.to_string())
1482 .collect()
1483}
1484
1485fn query_family_matches(
1490 current_query_tokens: &std::collections::HashSet<String>,
1491 fb_query: &str,
1492) -> bool {
1493 if fb_query.is_empty() {
1494 return true;
1495 }
1496 let fb_tokens = fb_query_tokens(fb_query);
1497 if fb_tokens.is_empty() {
1498 return true;
1499 }
1500 fb_tokens.iter().any(|t| current_query_tokens.contains(t))
1502}
1503
1504#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
1509pub struct FeedbackMetrics {
1510 pub entries_applied: usize,
1512 pub suppressed: usize,
1514 pub preserved_useful_siblings: usize,
1516 pub boosted: usize,
1518 pub recurring_fp_suppressed: usize,
1520 #[serde(default)]
1525 pub constraint_penalties_applied: usize,
1526 #[serde(default)]
1529 pub task_bias_boosted: usize,
1530 pub rules_applied: Vec<String>,
1535}
1536
1537pub fn read_active_task_scope(db_path_parent: Option<&std::path::Path>) -> Option<Vec<String>> {
1559 let env_raw = std::env::var("CTX_ACTIVE_TASK").ok();
1560 read_active_task_scope_from(env_raw.as_deref(), db_path_parent)
1561}
1562
1563pub fn read_active_task_scope_from(
1568 env_raw: Option<&str>,
1569 db_path_parent: Option<&std::path::Path>,
1570) -> Option<Vec<String>> {
1571 let raw: String = env_raw.map(|s| s.to_string()).or_else(|| {
1572 let p = db_path_parent?
1573 .join(".asd")
1574 .join("cache")
1575 .join("active-task.json");
1576 std::fs::read_to_string(p).ok()
1577 })?;
1578 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
1579 let arr = v.get("scope")?.as_array()?;
1580 let scope: Vec<String> = arr
1581 .iter()
1582 .filter_map(|s| s.as_str().map(|s| s.to_string()))
1583 .filter(|s| !s.is_empty())
1584 .collect();
1585 if scope.is_empty() { None } else { Some(scope) }
1586}
1587
1588pub fn apply_task_bias(
1594 engine: &Engine,
1595 index_store: &AsgIndexStore,
1596 scored: &mut Vec<(f64, String)>,
1597 scope: &[String],
1598 boost: f64,
1599) -> usize {
1600 if scope.is_empty() {
1601 return 0;
1602 }
1603
1604 let qnames_owned: Vec<String> = scored
1609 .iter()
1610 .filter(|(s, _)| s.is_finite())
1611 .map(|(_, q)| q.clone())
1612 .collect();
1613 let file_by_qname: std::collections::HashMap<String, String> =
1614 if let Some(fts) = engine.fts.as_ref() {
1615 let qnames_ref: Vec<&str> = qnames_owned.iter().map(|s| s.as_str()).collect();
1616 fts.files_for_qnames(&qnames_ref, &engine.ref_name)
1617 } else {
1618 std::collections::HashMap::new()
1619 };
1620
1621 let mut boosted = 0;
1622 for (score, qname) in scored.iter_mut() {
1623 if !score.is_finite() {
1624 continue;
1625 }
1626 let file = if let Some(f) = file_by_qname.get(qname) {
1628 f.clone()
1629 } else {
1630 match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
1631 Ok(Some(s)) => s.file,
1632 _ => continue,
1633 }
1634 };
1635 if scope.iter().any(|g| glob_match(g, &file)) {
1636 *score += boost;
1637 boosted += 1;
1638 }
1639 }
1640 boosted
1641}
1642
1643fn constraint_scope_from_body(body: Option<&str>) -> Option<Vec<String>> {
1649 let raw = body?;
1650 let v: serde_json::Value = serde_json::from_str(raw).ok()?;
1651 let arr = v.get("scope")?.as_array()?;
1652 let globs: Vec<String> = arr
1653 .iter()
1654 .filter_map(|g| g.as_str().map(String::from))
1655 .filter(|g| !g.is_empty())
1656 .collect();
1657 if globs.is_empty() { None } else { Some(globs) }
1658}
1659
1660pub fn apply_constraint_penalties(
1671 engine: &Engine,
1672 index_store: &AsgIndexStore,
1673 scored: &mut Vec<(f64, String)>,
1674) -> usize {
1675 if let Some(fts) = engine.fts.as_ref() {
1681 if let Ok(pairs) = fts.symbols_with_constraint_penalties_scoped(&engine.ref_name) {
1682 if !pairs.is_empty() {
1683 use std::collections::HashMap;
1688 let mut by_sym: HashMap<String, Vec<Option<Vec<String>>>> = HashMap::new();
1689 for (sid, scope) in pairs {
1690 by_sym.entry(sid).or_default().push(scope);
1691 }
1692 let mut suppressed = 0;
1693 for (score, qname) in scored.iter_mut() {
1694 if *score == f64::NEG_INFINITY {
1695 continue;
1696 }
1697 let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
1698 Ok(Some(s)) => s,
1699 _ => continue,
1700 };
1701 if let Some(entries) = by_sym.get(&sym.symbol_id) {
1702 let hit = entries.iter().any(|scope_opt| match scope_opt {
1703 None => true, Some(globs) => globs.iter().any(|g| glob_match(g, &sym.file)),
1705 });
1706 if hit {
1707 *score = f64::NEG_INFINITY;
1708 suppressed += 1;
1709 }
1710 }
1711 }
1712 return suppressed;
1713 }
1714 return 0;
1716 }
1717 }
1718
1719 let ledger_store = crate::ledger::AsgLedgerStore::from_engine(engine);
1722 let mut suppressed = 0;
1723 for (score, qname) in scored.iter_mut() {
1724 if *score == f64::NEG_INFINITY {
1725 continue;
1726 }
1727 let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
1728 Ok(Some(s)) => s,
1729 _ => continue,
1730 };
1731 let entries = match ledger_store.list_entries(&engine.ref_name, &sym.symbol_id) {
1732 Ok(e) => e,
1733 Err(_) => continue,
1734 };
1735 let hit = entries.iter().any(|entry| {
1736 let kind_matches = matches!(
1737 entry.kind,
1738 crate::schema::LedgerKind::Constraint | crate::schema::LedgerKind::Decision
1739 );
1740 if !kind_matches {
1741 return false;
1742 }
1743 let role_is_penalty = entry
1744 .role
1745 .as_deref()
1746 .and_then(crate::schema::RoleTag::from_str)
1747 .map(|r| r.is_penalty_role())
1748 .unwrap_or(false);
1749 if !role_is_penalty {
1750 return false;
1751 }
1752 match constraint_scope_from_body(entry.body.as_deref()) {
1755 None => true, Some(globs) => globs.iter().any(|g| glob_match(g, &sym.file)),
1757 }
1758 });
1759 if hit {
1760 *score = f64::NEG_INFINITY;
1761 suppressed += 1;
1762 }
1763 }
1764 suppressed
1765}
1766
1767pub fn apply_feedback_adjustments(
1768 engine: &Engine,
1769 index_store: &AsgIndexStore,
1770 query: &str,
1771 scored: &mut Vec<(f64, String)>,
1772 feedback_entries: &[(
1777 String,
1778 String,
1779 crate::schema::FeedbackVerdict,
1780 chrono::DateTime<chrono::Utc>,
1781 )],
1782) -> FeedbackMetrics {
1783 let task_bias_boosted = {
1787 let parent = engine
1788 .db_path
1789 .as_ref()
1790 .and_then(|p| p.parent())
1791 .map(|p| p.to_path_buf());
1792 let scope = read_active_task_scope(parent.as_deref()).unwrap_or_default();
1793 if scope.is_empty() {
1794 0
1795 } else {
1796 apply_task_bias(engine, index_store, scored, &scope, 1.0)
1797 }
1798 };
1799
1800 let constraint_penalties_applied = apply_constraint_penalties(engine, index_store, scored);
1803
1804 if feedback_entries.is_empty() {
1805 let mut m = FeedbackMetrics::default();
1806 m.constraint_penalties_applied = constraint_penalties_applied;
1807 m.task_bias_boosted = task_bias_boosted;
1808 if constraint_penalties_applied > 0 {
1809 m.rules_applied.push("constraint_penalty".to_string());
1810 }
1811 if task_bias_boosted > 0 {
1812 m.rules_applied.push("task_bias".to_string());
1813 }
1814 return m;
1815 }
1816 let query_norm = query.to_lowercase();
1817 let current_tokens: std::collections::HashSet<String> = fb_query_tokens(&query_norm);
1818 let current_tokens_vec: Vec<&str> = current_tokens.iter().map(|s| s.as_str()).collect();
1819
1820 let entries_applied = feedback_entries
1822 .iter()
1823 .filter(|(_, fb_q, _, _)| query_family_matches(¤t_tokens, fb_q))
1824 .count();
1825 let mut preserved_useful_siblings: usize = 0;
1826 let mut boosted: usize = 0;
1827 let mut recurring_fp_suppressed: usize = 0;
1828 let mut rules_seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1829 let mut rules_applied: Vec<String> = Vec::new();
1830 macro_rules! record_rule {
1831 ($name:expr) => {
1832 if rules_seen.insert($name) {
1833 rules_applied.push($name.to_string());
1834 }
1835 };
1836 }
1837
1838 let mut noisy_queries_by_sym: std::collections::HashMap<&str, Vec<&str>> =
1840 std::collections::HashMap::new();
1841 for (sym_id, fb_query, verdict, _created_at) in feedback_entries {
1842 if matches!(
1843 verdict,
1844 crate::schema::FeedbackVerdict::Noisy | crate::schema::FeedbackVerdict::WrongLayer
1845 ) {
1846 noisy_queries_by_sym
1847 .entry(sym_id.as_str())
1848 .or_default()
1849 .push(fb_query.as_str());
1850 }
1851 }
1852 let recurring_fp: std::collections::HashMap<&str, Vec<String>> = noisy_queries_by_sym
1853 .into_iter()
1854 .filter(|(_, queries)| queries.len() >= 2)
1855 .map(|(sym_id, queries)| {
1856 let mut token_counts: std::collections::HashMap<String, usize> =
1857 std::collections::HashMap::new();
1858 for q in &queries {
1859 let tokens = fb_query_tokens(q);
1860 for t in tokens {
1861 *token_counts.entry(t).or_default() += 1;
1862 }
1863 }
1864 let suppression_tokens: Vec<String> = token_counts
1865 .into_iter()
1866 .filter(|(_, count)| *count >= 2)
1867 .map(|(t, _)| t)
1868 .collect();
1869 (sym_id, suppression_tokens)
1870 })
1871 .collect();
1872
1873 struct NoisySymInfo {
1876 sym_id: String,
1877 name_tokens: HashSet<String>,
1878 }
1879 let mut noisy_file_syms: HashMap<String, Vec<NoisySymInfo>> = HashMap::new();
1880 {
1881 let noisy_ids: Vec<&str> = feedback_entries
1882 .iter()
1883 .filter(|(_, fb_query, verdict, _)| {
1884 matches!(
1885 verdict,
1886 crate::schema::FeedbackVerdict::Noisy
1887 | crate::schema::FeedbackVerdict::WrongLayer
1888 ) && query_family_matches(¤t_tokens, fb_query)
1889 })
1890 .map(|(sym_id, _, _, _)| sym_id.as_str())
1891 .collect();
1892 if !noisy_ids.is_empty() {
1893 let resolved = engine
1895 .fts
1896 .as_ref()
1897 .map(|fts| fts.resolve_symbol_ids_bulk(&noisy_ids))
1898 .unwrap_or_default();
1899 if !resolved.is_empty() {
1900 for (sym_id_str, rsym) in &resolved {
1901 let name_tokens = name_tokens_from_qname(&rsym.qname);
1902 noisy_file_syms
1903 .entry(rsym.file.clone())
1904 .or_default()
1905 .push(NoisySymInfo {
1906 sym_id: sym_id_str.clone(),
1907 name_tokens,
1908 });
1909 }
1910 } else {
1911 let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
1913 if let Ok(serde_json::Value::Object(map)) =
1914 engine.repo.get_tree(&engine.ref_name, &prefix)
1915 {
1916 let noisy_set: HashSet<&str> = noisy_ids.into_iter().collect();
1917 for sym_val in map.values() {
1918 if let Ok(sym) = serde_json::from_value::<Symbol>(sym_val.clone()) {
1919 if noisy_set.contains(sym.symbol_id.as_str()) {
1920 let name_tokens = name_tokens_from_qname(&sym.qname);
1921 noisy_file_syms.entry(sym.file.clone()).or_default().push(
1922 NoisySymInfo {
1923 sym_id: sym.symbol_id,
1924 name_tokens,
1925 },
1926 );
1927 }
1928 }
1929 }
1930 }
1931 }
1932 }
1933 }
1934
1935 let scored_qnames: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
1937 let resolved_map = engine
1938 .fts
1939 .as_ref()
1940 .map(|fts| fts.resolve_qnames_bulk(&scored_qnames))
1941 .unwrap_or_default();
1942
1943 for (score, qname) in scored.iter_mut() {
1945 let (sym_file, sym_symbol_id, sym_kind) =
1947 if let Some(rsym) = resolved_map.get(qname.as_str()) {
1948 (rsym.file.clone(), rsym.symbol_id.clone(), rsym.kind.clone())
1949 } else {
1950 match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
1951 Ok(Some(s)) => (s.file, s.symbol_id, format!("{:?}", s.kind).to_lowercase()),
1952 _ => continue,
1953 }
1954 };
1955 let sym_id = &sym_symbol_id;
1956 let sym_kind_enum = match sym_kind.as_str() {
1958 "class" => SymbolKind::Class,
1959 "module" => SymbolKind::Module,
1960 _ => SymbolKind::Function,
1961 };
1962
1963 if let Some(noisy_syms) = noisy_file_syms.get(&sym_file) {
1973 let is_container = matches!(sym_kind_enum, SymbolKind::Class | SymbolKind::Module);
1974 let is_the_noisy_sym = noisy_syms.iter().any(|ns| ns.sym_id == *sym_id);
1975 if !is_container && !is_the_noisy_sym {
1976 let has_useful = feedback_entries.iter().any(|(fid, fq, v, _)| {
1977 fid == sym_id
1978 && matches!(v, crate::schema::FeedbackVerdict::Useful)
1979 && query_family_matches(¤t_tokens, fq)
1980 });
1981 if !has_useful {
1982 let sibling_tokens = name_tokens_from_qname(qname);
1986 let name_related = noisy_syms.iter().any(|ns| {
1987 ns.sym_id != *sym_id
1988 && sibling_tokens.iter().any(|t| ns.name_tokens.contains(t))
1989 });
1990 let query_relevant = sibling_tokens.iter().any(|t| current_tokens.contains(t));
1993 if name_related && !query_relevant {
1994 *score = f64::NEG_INFINITY;
1995 record_rule!("same_file_sibling_suppression");
1996 continue;
1997 } else {
1998 preserved_useful_siblings += 1;
1999 }
2000 } else {
2001 preserved_useful_siblings += 1;
2002 }
2003 }
2004 }
2005
2006 let now_for_decay = chrono::Utc::now();
2013 for (fb_symbol_id, fb_query, verdict, fb_created_at) in feedback_entries {
2014 if fb_symbol_id != sym_id {
2015 continue;
2016 }
2017 if !query_family_matches(¤t_tokens, fb_query) {
2019 continue;
2020 }
2021 match verdict {
2022 crate::schema::FeedbackVerdict::Useful => {
2023 let decay = crate::feedback::decay_for_entry(
2031 *fb_created_at,
2032 now_for_decay,
2033 crate::feedback::DEFAULT_FEEDBACK_HALF_LIFE_DAYS,
2034 );
2035 *score += 1.5 * decay;
2036 boosted += 1;
2037 record_rule!("useful_boost");
2038 }
2039 crate::schema::FeedbackVerdict::Noisy
2040 | crate::schema::FeedbackVerdict::WrongLayer => {
2041 *score = f64::NEG_INFINITY;
2042 record_rule!("exact_noisy");
2043 }
2044 crate::schema::FeedbackVerdict::AlreadyCovered => {
2045 *score = f64::NEG_INFINITY;
2048 record_rule!("already_covered");
2049 }
2050 crate::schema::FeedbackVerdict::DiagnosticOnly => {
2051 *score = f64::NEG_INFINITY;
2056 record_rule!("diagnostic_only");
2057 }
2058 crate::schema::FeedbackVerdict::Missing => {}
2059 }
2060 }
2061
2062 if score.is_finite() {
2064 if let Some(sup_tokens) = recurring_fp.get(sym_id.as_str()) {
2065 if current_tokens_vec
2066 .iter()
2067 .any(|qt| sup_tokens.iter().any(|st| st == *qt))
2068 {
2069 *score = f64::NEG_INFINITY;
2070 recurring_fp_suppressed += 1;
2071 record_rule!("recurring_fp");
2072 }
2073 }
2074 }
2075 }
2076 let before = scored.len();
2077 scored.retain(|(s, _)| s.is_finite());
2078 let suppressed = before - scored.len();
2079 if constraint_penalties_applied > 0 && !rules_applied.iter().any(|r| r == "constraint_penalty")
2080 {
2081 rules_applied.push("constraint_penalty".to_string());
2082 }
2083 if task_bias_boosted > 0 && !rules_applied.iter().any(|r| r == "task_bias") {
2084 rules_applied.push("task_bias".to_string());
2085 }
2086 FeedbackMetrics {
2087 entries_applied,
2088 suppressed,
2089 preserved_useful_siblings,
2090 boosted,
2091 recurring_fp_suppressed,
2092 constraint_penalties_applied,
2093 task_bias_boosted,
2094 rules_applied,
2095 }
2096}
2097
2098#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2100pub struct FeedbackImpact {
2101 pub verdict: String,
2103 pub matched_query: String,
2105 pub author: String,
2107}
2108
2109pub fn explain_feedback_impacts(
2115 engine: &Engine,
2116 index_store: &AsgIndexStore,
2117 query: &str,
2118 qnames: &[String],
2119 feedback_entries: &[crate::schema::FeedbackEntry],
2120) -> HashMap<String, FeedbackImpact> {
2121 let mut impacts: HashMap<String, FeedbackImpact> = HashMap::new();
2122 if feedback_entries.is_empty() || qnames.is_empty() {
2123 return impacts;
2124 }
2125 let query_norm = query.to_lowercase();
2126 let current_tokens = fb_query_tokens(&query_norm);
2127
2128 struct NoisyFileEntry {
2130 sym_id: String,
2131 name_tokens: HashSet<String>,
2132 }
2133 let mut noisy_file_syms: HashMap<String, Vec<NoisyFileEntry>> = HashMap::new();
2134 {
2135 let noisy_ids: Vec<&str> = feedback_entries
2136 .iter()
2137 .filter(|e| {
2138 matches!(
2139 e.verdict,
2140 crate::schema::FeedbackVerdict::Noisy
2141 | crate::schema::FeedbackVerdict::WrongLayer
2142 ) && query_family_matches(¤t_tokens, &e.query)
2143 })
2144 .map(|e| e.symbol_id.as_str())
2145 .collect();
2146 if !noisy_ids.is_empty() {
2147 let resolved = engine
2148 .fts
2149 .as_ref()
2150 .map(|fts| fts.resolve_symbol_ids_bulk(&noisy_ids))
2151 .unwrap_or_default();
2152 if !resolved.is_empty() {
2153 for (sym_id_str, rsym) in &resolved {
2154 noisy_file_syms
2155 .entry(rsym.file.clone())
2156 .or_default()
2157 .push(NoisyFileEntry {
2158 sym_id: sym_id_str.clone(),
2159 name_tokens: name_tokens_from_qname(&rsym.qname),
2160 });
2161 }
2162 } else {
2163 let noisy_set: HashSet<&str> = noisy_ids.into_iter().collect();
2165 let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
2166 if let Ok(serde_json::Value::Object(map)) =
2167 engine.repo.get_tree(&engine.ref_name, &prefix)
2168 {
2169 for sym_val in map.values() {
2170 if let Ok(sym) = serde_json::from_value::<Symbol>(sym_val.clone()) {
2171 if noisy_set.contains(sym.symbol_id.as_str()) {
2172 noisy_file_syms.entry(sym.file.clone()).or_default().push(
2173 NoisyFileEntry {
2174 sym_id: sym.symbol_id,
2175 name_tokens: name_tokens_from_qname(&sym.qname),
2176 },
2177 );
2178 }
2179 }
2180 }
2181 }
2182 }
2183 }
2184 }
2185
2186 let qname_strs: Vec<&str> = qnames.iter().map(|q| q.as_str()).collect();
2188 let resolved_map = engine
2189 .fts
2190 .as_ref()
2191 .map(|fts| fts.resolve_qnames_bulk(&qname_strs))
2192 .unwrap_or_default();
2193
2194 for qname in qnames {
2195 let (sym_id_owned, sym_file, sym_kind_enum) =
2196 if let Some(rsym) = resolved_map.get(qname.as_str()) {
2197 let kind = match rsym.kind.as_str() {
2198 "class" => SymbolKind::Class,
2199 "module" => SymbolKind::Module,
2200 _ => SymbolKind::Function,
2201 };
2202 (rsym.symbol_id.clone(), rsym.file.clone(), kind)
2203 } else {
2204 match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
2205 Ok(Some(s)) => {
2206 let k = s.kind.clone();
2207 (s.symbol_id, s.file, k)
2208 }
2209 _ => continue,
2210 }
2211 };
2212 let sym_id = &sym_id_owned;
2213
2214 let mut found = false;
2216 for entry in feedback_entries {
2217 if entry.symbol_id != *sym_id {
2218 continue;
2219 }
2220 if !query_family_matches(¤t_tokens, &entry.query) {
2221 continue;
2222 }
2223 let v_str = entry.verdict.as_str();
2224 impacts.insert(
2225 qname.clone(),
2226 FeedbackImpact {
2227 verdict: v_str.to_string(),
2228 matched_query: entry.query.clone(),
2229 author: entry.author.clone(),
2230 },
2231 );
2232 found = true;
2233 break;
2234 }
2235 if found {
2236 continue;
2237 }
2238
2239 if let Some(noisy_syms) = noisy_file_syms.get(&sym_file) {
2241 if !matches!(sym_kind_enum, SymbolKind::Class | SymbolKind::Module) {
2242 let candidate_tokens = name_tokens_from_qname(qname);
2243 let overlaps = noisy_syms.iter().any(|ns| {
2244 ns.sym_id != *sym_id
2245 && candidate_tokens.iter().any(|t| ns.name_tokens.contains(t))
2246 });
2247 if overlaps {
2248 if let Some(entry) = feedback_entries.iter().find(|e| {
2249 noisy_syms.iter().any(|ns| ns.sym_id == e.symbol_id)
2250 && matches!(
2251 e.verdict,
2252 crate::schema::FeedbackVerdict::Noisy
2253 | crate::schema::FeedbackVerdict::WrongLayer
2254 )
2255 && query_family_matches(¤t_tokens, &e.query)
2256 }) {
2257 impacts.insert(
2258 qname.clone(),
2259 FeedbackImpact {
2260 verdict: "sibling_suppressed".to_string(),
2261 matched_query: entry.query.clone(),
2262 author: entry.author.clone(),
2263 },
2264 );
2265 }
2266 }
2267 }
2268 }
2269 }
2270 impacts
2271}
2272
2273pub fn apply_file_scope_feedback(
2282 engine: &Engine,
2283 index_store: &AsgIndexStore,
2284 query: &str,
2285 scored: &mut Vec<(f64, String)>,
2286 file_scope_entries: &[(
2290 String,
2291 crate::schema::FeedbackVerdict,
2292 String,
2293 chrono::DateTime<chrono::Utc>,
2294 )],
2295) {
2296 if file_scope_entries.is_empty() {
2297 return;
2298 }
2299 let query_norm = query.to_lowercase();
2300 let current_tokens = fb_query_tokens(&query_norm);
2301
2302 let qname_strs: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
2304 let resolved_map = engine
2305 .fts
2306 .as_ref()
2307 .map(|fts| fts.resolve_qnames_bulk(&qname_strs))
2308 .unwrap_or_default();
2309
2310 let now_for_decay = chrono::Utc::now();
2313 for (score, qname) in scored.iter_mut() {
2314 if !score.is_finite() {
2315 continue;
2316 }
2317 let file = if let Some(rsym) = resolved_map.get(qname.as_str()) {
2318 rsym.file.clone()
2319 } else {
2320 match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
2321 Ok(Some(s)) => s.file,
2322 _ => continue,
2323 }
2324 };
2325 for (file_glob, verdict, entry_query, fb_created_at) in file_scope_entries {
2326 if !query_family_matches(¤t_tokens, entry_query) {
2327 continue;
2328 }
2329 if !glob_match(file_glob, &file) {
2330 continue;
2331 }
2332 match verdict {
2337 crate::schema::FeedbackVerdict::Useful => {
2338 let decay = crate::feedback::decay_for_entry(
2340 *fb_created_at,
2341 now_for_decay,
2342 crate::feedback::DEFAULT_FEEDBACK_HALF_LIFE_DAYS,
2343 );
2344 *score += 1.5 * decay;
2345 }
2346 crate::schema::FeedbackVerdict::Missing => {}
2347 v if v.is_suppression() => {
2348 *score = f64::NEG_INFINITY;
2349 break;
2350 }
2351 _ => {}
2352 }
2353 }
2354 }
2355 scored.retain(|(s, _)| s.is_finite());
2356}
2357
2358#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2367#[serde(tag = "code", rename_all = "snake_case")]
2368pub enum UncertaintyReason {
2369 AmbiguousTerm {
2373 term: String,
2374 #[serde(skip_serializing_if = "Option::is_none")]
2376 file_count: Option<usize>,
2377 },
2378 GenericQuery { tokens: Vec<String> },
2382 LowResultCount { count: usize },
2386 MissingLayer {
2389 expected_layer: String,
2390 hint: String,
2392 },
2393 SparseIndex,
2397 DbStateUnannotated {
2401 state: String,
2403 },
2404}
2405
2406impl UncertaintyReason {
2407 pub fn weight(&self) -> f64 {
2409 match self {
2410 UncertaintyReason::AmbiguousTerm { .. } => 0.20,
2411 UncertaintyReason::GenericQuery { .. } => 0.25,
2412 UncertaintyReason::LowResultCount { .. } => 0.20,
2413 UncertaintyReason::MissingLayer { .. } => 0.10,
2414 UncertaintyReason::SparseIndex => 0.15,
2415 UncertaintyReason::DbStateUnannotated { .. } => 0.10,
2416 }
2417 }
2418
2419 pub fn source(&self) -> &'static str {
2423 match self {
2424 UncertaintyReason::AmbiguousTerm { .. } => "query",
2425 UncertaintyReason::GenericQuery { .. } => "query",
2426 UncertaintyReason::LowResultCount { .. } => "result_set",
2427 UncertaintyReason::MissingLayer { .. } => "result_set",
2428 UncertaintyReason::SparseIndex => "result_set",
2429 UncertaintyReason::DbStateUnannotated { .. } => "db_state",
2430 }
2431 }
2432}
2433
2434#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2436pub struct RecoverySuggestion {
2437 pub query: String,
2439 pub ambiguous_terms_removed: usize,
2441 pub domain_terms_added: Vec<String>,
2443 pub estimated_recovery: String,
2445}
2446
2447#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2452pub struct UncertaintySourceBreakdown {
2453 pub query: f64,
2456 pub db_state: f64,
2459 pub result_set: f64,
2462 pub primary: String,
2464}
2465
2466#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2471pub struct UncertaintyReport {
2472 pub level: String,
2474 pub score: f64,
2476 pub reasons: Vec<UncertaintyReason>,
2478 pub recommended_action: String,
2481 pub recovery_suggestions: Vec<RecoverySuggestion>,
2483 pub exact_symbol_match: bool,
2488 pub sources: UncertaintySourceBreakdown,
2493}
2494
2495impl UncertaintyReport {
2496 pub fn to_json(&self) -> serde_json::Value {
2497 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
2498 }
2499}
2500
2501pub fn compute_uncertainty(
2514 tokens: &[String],
2515 ambiguous_terms: &[String],
2516 possible_misses: &[String],
2517 result_count: usize,
2518 scoped_suggestions: &[String],
2519 fts: Option<&SearchFtsDb>,
2520 db_state: Option<&str>,
2521) -> UncertaintyReport {
2522 let mut reasons: Vec<UncertaintyReason> = Vec::new();
2523
2524 let per_token_counts: HashMap<String, usize> = if !ambiguous_terms.is_empty() {
2528 let tok_refs: Vec<&str> = ambiguous_terms.iter().map(|s| s.as_str()).collect();
2529 fts.and_then(|f| f.count_distinct_files_per_token(&tok_refs, false).ok())
2530 .unwrap_or_default()
2531 .into_iter()
2532 .collect()
2533 } else {
2534 HashMap::new()
2535 };
2536
2537 for term in ambiguous_terms {
2538 let file_count = per_token_counts.get(term.as_str()).copied();
2539 reasons.push(UncertaintyReason::AmbiguousTerm {
2540 term: term.clone(),
2541 file_count,
2542 });
2543 }
2544
2545 const BROAD_TERMS: &[&str] = &[
2549 "update", "get", "set", "handle", "process", "run", "execute", "init", "start", "stop",
2550 "load", "save", "create", "delete", "state", "data", "model", "manager", "service", "util",
2551 "helper", "config",
2552 ];
2553 let meaningful: Vec<&String> = tokens.iter().filter(|t| !is_stopword(t)).collect();
2554 let all_broad = !meaningful.is_empty()
2555 && meaningful
2556 .iter()
2557 .all(|t| BROAD_TERMS.contains(&t.as_str()) || ambiguous_terms.contains(t));
2558 if all_broad && meaningful.len() <= 2 {
2559 reasons.push(UncertaintyReason::GenericQuery {
2560 tokens: meaningful.iter().map(|s| s.to_string()).collect(),
2561 });
2562 }
2563
2564 if result_count < 3 && result_count > 0 && tokens.len() >= 2 {
2568 reasons.push(UncertaintyReason::LowResultCount {
2569 count: result_count,
2570 });
2571 }
2572 if result_count == 0 && !tokens.is_empty() {
2573 reasons.push(UncertaintyReason::LowResultCount { count: 0 });
2574 }
2575
2576 for miss in possible_misses {
2580 let ml = miss.to_lowercase();
2581 let (layer, hint) = if ml.contains("view") || ml.contains("ui") {
2582 (
2583 "view",
2584 "query implies UI involvement — check scope or path coverage",
2585 )
2586 } else if ml.contains("service") || ml.contains("domain") {
2587 (
2588 "service",
2589 "query implies service/domain involvement — check layer indexing",
2590 )
2591 } else if ml.contains("persist") || ml.contains("data") {
2592 (
2593 "persistence",
2594 "query implies data model involvement — check index coverage",
2595 )
2596 } else if ml.contains("scheduler") || ml.contains("timing") {
2597 (
2598 "scheduler",
2599 "query implies timing/dispatch — check scheduler symbols indexed",
2600 )
2601 } else if ml.contains("engine") || ml.contains("runtime") {
2602 (
2603 "engine",
2604 "query implies runtime processing — consider broadening scope",
2605 )
2606 } else if ml.contains("network") || ml.contains("api") {
2607 (
2608 "network",
2609 "query implies network/API — check network client indexing",
2610 )
2611 } else {
2612 continue; };
2614 reasons.push(UncertaintyReason::MissingLayer {
2615 expected_layer: layer.to_string(),
2616 hint: hint.to_string(),
2617 });
2618 }
2619
2620 if result_count < 3 && ambiguous_terms.is_empty() && !tokens.is_empty() && result_count > 0 {
2624 reasons.push(UncertaintyReason::SparseIndex);
2625 }
2626
2627 if let Some(state) = db_state {
2631 if matches!(state, "unannotated" | "clean_room") {
2632 reasons.push(UncertaintyReason::DbStateUnannotated {
2633 state: state.to_string(),
2634 });
2635 }
2636 }
2637
2638 let amb_count = reasons
2644 .iter()
2645 .filter(|r| matches!(r, UncertaintyReason::AmbiguousTerm { .. }))
2646 .count()
2647 .min(2);
2648 let non_amb_score: f64 = reasons
2649 .iter()
2650 .filter(|r| !matches!(r, UncertaintyReason::AmbiguousTerm { .. }))
2651 .map(|r| r.weight())
2652 .sum::<f64>()
2653 .min(0.60);
2654 let raw_score = (amb_count as f64 * 0.20 + non_amb_score).min(1.0);
2655 let score = (raw_score * 100.0).round() / 100.0;
2656
2657 let raw_level = if score < 0.15 {
2658 "low"
2659 } else if score < 0.45 {
2660 "medium"
2661 } else if score < 0.70 {
2662 "high"
2663 } else {
2664 "critical"
2665 };
2666
2667 let meaningful_tokens: Vec<&String> = tokens.iter().filter(|t| !is_stopword(t)).collect();
2678 let exact_symbol_match = if meaningful_tokens.len() == 1 {
2679 let tok = meaningful_tokens[0];
2680 if tok.len() >= 5 || tok.chars().next().map_or(false, |c| c.is_uppercase()) {
2683 fts.map(|f| f.has_exact_symbol_name(tok)).unwrap_or(false)
2684 } else {
2685 false
2686 }
2687 } else {
2688 false
2689 };
2690
2691 let level = if exact_symbol_match && matches!(raw_level, "high" | "critical") {
2693 "medium"
2694 } else {
2695 raw_level
2696 };
2697
2698 let recommended_action = if reasons
2702 .iter()
2703 .any(|r| matches!(r, UncertaintyReason::AmbiguousTerm { .. }))
2704 {
2705 "narrow_query"
2706 } else if reasons
2707 .iter()
2708 .any(|r| matches!(r, UncertaintyReason::GenericQuery { .. }))
2709 {
2710 "add_domain_term"
2711 } else if reasons.iter().any(|r| {
2712 matches!(
2713 r,
2714 UncertaintyReason::MissingLayer { .. } | UncertaintyReason::SparseIndex
2715 )
2716 }) {
2717 "check_index_coverage"
2718 } else if reasons
2719 .iter()
2720 .any(|r| matches!(r, UncertaintyReason::LowResultCount { .. }))
2721 {
2722 "broaden_query"
2723 } else {
2724 "none"
2725 };
2726
2727 let amb_set: std::collections::HashSet<&str> =
2731 ambiguous_terms.iter().map(|s| s.as_str()).collect();
2732 let original_token_set: std::collections::HashSet<&str> =
2733 tokens.iter().map(|s| s.as_str()).collect();
2734
2735 let recovery_suggestions: Vec<RecoverySuggestion> = scoped_suggestions
2736 .iter()
2737 .map(|sugg| {
2738 let sugg_tokens: Vec<String> = sugg
2739 .split_whitespace()
2740 .map(|t| t.to_lowercase())
2741 .filter(|t| !is_stopword(t))
2742 .collect();
2743
2744 let amb_in_sugg: std::collections::HashSet<&str> = sugg_tokens
2746 .iter()
2747 .map(|s| s.as_str())
2748 .filter(|t| amb_set.contains(t))
2749 .collect();
2750 let ambiguous_terms_removed = ambiguous_terms.len().saturating_sub(amb_in_sugg.len());
2751
2752 let domain_terms_added: Vec<String> = sugg_tokens
2754 .iter()
2755 .filter(|t| !original_token_set.contains(t.as_str()) && !is_stopword(t))
2756 .cloned()
2757 .collect();
2758
2759 let estimated_recovery = if ambiguous_terms_removed == ambiguous_terms.len()
2760 && !domain_terms_added.is_empty()
2761 {
2762 "strong"
2763 } else if ambiguous_terms_removed > 0 || !domain_terms_added.is_empty() {
2764 "partial"
2765 } else {
2766 "weak"
2767 };
2768
2769 RecoverySuggestion {
2770 query: sugg.clone(),
2771 ambiguous_terms_removed,
2772 domain_terms_added,
2773 estimated_recovery: estimated_recovery.to_string(),
2774 }
2775 })
2776 .collect();
2777
2778 reasons.sort_by(|a, b| {
2780 b.weight()
2781 .partial_cmp(&a.weight())
2782 .unwrap_or(std::cmp::Ordering::Equal)
2783 });
2784
2785 let query_score: f64 = reasons
2789 .iter()
2790 .filter(|r| r.source() == "query")
2791 .map(|r| r.weight())
2792 .sum::<f64>()
2793 .min(1.0);
2794 let db_state_score: f64 = reasons
2795 .iter()
2796 .filter(|r| r.source() == "db_state")
2797 .map(|r| r.weight())
2798 .sum::<f64>()
2799 .min(1.0);
2800 let result_set_score: f64 = reasons
2801 .iter()
2802 .filter(|r| r.source() == "result_set")
2803 .map(|r| r.weight())
2804 .sum::<f64>()
2805 .min(1.0);
2806
2807 let primary_source = {
2808 let scores = [
2809 ("query", query_score),
2810 ("db_state", db_state_score),
2811 ("result_set", result_set_score),
2812 ];
2813 let max = scores
2814 .iter()
2815 .cloned()
2816 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
2817 match max {
2818 Some((src, s)) if s > 0.0 => src,
2819 _ => "none",
2820 }
2821 };
2822
2823 let sources = UncertaintySourceBreakdown {
2824 query: ((query_score * 100.0).round() / 100.0).max(0.0),
2825 db_state: ((db_state_score * 100.0).round() / 100.0).max(0.0),
2826 result_set: ((result_set_score * 100.0).round() / 100.0).max(0.0),
2827 primary: primary_source.to_string(),
2828 };
2829
2830 UncertaintyReport {
2831 level: level.to_string(),
2832 score,
2833 reasons,
2834 recommended_action: recommended_action.to_string(),
2835 recovery_suggestions,
2836 exact_symbol_match,
2837 sources,
2838 }
2839}
2840
2841use crate::feedback::{AsgFeedbackStore, FeedbackStore};
2846
2847#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2853pub struct FeedbackState {
2854 pub available: bool,
2856 pub reason: String,
2861 pub entries_total: usize,
2863 pub query_matches: usize,
2866}
2867
2868impl FeedbackState {
2869 pub fn to_json(&self) -> serde_json::Value {
2870 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
2871 }
2872}
2873
2874pub fn build_feedback_state_from_entries(
2882 all: &[crate::schema::FeedbackEntry],
2883 query: &str,
2884 entries_applied: usize,
2885) -> FeedbackState {
2886 let entries_total = all.len();
2887
2888 if entries_total == 0 {
2889 return FeedbackState {
2890 available: false,
2891 reason: "no_feedback_entries".to_string(),
2892 entries_total: 0,
2893 query_matches: 0,
2894 };
2895 }
2896
2897 let q_tokens: std::collections::HashSet<String> = query
2899 .split_whitespace()
2900 .map(|t| t.to_lowercase())
2901 .filter(|t| t.len() >= 2)
2902 .collect();
2903
2904 let query_matches = all
2905 .iter()
2906 .filter(|e| {
2907 e.query
2908 .split_whitespace()
2909 .any(|t| q_tokens.contains(&t.to_lowercase()))
2910 })
2911 .count();
2912
2913 let reason = if entries_applied > 0 {
2914 "entries_applied"
2915 } else if query_matches > 0 {
2916 "entries_exist_no_query_match"
2917 } else {
2918 "entries_exist_no_query_match"
2919 };
2920
2921 FeedbackState {
2922 available: true,
2923 reason: reason.to_string(),
2924 entries_total,
2925 query_matches,
2926 }
2927}
2928
2929pub fn build_feedback_state(
2933 engine: &Engine,
2934 ref_name: &str,
2935 query: &str,
2936 entries_applied: usize,
2937) -> FeedbackState {
2938 let fb_store = AsgFeedbackStore::from_engine(engine);
2939 let all: Vec<crate::schema::FeedbackEntry> = fb_store
2942 .list_all(ref_name)
2943 .unwrap_or_default()
2944 .into_iter()
2945 .filter(|e| !e.is_expired())
2946 .collect();
2947 build_feedback_state_from_entries(&all, query, entries_applied)
2948}
2949
2950#[cfg(test)]
2951mod plan_c_t003_tests {
2952 use crate::engine::Engine;
2958 use crate::index::{AsgIndexStore, IndexStore};
2959 use crate::ledger::{AsgLedgerStore, LedgerStore};
2960 use crate::schema::{
2961 Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
2962 };
2963
2964 fn seed_engine_with_symbol() -> (Engine, String) {
2965 let engine = Engine::open_in_memory().unwrap();
2966 let index = AsgIndexStore::from_engine(&engine);
2967 let sym = Symbol {
2968 symbol_id: "sym_stale".into(),
2969 symbol_fp: "fp_stale".into(),
2970 qname: "store.legacy.api".into(),
2971 language: "python".into(),
2972 kind: SymbolKind::Function,
2973 file: "store/legacy.py".into(),
2974 start: Position { line: 1, col: 0 },
2975 end: Position { line: 5, col: 0 },
2976 signature: None,
2977 doc: None,
2978 };
2979 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
2980 (engine, sym.symbol_id)
2981 }
2982
2983 fn append_constraint(engine: &Engine, sym_id: &str, role: Option<&str>) {
2984 let ledger = AsgLedgerStore::from_engine(engine);
2985 let mut entry = LedgerEntry::new(
2986 sym_id,
2987 LedgerKind::Constraint,
2988 "legacy api should not be used in new code",
2989 Author {
2990 kind: AuthorKind::Agent,
2991 id: "test".into(),
2992 },
2993 );
2994 entry.role = role.map(str::to_string);
2995 ledger
2996 .append_entry(&engine.ref_name, &entry, "test")
2997 .unwrap();
2998 }
2999
3000 #[test]
3001 fn constraint_with_stale_api_role_suppresses_symbol() {
3002 let (engine, sym_id) = seed_engine_with_symbol();
3003 append_constraint(&engine, &sym_id, Some("stale-api"));
3004 let index = AsgIndexStore::from_engine(&engine);
3005
3006 let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
3007 let suppressed = super::apply_constraint_penalties(&engine, &index, &mut scored);
3008
3009 assert_eq!(suppressed, 1);
3010 assert_eq!(scored[0].0, f64::NEG_INFINITY);
3011 }
3012
3013 #[test]
3014 fn constraint_with_audit_pending_role_suppresses_symbol() {
3015 let (engine, sym_id) = seed_engine_with_symbol();
3016 append_constraint(&engine, &sym_id, Some("audit-pending"));
3017 let index = AsgIndexStore::from_engine(&engine);
3018 let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
3019 let suppressed = super::apply_constraint_penalties(&engine, &index, &mut scored);
3020 assert_eq!(suppressed, 1);
3021 assert_eq!(scored[0].0, f64::NEG_INFINITY);
3022 }
3023
3024 #[test]
3025 fn constraint_without_role_does_not_suppress() {
3026 let (engine, sym_id) = seed_engine_with_symbol();
3029 append_constraint(&engine, &sym_id, None);
3030 let index = AsgIndexStore::from_engine(&engine);
3031 let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
3032 let suppressed = super::apply_constraint_penalties(&engine, &index, &mut scored);
3033 assert_eq!(suppressed, 0);
3034 assert_eq!(scored[0].0, 10.0);
3035 }
3036
3037 #[test]
3038 fn non_penalty_role_does_not_suppress() {
3039 let (engine, sym_id) = seed_engine_with_symbol();
3042 append_constraint(&engine, &sym_id, Some("package-boundary"));
3043 let index = AsgIndexStore::from_engine(&engine);
3044 let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
3045 let suppressed = super::apply_constraint_penalties(&engine, &index, &mut scored);
3046 assert_eq!(suppressed, 0);
3047 }
3048
3049 #[test]
3050 fn apply_feedback_adjustments_reports_constraint_penalty_in_metrics() {
3051 let (engine, sym_id) = seed_engine_with_symbol();
3052 append_constraint(&engine, &sym_id, Some("stale-api"));
3053 let index = AsgIndexStore::from_engine(&engine);
3054 let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
3055 let metrics =
3057 super::apply_feedback_adjustments(&engine, &index, "legacy", &mut scored, &[]);
3058 assert_eq!(metrics.constraint_penalties_applied, 1);
3059 assert!(
3060 metrics
3061 .rules_applied
3062 .iter()
3063 .any(|r| r == "constraint_penalty")
3064 );
3065 assert_eq!(scored[0].0, f64::NEG_INFINITY);
3066 }
3067
3068 fn seed_plain_symbol() -> (Engine, String) {
3071 let engine = Engine::open_in_memory().unwrap();
3072 let index = AsgIndexStore::from_engine(&engine);
3073 let sym = Symbol {
3074 symbol_id: "sym_a".into(),
3075 symbol_fp: "fp_a".into(),
3076 qname: "a.b.c".into(),
3077 language: "python".into(),
3078 kind: SymbolKind::Function,
3079 file: "src/a.py".into(),
3080 start: Position { line: 1, col: 0 },
3081 end: Position { line: 5, col: 0 },
3082 signature: None,
3083 doc: None,
3084 };
3085 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
3086 (engine, sym.symbol_id)
3087 }
3088
3089 #[test]
3090 fn already_covered_verdict_suppresses_and_labels_rule() {
3091 let (engine, sym_id) = seed_plain_symbol();
3092 let index = AsgIndexStore::from_engine(&engine);
3093 let mut scored = vec![(10.0_f64, "a.b.c".to_string())];
3094 let metrics = super::apply_feedback_adjustments(
3095 &engine,
3096 &index,
3097 "match my query",
3098 &mut scored,
3099 &[(
3100 sym_id,
3101 "match my query".into(),
3102 crate::schema::FeedbackVerdict::AlreadyCovered,
3103 chrono::Utc::now(), )],
3105 );
3106 assert!(scored.is_empty(), "suppressed symbol must be dropped");
3109 assert!(metrics.rules_applied.iter().any(|r| r == "already_covered"));
3110 }
3111
3112 #[test]
3113 fn diagnostic_only_verdict_suppresses_and_labels_rule() {
3114 let (engine, sym_id) = seed_plain_symbol();
3115 let index = AsgIndexStore::from_engine(&engine);
3116 let mut scored = vec![(10.0_f64, "a.b.c".to_string())];
3117 let metrics = super::apply_feedback_adjustments(
3118 &engine,
3119 &index,
3120 "match my query",
3121 &mut scored,
3122 &[(
3123 sym_id,
3124 "match my query".into(),
3125 crate::schema::FeedbackVerdict::DiagnosticOnly,
3126 chrono::Utc::now(), )],
3128 );
3129 assert!(scored.is_empty(), "suppressed symbol must be dropped");
3130 assert!(metrics.rules_applied.iter().any(|r| r == "diagnostic_only"));
3131 }
3132
3133 #[test]
3134 fn feedback_verdict_is_suppression_classification() {
3135 use crate::schema::FeedbackVerdict::*;
3136 assert!(!Useful.is_suppression());
3137 assert!(!Missing.is_suppression());
3138 assert!(Noisy.is_suppression());
3139 assert!(WrongLayer.is_suppression());
3140 assert!(AlreadyCovered.is_suppression());
3141 assert!(DiagnosticOnly.is_suppression());
3142 }
3143
3144 #[test]
3147 fn apply_task_bias_boosts_in_scope_files_only() {
3148 let engine = Engine::open_in_memory().unwrap();
3149 let index = AsgIndexStore::from_engine(&engine);
3150 for (qn, file) in [
3151 ("pkg.in_scope.fn", "src/pkg/in_scope.py"),
3152 ("pkg.out_of_scope.fn", "src/other/file.py"),
3153 ] {
3154 let sym = Symbol {
3155 symbol_id: format!("sym_{qn}"),
3156 symbol_fp: "fp".into(),
3157 qname: qn.into(),
3158 language: "python".into(),
3159 kind: SymbolKind::Function,
3160 file: file.into(),
3161 start: Position { line: 1, col: 0 },
3162 end: Position { line: 2, col: 0 },
3163 signature: None,
3164 doc: None,
3165 };
3166 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
3167 }
3168 let mut scored = vec![
3169 (5.0_f64, "pkg.in_scope.fn".to_string()),
3170 (5.0_f64, "pkg.out_of_scope.fn".to_string()),
3171 ];
3172 let scope = vec!["src/pkg/**".to_string()];
3173 let n = super::apply_task_bias(&engine, &index, &mut scored, &scope, 1.0);
3174 assert_eq!(n, 1);
3175 let in_scope = scored.iter().find(|(_, q)| q == "pkg.in_scope.fn").unwrap();
3177 let out_scope = scored
3178 .iter()
3179 .find(|(_, q)| q == "pkg.out_of_scope.fn")
3180 .unwrap();
3181 assert_eq!(in_scope.0, 6.0);
3182 assert_eq!(out_scope.0, 5.0);
3183 }
3184
3185 #[test]
3186 fn apply_task_bias_with_empty_scope_is_a_noop() {
3187 let engine = Engine::open_in_memory().unwrap();
3188 let index = AsgIndexStore::from_engine(&engine);
3189 let mut scored = vec![(5.0_f64, "anything".to_string())];
3190 let n = super::apply_task_bias(&engine, &index, &mut scored, &[], 1.0);
3191 assert_eq!(n, 0);
3192 assert_eq!(scored[0].0, 5.0);
3193 }
3194
3195 #[test]
3196 fn apply_task_bias_skips_already_suppressed_candidates() {
3197 let engine = Engine::open_in_memory().unwrap();
3198 let index = AsgIndexStore::from_engine(&engine);
3199 let sym = Symbol {
3200 symbol_id: "sym_z".into(),
3201 symbol_fp: "fp".into(),
3202 qname: "z.in_scope.fn".into(),
3203 language: "python".into(),
3204 kind: SymbolKind::Function,
3205 file: "src/pkg/z.py".into(),
3206 start: Position { line: 1, col: 0 },
3207 end: Position { line: 2, col: 0 },
3208 signature: None,
3209 doc: None,
3210 };
3211 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
3212 let mut scored = vec![(f64::NEG_INFINITY, "z.in_scope.fn".to_string())];
3213 let n = super::apply_task_bias(
3214 &engine,
3215 &index,
3216 &mut scored,
3217 &["src/pkg/**".to_string()],
3218 1.0,
3219 );
3220 assert_eq!(n, 0, "NEG_INFINITY candidates must not be revived by bias");
3221 assert_eq!(scored[0].0, f64::NEG_INFINITY);
3222 }
3223
3224 #[test]
3230 fn read_active_task_scope_parses_json() {
3231 let raw = r#"{"task_id":"t-006","scope":["src/pkg/**","tests/pkg/**"]}"#;
3232 let scope = super::read_active_task_scope_from(Some(raw), None);
3233 assert_eq!(
3234 scope,
3235 Some(vec!["src/pkg/**".to_string(), "tests/pkg/**".to_string()])
3236 );
3237 }
3238
3239 #[test]
3240 fn read_active_task_scope_returns_none_when_unset() {
3241 let scope = super::read_active_task_scope_from(None, None);
3242 assert_eq!(scope, None);
3243 }
3244
3245 #[test]
3246 fn read_active_task_scope_returns_none_on_empty_scope_array() {
3247 let raw = r#"{"task_id":"t-006","scope":[]}"#;
3248 assert_eq!(super::read_active_task_scope_from(Some(raw), None), None);
3249 }
3250
3251 #[test]
3252 fn read_active_task_scope_returns_none_on_malformed_json() {
3253 assert_eq!(
3254 super::read_active_task_scope_from(Some("not json"), None),
3255 None
3256 );
3257 assert_eq!(super::read_active_task_scope_from(Some("{}"), None), None);
3258 }
3259
3260 #[test]
3263 fn constraint_scope_from_body_parses_json_scope_array() {
3264 let body = r#"{"scope":["src/legacy/**","tests/legacy/**"]}"#;
3265 assert_eq!(
3266 super::constraint_scope_from_body(Some(body)),
3267 Some(vec![
3268 "src/legacy/**".to_string(),
3269 "tests/legacy/**".to_string()
3270 ])
3271 );
3272 }
3273
3274 #[test]
3275 fn constraint_scope_from_body_returns_none_when_unset() {
3276 assert_eq!(super::constraint_scope_from_body(None), None);
3277 assert_eq!(
3278 super::constraint_scope_from_body(Some("plain text body")),
3279 None
3280 );
3281 assert_eq!(
3282 super::constraint_scope_from_body(Some(r#"{"other":"field"}"#)),
3283 None
3284 );
3285 assert_eq!(
3286 super::constraint_scope_from_body(Some(r#"{"scope":[]}"#)),
3287 None
3288 );
3289 }
3290
3291 fn seed_engine_with_two_symbols() -> (Engine, String, String) {
3292 let engine = Engine::open_in_memory().unwrap();
3293 let index = AsgIndexStore::from_engine(&engine);
3294 for (qn, file) in [
3295 ("app.legacy.api", "src/legacy/api.py"),
3296 ("app.modern.api", "src/modern/api.py"),
3297 ] {
3298 let sym = Symbol {
3299 symbol_id: format!("sym_{qn}"),
3300 symbol_fp: "fp".into(),
3301 qname: qn.into(),
3302 language: "python".into(),
3303 kind: SymbolKind::Function,
3304 file: file.into(),
3305 start: Position { line: 1, col: 0 },
3306 end: Position { line: 2, col: 0 },
3307 signature: None,
3308 doc: None,
3309 };
3310 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
3311 }
3312 (
3313 engine,
3314 "sym_app.legacy.api".into(),
3315 "sym_app.modern.api".into(),
3316 )
3317 }
3318
3319 fn append_scoped_constraint(engine: &Engine, sym_id: &str, scope_json: &str) {
3320 let ledger = AsgLedgerStore::from_engine(engine);
3321 let mut entry = LedgerEntry::new(
3322 sym_id,
3323 LedgerKind::Constraint,
3324 "legacy api may not be used in new code",
3325 Author {
3326 kind: AuthorKind::Agent,
3327 id: "t".into(),
3328 },
3329 );
3330 entry.role = Some("stale-api".into());
3331 entry.body = Some(scope_json.to_string());
3332 ledger
3333 .append_entry(&engine.ref_name, &entry, "test")
3334 .unwrap();
3335 }
3336
3337 #[test]
3338 fn scoped_constraint_suppresses_only_in_scope_symbols() {
3339 let (engine, legacy_id, _modern_id) = seed_engine_with_two_symbols();
3342 append_scoped_constraint(&engine, &legacy_id, r#"{"scope":["src/legacy/**"]}"#);
3343 let index = AsgIndexStore::from_engine(&engine);
3344 let mut scored = vec![
3345 (10.0_f64, "app.legacy.api".into()),
3346 (10.0_f64, "app.modern.api".into()),
3347 ];
3348 let n = super::apply_constraint_penalties(&engine, &index, &mut scored);
3349 assert_eq!(n, 1, "only legacy should be suppressed");
3350 let legacy = scored.iter().find(|(_, q)| q == "app.legacy.api").unwrap();
3351 let modern = scored.iter().find(|(_, q)| q == "app.modern.api").unwrap();
3352 assert_eq!(legacy.0, f64::NEG_INFINITY);
3353 assert_eq!(modern.0, 10.0);
3354 }
3355
3356 #[test]
3357 fn unscoped_constraint_still_suppresses_globally() {
3358 let (engine, legacy_id, _modern_id) = seed_engine_with_two_symbols();
3361 append_constraint(&engine, &legacy_id, Some("stale-api")); let index = AsgIndexStore::from_engine(&engine);
3363 let mut scored = vec![(10.0_f64, "app.legacy.api".into())];
3364 let n = super::apply_constraint_penalties(&engine, &index, &mut scored);
3365 assert_eq!(n, 1);
3366 assert_eq!(scored[0].0, f64::NEG_INFINITY);
3367 }
3368}
3369
3370#[cfg(test)]
3371mod plan_j_t012_explain_match_tests {
3372 use crate::candidates::explain_match;
3379 use crate::schema::{
3380 Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
3381 };
3382
3383 fn mk_sym(qname: &str, file: &str, sig: Option<&str>, doc: Option<&str>) -> Symbol {
3384 Symbol {
3385 symbol_id: format!("sym_{}", qname.replace('.', "_")),
3386 symbol_fp: "fp".into(),
3387 qname: qname.into(),
3388 language: "python".into(),
3389 kind: SymbolKind::Function,
3390 file: file.into(),
3391 start: Position { line: 1, col: 0 },
3392 end: Position { line: 5, col: 0 },
3393 signature: sig.map(str::to_string),
3394 doc: doc.map(str::to_string),
3395 }
3396 }
3397
3398 fn mk_entry(kind: LedgerKind, summary: &str) -> LedgerEntry {
3399 LedgerEntry::new(
3400 "sym_x",
3401 kind,
3402 summary,
3403 Author {
3404 kind: AuthorKind::Human,
3405 id: "alice".into(),
3406 },
3407 )
3408 }
3409
3410 #[test]
3411 fn name_match_yields_name_reason() {
3412 let sym = mk_sym("pkg.playhead", "src/playhead.py", None, None);
3413 let reasons = explain_match(&sym, &["playhead".into()], &[], false);
3414 assert!(
3415 reasons.iter().any(|r| r == "name:playhead"),
3416 "expected name:playhead in {reasons:?}"
3417 );
3418 }
3419
3420 #[test]
3421 fn file_match_yields_file_reason_when_name_misses() {
3422 let sym = mk_sym("pkg.x", "src/transport.py", None, None);
3423 let reasons = explain_match(&sym, &["transport".into()], &[], false);
3424 assert!(
3425 reasons.iter().any(|r| r == "file:transport"),
3426 "expected file:transport in {reasons:?}"
3427 );
3428 }
3429
3430 #[test]
3431 fn doc_match_yields_doc_reason_when_higher_priority_miss() {
3432 let sym = mk_sym(
3433 "pkg.x",
3434 "src/x.py",
3435 None,
3436 Some("Handles the audio mixer pipeline."),
3437 );
3438 let reasons = explain_match(&sym, &["mixer".into()], &[], false);
3439 assert!(
3440 reasons.iter().any(|r| r == "doc:mixer"),
3441 "expected doc:mixer in {reasons:?}"
3442 );
3443 }
3444
3445 #[test]
3446 fn hot_files_get_recent_edit_signal() {
3447 let sym = mk_sym("pkg.x", "src/x.py", None, None);
3448 let reasons = explain_match(&sym, &[], &[], true);
3449 assert!(
3450 reasons.contains(&"recent-edit".to_string()),
3451 "is_hot=true must surface `recent-edit`; got {reasons:?}"
3452 );
3453 }
3454
3455 #[test]
3456 fn invariant_count_appears_as_signal() {
3457 let sym = mk_sym("pkg.x", "src/x.py", None, None);
3458 let entries = vec![
3459 mk_entry(LedgerKind::Invariant, "must hold X"),
3460 mk_entry(LedgerKind::Invariant, "must hold Y"),
3461 ];
3462 let reasons = explain_match(&sym, &[], &entries, false);
3463 assert!(
3464 reasons.iter().any(|r| r == "invariant-attached:2"),
3465 "expected invariant-attached:2 in {reasons:?}"
3466 );
3467 }
3468
3469 #[test]
3470 fn hazard_count_appears_as_signal_with_pluralization() {
3471 let sym = mk_sym("pkg.x", "src/x.py", None, None);
3472 let one = vec![mk_entry(LedgerKind::Hazard, "be careful")];
3473 let two = vec![
3474 mk_entry(LedgerKind::Hazard, "be careful 1"),
3475 mk_entry(LedgerKind::Hazard, "be careful 2"),
3476 ];
3477 assert!(
3478 explain_match(&sym, &[], &one, false)
3479 .iter()
3480 .any(|r| r == "ledger:1 hazard")
3481 );
3482 assert!(
3483 explain_match(&sym, &[], &two, false)
3484 .iter()
3485 .any(|r| r == "ledger:2 hazards")
3486 );
3487 }
3488
3489 #[test]
3490 fn ownership_match_surfaces_when_summary_overlaps_query() {
3491 let sym = mk_sym("pkg.audio", "src/audio.py", None, None);
3492 let entries = vec![mk_entry(
3493 LedgerKind::Ownership,
3494 "audio mixer pipeline source of truth",
3495 )];
3496 let reasons = explain_match(&sym, &["audio".into()], &entries, false);
3497 assert!(
3498 reasons.iter().any(|r| r.starts_with("ownership:")),
3499 "expected ownership:* in {reasons:?}"
3500 );
3501 }
3502
3503 #[test]
3504 fn empty_input_returns_empty_reasons() {
3505 let sym = mk_sym("pkg.x", "src/x.py", None, None);
3506 let reasons = explain_match(&sym, &[], &[], false);
3507 assert!(
3508 reasons.is_empty(),
3509 "no tokens, no ledger, not hot → empty; got {reasons:?}"
3510 );
3511 }
3512}
3513
3514#[cfg(test)]
3515mod plan_j_t011_exclude_sets_tests {
3516 use super::{load_exclude_sets, matches_any_path_glob, resolve_exclude_set};
3520 use std::path::PathBuf;
3521
3522 fn write_scopes_toml(dir: &std::path::Path, body: &str) {
3523 std::fs::write(dir.join("scopes.toml"), body).unwrap();
3527 }
3528
3529 fn db_path_in(dir: &std::path::Path) -> PathBuf {
3530 dir.join(".asd-state.db")
3533 }
3534
3535 #[test]
3536 fn returns_empty_when_no_exclude_sets_table() {
3537 let tmp = tempfile::tempdir().unwrap();
3538 write_scopes_toml(tmp.path(), "drift-pad = [\"App/**/DriftPad*\"]\n");
3539 let map = load_exclude_sets(&db_path_in(tmp.path()));
3540 assert!(
3541 map.is_empty(),
3542 "no [exclude_sets] table should yield empty map; got {map:?}"
3543 );
3544 }
3545
3546 #[test]
3547 fn reads_named_exclude_sets() {
3548 let tmp = tempfile::tempdir().unwrap();
3549 write_scopes_toml(
3550 tmp.path(),
3551 "[exclude_sets]\n\
3552 tests = [\"**/test_*.py\", \"tests/**\"]\n\
3553 generated = [\"**/generated/**\"]\n",
3554 );
3555 let map = load_exclude_sets(&db_path_in(tmp.path()));
3556 assert_eq!(map.len(), 2);
3557 assert_eq!(
3558 map.get("tests").map(|v| v.len()),
3559 Some(2),
3560 "tests must have 2 globs; got {:?}",
3561 map.get("tests")
3562 );
3563 assert_eq!(map.get("generated").map(|v| v.len()), Some(1));
3564 }
3565
3566 #[test]
3567 fn resolve_unknown_returns_empty_not_treated_as_glob() {
3568 let tmp = tempfile::tempdir().unwrap();
3572 write_scopes_toml(tmp.path(), "[exclude_sets]\ntests = [\"tests/**\"]\n");
3573 let globs = resolve_exclude_set("nonexistent", &db_path_in(tmp.path()));
3574 assert!(
3575 globs.is_empty(),
3576 "unknown exclude-set name must return empty, not the literal name; got {globs:?}"
3577 );
3578 }
3579
3580 #[test]
3581 fn resolve_known_returns_globs() {
3582 let tmp = tempfile::tempdir().unwrap();
3583 write_scopes_toml(
3584 tmp.path(),
3585 "[exclude_sets]\ntests = [\"tests/**\", \"**/test_*.py\"]\n",
3586 );
3587 let globs = resolve_exclude_set("tests", &db_path_in(tmp.path()));
3588 assert_eq!(globs.len(), 2);
3589 assert!(matches_any_path_glob(&globs, "tests/foo.py"));
3591 assert!(matches_any_path_glob(&globs, "src/test_widget.py"));
3592 assert!(!matches_any_path_glob(&globs, "src/widget.py"));
3593 }
3594
3595 #[test]
3596 fn coexists_with_bare_scope_aliases() {
3597 let tmp = tempfile::tempdir().unwrap();
3600 write_scopes_toml(
3601 tmp.path(),
3602 "drift-pad = [\"App/**/DriftPad*\"]\n\
3603 \n[exclude_sets]\n\
3604 tests = [\"tests/**\"]\n",
3605 );
3606 let aliases = super::load_scope_aliases(&db_path_in(tmp.path()));
3607 let excl = load_exclude_sets(&db_path_in(tmp.path()));
3608 assert_eq!(aliases.get("drift-pad").map(|v| v.len()), Some(1));
3609 assert_eq!(excl.get("tests").map(|v| v.len()), Some(1));
3610 assert!(!aliases.contains_key("tests"));
3613 assert!(!excl.contains_key("drift-pad"));
3614 }
3615}
3616
3617#[cfg(test)]
3618mod plan_j_t006_view_layer_matches_tests {
3619 use super::view_layer_matches;
3622
3623 #[test]
3624 fn ui_layer_alone_qualifies() {
3625 assert!(view_layer_matches("ui", "anything/not/even/a/view.go"));
3627 }
3628
3629 #[test]
3630 fn viewmodel_layer_alone_qualifies() {
3631 assert!(view_layer_matches("viewmodel", "Foo.swift"));
3632 }
3633
3634 #[test]
3635 fn non_view_layer_with_non_view_file_does_not_qualify() {
3636 assert!(!view_layer_matches("scheduler", "src/audio/clock.rs"));
3637 assert!(!view_layer_matches("persistence", "src/db/migrations.rs"));
3638 }
3639
3640 #[test]
3641 fn file_role_view_closes_layer_gap() {
3642 assert!(view_layer_matches(
3647 "other",
3648 "app/components/AcmeFlowView.swift"
3649 ));
3650 }
3651
3652 #[test]
3653 fn file_role_view_via_vue_extension_qualifies() {
3654 assert!(view_layer_matches("other", "src/Components/UserCard.vue"));
3658 assert!(view_layer_matches("other", "src/widgets/Toolbar.svelte"));
3659 }
3660
3661 #[test]
3662 fn file_role_viewmodel_closes_layer_gap() {
3663 assert!(view_layer_matches(
3665 "other",
3666 "app/screens/login/LoginViewModel.kt"
3667 ));
3668 }
3669
3670 #[test]
3671 fn impl_file_role_not_a_view() {
3672 assert!(!view_layer_matches("other", "src/services/auth.rs"));
3675 assert!(!view_layer_matches("other", "src/util/helpers.ts"));
3676 }
3677
3678 #[test]
3679 fn previewservice_is_not_a_view_negative_regression() {
3680 assert!(!view_layer_matches(
3684 "other",
3685 "src/preview/PreviewService.swift"
3686 ));
3687 }
3688}