Skip to main content

agentstatedeveloper_core/
candidates.rs

1//! Shared candidate-selection logic used by CLI commands and the MCP server.
2//!
3//! `find_candidates` is the single entry point: it runs FTS5 hybrid search,
4//! injects file-stem matches, applies ledger-aware file deduplication (symbols
5//! with ledger entries win their file slot), falls back to an in-memory
6//! O(N) scorer when the FTS index is not populated, and finishes with a
7//! ledger-anchor pass that unconditionally injects invariant/hazard-bearing
8//! symbols whose summaries match any query token.
9
10use 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
21// ---------------------------------------------------------------------------
22// Query tokenisation
23// ---------------------------------------------------------------------------
24
25/// Tokenise a free-form query into lowercase, stopword-filtered terms.
26pub 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
34/// Parse a query string into `(positive_tokens, exclusion_terms)`.
35///
36/// Words prefixed with `-` (e.g. `-sample`) are treated as exclusions.
37/// The rest are tokenised with the same rules as `query_tokens`.
38///
39/// Example: `"drift playhead -sample -waveform"` →
40///   tokens: `["drift", "playhead"]`, exclusions: `["sample", "waveform"]`
41pub 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
66// ---------------------------------------------------------------------------
67// Kind helpers
68// ---------------------------------------------------------------------------
69
70/// Plan J t-006: gate for the +2.0 view-query boost. A symbol
71/// qualifies if EITHER:
72///   - `classify_layer_sym` puts it in `"ui"` or `"viewmodel"`
73///     (broader directory + qname-suffix sweep), OR
74///   - `classify_file_role` (Plan J t-003) puts it in `"view"` or
75///     `"viewmodel"` (stem + extension + `/view(s)/` patterns —
76///     catches `.vue`, `.svelte`, and projects that name view
77///     directories `components/`).
78///
79/// Either signal alone is sufficient; the union closes M22's
80/// reported gap where files clearly named `*View.swift` lived in
81/// non-`/views/` directories and missed the boost despite obvious
82/// intent.
83pub 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
101// ---------------------------------------------------------------------------
102// In-memory fallback scorer
103// ---------------------------------------------------------------------------
104
105/// Score a symbol against query tokens using simple substring matching.
106/// Used when the FTS index is not populated.
107pub 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
123/// Same scoring rules as [`in_memory_score`], but takes the symbol's
124/// lowercased ledger-summary text directly so bulk callers can supply
125/// it from one ledger-tree walk instead of a per-symbol `list_entries`
126/// read (Plan T scale finding).
127pub 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
154// ---------------------------------------------------------------------------
155// Glob path matching
156// ---------------------------------------------------------------------------
157
158/// Match a file path against a glob pattern.
159///
160/// Supports:
161///   `**`  — matches zero or more path segments
162///   `*`   — matches any characters within a single segment (no `/`)
163///   all other characters match literally (case-sensitive)
164pub 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            // Pattern exhausted with path remaining, or path exhausted but
176            // pattern has non-** remaining — check if remaining pattern is all **
177            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            // ** matches zero segments (skip it) or one segment (consume path head)
183            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
189/// Match a single path segment against a pattern segment (supports `*`).
190fn 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
216/// Return true if `file` matches any of the given glob patterns.
217pub fn matches_any_path_glob(globs: &[String], file: &str) -> bool {
218    globs.iter().any(|g| glob_match(g.as_str(), file))
219}
220
221// ---------------------------------------------------------------------------
222// Scope alias resolution
223// ---------------------------------------------------------------------------
224
225/// Load named scope aliases from `.asd/scopes.toml` in the current directory.
226///
227/// Format:
228/// ```toml
229/// drift-pad = ["App/**/DriftPad*", "Packages/SequencerCore/**"]
230/// sequencer-core = ["Packages/SequencerCore/**"]
231/// ```
232///
233/// Returns an empty map if the file is absent or unparseable.
234pub 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
269/// Resolve a `--scope` name to path globs.
270/// Falls back to treating the scope name itself as a path glob if not in the map.
271pub 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
279/// Plan J t-011: load named exclude sets from `.asd/scopes.toml`.
280///
281/// Format (sits alongside the bare-key scope aliases):
282/// ```toml
283/// # bare entries remain positive scope aliases (load_scope_aliases)
284/// drift-pad = ["App/**/DriftPad*"]
285///
286/// # new: exclude sets keyed under a dedicated table
287/// [exclude_sets]
288/// tests = ["**/test_*.py", "tests/**", "**/*_test.go"]
289/// generated = ["**/generated/**", "**/*_pb2.py"]
290/// ```
291///
292/// `--exclude-set tests` on the CLI expands to those globs and merges
293/// into `FtsFilters::exclude_paths`. Returns an empty map when no
294/// `[exclude_sets]` table is present (or when the file is missing).
295pub 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
333/// Resolve a `--exclude-set` name to negative path globs. Unknown
334/// names return an empty list rather than treating the name as a glob
335/// (avoids silently turning a typo into a path filter).
336pub fn resolve_exclude_set(name: &str, db_path: &Path) -> Vec<String> {
337    load_exclude_sets(db_path).remove(name).unwrap_or_default()
338}
339
340// ---------------------------------------------------------------------------
341// Path filter
342// ---------------------------------------------------------------------------
343
344/// Retain only candidates whose file matches at least one of the path globs.
345/// No-op when `paths_filter` is empty.
346fn 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    // M62: reuse the caller's open connection — no extra open() per call.
357    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
373// ---------------------------------------------------------------------------
374// Negative-path filter (Plan J t-011)
375// ---------------------------------------------------------------------------
376
377/// Drop candidates whose file matches ANY of the exclude globs.
378/// No-op when `exclude_paths` is empty.
379fn 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, // can't resolve → keep (conservative)
400            }
401        };
402        !matches_any_path_glob(exclude_paths, &file)
403    });
404}
405
406// ---------------------------------------------------------------------------
407// Negative-language filter (Plan J t-011)
408// ---------------------------------------------------------------------------
409
410/// Drop candidates whose `language` (case-insensitive) is in the list.
411/// No-op when `exclude_languages` is empty.
412///
413/// `ResolvedSymbol` (the FTS bulk-resolve return) doesn't carry the
414/// language field, so this filter falls through to index_store lookup
415/// per candidate. Acceptable because: (a) exclude_languages is
416/// typically empty, (b) when set, it's usually filtering OUT a large
417/// chunk so the lookup cost amortizes against the smaller post-filter
418/// set the rest of the pipeline processes.
419fn 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, // can't resolve → keep (conservative)
434        };
435        !lower.iter().any(|excl| excl == &lang)
436    });
437}
438
439// ---------------------------------------------------------------------------
440// Exclusion filter
441// ---------------------------------------------------------------------------
442
443/// Remove candidates that match any exclusion term (case-insensitive substring)
444/// in their qname, file path, doc comment, or signature.
445fn 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    // M62: reuse the caller's open connection — no extra open() per call.
456    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
487// ---------------------------------------------------------------------------
488// Ledger-anchor pass
489// ---------------------------------------------------------------------------
490
491/// Score assigned to anchored symbols — above zero so they appear in results,
492/// but below any genuine FTS hit so they don't displace real matches.
493const ANCHOR_SCORE: f64 = 0.5;
494
495/// Maximum number of extra symbols that can be injected by the anchor pass.
496const MAX_ANCHORS: usize = 5;
497
498/// After candidate selection, inject invariant/hazard-bearing symbols whose
499/// ledger summaries match a query token but were dropped by dedup or FTS ranking.
500///
501/// M60: replaced 2 `get_tree` git reads (ledger tree + qname index) with a
502/// single SQL query against the denormalized `ledger_text`/`ledger_flags`
503/// UNINDEXED columns in `asd_search_fts`.
504///
505/// Falls back to the git-based path when the FTS index is unavailable.
506fn 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    // Fast path: reuse caller's connection — zero extra open() calls.
519    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    // Fallback: git object store (used when FTS index is absent or empty).
530    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
585// ---------------------------------------------------------------------------
586// Candidate selection
587// ---------------------------------------------------------------------------
588
589const ASD_PATH_PREFIX: &str = "/asd/v1";
590
591/// Return the top-`depth` `(score, qname)` pairs for a query.
592///
593/// Strategy:
594/// 1. FTS5 hybrid search with BM25 + ledger boost.
595/// 2. File-stem injection: for each query token, find files whose stem
596///    contains the token and inject a representative symbol for any file
597///    not already in the top-`depth` FTS results.
598/// 3. Ledger-aware file dedup: per-file, prefer the symbol that has ledger
599///    entries (invariant/hazard/decision) over a higher-scoring sibling
600///    that does not.  Invariant-bearing symbols must not be silently dropped.
601/// 4. Falls back to in-memory O(N) scoring when FTS is unavailable.
602pub fn find_candidates(
603    engine: &Engine,
604    query: &str,
605    tokens: &[String],
606    filters: &FtsFilters,
607    // Kept for signature compatibility with the many CLI/MCP call sites;
608    // the fallback path now reads the ledger in one bulk tree walk
609    // (`in_memory_scored_symbols`) instead of per-symbol `list_entries`.
610    _ledger_store: &AsgLedgerStore,
611    index_store: &AsgIndexStore,
612    depth: usize,
613) -> Vec<(f64, String)> {
614    // --- FTS path ---
615    // Borrow the engine's already-open FTS connection — no additional
616    // Connection::open() per call.  `fts` is `Option<&SearchFtsDb>` and
617    // replaces the former `Option<SearchFtsDb>` that was opened per-call.
618    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        // Ledger-entry cache: read once per symbol during scoring, reuse in
625        // covered_files check below. Eliminates 2× list_entries per top-N hit.
626        let ledger_cache: HashMap<String, Vec<crate::schema::LedgerEntry>> =
627            HashMap::with_capacity(hits.len());
628        // Also keep qname → (symbol_id, file) to avoid get_symbol_by_qname in covered_files.
629        let mut qname_to_sym: HashMap<String, (String, String)> =
630            HashMap::with_capacity(hits.len());
631
632        // M59: track which symbol_ids have ledger entries using the denormalized
633        // FTS fields — no list_entries call needed in the scoring loop.
634        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                // M59: use denormalized ledger_text/flags from FTS row — no list_entries call.
641                // ledger_text = all summaries concatenated (lowercase).
642                // ledger_flags = comma-separated kinds: "ownership,invariant,hazard,decision".
643                let (ledger_boost, ownership_struct_boost) = {
644                    let sot = if hit.has_ownership() { 2.0_f64 } else { 0.0 };
645                    // Count token hits in the concatenated ledger text and weight by kind.
646                    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                            // Weight by highest-priority kind flagged for this symbol.
657                            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        // File-stem injection: a file is "covered" only when the claiming
685        // symbol in the top-`depth` results itself has ledger entries.  If a
686        // non-ledger symbol holds the slot, the file stays open so a
687        // ledger-bearing sibling can be injected via stem.  Files ranked
688        // depth+1..depth*4 are always eligible for re-injection.
689        // Uses cached ledger data and pre-built qname→sym map — no extra git reads.
690        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                // M59: check has_ledger_ids (populated from FTS denormalized fields) first;
696                // fall back to ledger_cache for stem-injected hits added below.
697                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        // M62: one UNION ALL query for all tokens — replaces N serial round-trips.
710        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                        // Plan J t-006: extend the M28 view-query +2.0
719                        // boost to ALSO fire on `file_role == "view"`
720                        // and `"viewmodel"`. The original gate keyed
721                        // only off `classify_layer_sym`, which uses
722                        // directory + qname-suffix patterns; that
723                        // misses files where the path doesn't follow
724                        // the typical UI convention (`.vue`, `.svelte`,
725                        // or any project that puts views in
726                        // `app/components/` instead of `views/`).
727                        // `classify_file_role` (Plan J t-003) is the
728                        // newer, stem+extension-aware check — applying
729                        // EITHER signal closes the gap without
730                        // changing behavior on already-caught hits
731                        // (boost is the same magnitude, scoring is
732                        // monotone, the boost still only fires when
733                        // `is_view_query` is true).
734                        let view_boost = if is_view_query && view_layer_matches(layer, &hit.file) {
735                            2.0
736                        } else {
737                            0.0
738                        };
739                        // Extend maps so the has_ledger + file-dedup passes below
740                        // can use cache lookups instead of get_symbol_by_qname reads.
741                        qname_to_sym
742                            .entry(hit.qname.clone())
743                            .or_insert_with(|| (hit.symbol_id.clone(), hit.file.clone()));
744                        // M59: propagate ledger presence from stem hit into has_ledger_ids.
745                        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        // Ledger-aware file dedup: promote symbols with ledger entries above
758        // same-file competitors that only have a score advantage, so an
759        // invariant-bearing method is never silently dropped.
760        //
761        // Uses the ledger_cache and qname_to_sym maps built during the scoring
762        // and stem-injection passes — no extra git/index reads needed here.
763        let has_ledger: HashSet<String> = scored
764            .iter()
765            .filter_map(|(_, qname)| {
766                let (sym_id, _) = qname_to_sym.get(qname.as_str())?;
767                // M59: check has_ledger_ids first (no git read), then ledger_cache fallback.
768                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, // unknown qname (should not happen) — keep it
785            }
786        });
787        // Restore score order for the final result.
788        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        // Plan J t-011: negative globs + language exclusions.
800        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: inject invariant/hazard-bearing symbols that
816        // matched query tokens but were dropped by dedup or FTS ranking.
817        // M62: pass shared fts handle — no extra Connection::open() call.
818        ledger_anchor_pass(engine, fts, tokens, &mut scored);
819
820        return scored;
821    }
822
823    // Plan M t-006 (1.0.101): in-memory fallback extracted to a named pipeline stage.
824    fallback_in_memory_search(engine, tokens, filters, index_store, fts, depth)
825}
826
827/// Bulk in-memory scorer shared by every fallback-search caller (core
828/// `find_candidates`, MCP `code_search`, CLI `asd search`): ONE
829/// by-qname tree read — deserializing the Symbol values the tree
830/// already contains — plus ONE ledger-tree walk, instead of a
831/// per-qname symbol fetch and a per-symbol ledger read (~54ms each on
832/// a cold FTS cache — ~9 minutes at 10k symbols while holding the
833/// engine mutex; Plan T scale finding).
834///
835/// Returns `(score, Symbol)` for every indexed symbol that passes the
836/// optional kind/language filters and matches at least one token, in
837/// by-qname tree order; callers sort/truncate to their own contract.
838/// `kind_filter` compares against [`kind_str`] output (lowercase).
839pub 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    // One ledger walk → symbol_id → lowercased live-entry summary text
855    // (the same text `in_memory_score` derives from `list_entries`).
856    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
892/// Plan M t-006 (1.0.101): in-memory O(N) candidate scoring used when
893/// the FTS index is absent or empty. Lives behind a single fn so the
894/// FTS path in `find_candidates` stays readable and the fallback can
895/// be exercised by direct unit tests without spinning up a fake FTS.
896fn 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
928/// Explain why a symbol was returned for a query.
929///
930/// Returns short reason strings like `"name:playhead"`, `"doc:transport"`,
931/// `"ledger:2 invariants"`, `"recent-edit"`. Useful as `match_reasons` in
932/// agent output so the caller can understand which signal drove ranking.
933pub 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    // Ownership-boundary: this symbol is explicitly declared as the source-of-truth
977    // for a domain concept that overlaps the query.
978    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
1001// ---------------------------------------------------------------------------
1002// Uncertainty model helpers
1003// ---------------------------------------------------------------------------
1004
1005/// Normalize raw scores to [0.1, 1.0] confidence within the result set.
1006/// The highest-scoring result gets 1.0; the lowest gets 0.1.
1007pub 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
1026/// Classify a result into a semantic bucket.
1027///
1028/// - `core`       — has ledger entries AND high-signal name match or hot file
1029/// - `relevant`   — has ledger OR name match (previously "supporting", signal-bearing)
1030/// - `peripheral` — only doc or file-path match; no ledger, name, or recent edit
1031/// - `noisy`      — no match signal at all
1032/// - `test-only`  — symbol lives in a test file
1033pub 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
1059/// One-line explanation of why a result has high or low confidence.
1060///
1061/// Used in `--explain` output and agent JSON to replace the bare bucket label.
1062pub 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
1100/// Detect query tokens that match too many unrelated files (broad/ambiguous terms).
1101///
1102/// Returns token strings whose FTS hit count across distinct files exceeds the
1103/// threshold, indicating they will add noise rather than precision.
1104///
1105/// Uses a single SQL UNION ALL query to check all tokens in one round-trip
1106/// (previously N separate fts.search() calls, each fetching and iterating rows).
1107pub 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
1137/// Heuristic possible-miss warnings for a result set.
1138///
1139/// Checks whether the result set covers the layers implied by the query.
1140/// Returns human-readable warning strings for use in `possible_misses` output.
1141/// Pass `scope_narrowed = true` to suppress all warnings when the user
1142/// intentionally restricted results via --scope / --paths / --exclude (t-001).
1143pub 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    // UI/view query with no view-layer results.
1152    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    // t-004: service/domain query with results only in UI layer.
1168    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    // t-004: data/model query missing persistence layer.
1192    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    // t-005: Named-layer warnings for scheduler, engine, and network.
1213    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    // Very few results for a precise multi-word query.
1271    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
1282/// Suggest refined, scoped queries when the current query contains ambiguous terms.
1283///
1284/// Extracts domain-specific co-occurring tokens from the top result qnames and
1285/// combines them with the non-ambiguous query tokens to produce 2–3 focused suggestions.
1286pub 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    // Extract candidate narrowing terms from top result qnames (last segment words).
1303    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        // Split CamelCase into words.
1310        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        // Also split snake_case.
1323        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    // Pick top 3 most-common co-occurring terms not already in the query.
1336    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
1351/// Distinguish whether low result confidence comes from ambiguous terms or a
1352/// sparse index. Returns a vec of typed warning objects with kind + message.
1353///
1354/// kinds: "ambiguous_term" | "sparse_index"
1355pub 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    // Ambiguous terms already detected upstream — annotate them with advice.
1365    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    // Sparse index: very few results but no ambiguous terms → index may be thin.
1377    if result_count < 3 && ambiguous_terms.is_empty() && !tokens.is_empty() {
1378        // Check total indexed symbol count as a proxy for index completeness.
1379        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
1395/// Suggest more specific queries when the current query is too broad.
1396///
1397/// Returns a vec of suggestion strings. Empty when the query is already focused.
1398pub 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    // Single-token broad queries.
1405    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    // Stopword-heavy query (>50% stopwords with 3+ tokens).
1421    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    // Very common single terms likely to be ambiguous.
1429    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
1447/// Tokenise a feedback query string into a set of non-stopword terms.
1448fn 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
1456/// Extract name tokens from a fully-qualified symbol name for sibling suppression.
1457/// Takes the last `::` or `.`-delimited segment, then splits snake_case by `_`
1458/// and CamelCase at uppercase boundaries.
1459fn name_tokens_from_qname(qname: &str) -> HashSet<String> {
1460    let last = qname
1461        .rsplit(|c| c == ':' || c == '.')
1462        .next()
1463        .unwrap_or(qname);
1464    // Split CamelCase at uppercase boundaries.
1465    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    // Also split by `_`.
1477    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
1485/// t-003: Query-family matching — a stored verdict applies to the current query
1486/// when their token sets overlap by at least one token (not just substring match).
1487/// This makes "drift playhead" verdicts apply to "playhead drift" and
1488/// "drift pad playhead position".
1489fn 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    // Any shared non-stopword token means the queries are in the same family.
1501    fb_tokens.iter().any(|t| current_query_tokens.contains(t))
1502}
1503
1504/// Apply feedback verdicts as score adjustments after candidate selection.
1505///
1506/// Metrics returned by `apply_feedback_adjustments`, describing how feedback
1507/// changed the ranked result set for this query.
1508#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
1509pub struct FeedbackMetrics {
1510    /// Feedback entries whose stored query matched the current query family.
1511    pub entries_applied: usize,
1512    /// Total symbols removed from results (all causes combined).
1513    pub suppressed: usize,
1514    /// Symbols in a noisy file preserved because they had an explicit Useful verdict.
1515    pub preserved_useful_siblings: usize,
1516    /// Useful verdicts that boosted a symbol's score.
1517    pub boosted: usize,
1518    /// Symbols suppressed by the recurring false-positive rule (≥2 noisy verdicts).
1519    pub recurring_fp_suppressed: usize,
1520    /// Plan C t-003: symbols suppressed by an active Constraint/Decision
1521    /// ledger entry carrying a penalty role (stale-api / audit-pending).
1522    /// Independent of `suppressed` so callers can tell ranking-time
1523    /// decisions apart from feedback-time verdicts.
1524    #[serde(default)]
1525    pub constraint_penalties_applied: usize,
1526    /// Plan C t-006: candidates boosted by +1.0 because they fall inside
1527    /// the active CTX task's recorded scope. Soft bias only.
1528    #[serde(default)]
1529    pub task_bias_boosted: usize,
1530    /// Unique rule names that fired, in first-encounter order.
1531    /// Values: "exact_noisy", "same_file_sibling_suppression", "useful_boost",
1532    /// "recurring_fp", "constraint_penalty" (Plan C t-003), "task_bias"
1533    /// (Plan C t-006), "already_covered" / "diagnostic_only" (Plan C t-005).
1534    pub rules_applied: Vec<String>,
1535}
1536
1537/// `Useful` entries add a boost; `Noisy` / `WrongLayer` entries demote the
1538/// symbol to negative infinity (effectively removed). Also:
1539///
1540/// - t-002: Sibling propagation — when a symbol in a file is marked Noisy or
1541///   WrongLayer, all other symbols from the same file are also suppressed for
1542///   query families that overlap the noisy verdict.
1543/// - t-003: Query-family matching — a stored verdict applies to the current
1544///   query when they share at least one non-stopword token, not just exact
1545///   substring match.
1546/// - Recurring false positives — symbols with ≥2 noisy verdicts sharing a
1547///   query token are suppressed for any query that overlaps those tokens.
1548///
1549/// Called at CLI/MCP call sites after `find_candidates`.
1550/// Plan C t-006: read the active CTX task scope (file globs) and return
1551/// it for bias scoring. Source preference: `CTX_ACTIVE_TASK` env var
1552/// (JSON object), else `.asd/cache/active-task.json` relative to the
1553/// db's parent dir, else None. Schema: `{"task_id": "...", "scope":
1554/// ["src/foo/**", ...]}` — only the `scope` array is consumed.
1555///
1556/// Returns None when no task state is found or the scope is empty. The
1557/// caller treats a None return as "no bias to apply".
1558pub 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
1563/// Plan E t-002: parallel-safe variant. Takes the raw env string
1564/// explicitly so unit tests can exercise the parser without mutating
1565/// process env (which is racy under `cargo test`'s default parallel
1566/// runner). Production code path stays simple via the wrapper above.
1567pub 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
1588/// Plan C t-006: apply a soft `boost` (default 1.0) to candidates whose
1589/// file matches any glob in the active task's scope. Soft = additive,
1590/// never a hard filter — so a wrong scope guess at most demotes the
1591/// agent's intended targets relative to in-scope ones, never hides
1592/// them. Returns the boosted count.
1593pub 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    // Plan E t-005: bulk qname→file lookup via the FTS cache when
1605    // available — one SQL roundtrip instead of N. Falls back to the
1606    // per-candidate index lookup when fts is None (in-memory engines,
1607    // mid-rebuild).
1608    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        // Bulk-cache hit avoids the index lookup.
1627        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
1643/// Plan E t-008: parse the optional `scope: ["glob"]` array out of a
1644/// constraint entry's body. The body may itself be a JSON-encoded
1645/// string OR null. Returns None when no scope is present (penalty
1646/// applies globally) or when parsing fails — same fallback as the
1647/// SQL-side helper.
1648fn 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
1660/// Plan C t-003: synthesize WrongLayer-like suppression from
1661/// Constraint/Decision ledger entries that carry a penalty `role`
1662/// (currently `stale-api` and `audit-pending`). Walks each candidate's
1663/// ledger entries via the existing AsgLedgerStore; matches go to
1664/// `NEG_INFINITY` just like the existing Noisy/WrongLayer path.
1665///
1666/// Returns the count of candidates suppressed. Cheap on small candidate
1667/// sets (one ledger walk per symbol via the SQLite cache); large
1668/// candidate sets can be optimized later via index-time denormalization
1669/// if needed.
1670pub fn apply_constraint_penalties(
1671    engine: &Engine,
1672    index_store: &AsgIndexStore,
1673    scored: &mut Vec<(f64, String)>,
1674) -> usize {
1675    // Plan E t-004: prefer one-shot SQL over per-candidate ledger walk.
1676    // Plan E t-008: each penalty entry MAY carry a `scope: [glob]` list in
1677    // its body JSON — if present, suppression only fires when the
1678    // candidate's file matches at least one glob. Entries without scope
1679    // apply globally (preserves the original Plan C t-003 semantics).
1680    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                // Build sym_id → Vec<Option<Vec<String>>> so the same
1684                // symbol may have several penalty entries (e.g. one global
1685                // + one scoped). The candidate is suppressed when AT
1686                // LEAST ONE entry would suppress it.
1687                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, // global — always suppresses
1704                            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            // No penalty entries in the cache — nothing to suppress.
1715            return 0;
1716        }
1717    }
1718
1719    // Fallback: per-candidate ledger walk when the FTS cache isn't
1720    // available. Same semantics as the original implementation.
1721    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            // Plan E t-008: honor scope[] in body JSON if present.
1753            // Symmetric with the fast path.
1754            match constraint_scope_from_body(entry.body.as_deref()) {
1755                None => true, // global penalty
1756                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    // Plan J t-016: tuple gained `created_at` so the Useful boost can
1773    // decay by age. Tuple shape kept (vs converting to a struct) to
1774    // minimize churn across the ~7 CLI/MCP callers; the trait helper
1775    // `FeedbackStore::flat_verdicts` already produces this shape.
1776    feedback_entries: &[(
1777        String,
1778        String,
1779        crate::schema::FeedbackVerdict,
1780        chrono::DateTime<chrono::Utc>,
1781    )],
1782) -> FeedbackMetrics {
1783    // Plan C t-006: bias toward the active CTX task's scope first, so
1784    // in-scope candidates start with a small head-start before
1785    // suppression/boost rules run.
1786    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    // Plan C t-003: apply constraint penalties FIRST so feedback rules
1801    // don't have to special-case symbols already suppressed.
1802    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    // Metrics accumulators.
1821    let entries_applied = feedback_entries
1822        .iter()
1823        .filter(|(_, fb_q, _, _)| query_family_matches(&current_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    // --- Recurring false positive map ---
1839    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    // --- t-001: Build noisy-file map via asd_symbols_meta (M60: no git read).
1874    // Falls back to get_tree when FTS index is unavailable.
1875    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(&current_tokens, fb_query)
1889            })
1890            .map(|(sym_id, _, _, _)| sym_id.as_str())
1891            .collect();
1892        if !noisy_ids.is_empty() {
1893            // M60: bulk-resolve noisy symbol_ids via FTS — one SQL query.
1894            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                // Fallback: git object store.
1912                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    // M60: pre-fetch all scored qnames in one SQL batch — no per-symbol git read.
1936    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    // --- Score adjustments ---
1944    for (score, qname) in scored.iter_mut() {
1945        // M60: use FTS bulk-resolved data; fall back to git read if missing.
1946        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        // Reconstruct a minimal view for the checks below.
1957        let sym_kind_enum = match sym_kind.as_str() {
1958            "class" => SymbolKind::Class,
1959            "module" => SymbolKind::Module,
1960            _ => SymbolKind::Function,
1961        };
1962
1963        // Sibling file suppression: a symbol sharing a file with a noisy symbol
1964        // is suppressed only when it passes ALL three guards:
1965        //   1. Not a Class/Module container (those host unrelated symbols).
1966        //   2. Not the noisy symbol itself (already handled below).
1967        //   3. Its name tokens overlap with the noisy symbol's name tokens
1968        //      (t-001: avoid collateral demotions on unrelated symbols).
1969        //   4. It has no explicit Useful verdict for this query family.
1970        //   5. It has no independent query affinity (its own name doesn't match
1971        //      the current query tokens — if it does, it's genuinely relevant).
1972        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(&current_tokens, fq)
1980                });
1981                if !has_useful {
1982                    // t-001: name-specificity guard — only suppress if the sibling's
1983                    // own name tokens overlap with those of the noisy symbol.
1984                    // Symbols with no name overlap are unrelated and must not be demoted.
1985                    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                    // Query-affinity guard: if the sibling's own name matches the current
1991                    // query tokens it is independently relevant and must be preserved.
1992                    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        // Per-symbol verdict matching (t-003: query-family aware).
2007        // Plan J t-016: snapshot `now` ONCE per scoring pass so all
2008        // entries in this loop decay against the same instant —
2009        // avoids the (cosmetic but real) bug where two entries with
2010        // the same created_at could get slightly different decay
2011        // factors if Utc::now() is called inside the loop body.
2012        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            // t-003: use token-family matching instead of substring containment.
2018            if !query_family_matches(&current_tokens, fb_query) {
2019                continue;
2020            }
2021            match verdict {
2022                crate::schema::FeedbackVerdict::Useful => {
2023                    // Plan J t-016: scale the +1.5 boost by age-based
2024                    // decay. Negative verdicts (Noisy / WrongLayer /
2025                    // AlreadyCovered / DiagnosticOnly) intentionally
2026                    // do NOT decay — they're explicit "this is wrong"
2027                    // signals, not preferences. The agent can use
2028                    // `asd feedback mark --ttl-days N` from Plan J
2029                    // t-014 if they want soft expiry on a negative.
2030                    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                    // Plan C t-005: suppress like Noisy, plus a distinct
2046                    // rule label so callers can tell what drove the cut.
2047                    *score = f64::NEG_INFINITY;
2048                    record_rule!("already_covered");
2049                }
2050                crate::schema::FeedbackVerdict::DiagnosticOnly => {
2051                    // Plan C t-005: suppress like Noisy; the t-003
2052                    // constraint-penalty pass will also catch the symbol
2053                    // on later queries once the caller writes the matching
2054                    // Classification entry.
2055                    *score = f64::NEG_INFINITY;
2056                    record_rule!("diagnostic_only");
2057                }
2058                crate::schema::FeedbackVerdict::Missing => {}
2059            }
2060        }
2061
2062        // Recurring false positive suppression.
2063        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/// Per-result explanation of which feedback verdict affected a search hit.
2099#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2100pub struct FeedbackImpact {
2101    /// Verdict that applied: "useful", "noisy", "wrong_layer", or "sibling_suppressed".
2102    pub verdict: String,
2103    /// The original feedback query that triggered this impact.
2104    pub matched_query: String,
2105    /// The author who recorded the feedback.
2106    pub author: String,
2107}
2108
2109/// For each qname in `qnames`, determine which feedback verdict (if any)
2110/// affected it and return a map of qname → FeedbackImpact.
2111///
2112/// Used by search commands to annotate results with why they were boosted,
2113/// demoted, or suppressed.
2114pub 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    // M60: Build noisy-file map via resolve_symbol_ids_bulk — no git read.
2129    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(&current_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                // Fallback: git object store.
2164                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    // M60: pre-fetch all qnames in one SQL batch.
2187    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        // Check per-symbol verdicts first (direct match takes priority).
2215        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(&current_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        // Check sibling suppression.
2240        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(&current_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
2273// ---------------------------------------------------------------------------
2274// File-scope feedback (t-003)
2275// ---------------------------------------------------------------------------
2276
2277/// Apply file-scoped feedback verdicts to `scored`.
2278///
2279/// Entries with `file_scope` set demote or boost all symbols from files
2280/// matching the glob pattern, subject to query-family matching.
2281pub fn apply_file_scope_feedback(
2282    engine: &Engine,
2283    index_store: &AsgIndexStore,
2284    query: &str,
2285    scored: &mut Vec<(f64, String)>,
2286    // Plan J t-016: tuple gained `created_at` so Useful boosts on
2287    // file-scope verdicts decay by age, same as the per-symbol path
2288    // in `apply_feedback_adjustments`.
2289    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    // M60: pre-fetch all scored qnames in one SQL batch — no per-symbol git read.
2303    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    // Plan J t-016: snapshot now once for the whole pass; see
2311    // matching comment in apply_feedback_adjustments.
2312    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(&current_tokens, entry_query) {
2327                continue;
2328            }
2329            if !glob_match(file_glob, &file) {
2330                continue;
2331            }
2332            // Plan C t-005: all suppression verdicts collapse here. Boost
2333            // and Missing handled explicitly; rest delegates to
2334            // `verdict.is_suppression()` so future variants don't need a
2335            // separate file-scope branch.
2336            match verdict {
2337                crate::schema::FeedbackVerdict::Useful => {
2338                    // Plan J t-016: age-decay the Useful boost.
2339                    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// ---------------------------------------------------------------------------
2359// Uncertainty Model — structured reason codes and recovery quality metrics
2360// ---------------------------------------------------------------------------
2361
2362/// A single, machine-readable uncertainty signal.
2363///
2364/// Each variant carries only the data required to understand and act on it.
2365/// CLI commands collect these into an [`UncertaintyReport`].
2366#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2367#[serde(tag = "code", rename_all = "snake_case")]
2368pub enum UncertaintyReason {
2369    /// A query token matches an excessive number of unrelated files.
2370    /// Action: add a more specific co-occurring domain term.
2371    /// Source: `query`
2372    AmbiguousTerm {
2373        term: String,
2374        /// Number of distinct files containing this token (if known).
2375        #[serde(skip_serializing_if = "Option::is_none")]
2376        file_count: Option<usize>,
2377    },
2378    /// The query consists entirely of generic/stopword-like tokens with
2379    /// no domain anchor. Results are likely noisy.
2380    /// Source: `query`
2381    GenericQuery { tokens: Vec<String> },
2382    /// Fewer results than expected for a multi-term query. Index may be
2383    /// incomplete for this area, or the query is too specific.
2384    /// Source: `result_set`
2385    LowResultCount { count: usize },
2386    /// The query implies a layer that is absent from the result set.
2387    /// Source: `result_set`
2388    MissingLayer {
2389        expected_layer: String,
2390        /// Short human-readable hint about what to check.
2391        hint: String,
2392    },
2393    /// Very few results and no ambiguous terms — the index may not cover
2394    /// this domain yet.
2395    /// Source: `result_set`
2396    SparseIndex,
2397    /// The workspace has no annotation data — results are based on
2398    /// structural/FTS signals only, so confidence is inherently reduced.
2399    /// Source: `db_state`
2400    DbStateUnannotated {
2401        /// The specific state code, e.g. "unannotated", "clean_room".
2402        state: String,
2403    },
2404}
2405
2406impl UncertaintyReason {
2407    /// Score contribution: how much uncertainty this reason adds (0.0–1.0 range).
2408    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    /// Which high-level source category does this reason belong to?
2420    ///
2421    /// Returns one of: `"query"` | `"db_state"` | `"result_set"`
2422    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/// Recovery quality estimate for a single scoped suggestion.
2435#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2436pub struct RecoverySuggestion {
2437    /// The suggested narrowed query string.
2438    pub query: String,
2439    /// Number of ambiguous terms eliminated by this suggestion.
2440    pub ambiguous_terms_removed: usize,
2441    /// Domain-specific tokens added vs the original query.
2442    pub domain_terms_added: Vec<String>,
2443    /// Coarse recovery estimate: "strong" | "partial" | "weak".
2444    pub estimated_recovery: String,
2445}
2446
2447/// Per-source uncertainty score contributions.
2448///
2449/// Allows agents to understand *why* uncertainty is high and which dimension
2450/// to address first — rather than treating it as a single opaque number.
2451#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2452pub struct UncertaintySourceBreakdown {
2453    /// Contribution from query signals (ambiguous terms, generic query).
2454    /// Range [0.0, 1.0]; reduce by narrowing or adding domain terms.
2455    pub query: f64,
2456    /// Contribution from workspace/DB state (unannotated, clean-room).
2457    /// Range [0.0, 1.0]; reduce by annotating commits or running task-close.
2458    pub db_state: f64,
2459    /// Contribution from result-set quality (low count, missing layers, sparse index).
2460    /// Range [0.0, 1.0]; reduce by broadening the query or checking index coverage.
2461    pub result_set: f64,
2462    /// Which source dominates: "query" | "db_state" | "result_set" | "none".
2463    pub primary: String,
2464}
2465
2466/// Machine-readable uncertainty rollup.
2467///
2468/// Always emitted — even when `level` is "low", so dashboards can rely on
2469/// structural consistency without null-checking.
2470#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2471pub struct UncertaintyReport {
2472    /// Aggregated uncertainty level: "low" | "medium" | "high" | "critical".
2473    pub level: String,
2474    /// Normalised uncertainty score [0.0, 1.0] (0 = certain, 1 = completely uncertain).
2475    pub score: f64,
2476    /// Structured reasons, sorted by weight descending.
2477    pub reasons: Vec<UncertaintyReason>,
2478    /// Single most-actionable recommendation for the agent.
2479    /// Values: "none" | "narrow_query" | "add_domain_term" | "check_index_coverage" | "broaden_query"
2480    pub recommended_action: String,
2481    /// Recovery quality metrics: one entry per scoped suggestion.
2482    pub recovery_suggestions: Vec<RecoverySuggestion>,
2483    /// True when the query is an exact match for a known indexed symbol name.
2484    ///
2485    /// When true, the level is capped at "medium" regardless of ambiguity signals —
2486    /// because the agent typed the exact name they want and ASD knows that symbol.
2487    pub exact_symbol_match: bool,
2488    /// Per-source uncertainty breakdown: query / db_state / result_set.
2489    ///
2490    /// Lets agents target the right fix (refine query, annotate more, broaden scope)
2491    /// instead of responding generically to a high uncertainty level.
2492    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
2501/// Build a structured [`UncertaintyReport`] from the signals already computed
2502/// by the search/investigate/prepare-change pipeline.
2503///
2504/// # Parameters
2505/// - `tokens` — parsed query tokens (post-stopword filter)
2506/// - `ambiguous_terms` — tokens detected by `detect_ambiguous_tokens`
2507/// - `possible_misses` — prose warnings from `detect_possible_misses` (parsed for layer names)
2508/// - `result_count` — number of results returned
2509/// - `scoped_suggestions` — suggestions from `suggest_scoped_queries`
2510/// - `fts` — borrowed FTS connection used to look up per-token file counts for richer reason data
2511/// - `db_state` — optional data-quality state from `compute_trust_score` (e.g. "unannotated").
2512///   When `Some("unannotated")` or `Some("clean_room")`, a `DbStateUnannotated` reason is added.
2513pub 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    // -----------------------------------------------------------------------
2525    // 1. Ambiguous terms — one reason per term with file count if available
2526    // -----------------------------------------------------------------------
2527    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    // -----------------------------------------------------------------------
2546    // 2. Generic query — all meaningful tokens are broad/stopword-adjacent
2547    // -----------------------------------------------------------------------
2548    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    // -----------------------------------------------------------------------
2565    // 3. Low result count (< 3 and multi-term query)
2566    // -----------------------------------------------------------------------
2567    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    // -----------------------------------------------------------------------
2577    // 4. Missing layer — parse possible_misses prose for layer names
2578    // -----------------------------------------------------------------------
2579    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; // Unknown layer, skip structured encoding
2613        };
2614        reasons.push(UncertaintyReason::MissingLayer {
2615            expected_layer: layer.to_string(),
2616            hint: hint.to_string(),
2617        });
2618    }
2619
2620    // -----------------------------------------------------------------------
2621    // 5. Sparse index — few results, no ambiguity → index may not cover this
2622    // -----------------------------------------------------------------------
2623    if result_count < 3 && ambiguous_terms.is_empty() && !tokens.is_empty() && result_count > 0 {
2624        reasons.push(UncertaintyReason::SparseIndex);
2625    }
2626
2627    // -----------------------------------------------------------------------
2628    // 6. DB state — workspace has no annotations yet (unannotated / clean_room)
2629    // -----------------------------------------------------------------------
2630    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    // -----------------------------------------------------------------------
2639    // Score + level
2640    // -----------------------------------------------------------------------
2641    // Cap ambiguous_term contributions at 2 terms (0.40 max) to avoid
2642    // artificially high scores for long ambiguous queries.
2643    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    // -----------------------------------------------------------------------
2668    // Exact-symbol override guard
2669    //
2670    // If the query is a single meaningful token that exactly matches a known
2671    // symbol name in the FTS index, cap the level at "medium".  This prevents
2672    // false-high uncertainty when an agent uses the exact symbol name — the
2673    // ambiguity signals fire because the token is also a broad term in other
2674    // contexts, but if the symbol exists verbatim, ASD knows exactly what the
2675    // agent wants.
2676    // -----------------------------------------------------------------------
2677    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        // Only check if the token looks like a proper name (starts with uppercase
2681        // or is >= 5 chars to avoid false-positives on tiny common words).
2682        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    // Cap level if exact symbol match — agent is asking for a specific known thing.
2692    let level = if exact_symbol_match && matches!(raw_level, "high" | "critical") {
2693        "medium"
2694    } else {
2695        raw_level
2696    };
2697
2698    // -----------------------------------------------------------------------
2699    // Recommended action (most actionable, highest-weight reason wins)
2700    // -----------------------------------------------------------------------
2701    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    // -----------------------------------------------------------------------
2728    // Recovery quality metrics per suggestion
2729    // -----------------------------------------------------------------------
2730    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            // How many ambiguous terms does this suggestion eliminate?
2745            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            // Which tokens are genuinely new (not in original query)?
2753            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    // Sort reasons by weight descending for readability.
2779    reasons.sort_by(|a, b| {
2780        b.weight()
2781            .partial_cmp(&a.weight())
2782            .unwrap_or(std::cmp::Ordering::Equal)
2783    });
2784
2785    // -----------------------------------------------------------------------
2786    // Source breakdown: per-source contribution scores
2787    // -----------------------------------------------------------------------
2788    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
2841// ---------------------------------------------------------------------------
2842// Feedback State
2843// ---------------------------------------------------------------------------
2844
2845use crate::feedback::{AsgFeedbackStore, FeedbackStore};
2846
2847/// Explicit description of feedback availability for the current query.
2848///
2849/// Allows agents to distinguish "no feedback exists" from "feedback exists
2850/// but didn't fire" from "feedback applied and modified results" — so that
2851/// a clean-room DB reset is never silently misread as a broken ranker.
2852#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2853pub struct FeedbackState {
2854    /// Whether any feedback entries exist for this workspace at all.
2855    pub available: bool,
2856    /// Reason code:
2857    /// - `"no_feedback_entries"` — clean-room or reset, nothing recorded
2858    /// - `"entries_exist_no_query_match"` — feedback exists but none matches
2859    /// - `"entries_applied"` — feedback entries influenced this query
2860    pub reason: String,
2861    /// Total feedback entries recorded across all symbols and queries.
2862    pub entries_total: usize,
2863    /// Number of feedback entries whose recorded query shares at least one
2864    /// token with the current query.
2865    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
2874/// Build a [`FeedbackState`] for `query` from the feedback store.
2875///
2876/// `entries_applied > 0` (from the `FeedbackMetrics` produced during ranking)
2877/// is passed in so we can distinguish applied vs present-but-unmatched.
2878/// Compute [`FeedbackState`] from a pre-fetched `entries` slice.
2879/// Prefer this over [`build_feedback_state`] when the caller already holds
2880/// the result of `list_all()` — avoids a second git-object read.
2881pub 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    // Count entries whose recorded query shares ≥1 token with current query.
2898    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
2929/// Convenience wrapper that fetches feedback entries before calling
2930/// [`build_feedback_state_from_entries`].  Use the `_from_entries` variant
2931/// when you already hold a `list_all()` result.
2932pub 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    // Plan J t-014: feedback-state-building is part of the ranking
2940    // path, so expired entries are filtered out here too.
2941    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    //! Decisions-as-constraints regression. A Constraint or Decision
2953    //! ledger entry carrying a penalty `role` (stale-api / audit-pending)
2954    //! must suppress its symbol in ranked results just like an explicit
2955    //! `WrongLayer` feedback verdict would.
2956
2957    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        // A bare Constraint with no role is still a passive note —
3027        // backward-compatible with the pre-Plan-C ledger model.
3028        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        // package-boundary is a BOOST role, not a penalty role — should
3040        // not trigger NEG_INFINITY.
3041        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        // Empty feedback set — exercises the early-return branch.
3056        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    // -- Plan C t-005: AlreadyCovered + DiagnosticOnly verdicts ------------
3069
3070    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(), // Plan J t-016: created_at for decay
3104            )],
3105        );
3106        // apply_feedback_adjustments retains only finite scores at the
3107        // end, so a NEG_INFINITY-suppressed symbol is removed entirely.
3108        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(), // Plan J t-016: created_at for decay
3127            )],
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    // -- Plan C t-006: CTX task bias ----------------------------------------
3145
3146    #[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        // In-scope was boosted, out-of-scope was not.
3176        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    // Plan E t-002: these tests now use read_active_task_scope_from()
3225    // and pass the env string explicitly. No process-env mutation, so
3226    // they're parallel-safe (the wrapper `read_active_task_scope` is a
3227    // thin std::env::var call covered by integration / smoke testing).
3228
3229    #[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    // -- Plan E t-008: scoped constraint penalties --------------------------
3261
3262    #[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        // Constraint scoped to src/legacy/** should suppress
3340        // app.legacy.api but NOT app.modern.api.
3341        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        // No body / no scope means global penalty — preserves Plan C t-003
3359        // behavior.
3360        let (engine, legacy_id, _modern_id) = seed_engine_with_two_symbols();
3361        append_constraint(&engine, &legacy_id, Some("stale-api")); // no body
3362        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    //! Plan J t-012: `match_reasons` is the per-hit "why this result"
3373    //! array surfaced on every search/investigate response. The
3374    //! infrastructure already shipped (Plan A / M26 era); these
3375    //! tests lock the signal-vocabulary contract so future tweaks
3376    //! can't silently shrink what's reported.
3377
3378    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    //! Plan J t-011: `[exclude_sets]` table parsing in `.asd/scopes.toml`
3517    //! and `resolve_exclude_set` lookups.
3518
3519    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        // load_exclude_sets reads `<db_parent>/scopes.toml` — NOT
3524        // under `.asd/`. The loader was designed before `.asd/` was
3525        // standardized; the existing path is load-bearing. Mirror it.
3526        std::fs::write(dir.join("scopes.toml"), body).unwrap();
3527    }
3528
3529    fn db_path_in(dir: &std::path::Path) -> PathBuf {
3530        // `<dir>/.asd-state.db` → parent is `<dir>` → loader joins
3531        // `scopes.toml` → reads `<dir>/scopes.toml`.
3532        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        // Unlike --scope (which falls back to treating the name as a
3569        // glob), --exclude-set on an unknown name returns [] so a typo
3570        // can't silently morph into a path filter.
3571        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        // Sanity: these globs actually exclude the kinds of files we expect.
3590        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        // The two namespaces (bare keys = scope aliases, [exclude_sets]
3598        // table = exclude sets) must not interfere.
3599        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        // Cross-namespace: a bare alias is NOT an exclude set and
3611        // vice-versa.
3612        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    //! Plan J t-006: view-query +2.0 boost gate union with file_role.
3620
3621    use super::view_layer_matches;
3622
3623    #[test]
3624    fn ui_layer_alone_qualifies() {
3625        // Pre-existing behavior preserved: any "ui" layer fires.
3626        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        // Plan J t-006: layer="other" (classify_layer misses the
3643        // file) BUT classify_file_role picks it up via stem suffix.
3644        // This is the M22 field-eval case — `*View.swift` outside
3645        // a /views/ directory used to miss the boost.
3646        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        // Web frontends — single-file components with .vue/.svelte
3655        // extension. classify_layer typically misses these (no
3656        // suffix convention); file_role catches them.
3657        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        // *ViewModel.kt in a non-`/viewmodels/` directory.
3664        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        // Negative: a regular implementation file shouldn't get the
3673        // boost just because the layer string is unrecognized.
3674        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        // From t-003 unit tests: stem_ends_with avoids `Preview*`
3681        // false-matching `view`. Lock the same regression here so
3682        // we don't reintroduce it via the boost gate.
3683        assert!(!view_layer_matches(
3684            "other",
3685            "src/preview/PreviewService.swift"
3686        ));
3687    }
3688}