use std::collections::{HashMap, HashSet};
use std::path::Path;
use crate::engine::Engine;
use crate::index::{AsgIndexStore, IndexStore};
use crate::ledger::{AsgLedgerStore, LedgerStore};
use crate::schema::{LedgerEntry, LedgerKind, Symbol, SymbolKind};
use crate::search_fts::{
FtsFilters, SearchFtsDb, classify_file_role, classify_layer_sym, hybrid_boost, is_stopword,
};
pub fn query_tokens(query: &str) -> Vec<String> {
query
.split(|c: char| c.is_whitespace() || c == '_' || c == '-' || c == '.')
.map(|t| t.to_lowercase())
.filter(|t| t.len() >= 2 && !is_stopword(t))
.collect()
}
pub fn parse_query(query: &str) -> (Vec<String>, Vec<String>) {
let mut positive_words: Vec<&str> = Vec::new();
let mut exclusions: Vec<String> = Vec::new();
for word in query.split_whitespace() {
if let Some(excl) = word.strip_prefix('-') {
let term = excl.to_lowercase();
if term.len() >= 2 {
exclusions.push(term);
}
} else {
positive_words.push(word);
}
}
let tokens = positive_words
.join(" ")
.split(|c: char| c.is_whitespace() || c == '_' || c == '-' || c == '.')
.map(|t| t.to_lowercase())
.filter(|t| t.len() >= 2 && !is_stopword(t))
.collect();
(tokens, exclusions)
}
pub fn view_layer_matches(layer: &str, file: &str) -> bool {
if layer == "ui" || layer == "viewmodel" {
return true;
}
let role = classify_file_role(file);
role == "view" || role == "viewmodel"
}
pub fn kind_str(kind: &SymbolKind) -> &'static str {
match kind {
SymbolKind::Module => "module",
SymbolKind::Function => "function",
SymbolKind::Method => "method",
SymbolKind::Class => "class",
SymbolKind::Variable => "variable",
}
}
pub fn in_memory_score(
sym: &crate::Symbol,
tokens: &[String],
ledger_store: &AsgLedgerStore,
engine: &Engine,
) -> u32 {
let ledger_text: String = ledger_store
.list_entries(&engine.ref_name, &sym.symbol_id)
.unwrap_or_default()
.iter()
.map(|e| e.summary.to_lowercase())
.collect::<Vec<_>>()
.join(" ");
in_memory_score_with_text(sym, tokens, &ledger_text)
}
pub fn in_memory_score_with_text(sym: &crate::Symbol, tokens: &[String], ledger_text: &str) -> u32 {
let qname_lower = sym.qname.to_lowercase();
let sig_lower = sym.signature.as_deref().unwrap_or("").to_lowercase();
let doc_lower = sym.doc.as_deref().unwrap_or("").to_lowercase();
let file_lower = sym.file.to_lowercase();
let mut score: u32 = 0;
for token in tokens {
if qname_lower.contains(token.as_str()) {
score += 4;
}
if !sig_lower.is_empty() && sig_lower.contains(token.as_str()) {
score += 3;
}
if !doc_lower.is_empty() && doc_lower.contains(token.as_str()) {
score += 3;
}
if !ledger_text.is_empty() && ledger_text.contains(token.as_str()) {
score += 2;
}
if file_lower.contains(token.as_str()) {
score += 1;
}
}
score
}
pub fn glob_match(pattern: &str, path: &str) -> bool {
glob_match_parts(
&pattern.split('/').collect::<Vec<_>>(),
&path.split('/').collect::<Vec<_>>(),
)
}
fn glob_match_parts(pat: &[&str], path: &[&str]) -> bool {
match (pat.first(), path.first()) {
(None, None) => true,
(None, _) | (_, None) => {
pat.is_empty() && path.is_empty()
|| pat.iter().all(|p| *p == "**") && path.is_empty()
|| pat.is_empty() && path.is_empty()
}
(Some(&"**"), _) => {
glob_match_parts(&pat[1..], path) || glob_match_parts(pat, &path[1..])
}
(Some(p), Some(s)) => segment_match(p, s) && glob_match_parts(&pat[1..], &path[1..]),
}
}
fn segment_match(pat: &str, seg: &str) -> bool {
let parts: Vec<&str> = pat.split('*').collect();
if parts.len() == 1 {
return pat == seg;
}
let mut remaining = seg;
for (i, part) in parts.iter().enumerate() {
if i == 0 {
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 {
if !remaining.ends_with(part) {
return false;
}
} else {
match remaining.find(part) {
Some(pos) => remaining = &remaining[pos + part.len()..],
None => return false,
}
}
}
true
}
pub fn matches_any_path_glob(globs: &[String], file: &str) -> bool {
globs.iter().any(|g| glob_match(g.as_str(), file))
}
pub fn load_scope_aliases(db_path: &Path) -> HashMap<String, Vec<String>> {
let scopes_path = db_path
.parent()
.unwrap_or(Path::new("."))
.join("scopes.toml");
let contents = match std::fs::read_to_string(&scopes_path) {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
let table: toml::Table = match toml::from_str(&contents) {
Ok(t) => t,
Err(_) => return HashMap::new(),
};
table
.into_iter()
.filter_map(|(k, v)| {
let globs = match v {
toml::Value::Array(arr) => arr
.into_iter()
.filter_map(|v| {
if let toml::Value::String(s) = v {
Some(s)
} else {
None
}
})
.collect(),
toml::Value::String(s) => vec![s],
_ => return None,
};
Some((k, globs))
})
.collect()
}
pub fn resolve_scope(scope: &str, db_path: &Path) -> Vec<String> {
let aliases = load_scope_aliases(db_path);
aliases
.get(scope)
.cloned()
.unwrap_or_else(|| vec![scope.to_string()])
}
pub fn load_exclude_sets(db_path: &Path) -> HashMap<String, Vec<String>> {
let scopes_path = db_path
.parent()
.unwrap_or(Path::new("."))
.join("scopes.toml");
let contents = match std::fs::read_to_string(&scopes_path) {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
let table: toml::Table = match toml::from_str(&contents) {
Ok(t) => t,
Err(_) => return HashMap::new(),
};
let excl = match table.get("exclude_sets") {
Some(toml::Value::Table(t)) => t.clone(),
_ => return HashMap::new(),
};
excl.into_iter()
.filter_map(|(k, v)| {
let globs = match v {
toml::Value::Array(arr) => arr
.into_iter()
.filter_map(|v| {
if let toml::Value::String(s) = v {
Some(s)
} else {
None
}
})
.collect(),
toml::Value::String(s) => vec![s],
_ => return None,
};
Some((k, globs))
})
.collect()
}
pub fn resolve_exclude_set(name: &str, db_path: &Path) -> Vec<String> {
load_exclude_sets(db_path).remove(name).unwrap_or_default()
}
fn apply_paths_filter(
engine: &Engine,
index_store: &AsgIndexStore,
fts: Option<&SearchFtsDb>,
paths_filter: &[String],
scored: &mut Vec<(f64, String)>,
) {
if paths_filter.is_empty() {
return;
}
let qname_strs: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
let resolved = fts
.map(|f| f.resolve_qnames_bulk(&qname_strs))
.unwrap_or_default();
scored.retain(|(_, qname)| {
if let Some(rsym) = resolved.get(qname.as_str()) {
matches_any_path_glob(paths_filter, &rsym.file)
} else {
match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(sym)) => matches_any_path_glob(paths_filter, &sym.file),
_ => true,
}
}
});
}
fn apply_exclude_paths_filter(
engine: &Engine,
index_store: &AsgIndexStore,
fts: Option<&SearchFtsDb>,
exclude_paths: &[String],
scored: &mut Vec<(f64, String)>,
) {
if exclude_paths.is_empty() {
return;
}
let qname_strs: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
let resolved = fts
.map(|f| f.resolve_qnames_bulk(&qname_strs))
.unwrap_or_default();
scored.retain(|(_, qname)| {
let file = if let Some(rsym) = resolved.get(qname.as_str()) {
rsym.file.clone()
} else {
match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(sym)) => sym.file,
_ => return true, }
};
!matches_any_path_glob(exclude_paths, &file)
});
}
fn apply_exclude_languages_filter(
engine: &Engine,
index_store: &AsgIndexStore,
_fts: Option<&SearchFtsDb>,
exclude_languages: &[String],
scored: &mut Vec<(f64, String)>,
) {
if exclude_languages.is_empty() {
return;
}
let lower: Vec<String> = exclude_languages.iter().map(|s| s.to_lowercase()).collect();
scored.retain(|(_, qname)| {
let lang = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(sym)) => sym.language.to_lowercase(),
_ => return true, };
!lower.iter().any(|excl| excl == &lang)
});
}
fn apply_exclusions(
engine: &Engine,
index_store: &AsgIndexStore,
fts: Option<&SearchFtsDb>,
exclude_terms: &[String],
scored: &mut Vec<(f64, String)>,
) {
if exclude_terms.is_empty() {
return;
}
let qname_strs: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
let resolved = fts
.map(|f| f.resolve_qnames_bulk(&qname_strs))
.unwrap_or_default();
scored.retain(|(_, qname)| {
let qname_lower = qname.to_lowercase();
let (file_lower, doc_lower, sig_lower) = if let Some(rsym) = resolved.get(qname.as_str()) {
(
rsym.file.to_lowercase(),
rsym.doc.as_deref().unwrap_or("").to_lowercase(),
rsym.signature.as_deref().unwrap_or("").to_lowercase(),
)
} else {
match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => (
s.file.to_lowercase(),
s.doc.as_deref().unwrap_or("").to_lowercase(),
s.signature.as_deref().unwrap_or("").to_lowercase(),
),
_ => return true,
}
};
!exclude_terms.iter().any(|excl| {
qname_lower.contains(excl.as_str())
|| file_lower.contains(excl.as_str())
|| doc_lower.contains(excl.as_str())
|| sig_lower.contains(excl.as_str())
})
});
}
const ANCHOR_SCORE: f64 = 0.5;
const MAX_ANCHORS: usize = 5;
fn ledger_anchor_pass(
engine: &Engine,
fts: Option<&SearchFtsDb>,
tokens: &[String],
candidates: &mut Vec<(f64, String)>,
) {
if tokens.is_empty() {
return;
}
let existing_qnames: HashSet<String> = candidates.iter().map(|(_, q)| q.clone()).collect();
if let Some(fts) = fts {
if fts.has_data() {
let hits = fts.anchor_candidates(tokens, &existing_qnames, MAX_ANCHORS);
for (qname, _sym_id) in hits {
candidates.push((ANCHOR_SCORE, qname));
}
return;
}
}
let ledger_prefix = format!("{}/ledger", crate::paths::ASD_ROOT);
let ledger_tree = match engine.repo.get_tree(&engine.ref_name, &ledger_prefix) {
Ok(v) => v,
Err(_) => return,
};
let mut matching_sym_ids: Vec<String> = Vec::new();
if let serde_json::Value::Object(by_symbol) = ledger_tree {
for (sym_id, per_symbol) in by_symbol {
if let serde_json::Value::Object(entries_map) = per_symbol {
for (_entry_id, v) in entries_map {
if let Ok(entry) = serde_json::from_value::<LedgerEntry>(v) {
if !matches!(entry.kind, LedgerKind::Invariant | LedgerKind::Hazard) {
continue;
}
let summary_lower = entry.summary.to_lowercase();
if tokens.iter().any(|t| summary_lower.contains(t.as_str())) {
matching_sym_ids.push(sym_id.clone());
break;
}
}
}
}
}
}
if matching_sym_ids.is_empty() {
return;
}
let qname_prefix = format!("{}/index/by-qname", crate::paths::ASD_ROOT);
let id_map: HashMap<String, Symbol> =
match engine.repo.get_tree(&engine.ref_name, &qname_prefix) {
Ok(serde_json::Value::Object(map)) => map
.values()
.filter_map(|v| serde_json::from_value::<Symbol>(v.clone()).ok())
.map(|s| (s.symbol_id.clone(), s))
.collect(),
_ => HashMap::new(),
};
let mut anchors = 0usize;
for sym_id in matching_sym_ids {
if anchors >= MAX_ANCHORS {
break;
}
if let Some(sym) = id_map.get(&sym_id) {
if !existing_qnames.contains(&sym.qname) {
candidates.push((ANCHOR_SCORE, sym.qname.clone()));
anchors += 1;
}
}
}
}
const ASD_PATH_PREFIX: &str = "/asd/v1";
pub fn find_candidates(
engine: &Engine,
query: &str,
tokens: &[String],
filters: &FtsFilters,
_ledger_store: &AsgLedgerStore,
index_store: &AsgIndexStore,
depth: usize,
) -> Vec<(f64, String)> {
let fts: Option<&SearchFtsDb> = engine.fts.as_ref();
let fts_result = fts
.filter(|f| f.has_data())
.and_then(|f| f.search(query, filters, depth * 2).ok());
if let Some(hits) = fts_result {
let ledger_cache: HashMap<String, Vec<crate::schema::LedgerEntry>> =
HashMap::with_capacity(hits.len());
let mut qname_to_sym: HashMap<String, (String, String)> =
HashMap::with_capacity(hits.len());
let mut has_ledger_ids: HashSet<String> = HashSet::new();
let mut scored: Vec<(f64, String)> = {
let mut tmp = Vec::with_capacity(hits.len());
for hit in hits {
let boost = hybrid_boost(&hit, tokens);
let (ledger_boost, ownership_struct_boost) = {
let sot = if hit.has_ownership() { 2.0_f64 } else { 0.0 };
let text_boost = if hit.ledger_text.is_empty() {
0.0
} else {
let matches = tokens
.iter()
.filter(|t| hit.ledger_text.contains(t.as_str()))
.count() as f64;
if matches == 0.0 {
0.0
} else {
let weight = if hit.has_ownership() {
3.0
} else if hit.has_invariant() {
1.5
} else if hit.has_hazard() {
1.0
} else {
0.5
};
matches * weight
}
};
if hit.has_ledger() {
has_ledger_ids.insert(hit.symbol_id.clone());
}
(text_boost, sot)
};
qname_to_sym.insert(hit.qname.clone(), (hit.symbol_id.clone(), hit.file.clone()));
tmp.push((
hit.bm25_score + boost + ledger_boost + ownership_struct_boost,
hit.qname,
));
}
tmp
};
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let covered_files: HashSet<String> = scored
.iter()
.take(depth)
.filter_map(|(_, qname)| {
let (sym_id, file) = qname_to_sym.get(qname)?;
let has_led = has_ledger_ids.contains(sym_id)
|| ledger_cache.get(sym_id).map_or(false, |e| !e.is_empty());
if has_led { Some(file.clone()) } else { None }
})
.collect();
const VIEW_STEM_HINTS: &[&str] = &[
"view", "ui", "render", "display", "screen", "layout", "widget", "cell", "button",
"pad",
];
let query_lower = query.to_lowercase();
let is_view_query = VIEW_STEM_HINTS.iter().any(|h| query_lower.contains(h));
if let Some(fts) = fts {
let limit = depth * 2 * tokens.len().max(1);
if let Ok(stem_hits) = fts.file_stem_candidates_batch(tokens, filters, limit) {
for hit in stem_hits {
if !covered_files.contains(&hit.file) {
let boost = hybrid_boost(&hit, tokens);
let tier = hit.tier;
let layer = classify_layer_sym(&hit.file, &hit.qname, tier, &[]);
let view_boost = if is_view_query && view_layer_matches(layer, &hit.file) {
2.0
} else {
0.0
};
qname_to_sym
.entry(hit.qname.clone())
.or_insert_with(|| (hit.symbol_id.clone(), hit.file.clone()));
if hit.has_ledger() {
has_ledger_ids.insert(hit.symbol_id.clone());
}
scored.push((1.0 + boost + view_boost, hit.qname));
}
}
}
}
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.dedup_by(|a, b| a.1 == b.1);
let has_ledger: HashSet<String> = scored
.iter()
.filter_map(|(_, qname)| {
let (sym_id, _) = qname_to_sym.get(qname.as_str())?;
let has = has_ledger_ids.contains(sym_id)
|| ledger_cache.get(sym_id).map_or(false, |e| !e.is_empty());
if has { Some(qname.clone()) } else { None }
})
.collect();
scored.sort_by(|a, b| {
let a_ledger = has_ledger.contains(&a.1);
let b_ledger = has_ledger.contains(&b.1);
b_ledger
.cmp(&a_ledger)
.then_with(|| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal))
});
let mut seen_files: HashSet<String> = HashSet::new();
scored.retain(|(_, qname)| {
match qname_to_sym.get(qname.as_str()) {
Some((_, file)) => seen_files.insert(file.clone()),
None => true, }
});
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(depth);
apply_paths_filter(engine, index_store, fts, &filters.paths_filter, &mut scored);
apply_exclusions(
engine,
index_store,
fts,
&filters.exclude_terms,
&mut scored,
);
apply_exclude_paths_filter(
engine,
index_store,
fts,
&filters.exclude_paths,
&mut scored,
);
apply_exclude_languages_filter(
engine,
index_store,
fts,
&filters.exclude_languages,
&mut scored,
);
ledger_anchor_pass(engine, fts, tokens, &mut scored);
return scored;
}
fallback_in_memory_search(engine, tokens, filters, index_store, fts, depth)
}
pub fn in_memory_scored_symbols(
engine: &Engine,
tokens: &[String],
kind_filter: Option<&str>,
lang_filter: Option<&str>,
) -> Vec<(u32, Symbol)> {
let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
let symbols: Vec<Symbol> = match engine.repo.get_tree(&engine.ref_name, &prefix) {
Ok(serde_json::Value::Object(map)) => map
.into_iter()
.filter_map(|(_, v)| serde_json::from_value(v).ok())
.collect(),
_ => Vec::new(),
};
let ledger_text: HashMap<String, String> = crate::thinking::all_ledger_buckets(engine)
.into_iter()
.map(|(symbol_id, entries)| {
let text = crate::thinking::live_entries(entries)
.iter()
.map(|e| e.summary.to_lowercase())
.collect::<Vec<_>>()
.join(" ");
(symbol_id, text)
})
.collect();
let mut scored: Vec<(u32, Symbol)> = Vec::new();
for sym in symbols {
if let Some(k) = kind_filter {
if kind_str(&sym.kind) != k {
continue;
}
}
if let Some(lang) = lang_filter {
if sym.language != lang {
continue;
}
}
let text = ledger_text
.get(&sym.symbol_id)
.map(String::as_str)
.unwrap_or("");
let s = in_memory_score_with_text(&sym, tokens, text);
if s > 0 {
scored.push((s, sym));
}
}
scored
}
fn fallback_in_memory_search(
engine: &Engine,
tokens: &[String],
filters: &FtsFilters,
index_store: &AsgIndexStore,
fts: Option<&SearchFtsDb>,
depth: usize,
) -> Vec<(f64, String)> {
eprintln!("asd: FTS index not populated — falling back to in-memory search");
let kind_filter = filters.kind.as_deref().map(|k| k.to_lowercase());
let lang_filter = filters.language.as_deref();
let mut scored: Vec<(f64, String)> =
in_memory_scored_symbols(engine, tokens, kind_filter.as_deref(), lang_filter)
.into_iter()
.map(|(s, sym)| (s as f64, sym.qname))
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(depth);
apply_paths_filter(engine, index_store, fts, &filters.paths_filter, &mut scored);
apply_exclusions(
engine,
index_store,
fts,
&filters.exclude_terms,
&mut scored,
);
ledger_anchor_pass(engine, fts, tokens, &mut scored);
scored
}
pub fn explain_match(
sym: &Symbol,
tokens: &[String],
ledger_entries: &[LedgerEntry],
is_hot: bool,
) -> Vec<String> {
let qname_lower = sym.qname.to_lowercase();
let file_lower = sym.file.to_lowercase();
let sig_lower = sym.signature.as_deref().unwrap_or("").to_lowercase();
let doc_lower = sym.doc.as_deref().unwrap_or("").to_lowercase();
let mut reasons: Vec<String> = Vec::new();
for token in tokens {
let t = token.as_str();
if qname_lower.contains(t) {
reasons.push(format!("name:{}", token));
} else if file_lower.contains(t) {
reasons.push(format!("file:{}", token));
} else if sig_lower.contains(t) {
reasons.push(format!("sig:{}", token));
} else if doc_lower.contains(t) {
reasons.push(format!("doc:{}", token));
}
}
let inv_count = ledger_entries
.iter()
.filter(|e| matches!(e.kind, LedgerKind::Invariant))
.count();
let haz_count = ledger_entries
.iter()
.filter(|e| matches!(e.kind, LedgerKind::Hazard))
.count();
if inv_count > 0 {
reasons.push(format!("invariant-attached:{}", inv_count));
}
if haz_count > 0 {
reasons.push(format!(
"ledger:{} hazard{}",
haz_count,
if haz_count == 1 { "" } else { "s" }
));
}
let owns: Vec<&str> = ledger_entries
.iter()
.filter(|e| e.kind == LedgerKind::Ownership)
.map(|e| e.summary.as_str())
.filter(|s| tokens.iter().any(|t| s.to_lowercase().contains(t.as_str())))
.collect();
if !owns.is_empty() {
reasons.push(format!(
"ownership:{}",
owns[0]
.split_whitespace()
.take(4)
.collect::<Vec<_>>()
.join("-")
));
}
if is_hot {
reasons.push("recent-edit".to_string());
}
reasons
}
pub fn confidence_scores(scores: &[f64]) -> Vec<f64> {
if scores.is_empty() {
return vec![];
}
if scores.len() == 1 {
return vec![1.0];
}
let max = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min = scores.iter().cloned().fold(f64::INFINITY, f64::min);
let range = max - min;
if range < 1e-9 {
return vec![1.0; scores.len()];
}
scores
.iter()
.map(|&s| 0.1 + 0.9 * (s - min) / range)
.collect()
}
pub fn result_bucket(
file: &str,
match_reasons: &[String],
has_ledger: bool,
is_hot: bool,
) -> &'static str {
use crate::search_fts::symbol_tier;
if symbol_tier(file) == 2 {
return "test-only";
}
let has_name = match_reasons.iter().any(|r| r.starts_with("name:"));
let has_doc_or_file = match_reasons
.iter()
.any(|r| r.starts_with("doc:") || r.starts_with("file:"));
if has_ledger && (has_name || is_hot) {
return "core";
}
if has_ledger || has_name {
return "relevant";
}
if is_hot || has_doc_or_file {
return "peripheral";
}
"noisy"
}
pub fn confidence_reason(match_reasons: &[String], has_ledger: bool, is_hot: bool) -> String {
let has_name = match_reasons.iter().any(|r| r.starts_with("name:"));
let has_inv = match_reasons
.iter()
.any(|r| r.starts_with("invariant-attached:"));
let has_own = match_reasons.iter().any(|r| r.starts_with("ownership:"));
let has_haz = match_reasons.iter().any(|r| r.starts_with("ledger:"));
let has_sig = match_reasons.iter().any(|r| r.starts_with("sig:"));
let has_doc = match_reasons.iter().any(|r| r.starts_with("doc:"));
let has_file = match_reasons.iter().any(|r| r.starts_with("file:"));
if has_name && has_inv {
"strong: name match + invariant-attached".to_string()
} else if has_name && has_own {
"strong: name match + ownership declaration".to_string()
} else if has_name && has_haz {
"strong: name match + hazard ledger entry".to_string()
} else if has_name && has_ledger {
"strong: name match + ledger entry".to_string()
} else if has_name && is_hot {
"moderate: name match + recently edited".to_string()
} else if has_name {
"moderate: name match only".to_string()
} else if has_ledger && has_sig {
"moderate: signature match + ledger entry".to_string()
} else if has_ledger {
"moderate: ledger entry (indirect match)".to_string()
} else if has_sig {
"weak: signature match only".to_string()
} else if has_doc {
"weak: doc-only match".to_string()
} else if has_file {
"weak: file path match only".to_string()
} else {
"weak: low-signal indirect match".to_string()
}
}
pub fn detect_ambiguous_tokens(
tokens: &[String],
fts: Option<&SearchFtsDb>,
filters: &FtsFilters,
) -> Vec<String> {
const THRESHOLD: usize = 25;
let fts = match fts {
Some(f) if f.has_data() => f,
_ => return vec![],
};
let candidates: Vec<&str> = tokens
.iter()
.filter(|t| !is_stopword(t))
.map(|t| t.as_str())
.collect();
if candidates.is_empty() {
return vec![];
}
let counts = fts
.count_distinct_files_per_token(&candidates, filters.include_tests)
.unwrap_or_default();
counts
.into_iter()
.filter(|(_, cnt)| *cnt > THRESHOLD)
.map(|(tok, _)| tok)
.collect()
}
pub fn detect_possible_misses(
query: &str,
layers_present: &HashSet<&str>,
result_count: usize,
) -> Vec<String> {
let ql = query.to_lowercase();
let mut warnings = Vec::new();
const VIEW_HINTS: &[&str] = &[
"view", " ui", "render", "display", "screen", "layout", "widget", "cell", "button", "pad",
];
const VIEW_LAYERS: &[&str] = &["view", "ui", "presentation"];
if VIEW_HINTS.iter().any(|h| ql.contains(h))
&& !VIEW_LAYERS.iter().any(|l| layers_present.contains(l))
&& result_count > 0
{
warnings.push(
"no view-layer symbols found — query suggests UI involvement; \
check --scope or path coverage"
.to_string(),
);
}
const SERVICE_HINTS: &[&str] = &[
"service",
"manager",
"coordinator",
"handler",
"processor",
"store",
"repository",
"repo",
];
const SERVICE_LAYERS: &[&str] = &["service", "domain", "core", "infrastructure"];
if SERVICE_HINTS.iter().any(|h| ql.contains(h))
&& result_count > 0
&& SERVICE_LAYERS.iter().all(|l| !layers_present.contains(l))
&& (layers_present.contains("view") || layers_present.contains("ui"))
{
warnings.push(
"results are all UI-layer — query suggests service/domain involvement; \
service layer symbols may not be indexed"
.to_string(),
);
}
const DATA_HINTS: &[&str] = &[
"model",
"entity",
"persist",
"database",
"migration",
"schema",
"table",
];
const DATA_LAYERS: &[&str] = &["persistence", "data", "infrastructure", "domain"];
if DATA_HINTS.iter().any(|h| ql.contains(h))
&& result_count > 0
&& DATA_LAYERS.iter().all(|l| !layers_present.contains(l))
{
warnings.push(
"no persistence/data-layer symbols found — query suggests data model involvement"
.to_string(),
);
}
const SCHEDULER_HINTS: &[&str] = &[
"scheduler",
"schedule",
"clock",
"timer",
"tick",
"dispatch",
"queue",
"async",
];
if SCHEDULER_HINTS.iter().any(|h| ql.contains(h))
&& result_count > 0
&& !layers_present.contains("scheduler")
{
warnings.push(
"scheduler layer absent from results — query implies timing/dispatch involvement; \
check that scheduler symbols are indexed"
.to_string(),
);
}
const ENGINE_HINTS: &[&str] = &[
"engine",
"audio",
"video",
"render",
"pipeline",
"runtime",
"processor",
];
if ENGINE_HINTS.iter().any(|h| ql.contains(h))
&& result_count > 0
&& !layers_present.contains("engine")
&& !layers_present.contains("core_model")
{
warnings.push(
"engine/core layer absent from results — query implies runtime/processing involvement; \
consider broadening scope or adding the engine layer to the index"
.to_string(),
);
}
const NETWORK_HINTS: &[&str] = &[
"network", "api", "http", "request", "response", "endpoint", "fetch", "upload", "download",
];
if NETWORK_HINTS.iter().any(|h| ql.contains(h))
&& result_count > 0
&& !layers_present.contains("network")
&& !layers_present.contains("infrastructure")
{
warnings.push(
"network layer absent from results — query implies API/network involvement; \
check that network client symbols are indexed"
.to_string(),
);
}
if result_count > 0 && result_count < 3 && ql.split_whitespace().count() >= 3 {
warnings.push(format!(
"only {} result{} for a multi-term query — symbols may not be indexed yet",
result_count,
if result_count == 1 { "" } else { "s" }
));
}
warnings
}
pub fn suggest_scoped_queries(
original_tokens: &[String],
ambiguous_terms: &[String],
top_qnames: &[String],
) -> Vec<String> {
if ambiguous_terms.is_empty() || top_qnames.is_empty() {
return vec![];
}
let amb_set: HashSet<&str> = ambiguous_terms.iter().map(|s| s.as_str()).collect();
let specific_tokens: Vec<&str> = original_tokens
.iter()
.filter(|t| !amb_set.contains(t.as_str()))
.map(|s| s.as_str())
.collect();
let mut cooccur: HashMap<String, usize> = HashMap::new();
for qname in top_qnames {
let last = qname
.rsplit(|c: char| c == '.' || c == ':' || c == '/')
.next()
.unwrap_or(qname);
let mut cur = String::new();
let mut words: Vec<String> = Vec::new();
for ch in last.chars() {
if ch.is_uppercase() && !cur.is_empty() {
words.push(cur.clone());
cur.clear();
}
cur.push(ch.to_lowercase().next().unwrap_or(ch));
}
if !cur.is_empty() {
words.push(cur);
}
let words: Vec<String> = words
.iter()
.flat_map(|w| w.split('_').map(|s| s.to_string()).collect::<Vec<_>>())
.filter(|w| w.len() > 2 && !is_stopword(w) && !amb_set.contains(w.as_str()))
.collect();
for w in words {
if !original_tokens.iter().any(|t| t == &w) {
*cooccur.entry(w).or_default() += 1;
}
}
}
let mut ranked: Vec<(usize, String)> = cooccur.into_iter().map(|(w, c)| (c, w)).collect();
ranked.sort_by(|a, b| b.0.cmp(&a.0));
let mut suggestions = Vec::new();
for (_, narrowing_term) in ranked.iter().take(3) {
let mut parts: Vec<&str> = specific_tokens.clone();
parts.push(narrowing_term.as_str());
let query = parts.join(" ");
if !suggestions.contains(&query) {
suggestions.push(query);
}
}
suggestions
}
pub fn detect_confidence_warnings(
tokens: &[String],
result_count: usize,
ambiguous_terms: &[String],
fts: Option<&SearchFtsDb>,
) -> Vec<serde_json::Value> {
use serde_json::json;
let mut warnings = Vec::new();
for term in ambiguous_terms {
warnings.push(json!({
"kind": "ambiguous_term",
"term": term,
"message": format!(
"\"{}\" matches too many unrelated files — add more specific terms to narrow results",
term
),
}));
}
if result_count < 3 && ambiguous_terms.is_empty() && !tokens.is_empty() {
let total = fts
.filter(|f| f.has_data())
.and_then(|f| f.search("", &FtsFilters::default(), 1).ok())
.is_some();
if total {
warnings.push(json!({
"kind": "sparse_index",
"message": "very few results — the index may not cover this area yet; \
try `asd index` or broaden the query",
}));
}
}
warnings
}
pub fn suggest_better_queries(tokens: &[String], query: &str) -> Vec<String> {
if tokens.is_empty() {
return vec![];
}
let mut suggestions = Vec::new();
let stopword_count = tokens.iter().filter(|t| is_stopword(t)).count();
let meaningful = tokens.len() - stopword_count;
if meaningful <= 1 {
let tok = tokens
.iter()
.find(|t| !is_stopword(t))
.map(|s| s.as_str())
.unwrap_or(query);
suggestions.push(format!(
"try a more specific phrase, e.g. \"{} <subsystem>\" or \"{} <action>\"",
tok, tok
));
}
if tokens.len() >= 3 && stopword_count * 2 > tokens.len() {
suggestions.push(
"query contains many common words — focus on domain nouns and verbs for better precision"
.to_string(),
);
}
const BROAD_TERMS: &[&str] = &[
"update", "get", "set", "handle", "process", "run", "execute", "init", "start", "stop",
"load", "save", "create", "delete", "state", "data", "model", "manager", "service", "util",
"helper", "config",
];
for tok in tokens {
if BROAD_TERMS.contains(&tok.as_str()) && tokens.len() == 1 {
suggestions.push(format!(
"\"{}\" is very broad — combine with a domain term, e.g. \"{} playhead\" or \"{} session\"",
tok, tok, tok
));
}
}
suggestions.dedup();
suggestions
}
fn fb_query_tokens(q: &str) -> std::collections::HashSet<String> {
q.split(|c: char| !c.is_alphabetic())
.filter(|t| t.len() > 2)
.map(|t| t.to_lowercase())
.filter(|t| !is_stopword(t))
.collect()
}
fn name_tokens_from_qname(qname: &str) -> HashSet<String> {
let last = qname
.rsplit(|c| c == ':' || c == '.')
.next()
.unwrap_or(qname);
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
for ch in last.chars() {
if ch.is_uppercase() && !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
cur.push(ch.to_lowercase().next().unwrap_or(ch));
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
.iter()
.flat_map(|t| t.split('_'))
.filter(|t| t.len() > 2 && !is_stopword(t))
.map(|t| t.to_string())
.collect()
}
fn query_family_matches(
current_query_tokens: &std::collections::HashSet<String>,
fb_query: &str,
) -> bool {
if fb_query.is_empty() {
return true;
}
let fb_tokens = fb_query_tokens(fb_query);
if fb_tokens.is_empty() {
return true;
}
fb_tokens.iter().any(|t| current_query_tokens.contains(t))
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct FeedbackMetrics {
pub entries_applied: usize,
pub suppressed: usize,
pub preserved_useful_siblings: usize,
pub boosted: usize,
pub recurring_fp_suppressed: usize,
#[serde(default)]
pub constraint_penalties_applied: usize,
#[serde(default)]
pub task_bias_boosted: usize,
pub rules_applied: Vec<String>,
}
pub fn read_active_task_scope(db_path_parent: Option<&std::path::Path>) -> Option<Vec<String>> {
let env_raw = std::env::var("CTX_ACTIVE_TASK").ok();
read_active_task_scope_from(env_raw.as_deref(), db_path_parent)
}
pub fn read_active_task_scope_from(
env_raw: Option<&str>,
db_path_parent: Option<&std::path::Path>,
) -> Option<Vec<String>> {
let raw: String = env_raw.map(|s| s.to_string()).or_else(|| {
let p = db_path_parent?
.join(".asd")
.join("cache")
.join("active-task.json");
std::fs::read_to_string(p).ok()
})?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
let arr = v.get("scope")?.as_array()?;
let scope: Vec<String> = arr
.iter()
.filter_map(|s| s.as_str().map(|s| s.to_string()))
.filter(|s| !s.is_empty())
.collect();
if scope.is_empty() { None } else { Some(scope) }
}
pub fn apply_task_bias(
engine: &Engine,
index_store: &AsgIndexStore,
scored: &mut Vec<(f64, String)>,
scope: &[String],
boost: f64,
) -> usize {
if scope.is_empty() {
return 0;
}
let qnames_owned: Vec<String> = scored
.iter()
.filter(|(s, _)| s.is_finite())
.map(|(_, q)| q.clone())
.collect();
let file_by_qname: std::collections::HashMap<String, String> =
if let Some(fts) = engine.fts.as_ref() {
let qnames_ref: Vec<&str> = qnames_owned.iter().map(|s| s.as_str()).collect();
fts.files_for_qnames(&qnames_ref, &engine.ref_name)
} else {
std::collections::HashMap::new()
};
let mut boosted = 0;
for (score, qname) in scored.iter_mut() {
if !score.is_finite() {
continue;
}
let file = if let Some(f) = file_by_qname.get(qname) {
f.clone()
} else {
match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => s.file,
_ => continue,
}
};
if scope.iter().any(|g| glob_match(g, &file)) {
*score += boost;
boosted += 1;
}
}
boosted
}
fn constraint_scope_from_body(body: Option<&str>) -> Option<Vec<String>> {
let raw = body?;
let v: serde_json::Value = serde_json::from_str(raw).ok()?;
let arr = v.get("scope")?.as_array()?;
let globs: Vec<String> = arr
.iter()
.filter_map(|g| g.as_str().map(String::from))
.filter(|g| !g.is_empty())
.collect();
if globs.is_empty() { None } else { Some(globs) }
}
pub fn apply_constraint_penalties(
engine: &Engine,
index_store: &AsgIndexStore,
scored: &mut Vec<(f64, String)>,
) -> usize {
if let Some(fts) = engine.fts.as_ref() {
if let Ok(pairs) = fts.symbols_with_constraint_penalties_scoped(&engine.ref_name) {
if !pairs.is_empty() {
use std::collections::HashMap;
let mut by_sym: HashMap<String, Vec<Option<Vec<String>>>> = HashMap::new();
for (sid, scope) in pairs {
by_sym.entry(sid).or_default().push(scope);
}
let mut suppressed = 0;
for (score, qname) in scored.iter_mut() {
if *score == f64::NEG_INFINITY {
continue;
}
let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => s,
_ => continue,
};
if let Some(entries) = by_sym.get(&sym.symbol_id) {
let hit = entries.iter().any(|scope_opt| match scope_opt {
None => true, Some(globs) => globs.iter().any(|g| glob_match(g, &sym.file)),
});
if hit {
*score = f64::NEG_INFINITY;
suppressed += 1;
}
}
}
return suppressed;
}
return 0;
}
}
let ledger_store = crate::ledger::AsgLedgerStore::from_engine(engine);
let mut suppressed = 0;
for (score, qname) in scored.iter_mut() {
if *score == f64::NEG_INFINITY {
continue;
}
let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => s,
_ => continue,
};
let entries = match ledger_store.list_entries(&engine.ref_name, &sym.symbol_id) {
Ok(e) => e,
Err(_) => continue,
};
let hit = entries.iter().any(|entry| {
let kind_matches = matches!(
entry.kind,
crate::schema::LedgerKind::Constraint | crate::schema::LedgerKind::Decision
);
if !kind_matches {
return false;
}
let role_is_penalty = entry
.role
.as_deref()
.and_then(crate::schema::RoleTag::from_str)
.map(|r| r.is_penalty_role())
.unwrap_or(false);
if !role_is_penalty {
return false;
}
match constraint_scope_from_body(entry.body.as_deref()) {
None => true, Some(globs) => globs.iter().any(|g| glob_match(g, &sym.file)),
}
});
if hit {
*score = f64::NEG_INFINITY;
suppressed += 1;
}
}
suppressed
}
pub fn apply_feedback_adjustments(
engine: &Engine,
index_store: &AsgIndexStore,
query: &str,
scored: &mut Vec<(f64, String)>,
feedback_entries: &[(
String,
String,
crate::schema::FeedbackVerdict,
chrono::DateTime<chrono::Utc>,
)],
) -> FeedbackMetrics {
let task_bias_boosted = {
let parent = engine
.db_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf());
let scope = read_active_task_scope(parent.as_deref()).unwrap_or_default();
if scope.is_empty() {
0
} else {
apply_task_bias(engine, index_store, scored, &scope, 1.0)
}
};
let constraint_penalties_applied = apply_constraint_penalties(engine, index_store, scored);
if feedback_entries.is_empty() {
let mut m = FeedbackMetrics::default();
m.constraint_penalties_applied = constraint_penalties_applied;
m.task_bias_boosted = task_bias_boosted;
if constraint_penalties_applied > 0 {
m.rules_applied.push("constraint_penalty".to_string());
}
if task_bias_boosted > 0 {
m.rules_applied.push("task_bias".to_string());
}
return m;
}
let query_norm = query.to_lowercase();
let current_tokens: std::collections::HashSet<String> = fb_query_tokens(&query_norm);
let current_tokens_vec: Vec<&str> = current_tokens.iter().map(|s| s.as_str()).collect();
let entries_applied = feedback_entries
.iter()
.filter(|(_, fb_q, _, _)| query_family_matches(¤t_tokens, fb_q))
.count();
let mut preserved_useful_siblings: usize = 0;
let mut boosted: usize = 0;
let mut recurring_fp_suppressed: usize = 0;
let mut rules_seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut rules_applied: Vec<String> = Vec::new();
macro_rules! record_rule {
($name:expr) => {
if rules_seen.insert($name) {
rules_applied.push($name.to_string());
}
};
}
let mut noisy_queries_by_sym: std::collections::HashMap<&str, Vec<&str>> =
std::collections::HashMap::new();
for (sym_id, fb_query, verdict, _created_at) in feedback_entries {
if matches!(
verdict,
crate::schema::FeedbackVerdict::Noisy | crate::schema::FeedbackVerdict::WrongLayer
) {
noisy_queries_by_sym
.entry(sym_id.as_str())
.or_default()
.push(fb_query.as_str());
}
}
let recurring_fp: std::collections::HashMap<&str, Vec<String>> = noisy_queries_by_sym
.into_iter()
.filter(|(_, queries)| queries.len() >= 2)
.map(|(sym_id, queries)| {
let mut token_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for q in &queries {
let tokens = fb_query_tokens(q);
for t in tokens {
*token_counts.entry(t).or_default() += 1;
}
}
let suppression_tokens: Vec<String> = token_counts
.into_iter()
.filter(|(_, count)| *count >= 2)
.map(|(t, _)| t)
.collect();
(sym_id, suppression_tokens)
})
.collect();
struct NoisySymInfo {
sym_id: String,
name_tokens: HashSet<String>,
}
let mut noisy_file_syms: HashMap<String, Vec<NoisySymInfo>> = HashMap::new();
{
let noisy_ids: Vec<&str> = feedback_entries
.iter()
.filter(|(_, fb_query, verdict, _)| {
matches!(
verdict,
crate::schema::FeedbackVerdict::Noisy
| crate::schema::FeedbackVerdict::WrongLayer
) && query_family_matches(¤t_tokens, fb_query)
})
.map(|(sym_id, _, _, _)| sym_id.as_str())
.collect();
if !noisy_ids.is_empty() {
let resolved = engine
.fts
.as_ref()
.map(|fts| fts.resolve_symbol_ids_bulk(&noisy_ids))
.unwrap_or_default();
if !resolved.is_empty() {
for (sym_id_str, rsym) in &resolved {
let name_tokens = name_tokens_from_qname(&rsym.qname);
noisy_file_syms
.entry(rsym.file.clone())
.or_default()
.push(NoisySymInfo {
sym_id: sym_id_str.clone(),
name_tokens,
});
}
} else {
let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
if let Ok(serde_json::Value::Object(map)) =
engine.repo.get_tree(&engine.ref_name, &prefix)
{
let noisy_set: HashSet<&str> = noisy_ids.into_iter().collect();
for sym_val in map.values() {
if let Ok(sym) = serde_json::from_value::<Symbol>(sym_val.clone()) {
if noisy_set.contains(sym.symbol_id.as_str()) {
let name_tokens = name_tokens_from_qname(&sym.qname);
noisy_file_syms.entry(sym.file.clone()).or_default().push(
NoisySymInfo {
sym_id: sym.symbol_id,
name_tokens,
},
);
}
}
}
}
}
}
}
let scored_qnames: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
let resolved_map = engine
.fts
.as_ref()
.map(|fts| fts.resolve_qnames_bulk(&scored_qnames))
.unwrap_or_default();
for (score, qname) in scored.iter_mut() {
let (sym_file, sym_symbol_id, sym_kind) =
if let Some(rsym) = resolved_map.get(qname.as_str()) {
(rsym.file.clone(), rsym.symbol_id.clone(), rsym.kind.clone())
} else {
match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => (s.file, s.symbol_id, format!("{:?}", s.kind).to_lowercase()),
_ => continue,
}
};
let sym_id = &sym_symbol_id;
let sym_kind_enum = match sym_kind.as_str() {
"class" => SymbolKind::Class,
"module" => SymbolKind::Module,
_ => SymbolKind::Function,
};
if let Some(noisy_syms) = noisy_file_syms.get(&sym_file) {
let is_container = matches!(sym_kind_enum, SymbolKind::Class | SymbolKind::Module);
let is_the_noisy_sym = noisy_syms.iter().any(|ns| ns.sym_id == *sym_id);
if !is_container && !is_the_noisy_sym {
let has_useful = feedback_entries.iter().any(|(fid, fq, v, _)| {
fid == sym_id
&& matches!(v, crate::schema::FeedbackVerdict::Useful)
&& query_family_matches(¤t_tokens, fq)
});
if !has_useful {
let sibling_tokens = name_tokens_from_qname(qname);
let name_related = noisy_syms.iter().any(|ns| {
ns.sym_id != *sym_id
&& sibling_tokens.iter().any(|t| ns.name_tokens.contains(t))
});
let query_relevant = sibling_tokens.iter().any(|t| current_tokens.contains(t));
if name_related && !query_relevant {
*score = f64::NEG_INFINITY;
record_rule!("same_file_sibling_suppression");
continue;
} else {
preserved_useful_siblings += 1;
}
} else {
preserved_useful_siblings += 1;
}
}
}
let now_for_decay = chrono::Utc::now();
for (fb_symbol_id, fb_query, verdict, fb_created_at) in feedback_entries {
if fb_symbol_id != sym_id {
continue;
}
if !query_family_matches(¤t_tokens, fb_query) {
continue;
}
match verdict {
crate::schema::FeedbackVerdict::Useful => {
let decay = crate::feedback::decay_for_entry(
*fb_created_at,
now_for_decay,
crate::feedback::DEFAULT_FEEDBACK_HALF_LIFE_DAYS,
);
*score += 1.5 * decay;
boosted += 1;
record_rule!("useful_boost");
}
crate::schema::FeedbackVerdict::Noisy
| crate::schema::FeedbackVerdict::WrongLayer => {
*score = f64::NEG_INFINITY;
record_rule!("exact_noisy");
}
crate::schema::FeedbackVerdict::AlreadyCovered => {
*score = f64::NEG_INFINITY;
record_rule!("already_covered");
}
crate::schema::FeedbackVerdict::DiagnosticOnly => {
*score = f64::NEG_INFINITY;
record_rule!("diagnostic_only");
}
crate::schema::FeedbackVerdict::Missing => {}
}
}
if score.is_finite() {
if let Some(sup_tokens) = recurring_fp.get(sym_id.as_str()) {
if current_tokens_vec
.iter()
.any(|qt| sup_tokens.iter().any(|st| st == *qt))
{
*score = f64::NEG_INFINITY;
recurring_fp_suppressed += 1;
record_rule!("recurring_fp");
}
}
}
}
let before = scored.len();
scored.retain(|(s, _)| s.is_finite());
let suppressed = before - scored.len();
if constraint_penalties_applied > 0 && !rules_applied.iter().any(|r| r == "constraint_penalty")
{
rules_applied.push("constraint_penalty".to_string());
}
if task_bias_boosted > 0 && !rules_applied.iter().any(|r| r == "task_bias") {
rules_applied.push("task_bias".to_string());
}
FeedbackMetrics {
entries_applied,
suppressed,
preserved_useful_siblings,
boosted,
recurring_fp_suppressed,
constraint_penalties_applied,
task_bias_boosted,
rules_applied,
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FeedbackImpact {
pub verdict: String,
pub matched_query: String,
pub author: String,
}
pub fn explain_feedback_impacts(
engine: &Engine,
index_store: &AsgIndexStore,
query: &str,
qnames: &[String],
feedback_entries: &[crate::schema::FeedbackEntry],
) -> HashMap<String, FeedbackImpact> {
let mut impacts: HashMap<String, FeedbackImpact> = HashMap::new();
if feedback_entries.is_empty() || qnames.is_empty() {
return impacts;
}
let query_norm = query.to_lowercase();
let current_tokens = fb_query_tokens(&query_norm);
struct NoisyFileEntry {
sym_id: String,
name_tokens: HashSet<String>,
}
let mut noisy_file_syms: HashMap<String, Vec<NoisyFileEntry>> = HashMap::new();
{
let noisy_ids: Vec<&str> = feedback_entries
.iter()
.filter(|e| {
matches!(
e.verdict,
crate::schema::FeedbackVerdict::Noisy
| crate::schema::FeedbackVerdict::WrongLayer
) && query_family_matches(¤t_tokens, &e.query)
})
.map(|e| e.symbol_id.as_str())
.collect();
if !noisy_ids.is_empty() {
let resolved = engine
.fts
.as_ref()
.map(|fts| fts.resolve_symbol_ids_bulk(&noisy_ids))
.unwrap_or_default();
if !resolved.is_empty() {
for (sym_id_str, rsym) in &resolved {
noisy_file_syms
.entry(rsym.file.clone())
.or_default()
.push(NoisyFileEntry {
sym_id: sym_id_str.clone(),
name_tokens: name_tokens_from_qname(&rsym.qname),
});
}
} else {
let noisy_set: HashSet<&str> = noisy_ids.into_iter().collect();
let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
if let Ok(serde_json::Value::Object(map)) =
engine.repo.get_tree(&engine.ref_name, &prefix)
{
for sym_val in map.values() {
if let Ok(sym) = serde_json::from_value::<Symbol>(sym_val.clone()) {
if noisy_set.contains(sym.symbol_id.as_str()) {
noisy_file_syms.entry(sym.file.clone()).or_default().push(
NoisyFileEntry {
sym_id: sym.symbol_id,
name_tokens: name_tokens_from_qname(&sym.qname),
},
);
}
}
}
}
}
}
}
let qname_strs: Vec<&str> = qnames.iter().map(|q| q.as_str()).collect();
let resolved_map = engine
.fts
.as_ref()
.map(|fts| fts.resolve_qnames_bulk(&qname_strs))
.unwrap_or_default();
for qname in qnames {
let (sym_id_owned, sym_file, sym_kind_enum) =
if let Some(rsym) = resolved_map.get(qname.as_str()) {
let kind = match rsym.kind.as_str() {
"class" => SymbolKind::Class,
"module" => SymbolKind::Module,
_ => SymbolKind::Function,
};
(rsym.symbol_id.clone(), rsym.file.clone(), kind)
} else {
match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => {
let k = s.kind.clone();
(s.symbol_id, s.file, k)
}
_ => continue,
}
};
let sym_id = &sym_id_owned;
let mut found = false;
for entry in feedback_entries {
if entry.symbol_id != *sym_id {
continue;
}
if !query_family_matches(¤t_tokens, &entry.query) {
continue;
}
let v_str = entry.verdict.as_str();
impacts.insert(
qname.clone(),
FeedbackImpact {
verdict: v_str.to_string(),
matched_query: entry.query.clone(),
author: entry.author.clone(),
},
);
found = true;
break;
}
if found {
continue;
}
if let Some(noisy_syms) = noisy_file_syms.get(&sym_file) {
if !matches!(sym_kind_enum, SymbolKind::Class | SymbolKind::Module) {
let candidate_tokens = name_tokens_from_qname(qname);
let overlaps = noisy_syms.iter().any(|ns| {
ns.sym_id != *sym_id
&& candidate_tokens.iter().any(|t| ns.name_tokens.contains(t))
});
if overlaps {
if let Some(entry) = feedback_entries.iter().find(|e| {
noisy_syms.iter().any(|ns| ns.sym_id == e.symbol_id)
&& matches!(
e.verdict,
crate::schema::FeedbackVerdict::Noisy
| crate::schema::FeedbackVerdict::WrongLayer
)
&& query_family_matches(¤t_tokens, &e.query)
}) {
impacts.insert(
qname.clone(),
FeedbackImpact {
verdict: "sibling_suppressed".to_string(),
matched_query: entry.query.clone(),
author: entry.author.clone(),
},
);
}
}
}
}
}
impacts
}
pub fn apply_file_scope_feedback(
engine: &Engine,
index_store: &AsgIndexStore,
query: &str,
scored: &mut Vec<(f64, String)>,
file_scope_entries: &[(
String,
crate::schema::FeedbackVerdict,
String,
chrono::DateTime<chrono::Utc>,
)],
) {
if file_scope_entries.is_empty() {
return;
}
let query_norm = query.to_lowercase();
let current_tokens = fb_query_tokens(&query_norm);
let qname_strs: Vec<&str> = scored.iter().map(|(_, q)| q.as_str()).collect();
let resolved_map = engine
.fts
.as_ref()
.map(|fts| fts.resolve_qnames_bulk(&qname_strs))
.unwrap_or_default();
let now_for_decay = chrono::Utc::now();
for (score, qname) in scored.iter_mut() {
if !score.is_finite() {
continue;
}
let file = if let Some(rsym) = resolved_map.get(qname.as_str()) {
rsym.file.clone()
} else {
match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
Ok(Some(s)) => s.file,
_ => continue,
}
};
for (file_glob, verdict, entry_query, fb_created_at) in file_scope_entries {
if !query_family_matches(¤t_tokens, entry_query) {
continue;
}
if !glob_match(file_glob, &file) {
continue;
}
match verdict {
crate::schema::FeedbackVerdict::Useful => {
let decay = crate::feedback::decay_for_entry(
*fb_created_at,
now_for_decay,
crate::feedback::DEFAULT_FEEDBACK_HALF_LIFE_DAYS,
);
*score += 1.5 * decay;
}
crate::schema::FeedbackVerdict::Missing => {}
v if v.is_suppression() => {
*score = f64::NEG_INFINITY;
break;
}
_ => {}
}
}
}
scored.retain(|(s, _)| s.is_finite());
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum UncertaintyReason {
AmbiguousTerm {
term: String,
#[serde(skip_serializing_if = "Option::is_none")]
file_count: Option<usize>,
},
GenericQuery { tokens: Vec<String> },
LowResultCount { count: usize },
MissingLayer {
expected_layer: String,
hint: String,
},
SparseIndex,
DbStateUnannotated {
state: String,
},
}
impl UncertaintyReason {
pub fn weight(&self) -> f64 {
match self {
UncertaintyReason::AmbiguousTerm { .. } => 0.20,
UncertaintyReason::GenericQuery { .. } => 0.25,
UncertaintyReason::LowResultCount { .. } => 0.20,
UncertaintyReason::MissingLayer { .. } => 0.10,
UncertaintyReason::SparseIndex => 0.15,
UncertaintyReason::DbStateUnannotated { .. } => 0.10,
}
}
pub fn source(&self) -> &'static str {
match self {
UncertaintyReason::AmbiguousTerm { .. } => "query",
UncertaintyReason::GenericQuery { .. } => "query",
UncertaintyReason::LowResultCount { .. } => "result_set",
UncertaintyReason::MissingLayer { .. } => "result_set",
UncertaintyReason::SparseIndex => "result_set",
UncertaintyReason::DbStateUnannotated { .. } => "db_state",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecoverySuggestion {
pub query: String,
pub ambiguous_terms_removed: usize,
pub domain_terms_added: Vec<String>,
pub estimated_recovery: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UncertaintySourceBreakdown {
pub query: f64,
pub db_state: f64,
pub result_set: f64,
pub primary: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UncertaintyReport {
pub level: String,
pub score: f64,
pub reasons: Vec<UncertaintyReason>,
pub recommended_action: String,
pub recovery_suggestions: Vec<RecoverySuggestion>,
pub exact_symbol_match: bool,
pub sources: UncertaintySourceBreakdown,
}
impl UncertaintyReport {
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
}
}
pub fn compute_uncertainty(
tokens: &[String],
ambiguous_terms: &[String],
possible_misses: &[String],
result_count: usize,
scoped_suggestions: &[String],
fts: Option<&SearchFtsDb>,
db_state: Option<&str>,
) -> UncertaintyReport {
let mut reasons: Vec<UncertaintyReason> = Vec::new();
let per_token_counts: HashMap<String, usize> = if !ambiguous_terms.is_empty() {
let tok_refs: Vec<&str> = ambiguous_terms.iter().map(|s| s.as_str()).collect();
fts.and_then(|f| f.count_distinct_files_per_token(&tok_refs, false).ok())
.unwrap_or_default()
.into_iter()
.collect()
} else {
HashMap::new()
};
for term in ambiguous_terms {
let file_count = per_token_counts.get(term.as_str()).copied();
reasons.push(UncertaintyReason::AmbiguousTerm {
term: term.clone(),
file_count,
});
}
const BROAD_TERMS: &[&str] = &[
"update", "get", "set", "handle", "process", "run", "execute", "init", "start", "stop",
"load", "save", "create", "delete", "state", "data", "model", "manager", "service", "util",
"helper", "config",
];
let meaningful: Vec<&String> = tokens.iter().filter(|t| !is_stopword(t)).collect();
let all_broad = !meaningful.is_empty()
&& meaningful
.iter()
.all(|t| BROAD_TERMS.contains(&t.as_str()) || ambiguous_terms.contains(t));
if all_broad && meaningful.len() <= 2 {
reasons.push(UncertaintyReason::GenericQuery {
tokens: meaningful.iter().map(|s| s.to_string()).collect(),
});
}
if result_count < 3 && result_count > 0 && tokens.len() >= 2 {
reasons.push(UncertaintyReason::LowResultCount {
count: result_count,
});
}
if result_count == 0 && !tokens.is_empty() {
reasons.push(UncertaintyReason::LowResultCount { count: 0 });
}
for miss in possible_misses {
let ml = miss.to_lowercase();
let (layer, hint) = if ml.contains("view") || ml.contains("ui") {
(
"view",
"query implies UI involvement — check scope or path coverage",
)
} else if ml.contains("service") || ml.contains("domain") {
(
"service",
"query implies service/domain involvement — check layer indexing",
)
} else if ml.contains("persist") || ml.contains("data") {
(
"persistence",
"query implies data model involvement — check index coverage",
)
} else if ml.contains("scheduler") || ml.contains("timing") {
(
"scheduler",
"query implies timing/dispatch — check scheduler symbols indexed",
)
} else if ml.contains("engine") || ml.contains("runtime") {
(
"engine",
"query implies runtime processing — consider broadening scope",
)
} else if ml.contains("network") || ml.contains("api") {
(
"network",
"query implies network/API — check network client indexing",
)
} else {
continue; };
reasons.push(UncertaintyReason::MissingLayer {
expected_layer: layer.to_string(),
hint: hint.to_string(),
});
}
if result_count < 3 && ambiguous_terms.is_empty() && !tokens.is_empty() && result_count > 0 {
reasons.push(UncertaintyReason::SparseIndex);
}
if let Some(state) = db_state {
if matches!(state, "unannotated" | "clean_room") {
reasons.push(UncertaintyReason::DbStateUnannotated {
state: state.to_string(),
});
}
}
let amb_count = reasons
.iter()
.filter(|r| matches!(r, UncertaintyReason::AmbiguousTerm { .. }))
.count()
.min(2);
let non_amb_score: f64 = reasons
.iter()
.filter(|r| !matches!(r, UncertaintyReason::AmbiguousTerm { .. }))
.map(|r| r.weight())
.sum::<f64>()
.min(0.60);
let raw_score = (amb_count as f64 * 0.20 + non_amb_score).min(1.0);
let score = (raw_score * 100.0).round() / 100.0;
let raw_level = if score < 0.15 {
"low"
} else if score < 0.45 {
"medium"
} else if score < 0.70 {
"high"
} else {
"critical"
};
let meaningful_tokens: Vec<&String> = tokens.iter().filter(|t| !is_stopword(t)).collect();
let exact_symbol_match = if meaningful_tokens.len() == 1 {
let tok = meaningful_tokens[0];
if tok.len() >= 5 || tok.chars().next().map_or(false, |c| c.is_uppercase()) {
fts.map(|f| f.has_exact_symbol_name(tok)).unwrap_or(false)
} else {
false
}
} else {
false
};
let level = if exact_symbol_match && matches!(raw_level, "high" | "critical") {
"medium"
} else {
raw_level
};
let recommended_action = if reasons
.iter()
.any(|r| matches!(r, UncertaintyReason::AmbiguousTerm { .. }))
{
"narrow_query"
} else if reasons
.iter()
.any(|r| matches!(r, UncertaintyReason::GenericQuery { .. }))
{
"add_domain_term"
} else if reasons.iter().any(|r| {
matches!(
r,
UncertaintyReason::MissingLayer { .. } | UncertaintyReason::SparseIndex
)
}) {
"check_index_coverage"
} else if reasons
.iter()
.any(|r| matches!(r, UncertaintyReason::LowResultCount { .. }))
{
"broaden_query"
} else {
"none"
};
let amb_set: std::collections::HashSet<&str> =
ambiguous_terms.iter().map(|s| s.as_str()).collect();
let original_token_set: std::collections::HashSet<&str> =
tokens.iter().map(|s| s.as_str()).collect();
let recovery_suggestions: Vec<RecoverySuggestion> = scoped_suggestions
.iter()
.map(|sugg| {
let sugg_tokens: Vec<String> = sugg
.split_whitespace()
.map(|t| t.to_lowercase())
.filter(|t| !is_stopword(t))
.collect();
let amb_in_sugg: std::collections::HashSet<&str> = sugg_tokens
.iter()
.map(|s| s.as_str())
.filter(|t| amb_set.contains(t))
.collect();
let ambiguous_terms_removed = ambiguous_terms.len().saturating_sub(amb_in_sugg.len());
let domain_terms_added: Vec<String> = sugg_tokens
.iter()
.filter(|t| !original_token_set.contains(t.as_str()) && !is_stopword(t))
.cloned()
.collect();
let estimated_recovery = if ambiguous_terms_removed == ambiguous_terms.len()
&& !domain_terms_added.is_empty()
{
"strong"
} else if ambiguous_terms_removed > 0 || !domain_terms_added.is_empty() {
"partial"
} else {
"weak"
};
RecoverySuggestion {
query: sugg.clone(),
ambiguous_terms_removed,
domain_terms_added,
estimated_recovery: estimated_recovery.to_string(),
}
})
.collect();
reasons.sort_by(|a, b| {
b.weight()
.partial_cmp(&a.weight())
.unwrap_or(std::cmp::Ordering::Equal)
});
let query_score: f64 = reasons
.iter()
.filter(|r| r.source() == "query")
.map(|r| r.weight())
.sum::<f64>()
.min(1.0);
let db_state_score: f64 = reasons
.iter()
.filter(|r| r.source() == "db_state")
.map(|r| r.weight())
.sum::<f64>()
.min(1.0);
let result_set_score: f64 = reasons
.iter()
.filter(|r| r.source() == "result_set")
.map(|r| r.weight())
.sum::<f64>()
.min(1.0);
let primary_source = {
let scores = [
("query", query_score),
("db_state", db_state_score),
("result_set", result_set_score),
];
let max = scores
.iter()
.cloned()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
match max {
Some((src, s)) if s > 0.0 => src,
_ => "none",
}
};
let sources = UncertaintySourceBreakdown {
query: ((query_score * 100.0).round() / 100.0).max(0.0),
db_state: ((db_state_score * 100.0).round() / 100.0).max(0.0),
result_set: ((result_set_score * 100.0).round() / 100.0).max(0.0),
primary: primary_source.to_string(),
};
UncertaintyReport {
level: level.to_string(),
score,
reasons,
recommended_action: recommended_action.to_string(),
recovery_suggestions,
exact_symbol_match,
sources,
}
}
use crate::feedback::{AsgFeedbackStore, FeedbackStore};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FeedbackState {
pub available: bool,
pub reason: String,
pub entries_total: usize,
pub query_matches: usize,
}
impl FeedbackState {
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
}
}
pub fn build_feedback_state_from_entries(
all: &[crate::schema::FeedbackEntry],
query: &str,
entries_applied: usize,
) -> FeedbackState {
let entries_total = all.len();
if entries_total == 0 {
return FeedbackState {
available: false,
reason: "no_feedback_entries".to_string(),
entries_total: 0,
query_matches: 0,
};
}
let q_tokens: std::collections::HashSet<String> = query
.split_whitespace()
.map(|t| t.to_lowercase())
.filter(|t| t.len() >= 2)
.collect();
let query_matches = all
.iter()
.filter(|e| {
e.query
.split_whitespace()
.any(|t| q_tokens.contains(&t.to_lowercase()))
})
.count();
let reason = if entries_applied > 0 {
"entries_applied"
} else if query_matches > 0 {
"entries_exist_no_query_match"
} else {
"entries_exist_no_query_match"
};
FeedbackState {
available: true,
reason: reason.to_string(),
entries_total,
query_matches,
}
}
pub fn build_feedback_state(
engine: &Engine,
ref_name: &str,
query: &str,
entries_applied: usize,
) -> FeedbackState {
let fb_store = AsgFeedbackStore::from_engine(engine);
let all: Vec<crate::schema::FeedbackEntry> = fb_store
.list_all(ref_name)
.unwrap_or_default()
.into_iter()
.filter(|e| !e.is_expired())
.collect();
build_feedback_state_from_entries(&all, query, entries_applied)
}
#[cfg(test)]
mod plan_c_t003_tests {
use crate::engine::Engine;
use crate::index::{AsgIndexStore, IndexStore};
use crate::ledger::{AsgLedgerStore, LedgerStore};
use crate::schema::{
Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
};
fn seed_engine_with_symbol() -> (Engine, String) {
let engine = Engine::open_in_memory().unwrap();
let index = AsgIndexStore::from_engine(&engine);
let sym = Symbol {
symbol_id: "sym_stale".into(),
symbol_fp: "fp_stale".into(),
qname: "store.legacy.api".into(),
language: "python".into(),
kind: SymbolKind::Function,
file: "store/legacy.py".into(),
start: Position { line: 1, col: 0 },
end: Position { line: 5, col: 0 },
signature: None,
doc: None,
};
index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
(engine, sym.symbol_id)
}
fn append_constraint(engine: &Engine, sym_id: &str, role: Option<&str>) {
let ledger = AsgLedgerStore::from_engine(engine);
let mut entry = LedgerEntry::new(
sym_id,
LedgerKind::Constraint,
"legacy api should not be used in new code",
Author {
kind: AuthorKind::Agent,
id: "test".into(),
},
);
entry.role = role.map(str::to_string);
ledger
.append_entry(&engine.ref_name, &entry, "test")
.unwrap();
}
#[test]
fn constraint_with_stale_api_role_suppresses_symbol() {
let (engine, sym_id) = seed_engine_with_symbol();
append_constraint(&engine, &sym_id, Some("stale-api"));
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
let suppressed = super::apply_constraint_penalties(&engine, &index, &mut scored);
assert_eq!(suppressed, 1);
assert_eq!(scored[0].0, f64::NEG_INFINITY);
}
#[test]
fn constraint_with_audit_pending_role_suppresses_symbol() {
let (engine, sym_id) = seed_engine_with_symbol();
append_constraint(&engine, &sym_id, Some("audit-pending"));
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
let suppressed = super::apply_constraint_penalties(&engine, &index, &mut scored);
assert_eq!(suppressed, 1);
assert_eq!(scored[0].0, f64::NEG_INFINITY);
}
#[test]
fn constraint_without_role_does_not_suppress() {
let (engine, sym_id) = seed_engine_with_symbol();
append_constraint(&engine, &sym_id, None);
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
let suppressed = super::apply_constraint_penalties(&engine, &index, &mut scored);
assert_eq!(suppressed, 0);
assert_eq!(scored[0].0, 10.0);
}
#[test]
fn non_penalty_role_does_not_suppress() {
let (engine, sym_id) = seed_engine_with_symbol();
append_constraint(&engine, &sym_id, Some("package-boundary"));
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
let suppressed = super::apply_constraint_penalties(&engine, &index, &mut scored);
assert_eq!(suppressed, 0);
}
#[test]
fn apply_feedback_adjustments_reports_constraint_penalty_in_metrics() {
let (engine, sym_id) = seed_engine_with_symbol();
append_constraint(&engine, &sym_id, Some("stale-api"));
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(10.0_f64, "store.legacy.api".to_string())];
let metrics =
super::apply_feedback_adjustments(&engine, &index, "legacy", &mut scored, &[]);
assert_eq!(metrics.constraint_penalties_applied, 1);
assert!(
metrics
.rules_applied
.iter()
.any(|r| r == "constraint_penalty")
);
assert_eq!(scored[0].0, f64::NEG_INFINITY);
}
fn seed_plain_symbol() -> (Engine, String) {
let engine = Engine::open_in_memory().unwrap();
let index = AsgIndexStore::from_engine(&engine);
let sym = Symbol {
symbol_id: "sym_a".into(),
symbol_fp: "fp_a".into(),
qname: "a.b.c".into(),
language: "python".into(),
kind: SymbolKind::Function,
file: "src/a.py".into(),
start: Position { line: 1, col: 0 },
end: Position { line: 5, col: 0 },
signature: None,
doc: None,
};
index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
(engine, sym.symbol_id)
}
#[test]
fn already_covered_verdict_suppresses_and_labels_rule() {
let (engine, sym_id) = seed_plain_symbol();
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(10.0_f64, "a.b.c".to_string())];
let metrics = super::apply_feedback_adjustments(
&engine,
&index,
"match my query",
&mut scored,
&[(
sym_id,
"match my query".into(),
crate::schema::FeedbackVerdict::AlreadyCovered,
chrono::Utc::now(), )],
);
assert!(scored.is_empty(), "suppressed symbol must be dropped");
assert!(metrics.rules_applied.iter().any(|r| r == "already_covered"));
}
#[test]
fn diagnostic_only_verdict_suppresses_and_labels_rule() {
let (engine, sym_id) = seed_plain_symbol();
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(10.0_f64, "a.b.c".to_string())];
let metrics = super::apply_feedback_adjustments(
&engine,
&index,
"match my query",
&mut scored,
&[(
sym_id,
"match my query".into(),
crate::schema::FeedbackVerdict::DiagnosticOnly,
chrono::Utc::now(), )],
);
assert!(scored.is_empty(), "suppressed symbol must be dropped");
assert!(metrics.rules_applied.iter().any(|r| r == "diagnostic_only"));
}
#[test]
fn feedback_verdict_is_suppression_classification() {
use crate::schema::FeedbackVerdict::*;
assert!(!Useful.is_suppression());
assert!(!Missing.is_suppression());
assert!(Noisy.is_suppression());
assert!(WrongLayer.is_suppression());
assert!(AlreadyCovered.is_suppression());
assert!(DiagnosticOnly.is_suppression());
}
#[test]
fn apply_task_bias_boosts_in_scope_files_only() {
let engine = Engine::open_in_memory().unwrap();
let index = AsgIndexStore::from_engine(&engine);
for (qn, file) in [
("pkg.in_scope.fn", "src/pkg/in_scope.py"),
("pkg.out_of_scope.fn", "src/other/file.py"),
] {
let sym = Symbol {
symbol_id: format!("sym_{qn}"),
symbol_fp: "fp".into(),
qname: qn.into(),
language: "python".into(),
kind: SymbolKind::Function,
file: file.into(),
start: Position { line: 1, col: 0 },
end: Position { line: 2, col: 0 },
signature: None,
doc: None,
};
index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
}
let mut scored = vec![
(5.0_f64, "pkg.in_scope.fn".to_string()),
(5.0_f64, "pkg.out_of_scope.fn".to_string()),
];
let scope = vec!["src/pkg/**".to_string()];
let n = super::apply_task_bias(&engine, &index, &mut scored, &scope, 1.0);
assert_eq!(n, 1);
let in_scope = scored.iter().find(|(_, q)| q == "pkg.in_scope.fn").unwrap();
let out_scope = scored
.iter()
.find(|(_, q)| q == "pkg.out_of_scope.fn")
.unwrap();
assert_eq!(in_scope.0, 6.0);
assert_eq!(out_scope.0, 5.0);
}
#[test]
fn apply_task_bias_with_empty_scope_is_a_noop() {
let engine = Engine::open_in_memory().unwrap();
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(5.0_f64, "anything".to_string())];
let n = super::apply_task_bias(&engine, &index, &mut scored, &[], 1.0);
assert_eq!(n, 0);
assert_eq!(scored[0].0, 5.0);
}
#[test]
fn apply_task_bias_skips_already_suppressed_candidates() {
let engine = Engine::open_in_memory().unwrap();
let index = AsgIndexStore::from_engine(&engine);
let sym = Symbol {
symbol_id: "sym_z".into(),
symbol_fp: "fp".into(),
qname: "z.in_scope.fn".into(),
language: "python".into(),
kind: SymbolKind::Function,
file: "src/pkg/z.py".into(),
start: Position { line: 1, col: 0 },
end: Position { line: 2, col: 0 },
signature: None,
doc: None,
};
index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
let mut scored = vec![(f64::NEG_INFINITY, "z.in_scope.fn".to_string())];
let n = super::apply_task_bias(
&engine,
&index,
&mut scored,
&["src/pkg/**".to_string()],
1.0,
);
assert_eq!(n, 0, "NEG_INFINITY candidates must not be revived by bias");
assert_eq!(scored[0].0, f64::NEG_INFINITY);
}
#[test]
fn read_active_task_scope_parses_json() {
let raw = r#"{"task_id":"t-006","scope":["src/pkg/**","tests/pkg/**"]}"#;
let scope = super::read_active_task_scope_from(Some(raw), None);
assert_eq!(
scope,
Some(vec!["src/pkg/**".to_string(), "tests/pkg/**".to_string()])
);
}
#[test]
fn read_active_task_scope_returns_none_when_unset() {
let scope = super::read_active_task_scope_from(None, None);
assert_eq!(scope, None);
}
#[test]
fn read_active_task_scope_returns_none_on_empty_scope_array() {
let raw = r#"{"task_id":"t-006","scope":[]}"#;
assert_eq!(super::read_active_task_scope_from(Some(raw), None), None);
}
#[test]
fn read_active_task_scope_returns_none_on_malformed_json() {
assert_eq!(
super::read_active_task_scope_from(Some("not json"), None),
None
);
assert_eq!(super::read_active_task_scope_from(Some("{}"), None), None);
}
#[test]
fn constraint_scope_from_body_parses_json_scope_array() {
let body = r#"{"scope":["src/legacy/**","tests/legacy/**"]}"#;
assert_eq!(
super::constraint_scope_from_body(Some(body)),
Some(vec![
"src/legacy/**".to_string(),
"tests/legacy/**".to_string()
])
);
}
#[test]
fn constraint_scope_from_body_returns_none_when_unset() {
assert_eq!(super::constraint_scope_from_body(None), None);
assert_eq!(
super::constraint_scope_from_body(Some("plain text body")),
None
);
assert_eq!(
super::constraint_scope_from_body(Some(r#"{"other":"field"}"#)),
None
);
assert_eq!(
super::constraint_scope_from_body(Some(r#"{"scope":[]}"#)),
None
);
}
fn seed_engine_with_two_symbols() -> (Engine, String, String) {
let engine = Engine::open_in_memory().unwrap();
let index = AsgIndexStore::from_engine(&engine);
for (qn, file) in [
("app.legacy.api", "src/legacy/api.py"),
("app.modern.api", "src/modern/api.py"),
] {
let sym = Symbol {
symbol_id: format!("sym_{qn}"),
symbol_fp: "fp".into(),
qname: qn.into(),
language: "python".into(),
kind: SymbolKind::Function,
file: file.into(),
start: Position { line: 1, col: 0 },
end: Position { line: 2, col: 0 },
signature: None,
doc: None,
};
index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
}
(
engine,
"sym_app.legacy.api".into(),
"sym_app.modern.api".into(),
)
}
fn append_scoped_constraint(engine: &Engine, sym_id: &str, scope_json: &str) {
let ledger = AsgLedgerStore::from_engine(engine);
let mut entry = LedgerEntry::new(
sym_id,
LedgerKind::Constraint,
"legacy api may not be used in new code",
Author {
kind: AuthorKind::Agent,
id: "t".into(),
},
);
entry.role = Some("stale-api".into());
entry.body = Some(scope_json.to_string());
ledger
.append_entry(&engine.ref_name, &entry, "test")
.unwrap();
}
#[test]
fn scoped_constraint_suppresses_only_in_scope_symbols() {
let (engine, legacy_id, _modern_id) = seed_engine_with_two_symbols();
append_scoped_constraint(&engine, &legacy_id, r#"{"scope":["src/legacy/**"]}"#);
let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![
(10.0_f64, "app.legacy.api".into()),
(10.0_f64, "app.modern.api".into()),
];
let n = super::apply_constraint_penalties(&engine, &index, &mut scored);
assert_eq!(n, 1, "only legacy should be suppressed");
let legacy = scored.iter().find(|(_, q)| q == "app.legacy.api").unwrap();
let modern = scored.iter().find(|(_, q)| q == "app.modern.api").unwrap();
assert_eq!(legacy.0, f64::NEG_INFINITY);
assert_eq!(modern.0, 10.0);
}
#[test]
fn unscoped_constraint_still_suppresses_globally() {
let (engine, legacy_id, _modern_id) = seed_engine_with_two_symbols();
append_constraint(&engine, &legacy_id, Some("stale-api")); let index = AsgIndexStore::from_engine(&engine);
let mut scored = vec![(10.0_f64, "app.legacy.api".into())];
let n = super::apply_constraint_penalties(&engine, &index, &mut scored);
assert_eq!(n, 1);
assert_eq!(scored[0].0, f64::NEG_INFINITY);
}
}
#[cfg(test)]
mod plan_j_t012_explain_match_tests {
use crate::candidates::explain_match;
use crate::schema::{
Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
};
fn mk_sym(qname: &str, file: &str, sig: Option<&str>, doc: Option<&str>) -> Symbol {
Symbol {
symbol_id: format!("sym_{}", qname.replace('.', "_")),
symbol_fp: "fp".into(),
qname: qname.into(),
language: "python".into(),
kind: SymbolKind::Function,
file: file.into(),
start: Position { line: 1, col: 0 },
end: Position { line: 5, col: 0 },
signature: sig.map(str::to_string),
doc: doc.map(str::to_string),
}
}
fn mk_entry(kind: LedgerKind, summary: &str) -> LedgerEntry {
LedgerEntry::new(
"sym_x",
kind,
summary,
Author {
kind: AuthorKind::Human,
id: "alice".into(),
},
)
}
#[test]
fn name_match_yields_name_reason() {
let sym = mk_sym("pkg.playhead", "src/playhead.py", None, None);
let reasons = explain_match(&sym, &["playhead".into()], &[], false);
assert!(
reasons.iter().any(|r| r == "name:playhead"),
"expected name:playhead in {reasons:?}"
);
}
#[test]
fn file_match_yields_file_reason_when_name_misses() {
let sym = mk_sym("pkg.x", "src/transport.py", None, None);
let reasons = explain_match(&sym, &["transport".into()], &[], false);
assert!(
reasons.iter().any(|r| r == "file:transport"),
"expected file:transport in {reasons:?}"
);
}
#[test]
fn doc_match_yields_doc_reason_when_higher_priority_miss() {
let sym = mk_sym(
"pkg.x",
"src/x.py",
None,
Some("Handles the audio mixer pipeline."),
);
let reasons = explain_match(&sym, &["mixer".into()], &[], false);
assert!(
reasons.iter().any(|r| r == "doc:mixer"),
"expected doc:mixer in {reasons:?}"
);
}
#[test]
fn hot_files_get_recent_edit_signal() {
let sym = mk_sym("pkg.x", "src/x.py", None, None);
let reasons = explain_match(&sym, &[], &[], true);
assert!(
reasons.contains(&"recent-edit".to_string()),
"is_hot=true must surface `recent-edit`; got {reasons:?}"
);
}
#[test]
fn invariant_count_appears_as_signal() {
let sym = mk_sym("pkg.x", "src/x.py", None, None);
let entries = vec![
mk_entry(LedgerKind::Invariant, "must hold X"),
mk_entry(LedgerKind::Invariant, "must hold Y"),
];
let reasons = explain_match(&sym, &[], &entries, false);
assert!(
reasons.iter().any(|r| r == "invariant-attached:2"),
"expected invariant-attached:2 in {reasons:?}"
);
}
#[test]
fn hazard_count_appears_as_signal_with_pluralization() {
let sym = mk_sym("pkg.x", "src/x.py", None, None);
let one = vec![mk_entry(LedgerKind::Hazard, "be careful")];
let two = vec![
mk_entry(LedgerKind::Hazard, "be careful 1"),
mk_entry(LedgerKind::Hazard, "be careful 2"),
];
assert!(
explain_match(&sym, &[], &one, false)
.iter()
.any(|r| r == "ledger:1 hazard")
);
assert!(
explain_match(&sym, &[], &two, false)
.iter()
.any(|r| r == "ledger:2 hazards")
);
}
#[test]
fn ownership_match_surfaces_when_summary_overlaps_query() {
let sym = mk_sym("pkg.audio", "src/audio.py", None, None);
let entries = vec![mk_entry(
LedgerKind::Ownership,
"audio mixer pipeline source of truth",
)];
let reasons = explain_match(&sym, &["audio".into()], &entries, false);
assert!(
reasons.iter().any(|r| r.starts_with("ownership:")),
"expected ownership:* in {reasons:?}"
);
}
#[test]
fn empty_input_returns_empty_reasons() {
let sym = mk_sym("pkg.x", "src/x.py", None, None);
let reasons = explain_match(&sym, &[], &[], false);
assert!(
reasons.is_empty(),
"no tokens, no ledger, not hot → empty; got {reasons:?}"
);
}
}
#[cfg(test)]
mod plan_j_t011_exclude_sets_tests {
use super::{load_exclude_sets, matches_any_path_glob, resolve_exclude_set};
use std::path::PathBuf;
fn write_scopes_toml(dir: &std::path::Path, body: &str) {
std::fs::write(dir.join("scopes.toml"), body).unwrap();
}
fn db_path_in(dir: &std::path::Path) -> PathBuf {
dir.join(".asd-state.db")
}
#[test]
fn returns_empty_when_no_exclude_sets_table() {
let tmp = tempfile::tempdir().unwrap();
write_scopes_toml(tmp.path(), "drift-pad = [\"App/**/DriftPad*\"]\n");
let map = load_exclude_sets(&db_path_in(tmp.path()));
assert!(
map.is_empty(),
"no [exclude_sets] table should yield empty map; got {map:?}"
);
}
#[test]
fn reads_named_exclude_sets() {
let tmp = tempfile::tempdir().unwrap();
write_scopes_toml(
tmp.path(),
"[exclude_sets]\n\
tests = [\"**/test_*.py\", \"tests/**\"]\n\
generated = [\"**/generated/**\"]\n",
);
let map = load_exclude_sets(&db_path_in(tmp.path()));
assert_eq!(map.len(), 2);
assert_eq!(
map.get("tests").map(|v| v.len()),
Some(2),
"tests must have 2 globs; got {:?}",
map.get("tests")
);
assert_eq!(map.get("generated").map(|v| v.len()), Some(1));
}
#[test]
fn resolve_unknown_returns_empty_not_treated_as_glob() {
let tmp = tempfile::tempdir().unwrap();
write_scopes_toml(tmp.path(), "[exclude_sets]\ntests = [\"tests/**\"]\n");
let globs = resolve_exclude_set("nonexistent", &db_path_in(tmp.path()));
assert!(
globs.is_empty(),
"unknown exclude-set name must return empty, not the literal name; got {globs:?}"
);
}
#[test]
fn resolve_known_returns_globs() {
let tmp = tempfile::tempdir().unwrap();
write_scopes_toml(
tmp.path(),
"[exclude_sets]\ntests = [\"tests/**\", \"**/test_*.py\"]\n",
);
let globs = resolve_exclude_set("tests", &db_path_in(tmp.path()));
assert_eq!(globs.len(), 2);
assert!(matches_any_path_glob(&globs, "tests/foo.py"));
assert!(matches_any_path_glob(&globs, "src/test_widget.py"));
assert!(!matches_any_path_glob(&globs, "src/widget.py"));
}
#[test]
fn coexists_with_bare_scope_aliases() {
let tmp = tempfile::tempdir().unwrap();
write_scopes_toml(
tmp.path(),
"drift-pad = [\"App/**/DriftPad*\"]\n\
\n[exclude_sets]\n\
tests = [\"tests/**\"]\n",
);
let aliases = super::load_scope_aliases(&db_path_in(tmp.path()));
let excl = load_exclude_sets(&db_path_in(tmp.path()));
assert_eq!(aliases.get("drift-pad").map(|v| v.len()), Some(1));
assert_eq!(excl.get("tests").map(|v| v.len()), Some(1));
assert!(!aliases.contains_key("tests"));
assert!(!excl.contains_key("drift-pad"));
}
}
#[cfg(test)]
mod plan_j_t006_view_layer_matches_tests {
use super::view_layer_matches;
#[test]
fn ui_layer_alone_qualifies() {
assert!(view_layer_matches("ui", "anything/not/even/a/view.go"));
}
#[test]
fn viewmodel_layer_alone_qualifies() {
assert!(view_layer_matches("viewmodel", "Foo.swift"));
}
#[test]
fn non_view_layer_with_non_view_file_does_not_qualify() {
assert!(!view_layer_matches("scheduler", "src/audio/clock.rs"));
assert!(!view_layer_matches("persistence", "src/db/migrations.rs"));
}
#[test]
fn file_role_view_closes_layer_gap() {
assert!(view_layer_matches(
"other",
"app/components/AcmeFlowView.swift"
));
}
#[test]
fn file_role_view_via_vue_extension_qualifies() {
assert!(view_layer_matches("other", "src/Components/UserCard.vue"));
assert!(view_layer_matches("other", "src/widgets/Toolbar.svelte"));
}
#[test]
fn file_role_viewmodel_closes_layer_gap() {
assert!(view_layer_matches(
"other",
"app/screens/login/LoginViewModel.kt"
));
}
#[test]
fn impl_file_role_not_a_view() {
assert!(!view_layer_matches("other", "src/services/auth.rs"));
assert!(!view_layer_matches("other", "src/util/helpers.ts"));
}
#[test]
fn previewservice_is_not_a_view_negative_regression() {
assert!(!view_layer_matches(
"other",
"src/preview/PreviewService.swift"
));
}
}