Skip to main content

reference_query/search/
mod.rs

1//! Search — the staged ranking pipeline.
2//!
3//! Layers 1–3 (exact/prefix, abbreviation-aware fuzzy, path) over the index,
4//! scored by an additive, `--explain`-able scorer. Layers 4–5 (live scan,
5//! opportunistic extraction) and true streaming/early-exit arrive in phase 2;
6//! for now the candidate set is gathered once and ranked.
7
8mod score;
9
10pub use score::{Boosts, Feature, Scored, match_positions};
11
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14use std::time::{Instant, SystemTime, UNIX_EPOCH};
15
16use crate::store::{Store, SymbolRow};
17
18/// Per-layer cap on candidates pulled from the store before ranking. Exact and
19/// prefix matches are guaranteed in full (see `Store::search_candidates`); this
20/// only bounds the broad first-char-anchor and trigram-fuzzy recall layers.
21/// Scoring is linear and cheap, so this sits well under the latency budget.
22const CANDIDATE_LIMIT: usize = 8000;
23
24/// Sentinel repository id for live-scan (Layer 4) results — distinct from any
25/// real row id, and treated as "the current repo" so the boost applies.
26const LIVE_REPO_ID: i64 = -1;
27
28/// Boost for a symbol whose file you're actively changing on this branch.
29const BRANCH_FILE_BOOST: f64 = 180.0;
30/// Smaller boost for a symbol in a directory you're changing (a neighbor).
31const BRANCH_DIR_BOOST: f64 = 60.0;
32
33/// Files you're working on this branch — those that differ from the trunk —
34/// plus the directories holding them. Symbols in those files (or their
35/// directory neighbors) get a branch boost. Empty on the trunk / outside git.
36#[derive(Debug, Default, Clone)]
37pub struct ActiveFiles {
38    files: HashSet<String>,
39    dirs: HashSet<String>,
40}
41
42impl ActiveFiles {
43    /// Build from a list of repo-relative paths changed on the branch.
44    pub fn new<I: IntoIterator<Item = String>>(paths: I) -> Self {
45        let files: HashSet<String> = paths.into_iter().collect();
46        let dirs = files
47            .iter()
48            .filter_map(|f| parent_dir(f))
49            .map(str::to_string)
50            .collect();
51        ActiveFiles { files, dirs }
52    }
53
54    fn is_empty(&self) -> bool {
55        self.files.is_empty()
56    }
57
58    /// The branch boost for a candidate's file: full if the file itself is
59    /// changing, smaller if a sibling in the same directory is.
60    fn boost(&self, path: &str) -> f64 {
61        if self.files.contains(path) {
62            BRANCH_FILE_BOOST
63        } else if parent_dir(path).is_some_and(|d| self.dirs.contains(d)) {
64            BRANCH_DIR_BOOST
65        } else {
66            0.0
67        }
68    }
69}
70
71/// The directory portion of a repo-relative path (`app/models/user.rb` →
72/// `app/models`), or `None` for a top-level file.
73fn parent_dir(path: &str) -> Option<&str> {
74    path.rfind('/').map(|i| &path[..i])
75}
76
77/// A ranked search result. Serializes for `--json` / `--ndjson`.
78#[derive(Debug, Clone, PartialEq, serde::Serialize)]
79pub struct Hit {
80    pub name: String,
81    pub kind: String,
82    pub language: String,
83    pub file: String,
84    pub line: i64,
85    pub parent: Option<String>,
86    #[serde(rename = "repo")]
87    pub repo_identity: String,
88    pub score: f64,
89    pub features: Vec<Feature>,
90    /// The definition's source line (trimmed) — filled for displayed results in
91    /// machine-readable output. `None` when unread or in text mode.
92    pub signature: Option<String>,
93}
94
95/// Search the index for `query`, returning up to `limit` ranked hits.
96/// `current_repo_id` (if any) boosts results from the repository you're in;
97/// `active` boosts files you're changing on the current branch.
98pub fn search(
99    store: &Store,
100    query: &str,
101    current_repo_id: Option<i64>,
102    active: &ActiveFiles,
103    limit: usize,
104) -> crate::store::Result<Vec<Hit>> {
105    // A wildcard query keys candidate recall off its literal chars (the store
106    // indexes literal trigrams); the glob then matches precisely during scoring.
107    let stripped;
108    let recall = if score::has_wildcard(query) {
109        stripped = score::strip_wildcards(query);
110        stripped.as_str()
111    } else {
112        query
113    };
114    let trace_on = crate::trace::enabled();
115    let t = std::time::Instant::now();
116    let candidates =
117        store.search_candidates(recall, CANDIDATE_LIMIT, score::has_wildcard(query))?;
118    let n_candidates = candidates.len();
119    let t_recall = t.elapsed();
120    let t = std::time::Instant::now();
121    let learned = learned_boosts(store, query)?;
122    let now = now_unix();
123
124    let mut hits: Vec<Hit> = candidates
125        .into_iter()
126        .filter_map(|c| {
127            let key = (c.repository_id, c.file.clone(), c.name.clone());
128            let boosts = Boosts {
129                learned: learned.get(&key).copied().unwrap_or(0.0),
130                // prefer whichever recency signal is more recent: a recent edit
131                // (mtime) or a recent commit (git_ts)
132                recency: recency_boost(c.git_ts.max(c.mtime), now),
133                branch: if active.is_empty() {
134                    0.0
135                } else {
136                    active.boost(&c.file)
137                },
138            };
139            rank_one(query, c, current_repo_id, boosts)
140        })
141        .collect();
142    let n_hits = hits.len();
143    let t_score = t.elapsed();
144
145    let t = std::time::Instant::now();
146    sort_and_truncate(&mut hits, limit);
147    if trace_on {
148        crate::trace!(
149            "search {query:?}: recall {n_candidates} cand in {} ms, score→{n_hits} hits in {} ms, sort {} ms",
150            t_recall.as_millis(),
151            t_score.as_millis(),
152            t.elapsed().as_millis(),
153        );
154    }
155    Ok(hits)
156}
157
158/// Symbols in recently-modified files rank higher. ~14-day half-life and no
159/// floor, so files untouched for a while contribute nothing.
160fn recency_boost(mtime: Option<i64>, now: i64) -> f64 {
161    let Some(mtime) = mtime else {
162        return 0.0;
163    };
164    let age_days = (now - mtime).max(0) as f64 / 86_400.0;
165    let boost = 120.0 * 0.5_f64.powf(age_days / 14.0);
166    if boost < 1.0 { 0.0 } else { boost }
167}
168
169/// Decay-weighted learned boosts for a query, keyed by `(repo, file, name)`.
170fn learned_boosts(
171    store: &Store,
172    query: &str,
173) -> crate::store::Result<HashMap<(i64, String, String), f64>> {
174    let now = now_unix();
175    let q = query.to_ascii_lowercase();
176    let mut map: HashMap<(i64, String, String), f64> = HashMap::new();
177    for s in store.selections_for(&q)? {
178        // several stored queries can match (e.g. "han" and "handler"); keep the
179        // strongest boost for each candidate
180        let boost = learned_boost(s.selections, s.last_selected_at, now);
181        let entry = map.entry((s.repository_id, s.file, s.name)).or_insert(0.0);
182        *entry = entry.max(boost);
183    }
184    Ok(map)
185}
186
187/// Turn a selection count + recency into a ranking boost. Evidence ramps over
188/// ~5 selections; recency decays with a ~30-day half-life, floored so old picks
189/// still count for something.
190fn learned_boost(selections: i64, last_selected_at: i64, now: i64) -> f64 {
191    if selections <= 0 {
192        return 0.0;
193    }
194    let strength = (selections.min(5) as f64) / 5.0;
195    let age_days = (now - last_selected_at).max(0) as f64 / 86_400.0;
196    let recency = 0.5_f64.powf(age_days / 30.0).max(0.25);
197    260.0 * strength * recency
198}
199
200fn now_unix() -> i64 {
201    SystemTime::now()
202        .duration_since(UNIX_EPOCH)
203        .map(|d| d.as_secs() as i64)
204        .unwrap_or(0)
205}
206
207/// Layer 4: scan `root` live (no index required) and return ranked hits.
208/// Results are treated as the current repo, so the current-repo boost applies.
209/// `skip` names already-indexed files to ignore, and `deadline` bounds the scan
210/// — both empty/`None` for an unbounded scan of a never-indexed directory. When
211/// `prefilter` is set, only files containing the query (substring) are parsed —
212/// fast for exact/prefix/substring queries, but blind to fuzzy abbreviations, so
213/// callers retry with `prefilter = false` if a filtered scan finds nothing.
214pub fn live_search(
215    root: &Path,
216    query: &str,
217    limit: usize,
218    skip: &HashSet<String>,
219    deadline: Option<Instant>,
220    prefilter: bool,
221) -> Vec<Hit> {
222    let needle = prefilter.then_some(query.as_bytes());
223    let identity = crate::index::detect_identity(root).to_string();
224    let mut hits: Vec<Hit> = crate::index::scan(root, skip, deadline, needle)
225        .into_iter()
226        .flat_map(|fs| fs.symbols)
227        .filter_map(|s| {
228            let row = SymbolRow {
229                name: s.name,
230                kind: s.kind.as_str().to_string(),
231                language: s.language,
232                file: s.file,
233                line: s.line as i64,
234                parent: s.parent,
235                repository_id: LIVE_REPO_ID,
236                repo_identity: identity.clone(),
237                mtime: None,
238                git_ts: None,
239            };
240            rank_one(query, row, Some(LIVE_REPO_ID), Boosts::default())
241        })
242        .collect();
243    sort_and_truncate(&mut hits, limit);
244    hits
245}
246
247/// Merge two ranked lists, de-duplicating by location and name (keeping the
248/// higher score), then re-rank and truncate. Used to blend index and live-scan
249/// results.
250pub fn merge(a: Vec<Hit>, b: Vec<Hit>, limit: usize) -> Vec<Hit> {
251    use std::collections::HashMap;
252    let mut by_key: HashMap<(String, i64, String), Hit> = HashMap::new();
253    for hit in a.into_iter().chain(b) {
254        let key = (hit.file.clone(), hit.line, hit.name.clone());
255        match by_key.get(&key) {
256            Some(existing) if existing.score >= hit.score => {}
257            _ => {
258                by_key.insert(key, hit);
259            }
260        }
261    }
262    let mut hits: Vec<Hit> = by_key.into_values().collect();
263    sort_and_truncate(&mut hits, limit);
264    hits
265}
266
267/// Highest score first; ties broken toward shorter (more specific) names.
268fn sort_and_truncate(hits: &mut Vec<Hit>, limit: usize) {
269    hits.sort_by(|a, b| {
270        b.score
271            .partial_cmp(&a.score)
272            .unwrap_or(std::cmp::Ordering::Equal)
273            .then_with(|| a.name.len().cmp(&b.name.len()))
274            .then_with(|| a.name.cmp(&b.name))
275    });
276    hits.truncate(limit);
277}
278
279fn rank_one(
280    query: &str,
281    c: SymbolRow,
282    current_repo_id: Option<i64>,
283    boosts: Boosts,
284) -> Option<Hit> {
285    let scored = score::score(query, &c, current_repo_id, boosts)?;
286    Some(Hit {
287        name: c.name,
288        kind: c.kind,
289        language: c.language,
290        file: c.file,
291        line: c.line,
292        parent: c.parent,
293        repo_identity: c.repo_identity,
294        score: scored.total,
295        features: scored.features,
296        signature: None,
297    })
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::core::{Kind, Symbol};
304
305    fn sym(name: &str, kind: Kind) -> Symbol {
306        Symbol {
307            name: name.into(),
308            kind,
309            language: "ruby".into(),
310            file: "app/x.rb".into(),
311            line: 1,
312            parent: None,
313        }
314    }
315
316    fn store_with(symbols: &[Symbol]) -> Store {
317        let mut store = Store::open_in_memory().unwrap();
318        let repo = store
319            .upsert_repository(&crate::core::RepoIdentity::local("/tmp/x"), None)
320            .unwrap();
321        store
322            .replace_file_symbols(repo, "app/x.rb", "ruby", None, "h", symbols)
323            .unwrap();
324        store
325    }
326
327    fn names(hits: &[Hit]) -> Vec<&str> {
328        hits.iter().map(|h| h.name.as_str()).collect()
329    }
330
331    #[test]
332    fn ranks_exact_match_first() {
333        let store = store_with(&[
334            sym("Users", Kind::Class),
335            sym("User", Kind::Class),
336            sym("UserMailer", Kind::Class),
337        ]);
338        let hits = search(&store, "user", None, &ActiveFiles::default(), 10).unwrap();
339        assert_eq!(hits[0].name, "User");
340    }
341
342    #[test]
343    fn abbreviation_finds_the_intended_symbol() {
344        let store = store_with(&[
345            sym("RefundProcessor", Kind::Class),
346            sym("Refund", Kind::Class),
347            sym("Payment", Kind::Class),
348        ]);
349        let hits = search(&store, "refundproc", None, &ActiveFiles::default(), 10).unwrap();
350        assert_eq!(hits[0].name, "RefundProcessor");
351        assert!(!names(&hits).contains(&"Payment"));
352    }
353
354    #[test]
355    fn short_fuzzy_query_still_resolves() {
356        let store = store_with(&[sym("User", Kind::Class), sym("Account", Kind::Class)]);
357        let hits = search(&store, "usr", None, &ActiveFiles::default(), 10).unwrap();
358        assert_eq!(hits[0].name, "User");
359    }
360
361    #[test]
362    fn no_match_returns_empty() {
363        let store = store_with(&[sym("User", Kind::Class)]);
364        let hits = search(&store, "zzzzz", None, &ActiveFiles::default(), 10).unwrap();
365        assert!(hits.is_empty());
366    }
367
368    #[test]
369    fn merge_dedups_by_location_keeping_higher_score() {
370        let mk = |name: &str, score: f64| Hit {
371            name: name.into(),
372            kind: "class".into(),
373            language: "ruby".into(),
374            file: "a.rb".into(),
375            line: 1,
376            parent: None,
377            repo_identity: "r".into(),
378            score,
379            features: vec![],
380            signature: None,
381        };
382        let from_index = vec![mk("User", 100.0)];
383        let from_live = vec![mk("User", 500.0), mk("Account", 200.0)];
384        let merged = merge(from_index, from_live, 10);
385        assert_eq!(merged.len(), 2, "the duplicate User is collapsed");
386        assert_eq!(merged[0].name, "User");
387        assert_eq!(merged[0].score, 500.0, "the higher-scored duplicate wins");
388    }
389
390    #[test]
391    fn active_files_boosts_the_file_and_its_neighbors() {
392        let active = ActiveFiles::new(["app/services/refund.rb".to_string()]);
393        // the changed file itself: full boost
394        assert_eq!(active.boost("app/services/refund.rb"), BRANCH_FILE_BOOST);
395        // a sibling in the same directory: neighbor boost
396        assert_eq!(active.boost("app/services/charge.rb"), BRANCH_DIR_BOOST);
397        // unrelated directory: nothing
398        assert_eq!(active.boost("app/models/user.rb"), 0.0);
399    }
400
401    #[test]
402    fn branch_boost_lifts_an_active_file() {
403        let store = store_with(&[sym("User", Kind::Class)]); // lives in app/x.rb
404        let active = ActiveFiles::new(["app/x.rb".to_string()]);
405        let hits = search(&store, "user", None, &active, 10).unwrap();
406        assert!(hits[0].features.iter().any(|f| f.name == "branch"));
407    }
408}