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}