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}