Skip to main content

ski/
rank.rs

1//! Hybrid ranking: cosine(query, skill-description) + context blend + file boost
2//! + ambient project boost + keyword boost + phrase boost.
3
4use crate::config::Config;
5use crate::index::Index;
6use crate::text::{match_tokens, norm_token, tokenize};
7use std::collections::{BTreeSet, HashSet};
8
9#[derive(Clone, Debug)]
10pub struct Hit {
11    pub id: String,
12    pub name: String,
13    /// Cosine of the *current prompt* against the skill — kept pure (never folded
14    /// with the context blend) so confidence/agreement gates can still read the
15    /// prompt's own signal.
16    pub cosine: f32,
17    /// Boost from conversational context (see [`rank_all_ctx`]). Zero when the
18    /// context feature is off, the prompt is confident, or the skill is no more
19    /// context-relevant than average. Kept separate from `cosine` for attribution.
20    pub context: f32,
21    /// Boost from a referenced file of this skill's type (see
22    /// [`crate::context::file_ids`]). Zero unless a matching file was named in the
23    /// prompt or recent context. Separate for attribution — the highest-precision,
24    /// directly-attributable context signal.
25    pub file: f32,
26    /// Boost from the working directory's project ecosystem (see
27    /// [`crate::context::project_terms`] / [`crate::context::skills_for_terms`]).
28    /// Zero unless the channel is on and the skill's ecosystem matches; gated on
29    /// `cosine >= min_similarity - PROJECT_GATE_SLACK` so this ambient signal can
30    /// lift a *near*-floor ecosystem skill over the line but never rescue a
31    /// clearly-irrelevant one. Separate for attribution.
32    pub project: f32,
33    pub keyword: f32,
34    /// Boost from matched trigger phrases (see [`phrase_score`]).
35    pub phrase: f32,
36    pub score: f32,
37}
38
39impl Hit {
40    /// The stage-1 hybrid score: the sum of every channel. The single source for
41    /// the `score` field, the reranker's stage-1 agreement gate
42    /// ([`crate::rerank::passes`]), and `ski why`'s breakdown display — so the
43    /// channel set can never drift apart across the three (it previously did: two
44    /// call sites silently omitted `project`).
45    pub fn stage1_score(&self) -> f32 {
46        self.cosine + self.context + self.file + self.project + self.keyword + self.phrase
47    }
48
49    /// The per-channel contributions, in summation order, for attribution display.
50    pub fn breakdown(&self) -> [(&'static str, f32); 6] {
51        [
52            ("cos", self.cosine),
53            ("ctx", self.context),
54            ("file", self.file),
55            ("project", self.project),
56            ("kw", self.keyword),
57            ("ph", self.phrase),
58        ]
59    }
60}
61
62/// Cosine similarity. `0.0` on a dimension mismatch — rather than silently
63/// zipping to the shorter vector (a meaningless partial dot product) — since a
64/// query and an index entry from different embedders/dimensions should never be
65/// compared at all; the `model == id()` guard in `hook::load_or_build_index`
66/// normally prevents this, but a hand-edited or same-id-different-dim index
67/// should score as "no match", not a truncated garbage value.
68pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
69    if a.len() != b.len() {
70        return 0.0;
71    }
72    let (mut dot, mut na, mut nb) = (0f32, 0f32, 0f32);
73    for (x, y) in a.iter().zip(b.iter()) {
74        dot += x * y;
75        na += x * x;
76        nb += y * y;
77    }
78    if na == 0.0 || nb == 0.0 {
79        return 0.0;
80    }
81    let c = dot / (na.sqrt() * nb.sqrt());
82    // A corrupt index (a hand-edited or overflowed vector, e.g. `1e999` parsing
83    // to +inf) yields a NaN here; NaN compares Equal to everything in the sort
84    // below, so the corrupt entry could silently claim rank 0. Treat it as "no
85    // signal" instead.
86    if c.is_finite() {
87        c
88    } else {
89        0.0
90    }
91}
92
93/// Descending comparator for sorting by score. `f32::partial_cmp` returns `None`
94/// only when a NaN is involved; the common `.unwrap_or(Ordering::Equal)` fallback
95/// then makes a NaN compare equal to everything, which can leave it sorted into
96/// rank 0 (a stable sort keeps *some* input order among "equal" elements, and a
97/// NaN score should never win). This instead sorts any NaN strictly last,
98/// regardless of which side of the comparison it's on.
99pub fn cmp_score_desc(a: f32, b: f32) -> std::cmp::Ordering {
100    match (a.is_nan(), b.is_nan()) {
101        (false, false) => b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal),
102        (true, true) => std::cmp::Ordering::Equal,
103        (true, false) => std::cmp::Ordering::Greater, // a is NaN -> sorts after b
104        (false, true) => std::cmp::Ordering::Less,    // b is NaN -> sorts after a
105    }
106}
107
108/// Keyword channel: `boost` per keyword found in the prompt. Both sides are
109/// normalized through [`norm_token`] at match time, so "make some charts" still
110/// hits a `chart` keyword — the surface channels shouldn't lose a match to
111/// trivial inflection the dense channel shrugs off.
112pub fn keyword_score(prompt: &str, keywords: &[String], boost: f32) -> f32 {
113    let toks: HashSet<String> = tokenize(prompt).iter().map(|t| norm_token(t)).collect();
114    let hits = keywords
115        .iter()
116        .filter(|k| toks.contains(&norm_token(k)))
117        .count();
118    hits as f32 * boost
119}
120
121/// Phrase channel: `boost` per trigger phrase whose every content token appears in
122/// the prompt. A phrase is the normalized (content-token) form produced by
123/// [`crate::skill::extract_phrases`]; requiring *all* its tokens (>=2 by
124/// construction) keeps the signal high-precision, so it lifts a skill on the
125/// exact wording the bi-encoder dilutes without firing on incidental overlap.
126/// Tokens on both sides are singular-normalized ([`norm_token`]) at match time,
127/// so a stored `merge pdf files` still fires on "merge these pdf file chunks".
128pub fn phrase_score(prompt: &str, phrases: &[String], boost: f32) -> f32 {
129    if phrases.is_empty() {
130        return 0.0;
131    }
132    let toks: HashSet<String> = match_tokens(prompt).into_iter().collect();
133    let hits = phrases
134        .iter()
135        .filter(|p| {
136            let mut pt = p.split_whitespace().peekable();
137            pt.peek().is_some() && pt.all(|t| toks.contains(&norm_token(t)))
138        })
139        .count();
140    hits as f32 * boost
141}
142
143/// How far below `min_similarity` a skill's own cosine may sit and still receive
144/// the ambient project boost. The project signal is present on every turn, so it
145/// must not resurrect arbitrary skills — but the whole point of the channel is to
146/// surface the workspace's ecosystem skill on prompts that are *about* the project
147/// without naming the ecosystem ("add that dependency", "set up tests"), whose
148/// cosine hovers just under the floor. A small slack lets the boost carry exactly
149/// those over the line (ski deliberately errs toward surfacing; the model ignores
150/// a skill it doesn't need, and per-session dedup caps the cost at one showing),
151/// while a clearly-off-topic skill stays gated out. Mirrors
152/// [`crate::rerank`]'s `AGREEMENT_SLACK` shape.
153pub const PROJECT_GATE_SLACK: f32 = 0.06;
154
155/// Effective context-blend weight for a prompt whose best self-match cosine is
156/// `prompt_top`. Scales from `cfg.context_weight` (a *fully vague* prompt,
157/// `prompt_top <= vague_lo`) down to `0` (a *confident* prompt,
158/// `prompt_top >= vague_hi`), linearly between. So a specific prompt ignores
159/// context — avoiding the redundancy that regressed bi-encoder mean-centering
160/// (see `crate::rerank` module docs) — while a vague follow-up leans on it.
161/// Returns `0` whenever the feature is disabled (`context_weight <= 0` or
162/// `context_depth == 0`).
163pub fn context_weight(prompt_top: f32, cfg: &Config) -> f32 {
164    if cfg.context_weight <= 0.0 || cfg.context_depth == 0 {
165        return 0.0;
166    }
167    let (lo, hi) = (cfg.vague_lo, cfg.vague_hi);
168    let vagueness = if hi <= lo {
169        // Degenerate band: a hard step at `hi`.
170        if prompt_top >= hi {
171            0.0
172        } else {
173            1.0
174        }
175    } else {
176        ((hi - prompt_top) / (hi - lo)).clamp(0.0, 1.0)
177    };
178    cfg.context_weight * vagueness
179}
180
181/// All skills, scored and sorted by descending hybrid score. No threshold — for
182/// `ski why` and as input to [`select`]. No conversational context.
183pub fn rank_all(query: &[f32], prompt: &str, index: &Index, cfg: &Config) -> Vec<Hit> {
184    rank_all_ctx(
185        query,
186        None,
187        &BTreeSet::new(),
188        &BTreeSet::new(),
189        prompt,
190        index,
191        cfg,
192    )
193}
194
195/// Like [`rank_all`], but blends an optional conversational-context vector into
196/// each skill's score. The blend is gated by how *vague* the current prompt is
197/// ([`context_weight`]) and is a *relative* signal: a skill is boosted only in
198/// proportion to how much more context-relevant it is than the average skill
199/// (`cos(context, skill) - mean`), clamped at 0. That self-normalization is what
200/// keeps the anisotropic bge floor (every skill cosines ~0.5 to anything) from
201/// uniformly inflating scores and manufacturing false injects.
202///
203/// `file_ids` carries the file-type channel: any skill whose id is in the set
204/// (a file of its type was named in the prompt/context — see
205/// [`crate::context::file_ids`]) gets a flat `cfg.file_boost`, *not* gated on
206/// vagueness, since a named file is unambiguous.
207///
208/// `project_ids` carries the ambient project-type channel (see
209/// [`crate::context::project_terms`] / [`crate::context::skills_for_terms`]): a
210/// skill whose ecosystem matches the working directory's manifests (or a code file
211/// referenced in the conversation) gets `cfg.project_boost`, but — because this
212/// signal is present every turn — only when the skill's own cosine is within
213/// [`PROJECT_GATE_SLACK`] of `cfg.min_similarity`. So it lifts near-plausible
214/// ecosystem skills over the floor and reorders among plausible ones, but cannot
215/// rescue a clearly-irrelevant skill.
216///
217/// With `context = None`, empty `file_ids`/`project_ids`, and the features
218/// disabled, this is identical to [`rank_all`].
219pub fn rank_all_ctx(
220    query: &[f32],
221    context: Option<&[f32]>,
222    file_ids: &BTreeSet<String>,
223    project_ids: &BTreeSet<String>,
224    prompt: &str,
225    index: &Index,
226    cfg: &Config,
227) -> Vec<Hit> {
228    // The prompt's own cosines; their max gauges prompt specificity, which sets
229    // how much (if any) context is allowed to contribute.
230    let prompt_cos: Vec<f32> = index
231        .skills
232        .iter()
233        .map(|e| cosine(query, &e.embedding))
234        .collect();
235    let prompt_top = prompt_cos.iter().copied().fold(0.0_f32, f32::max);
236    let lambda = match context {
237        Some(_) => context_weight(prompt_top, cfg),
238        None => 0.0,
239    };
240
241    // Context cosines and their mean (the relative-boost baseline), computed once.
242    let ctx_cos: Vec<f32> = match (lambda > 0.0, context) {
243        (true, Some(c)) => index
244            .skills
245            .iter()
246            .map(|e| cosine(c, &e.embedding))
247            .collect(),
248        _ => Vec::new(),
249    };
250    let ctx_mean = if ctx_cos.is_empty() {
251        0.0
252    } else {
253        ctx_cos.iter().sum::<f32>() / ctx_cos.len() as f32
254    };
255
256    let mut hits: Vec<Hit> = index
257        .skills
258        .iter()
259        .enumerate()
260        .map(|(i, e)| {
261            let cosine = prompt_cos[i];
262            let context = ctx_cos
263                .get(i)
264                .map(|&c| lambda * (c - ctx_mean).max(0.0))
265                .unwrap_or(0.0);
266            let file = if cfg.file_boost > 0.0 && file_ids.contains(&e.id) {
267                cfg.file_boost
268            } else {
269                0.0
270            };
271            // Ambient project signal: gated on the skill's own cosine sitting
272            // within PROJECT_GATE_SLACK of the injection floor, so it lifts the
273            // workspace's ecosystem skill on near-plausible prompts but never
274            // rescues a clearly-irrelevant one (the failure mode the keyword
275            // channel can hit on incidental mentions).
276            let project = if cfg.project_boost > 0.0
277                && cosine >= cfg.min_similarity - PROJECT_GATE_SLACK
278                && project_ids.contains(&e.id)
279            {
280                cfg.project_boost
281            } else {
282                0.0
283            };
284            let keyword = keyword_score(prompt, &e.keywords, cfg.keyword_boost);
285            let phrase = phrase_score(prompt, &e.trigger_phrases, cfg.phrase_boost);
286            let mut hit = Hit {
287                id: e.id.clone(),
288                name: e.name.clone(),
289                cosine,
290                context,
291                file,
292                project,
293                keyword,
294                phrase,
295                score: 0.0,
296            };
297            hit.score = hit.stage1_score();
298            hit
299        })
300        .collect();
301    hits.sort_by(|a, b| cmp_score_desc(a.score, b.score));
302    hits
303}
304
305/// Apply the injection guardrails: drop below `min_similarity`, cap at `max_skills`.
306pub fn select(hits: Vec<Hit>, cfg: &Config) -> Vec<Hit> {
307    hits.into_iter()
308        .filter(|h| h.score >= cfg.min_similarity)
309        .take(cfg.max_skills)
310        .collect()
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::index::{Entry, Index};
317
318    fn no_files() -> BTreeSet<String> {
319        BTreeSet::new()
320    }
321
322    /// Context enabled with a known vague band, everything else default.
323    fn ctx_cfg() -> Config {
324        Config {
325            context_depth: 1,
326            context_weight: 0.3,
327            vague_lo: 0.55,
328            vague_hi: 0.65,
329            file_boost: 0.0, // context-only baseline; file tests opt the channel in
330            project_boost: 0.0, // likewise for the (default-on) project channel
331            ..Default::default()
332        }
333    }
334
335    fn idx2() -> Index {
336        let entry = |id: &str, emb: Vec<f32>| Entry {
337            id: id.to_string(),
338            name: id.to_string(),
339            description: String::new(),
340            path: String::new(),
341            keywords: Vec::new(),
342            trigger_phrases: Vec::new(),
343            body_head: String::new(),
344            hash: String::new(),
345            embedding: emb,
346        };
347        Index {
348            model: "m".into(),
349            dim: 2,
350            skills: vec![entry("a", vec![1.0, 0.0]), entry("b", vec![0.0, 1.0])],
351        }
352    }
353
354    #[test]
355    fn context_weight_scales_with_vagueness() {
356        let cfg = ctx_cfg(); // lo 0.55, hi 0.65, weight 0.3
357        assert!((context_weight(0.50, &cfg) - 0.30).abs() < 1e-6); // <= lo: full
358        assert_eq!(context_weight(0.65, &cfg), 0.0); // >= hi: none
359        assert!((context_weight(0.60, &cfg) - 0.15).abs() < 1e-6); // midpoint: half
360    }
361
362    #[test]
363    fn context_weight_zero_when_disabled() {
364        let off_weight = Config {
365            context_depth: 1,
366            context_weight: 0.0,
367            ..Default::default()
368        };
369        let off_depth = Config {
370            context_depth: 0,
371            context_weight: 0.3,
372            ..Default::default()
373        };
374        assert_eq!(context_weight(0.10, &off_weight), 0.0);
375        assert_eq!(context_weight(0.10, &off_depth), 0.0);
376    }
377
378    #[test]
379    fn context_none_matches_plain_rank() {
380        // With no context vector, scores are exactly cosine+keyword+phrase and the
381        // context term is zero — identical to the pre-feature path.
382        let q = [0.5, 0.5];
383        let hits = rank_all_ctx(&q, None, &no_files(), &no_files(), "", &idx2(), &ctx_cfg());
384        for h in &hits {
385            assert_eq!(h.context, 0.0);
386            assert!((h.score - h.cosine).abs() < 1e-6);
387        }
388    }
389
390    #[test]
391    fn vague_prompt_lets_context_break_a_tie() {
392        // Prompt sits symmetrically between the two skills (cosine 0.707 to each),
393        // and is vague (top 0.707 >= hi 0.65 -> NOT vague). Widen the band so it
394        // counts as vague, then context pointing at `a` lifts `a` above `b`.
395        let cfg = Config {
396            vague_lo: 0.80,
397            vague_hi: 0.90,
398            ..ctx_cfg()
399        };
400        let q = [0.5, 0.5]; // equal cosine to a and b
401        let ctx = [1.0, 0.0]; // points at a
402        let hits = rank_all_ctx(&q, Some(&ctx), &no_files(), &no_files(), "", &idx2(), &cfg);
403        assert_eq!(hits[0].id, "a"); // context broke the tie
404        assert!(hits[0].context > 0.0);
405        // `b` is no more context-relevant than average, so it gets no boost.
406        let b = hits.iter().find(|h| h.id == "b").unwrap();
407        assert_eq!(b.context, 0.0);
408    }
409
410    #[test]
411    fn confident_prompt_suppresses_context() {
412        // Prompt is exactly `a` (cosine 1.0 >= hi): context (pointing at `b`) must
413        // not contribute, so no skill carries a context boost.
414        let q = [1.0, 0.0];
415        let ctx = [0.0, 1.0];
416        let hits = rank_all_ctx(
417            &q,
418            Some(&ctx),
419            &no_files(),
420            &no_files(),
421            "",
422            &idx2(),
423            &ctx_cfg(),
424        );
425        assert!(hits.iter().all(|h| h.context == 0.0));
426        assert_eq!(hits[0].id, "a");
427    }
428
429    #[test]
430    fn file_boost_lifts_named_skill_ungated() {
431        // A referenced file boosts its skill even when the prompt is *confident*
432        // about a different skill (file channel is not vagueness-gated). Prompt is
433        // exactly `a`; a file of `b`'s type is named.
434        let cfg = Config {
435            file_boost: 0.2,
436            ..ctx_cfg()
437        };
438        let q = [1.0, 0.0]; // confident about `a`
439        let files: BTreeSet<String> = ["b".to_string()].into_iter().collect();
440        let hits = rank_all_ctx(&q, None, &files, &no_files(), "", &idx2(), &cfg);
441        let b = hits.iter().find(|h| h.id == "b").unwrap();
442        assert!((b.file - 0.2).abs() < 1e-6); // b carries the file boost
443        let a = hits.iter().find(|h| h.id == "a").unwrap();
444        assert_eq!(a.file, 0.0); // a does not (no file of its type)
445    }
446
447    #[test]
448    fn file_boost_off_when_zero() {
449        let q = [1.0, 0.0];
450        let files: BTreeSet<String> = ["b".to_string()].into_iter().collect();
451        // file_boost defaults to 0.0 in ctx_cfg -> no file term anywhere.
452        let hits = rank_all_ctx(&q, None, &files, &no_files(), "", &idx2(), &ctx_cfg());
453        assert!(hits.iter().all(|h| h.file == 0.0));
454    }
455
456    #[test]
457    fn project_boost_gated_on_cosine_floor() {
458        // The ambient project signal lifts a plausible skill but is gated on the
459        // skill's own cosine sitting within PROJECT_GATE_SLACK of `min_similarity`
460        // (default 0.30): it can lift a near-floor ecosystem skill, but never
461        // rescues a clearly-irrelevant one.
462        let cfg = Config {
463            project_boost: 0.2,
464            ..ctx_cfg()
465        };
466        let proj: BTreeSet<String> = ["b".to_string()].into_iter().collect();
467
468        // Query aligned with `b`: cosine(q,b) = 1.0 >= gate -> boost applies.
469        let hits = rank_all_ctx(&[0.0, 1.0], None, &no_files(), &proj, "", &idx2(), &cfg);
470        let b = hits.iter().find(|h| h.id == "b").unwrap();
471        assert!((b.project - 0.2).abs() < 1e-6);
472
473        // Query aligned with `a`: cosine(q,b) = 0.0, far below the gate -> gated
474        // out despite `b` being in the project set.
475        let hits = rank_all_ctx(&[1.0, 0.0], None, &no_files(), &proj, "", &idx2(), &cfg);
476        let b = hits.iter().find(|h| h.id == "b").unwrap();
477        assert_eq!(b.project, 0.0);
478    }
479
480    #[test]
481    fn project_boost_lifts_near_floor_skill_over_the_line() {
482        // A prompt about the project without naming the ecosystem: the skill's own
483        // cosine sits just *under* the floor but within the slack, so the project
484        // boost applies and can carry it over the injection floor. This is the
485        // uv-in-a-python-repo case the channel exists for.
486        let cfg = Config {
487            project_boost: 0.2,
488            min_similarity: 0.30,
489            ..ctx_cfg()
490        };
491        let proj: BTreeSet<String> = ["b".to_string()].into_iter().collect();
492        // cosine(q,b) ~= 0.28: sub-floor (0.30) but within the 0.06 slack.
493        let q = [0.9578, 0.2873];
494        let hits = rank_all_ctx(&q, None, &no_files(), &proj, "", &idx2(), &cfg);
495        let b = hits.iter().find(|h| h.id == "b").unwrap();
496        assert!(b.cosine < cfg.min_similarity, "cosine {}", b.cosine);
497        assert!(b.cosine >= cfg.min_similarity - PROJECT_GATE_SLACK);
498        assert!((b.project - 0.2).abs() < 1e-6);
499        assert!(b.score >= cfg.min_similarity); // boosted over the floor
500    }
501
502    #[test]
503    fn project_boost_off_when_zero() {
504        // project_boost defaults to 0.0 in ctx_cfg -> no project term anywhere.
505        let proj: BTreeSet<String> = ["b".to_string()].into_iter().collect();
506        let hits = rank_all_ctx(
507            &[0.0, 1.0],
508            None,
509            &no_files(),
510            &proj,
511            "",
512            &idx2(),
513            &ctx_cfg(),
514        );
515        assert!(hits.iter().all(|h| h.project == 0.0));
516    }
517
518    #[test]
519    fn corrupt_infinite_embedding_cannot_claim_rank() {
520        // An overflowed vector in a hand-edited/corrupt index (`1e999` parses to
521        // +inf) used to produce a NaN cosine, which compared Equal to every score
522        // and could land anywhere — including rank 0. It must score 0 instead.
523        let entry = |id: &str, emb: Vec<f32>| crate::index::Entry {
524            id: id.to_string(),
525            name: id.to_string(),
526            description: String::new(),
527            path: String::new(),
528            keywords: Vec::new(),
529            trigger_phrases: Vec::new(),
530            body_head: String::new(),
531            hash: String::new(),
532            embedding: emb,
533        };
534        let idx = Index {
535            model: "m".into(),
536            dim: 2,
537            skills: vec![
538                entry("corrupt", vec![f32::INFINITY, 0.0]),
539                entry("real", vec![1.0, 0.0]),
540            ],
541        };
542        let hits = rank_all(&[1.0, 0.0], "", &idx, &Config::default());
543        assert_eq!(hits[0].id, "real");
544        assert_eq!(hits.iter().find(|h| h.id == "corrupt").unwrap().score, 0.0);
545        assert!(hits.iter().all(|h| h.score.is_finite()));
546    }
547
548    #[test]
549    fn cosine_bounds() {
550        let a = [1.0, 0.0, 0.0];
551        let b = [1.0, 0.0, 0.0];
552        let c = [0.0, 1.0, 0.0];
553        assert!((cosine(&a, &b) - 1.0).abs() < 1e-6);
554        assert!(cosine(&a, &c).abs() < 1e-6);
555    }
556
557    #[test]
558    fn cosine_rejects_dimension_mismatch() {
559        // A shorter/longer vector must score 0.0 (no match), not a truncated
560        // partial dot product silently computed over the shared prefix.
561        let a = [1.0, 0.0, 0.0];
562        let b = [1.0, 0.0];
563        assert_eq!(cosine(&a, &b), 0.0);
564    }
565
566    #[test]
567    fn cmp_score_desc_sorts_nan_last_either_side() {
568        let mut v = [f32::NAN, 0.5, 2.0, -1.0];
569        v.sort_by(|a, b| cmp_score_desc(*a, *b));
570        assert_eq!(&v[..3], &[2.0, 0.5, -1.0]);
571        assert!(v[3].is_nan());
572    }
573
574    #[test]
575    fn cmp_score_desc_regular_values_descend() {
576        let mut v = vec![1.0, 3.0, 2.0];
577        v.sort_by(|a, b| cmp_score_desc(*a, *b));
578        assert_eq!(v, [3.0, 2.0, 1.0]);
579    }
580
581    #[test]
582    fn keyword_boost_counts_matches() {
583        let kw = vec!["uv".to_string(), "setup".to_string()];
584        assert!((keyword_score("set up with uv", &kw, 0.1) - 0.1).abs() < 1e-6); // only "uv"
585        assert!((keyword_score("uv setup now", &kw, 0.1) - 0.2).abs() < 1e-6); // both
586    }
587
588    #[test]
589    fn keyword_boost_matches_across_plural_inflection() {
590        // "charts" in the prompt must hit a "chart" keyword (and vice versa): the
591        // surface channels normalize both sides through `norm_token` at match time.
592        let kw = vec!["chart".to_string(), "dependencies".to_string()];
593        assert!((keyword_score("make some charts", &kw, 0.1) - 0.1).abs() < 1e-6);
594        assert!((keyword_score("add a dependency", &kw, 0.1) - 0.1).abs() < 1e-6);
595    }
596
597    #[test]
598    fn phrase_matches_across_plural_inflection() {
599        // Stored phrase tokens and prompt tokens are singular-normalized at match
600        // time, so trivial inflection doesn't defeat a full-phrase match.
601        let ph = vec!["merge pdf files".to_string()];
602        assert!((phrase_score("merge these pdf file chunks", &ph, 0.2) - 0.2).abs() < 1e-6);
603        assert!((phrase_score("merging is off topic here", &ph, 0.2) - 0.0).abs() < 1e-6);
604    }
605
606    #[test]
607    fn phrase_fires_only_when_all_tokens_present() {
608        let ph = vec!["screen reader support".to_string()];
609        // Full phrase present (any order, extra words around) -> boost.
610        assert!(
611            (phrase_score("does my form have screen reader support today", &ph, 0.2) - 0.2).abs()
612                < 1e-6
613        );
614        // Reordered, still all tokens present -> boost.
615        assert!((phrase_score("support for a screen reader", &ph, 0.2) - 0.2).abs() < 1e-6);
616    }
617
618    #[test]
619    fn phrase_does_not_fire_on_partial_overlap() {
620        // Precision guard: a partial token overlap must NOT boost, or the phrase
621        // channel would manufacture false positives on unrelated prompts.
622        let ph = vec!["screen reader support".to_string()];
623        assert_eq!(
624            phrase_score("split this screen into two panes", &ph, 0.2),
625            0.0
626        );
627        assert_eq!(
628            phrase_score(
629                "implement a debounce function in vanilla javascript",
630                &ph,
631                0.2
632            ),
633            0.0
634        );
635    }
636
637    #[test]
638    fn phrase_score_sums_distinct_phrases() {
639        // Phrases are stored already normalized to content tokens (no stopwords),
640        // the form `extract_phrases` produces.
641        let ph = vec![
642            "convert markdown pdf".to_string(),
643            "merge two pdf files".to_string(),
644        ];
645        assert!(
646            (phrase_score(
647                "convert this markdown to pdf and merge two pdf files",
648                &ph,
649                0.2
650            ) - 0.4)
651                .abs()
652                < 1e-6
653        );
654    }
655}