Skip to main content

lattice_completion/builtins/
rankers.rs

1//! Built-in rankers (DESIGN.md ยง5.11.3).
2
3use std::sync::Arc;
4
5use crate::candidate::{RawCandidate, ScoredCandidate};
6use crate::traits::CandidateRanker;
7
8/// `rank:score`. Default v1 ranker. Sorts by descending score with
9/// alphabetical tie-break on candidate text.
10pub struct ScoreRanker;
11
12impl CandidateRanker for ScoreRanker {
13    fn rank(&self, scored: &mut Vec<ScoredCandidate>) {
14        scored.sort_by(|a, b| {
15            b.score
16                .cmp(&a.score)
17                .then_with(|| a.raw.text.cmp(&b.raw.text))
18        });
19    }
20}
21
22/// `rank:alphabetical`. Plain A-Z sort on text. Useful when score
23/// information isn't trustworthy (e.g. uniform-score generators
24/// like `gen:files`).
25pub struct AlphabeticalRanker;
26
27impl CandidateRanker for AlphabeticalRanker {
28    fn rank(&self, scored: &mut Vec<ScoredCandidate>) {
29        scored.sort_by(|a, b| a.raw.text.cmp(&b.raw.text));
30    }
31}
32
33/// `rank:mru`. Combines the matcher's `MatchScore` with a per-
34/// candidate bonus (frecency / recency / MRU-style) supplied by
35/// the caller. Bonus is added to the score; the result is
36/// re-sorted descending with alphabetical tie-break.
37///
38/// Slice `3c.unify.mru-promotion`: promoted from
39/// `lattice-picker`'s inline `combined = score + bonus`
40/// arithmetic to a first-class `CandidateRanker` impl. The
41/// bonus lookup is a caller-supplied closure so different
42/// surfaces can choose their identity scheme:
43///
44///   - Picker decodes the index encoded in
45///     `CandidateData::Extension` (the existing
46///     parallel-bonus-vec scheme).
47///   - Cmdline-completion (future) can look up by candidate text
48///     in a `HashMap<String, f64>` derived from the host's MRU
49///     index at filter time.
50///   - Plugins supply their own lookup against whatever identity
51///     scheme they prefer.
52///
53/// `MruRanker` subsumes the `ScoreRanker` behavior (it sorts by
54/// score when every bonus is 0.0), so the typical pipeline
55/// composition is `rankers: vec![Arc::new(MruRanker::new(...))]`
56/// โ€” not stacking `[ScoreRanker, MruRanker]` which would just be
57/// a wasted first sort.
58pub struct MruRanker {
59    bonus_lookup: Arc<dyn Fn(&RawCandidate) -> f64 + Send + Sync>,
60}
61
62impl MruRanker {
63    /// `bonus_lookup(raw)` returns the candidate's MRU bonus โ€”
64    /// a non-negative f64 added to its `MatchScore.get() as f64`
65    /// for ranking. Returning 0.0 means "no MRU history; rank
66    /// purely by match score."
67    pub fn new(bonus_lookup: impl Fn(&RawCandidate) -> f64 + Send + Sync + 'static) -> Self {
68        Self {
69            bonus_lookup: Arc::new(bonus_lookup),
70        }
71    }
72}
73
74impl CandidateRanker for MruRanker {
75    fn rank(&self, scored: &mut Vec<ScoredCandidate>) {
76        // Stable sort: equal-combined-score candidates retain
77        // their input order. Picker callers depend on this for
78        // host-supplied ordering (e.g. buffer switcher's
79        // alternate-buffer-to-bottom float, jumps-newest-first,
80        // workspace symbols in depth order). NO alphabetical
81        // tie-break here โ€” that's `ScoreRanker`'s convention for
82        // cmdline / palette surfaces where predictable A-Z
83        // ordering helps. MruRanker is for surfaces that want
84        // recency-weighted insertion-order behavior.
85        //
86        // Note: the bonus lookup fires twice per sort comparison
87        // (a's bonus and b's bonus). For picker-scale inputs
88        // (5k candidates) that's ~130k Arc<dyn Fn> calls per
89        // refilter. Precompute-into-pair-vec was experimentally
90        // worse (extra Vec rebuild + ScoredCandidate move pass
91        // overshadowed the saved lookup calls on the empty-query
92        // hot path). The simple `sort_by`-with-lookup wins
93        // empirically; documented here so future maintainers
94        // don't re-attempt the same optimization without
95        // benchmarking the empty-query case.
96        scored.sort_by(|a, b| {
97            let ba = a.score.get() as f64 + (self.bonus_lookup)(&a.raw);
98            let bb = b.score.get() as f64 + (self.bonus_lookup)(&b.raw);
99            bb.partial_cmp(&ba).unwrap_or(std::cmp::Ordering::Equal)
100        });
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    #![allow(clippy::unwrap_used, clippy::panic)]
107    use super::*;
108    use crate::candidate::{CandidateKind, MatchScore, RawCandidate};
109
110    fn s(text: &str, score: u32) -> ScoredCandidate {
111        ScoredCandidate {
112            raw: RawCandidate::plain(text, CandidateKind::Plain),
113            score: MatchScore(score),
114            match_ranges: Vec::new(),
115        }
116    }
117
118    #[test]
119    fn score_ranker_orders_descending() {
120        let mut v = vec![s("a", 100), s("b", 500), s("c", 300)];
121        ScoreRanker.rank(&mut v);
122        assert_eq!(v[0].raw.text, "b"); // 500
123        assert_eq!(v[1].raw.text, "c"); // 300
124        assert_eq!(v[2].raw.text, "a"); // 100
125    }
126
127    #[test]
128    fn score_ranker_breaks_ties_alphabetically() {
129        let mut v = vec![s("zebra", 500), s("apple", 500), s("mango", 500)];
130        ScoreRanker.rank(&mut v);
131        assert_eq!(v[0].raw.text, "apple");
132        assert_eq!(v[1].raw.text, "mango");
133        assert_eq!(v[2].raw.text, "zebra");
134    }
135
136    #[test]
137    fn alphabetical_ranker_ignores_score() {
138        let mut v = vec![s("zebra", 999), s("apple", 1)];
139        AlphabeticalRanker.rank(&mut v);
140        assert_eq!(v[0].raw.text, "apple");
141        assert_eq!(v[1].raw.text, "zebra");
142    }
143
144    #[test]
145    fn ranker_is_stable_for_empty_vec() {
146        let mut v: Vec<ScoredCandidate> = Vec::new();
147        ScoreRanker.rank(&mut v);
148        AlphabeticalRanker.rank(&mut v);
149        assert!(v.is_empty());
150    }
151}