1use std::collections::{HashMap, HashSet};
9
10use serde::{Deserialize, Serialize};
11
12use crate::schema::{EdgeBasis, Graph, Kind, Node};
13
14mod focus;
15mod intent;
16mod scoring;
17
18pub use focus::{
19 CompoundAnchor, CompoundOmittedAnchor, CompoundSubgraph, OmittedContextCandidate, Subgraph,
20 ground_subgraph,
21};
22pub use scoring::{GroundIndex, RankerConfig};
23
24use intent::query_intent_matches;
25use scoring::{
26 Bm25fNodeShape, Bm25fScorer, bm25f_df, bm25f_score, relation_phrase, reverse_relation_phrase,
27};
28
29#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
30#[serde(rename_all = "lowercase")]
31pub enum Confidence {
32 Exact,
33 Strong,
34 Ambiguous,
35 Weak,
36 Fallback,
37}
38
39#[derive(Serialize, Clone, Debug)]
40pub struct Hit {
41 pub id: String,
42 pub score: i64,
45 pub lexical_score: i64,
48 pub confidence: Confidence,
49 pub why: Vec<String>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub relation_matches: Vec<RelationMatch>,
53 #[serde(skip)]
56 pub anchor: f64,
57 #[serde(skip)]
60 pub relation_anchor: bool,
61}
62
63#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
64pub struct RelationMatch {
65 pub relation: String,
66 pub direction: String,
67 pub phrase: String,
68 pub endpoint_id: String,
69 pub endpoint_title: String,
70 pub endpoint_hits: usize,
71 pub score_boost: i64,
72}
73
74use crate::calibration::{AMBIGUITY_RATIO, DISTINCTIVE_NAME_IDF};
77
78const STOPWORDS: &[&str] = &[
83 "a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "did", "do", "does", "for",
84 "from", "had", "has", "have", "how", "i", "in", "is", "it", "its", "me", "my", "need", "of",
85 "on", "or", "our", "should", "so", "that", "the", "their", "them", "then", "there", "these",
86 "this", "those", "to", "up", "us", "was", "we", "were", "what", "when", "where", "which",
87 "will", "with", "would", "you", "your",
88];
89
90fn is_stopword(t: &str) -> bool {
91 STOPWORDS.binary_search(&t).is_ok()
92}
93
94fn within_edit1(a: &[u8], b: &[u8]) -> bool {
97 let (la, lb) = (a.len(), b.len());
98 if la > lb {
99 return within_edit1(b, a);
100 }
101 if lb - la > 1 {
102 return false;
103 }
104 let (mut i, mut j, mut edited) = (0usize, 0usize, false);
105 while i < la && j < lb {
106 if a[i] == b[j] {
107 i += 1;
108 j += 1;
109 } else if edited {
110 return false;
111 } else {
112 edited = true;
113 if la == lb {
114 i += 1; }
116 j += 1; }
118 }
119 true
120}
121
122fn stem(t: &str) -> String {
127 let n = t.len();
128 if n > 5 && t.ends_with("ing") {
129 return restore_e(&t[..n - 3]);
130 }
131 if n > 4 && t.ends_with("ed") {
132 return restore_e(&t[..n - 2]);
133 }
134 if n > 3 && t.ends_with('s') && !t.ends_with("ss") && !t.ends_with("us") && !t.ends_with("is") {
135 return t[..n - 1].to_string();
136 }
137 t.to_string()
138}
139
140fn restore_e(base: &str) -> String {
145 let b = base.as_bytes();
146 let cvc = b.len() >= 3
147 && is_consonant(b[b.len() - 3])
148 && !is_consonant(b[b.len() - 2])
149 && is_consonant(b[b.len() - 1])
150 && !matches!(b[b.len() - 1], b'w' | b'x' | b'y');
151 if cvc {
152 format!("{base}e")
153 } else {
154 base.to_string()
155 }
156}
157
158fn is_consonant(c: u8) -> bool {
159 !matches!(c.to_ascii_lowercase(), b'a' | b'e' | b'i' | b'o' | b'u')
160}
161
162fn terms_of(query_lower: &str) -> Vec<String> {
163 query_lower
164 .split_whitespace()
165 .map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()).to_string())
166 .filter(|t| t.chars().count() > 1 && !is_stopword(t))
167 .map(|t| stem(&t))
168 .collect()
169}
170
171fn raw_terms_of(query_lower: &str) -> Vec<String> {
172 query_lower
173 .split_whitespace()
174 .map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()).to_string())
175 .filter(|t| t.chars().count() > 1 && !is_stopword(t))
176 .collect()
177}
178
179fn band(score: i64) -> Confidence {
183 if score >= crate::calibration::BAND_STRONG_MIN {
184 Confidence::Strong
185 } else if score >= crate::calibration::BAND_WEAK_MIN {
186 Confidence::Weak
187 } else {
188 Confidence::Fallback
189 }
190}
191
192fn band_anchored(score: i64, anchor: f64, coverage: f64) -> Confidence {
205 let base = band(score);
206 if anchor <= 0.0 {
207 if coverage >= 1.0 && score >= crate::calibration::BAND_WEAK_MIN {
212 return Confidence::Weak;
213 }
214 return match base {
215 Confidence::Exact | Confidence::Strong | Confidence::Ambiguous | Confidence::Weak => {
216 Confidence::Fallback
217 }
218 other => other,
219 };
220 }
221 base
222}
223
224fn is_distinct_code_symbol_name(title: &str) -> bool {
227 let significant_chars = title.chars().filter(|c| c.is_alphanumeric()).count();
228 significant_chars >= 6 && title.chars().any(|c| c.is_ascii_alphabetic())
229}
230
231fn intent_boosts(
232 intents: &[&str],
233 is_code: bool,
234 is_generic_symbol_name: bool,
235 matched_identity: usize,
236 exact: f64,
237 name_boost: f64,
238) -> (f64, f64) {
239 let generic_code_only = is_code
240 && is_generic_symbol_name
241 && intents.len() == 1
242 && intents.first() == Some(&"code")
243 && matched_identity == 0
244 && exact <= 0.0
245 && name_boost <= 0.0;
246 if generic_code_only {
247 return (6.0, 0.0);
252 }
253 let boost = intents.len() as f64 * 20.0;
254 (boost, boost)
255}
256
257fn calibrate(hits: &mut [Hit]) {
260 if hits.len() >= 2 && hits[0].confidence == Confidence::Strong {
261 let top = hits[0].score as f64;
262 let runner = hits[1].score as f64;
263 if top > 0.0 && runner >= AMBIGUITY_RATIO * top {
264 hits[0].confidence = Confidence::Ambiguous;
265 }
266 }
267}
268
269pub fn apply_floor(hits: &mut Vec<Hit>) {
278 let has_confident = hits.iter().any(|h| {
279 matches!(
280 h.confidence,
281 Confidence::Exact | Confidence::Strong | Confidence::Ambiguous
282 )
283 });
284 if has_confident {
285 hits.retain(|h| h.confidence != Confidence::Fallback);
286 }
287}
288
289fn apply_canonical_dominance(hits: &mut [Hit], graph: &Graph) {
295 let score_by_id: HashMap<String, i64> = hits.iter().map(|h| (h.id.clone(), h.score)).collect();
296 for h in hits.iter_mut() {
297 let best_child = graph
298 .edges
299 .iter()
300 .filter(|e| e.relation == crate::schema::relation::HAS_KNOWLEDGE && e.from == h.id)
301 .filter_map(|e| score_by_id.get(&e.to).copied())
302 .max();
303 if let Some(bc) = best_child
304 && bc + 1 > h.score
305 {
306 h.score = bc + 1;
307 h.confidence = band_anchored(h.score, h.anchor, 1.0);
308 h.why
309 .push("canonical: front door over its own pages".into());
310 }
311 }
312}
313
314pub fn ground(graph: &Graph, query: &str, limit: usize) -> Vec<Hit> {
318 ground_with(graph, &GroundIndex::build(graph), query, limit)
319}
320
321#[derive(Clone, PartialEq, Eq, Debug, Default)]
327pub enum Scope {
328 #[default]
329 All,
330 Docs,
331 Code,
332 Section,
334 Subkind(String),
336}
337
338impl Scope {
339 pub fn parse(s: &str) -> Option<Scope> {
343 Some(match s.trim().to_ascii_lowercase().as_str() {
344 "all" | "" => Scope::All,
345 "docs" | "doc" => Scope::Docs,
346 "code" => Scope::Code,
347 "section" | "sections" => Scope::Section,
348 other => Scope::Subkind(other.to_string()),
349 })
350 }
351
352 pub fn admits(&self, node: &Node) -> bool {
354 use crate::schema::Kind;
355 let is_code = matches!(
356 node.kind,
357 Kind::Function | Kind::Type | Kind::Trait | Kind::Module
358 );
359 let is_section = node.kind == Kind::Section;
360 match self {
361 Scope::All => !is_section,
363 Scope::Code => is_code,
364 Scope::Docs => !is_code && !is_section,
365 Scope::Section => is_section,
366 Scope::Subkind(s) => node.subkind.as_deref() == Some(s.as_str()),
367 }
368 }
369}
370
371fn directional_relation_boosts<'a>(
372 graph: &'a Graph,
373 terms: &[String],
374 query_lower: &str,
375) -> HashMap<&'a str, Vec<RelationMatch>> {
376 if terms.is_empty() {
377 return HashMap::new();
378 }
379 let term_set = terms.iter().map(String::as_str).collect::<HashSet<_>>();
380 let raw_term_set = raw_terms_of(query_lower)
381 .into_iter()
382 .collect::<HashSet<_>>();
383 let node_by_id = graph
384 .nodes
385 .iter()
386 .map(|node| (node.id.as_str(), node))
387 .collect::<HashMap<_, _>>();
388 let mut boosts: HashMap<&str, Vec<RelationMatch>> = HashMap::new();
389 let mut seen_boosts = HashSet::new();
390
391 for edge in &graph.edges {
392 if edge.basis != EdgeBasis::Resolved {
393 continue;
394 }
395 let Some(from) = node_by_id.get(edge.from.as_str()) else {
396 continue;
397 };
398 let Some(to) = node_by_id.get(edge.to.as_str()) else {
399 continue;
400 };
401 if !relation_is_directionally_searchable(graph, &edge.relation) {
402 continue;
403 }
404
405 let forward_terms =
406 terms_of(&relation_phrase(&edge.relation, &graph.relation_profiles).to_lowercase());
407 if !forward_terms.is_empty()
408 && forward_terms
409 .iter()
410 .all(|term| term_set.contains(term.as_str()))
411 {
412 let endpoint_hits = endpoint_term_hits(to, terms);
413 if endpoint_hits > 0
414 && seen_boosts.insert((
415 canonical_relation_endpoint(&edge.from),
416 canonical_relation_endpoint(&edge.to),
417 edge.relation.clone(),
418 "forward",
419 ))
420 {
421 let score_boost = 48 + endpoint_hits as i64 * 8;
422 boosts
423 .entry(edge.from.as_str())
424 .or_default()
425 .push(RelationMatch {
426 relation: edge.relation.clone(),
427 direction: "forward".to_string(),
428 phrase: relation_phrase(&edge.relation, &graph.relation_profiles),
429 endpoint_id: to.id.clone(),
430 endpoint_title: to.title.clone(),
431 endpoint_hits,
432 score_boost,
433 });
434 }
435 }
436
437 let reverse_terms = terms_of(
438 &reverse_relation_phrase(&edge.relation, &graph.relation_profiles).to_lowercase(),
439 );
440 let raw_reverse_terms = raw_terms_of(
441 &reverse_relation_phrase(&edge.relation, &graph.relation_profiles).to_lowercase(),
442 );
443 if !reverse_terms.is_empty()
444 && raw_reverse_terms
445 .iter()
446 .all(|term| raw_term_set.contains(term))
447 {
448 let endpoint_hits = endpoint_term_hits(from, terms);
449 if endpoint_hits >= 2
450 && seen_boosts.insert((
451 canonical_relation_endpoint(&edge.to),
452 canonical_relation_endpoint(&edge.from),
453 edge.relation.clone(),
454 "reverse",
455 ))
456 {
457 let score_boost = 48 + endpoint_hits as i64 * 8;
458 boosts
459 .entry(edge.to.as_str())
460 .or_default()
461 .push(RelationMatch {
462 relation: edge.relation.clone(),
463 direction: "reverse".to_string(),
464 phrase: reverse_relation_phrase(&edge.relation, &graph.relation_profiles),
465 endpoint_id: from.id.clone(),
466 endpoint_title: from.title.clone(),
467 endpoint_hits,
468 score_boost,
469 });
470 }
471 }
472 }
473
474 boosts
475}
476
477fn canonical_relation_endpoint(id: &str) -> String {
478 id.split_once('#')
479 .map_or(id, |(parent, _)| parent)
480 .to_string()
481}
482
483fn endpoint_term_hits(node: &Node, terms: &[String]) -> usize {
484 let mut surface = normalized_endpoint_text(&node.id);
485 surface.push(' ');
486 surface.push_str(&normalized_endpoint_text(&node.title));
487 for alias in &node.aliases {
488 surface.push(' ');
489 surface.push_str(&normalized_endpoint_text(alias));
490 }
491 terms
492 .iter()
493 .filter(|term| {
494 let term = normalized_endpoint_text(term);
495 !term.is_empty() && surface.contains(&term)
496 })
497 .count()
498}
499
500fn normalized_endpoint_text(value: &str) -> String {
501 value
502 .chars()
503 .filter(char::is_ascii_alphanumeric)
504 .flat_map(char::to_lowercase)
505 .collect()
506}
507
508fn relation_is_directionally_searchable(graph: &Graph, relation: &str) -> bool {
509 graph
510 .relation_profiles
511 .get(relation)
512 .cloned()
513 .or_else(|| crate::schema::core_relation_profile(relation))
514 .is_none_or(|profile| profile.searchable)
515}
516
517pub fn ground_with(graph: &Graph, index: &GroundIndex, query: &str, limit: usize) -> Vec<Hit> {
520 ground_scoped(graph, index, query, limit, Scope::All)
521}
522
523pub fn ground_scoped(
527 graph: &Graph,
528 index: &GroundIndex,
529 query: &str,
530 limit: usize,
531 scope: Scope,
532) -> Vec<Hit> {
533 let q = query.to_lowercase();
534 if q.trim().is_empty() {
537 return Vec::new();
538 }
539 let terms = terms_of(&q);
540 let n = graph.nodes.len();
541
542 let candidate_indices: Vec<usize> = (0..n).collect();
549
550 let mut hits: Vec<Hit> = {
551 let bdf = bm25f_df(&terms, &index.bm25_fields);
555 let params = index.config;
558 let sat = params.weights[1] / (params.k1 + params.weights[1]); let qmax: f64 = terms
564 .iter()
565 .map(|t| {
566 let dft = *bdf.get(t.as_str()).unwrap_or(&0);
567 if dft == 0 {
568 0.0
569 } else {
570 ((n as f64 - dft as f64 + 0.5) / (dft as f64 + 0.5) + 1.0).ln() * sat
571 }
572 })
573 .sum::<f64>()
574 .max(1e-3);
575 let scorer = Bm25fScorer {
576 terms: &terms,
577 df: &bdf,
578 n,
579 avglen: &index.bm25_avglen,
580 params,
581 };
582 let known_coverage = terms
588 .iter()
589 .filter(|t| *bdf.get(t.as_str()).unwrap_or(&0) > 0)
590 .count() as f64
591 / terms.len().max(1) as f64;
592 let directional_relation_boosts = directional_relation_boosts(graph, &terms, &q);
593 graph
594 .nodes
595 .iter()
596 .enumerate()
597 .filter(|(i, _)| candidate_indices.binary_search(i).is_ok())
598 .filter(|(_, node)| scope.admits(node))
599 .filter_map(|(i, node)| {
600 let is_code = matches!(
601 node.kind,
602 Kind::Function | Kind::Type | Kind::Trait | Kind::Module
603 );
604 let is_module = node.kind == Kind::Module;
605 let (s, id_s, matched, matched_identity, matched_name, max_exact_name_idf) =
606 bm25f_score(
607 &index.bm25_fields[i],
608 &scorer,
609 Bm25fNodeShape { is_code, is_module },
610 );
611 let idl = node.id.to_lowercase();
616 let titlel = node.title.to_lowercase();
617 let exact_query_example = node
618 .query_examples
619 .iter()
620 .any(|example| example.to_lowercase() == q);
621 let exact = if idl == q
622 || titlel == q
623 || node.aliases.iter().any(|a| a.to_lowercase() == q)
624 {
625 80.0 } else if titlel.contains(&q)
628 || node.aliases.iter().any(|a| a.to_lowercase().contains(&q))
629 {
630 6.0 } else {
632 0.0
633 };
634 let query_example_boost = if exact_query_example && !is_code {
635 60.0
636 } else {
637 0.0
638 };
639 if s <= 0.0 && exact <= 0.0 && query_example_boost <= 0.0 {
640 return None;
641 }
642 let coverage = matched as f64 / terms.len().max(1) as f64;
643 let id_coverage = matched_identity as f64 / terms.len().max(1) as f64;
647 let aliases: Vec<String> = node.aliases.iter().map(|a| a.to_lowercase()).collect();
651 let intents = query_intent_matches(node, &aliases, &terms);
652 let term_eq_title =
660 !titlel.is_empty() && terms.iter().any(|t| t.as_str() == titlel);
661 let kind_kw = match node.kind {
662 Kind::Type => terms
663 .iter()
664 .any(|t| matches!(t.as_str(), "struct" | "enum" | "type")),
665 Kind::Trait => terms.iter().any(|t| t == "trait"),
666 Kind::Module => terms.iter().any(|t| matches!(t.as_str(), "module" | "mod")),
667 Kind::Function => terms
668 .iter()
669 .any(|t| matches!(t.as_str(), "function" | "fn" | "method" | "func")),
670 _ => false,
671 };
672 let name_boost = if term_eq_title && (!is_code || kind_kw) {
673 20.0
674 } else {
675 0.0
676 };
677 let (intent_boost, anchor_intent_boost) = intent_boosts(
678 &intents,
679 is_code,
680 !is_distinct_code_symbol_name(&titlel),
681 matched_identity,
682 exact,
683 name_boost,
684 );
685 let has_relation_intent = terms.iter().any(|term| is_relation_query_term(term));
686 let relation_term_hits = if has_relation_intent {
687 terms
688 .iter()
689 .filter(|term| index.bm25_fields[i][5].iter().any(|token| token == *term))
690 .count()
691 } else {
692 0
693 };
694 let relation_boost = (relation_term_hits as f64) * 12.0;
695 let relation_matches = directional_relation_boosts
696 .get(node.id.as_str())
697 .cloned()
698 .unwrap_or_default();
699 let directional_relation_boost =
700 relation_matches.iter().map(|m| m.score_boost).sum::<i64>() as f64;
701 let relation_anchor = directional_relation_boost > 0.0;
702 let code_action = code_action_subject_match(node, &terms);
703 let code_action_boost = code_action
704 .as_ref()
705 .map_or(0.0, |signal| signal.score_boost);
706 let score = ((s / qmax) * 100.0
713 + exact
714 + query_example_boost
715 + intent_boost
716 + relation_boost
717 + directional_relation_boost
718 + code_action_boost
719 + matched_identity as f64
720 + name_boost)
721 .round() as i64;
722 let distinctive_name =
736 max_exact_name_idf >= DISTINCTIVE_NAME_IDF && known_coverage >= 0.6;
737 let anchored = exact > 0.0
738 || query_example_boost > 0.0
739 || anchor_intent_boost > 0.0
740 || name_boost > 0.0
741 || code_action_boost > 0.0
742 || relation_anchor
743 || distinctive_name
744 || (id_coverage >= 0.4 && matched_name > 0 && known_coverage >= 0.6);
745 let anchor = if anchored {
746 id_s + exact
747 + query_example_boost
748 + anchor_intent_boost
749 + name_boost
750 + code_action_boost
751 + directional_relation_boost
752 } else {
753 0.0
754 };
755 let rank_score =
756 route_score(score as f64, anchor, matched_identity, coverage).round() as i64;
757 let mut why = vec![format!(
758 "bm25f {s:.2} (identity {id_s:.2}, cover {:.0}%)",
759 coverage * 100.0
760 )];
761 if !intents.is_empty() {
762 why.push(format!("query intent match: {}", intents.join(", ")));
763 }
764 if relation_term_hits > 0 {
765 why.push(format!(
766 "relation context match: {relation_term_hits} terms"
767 ));
768 }
769 if relation_anchor {
770 let relations = relation_matches
771 .iter()
772 .map(|m| format!("{} {} {}", m.direction, m.relation, m.endpoint_id))
773 .collect::<Vec<_>>()
774 .join(", ");
775 why.push(format!(
776 "directed relation match: +{} ({relations})",
777 directional_relation_boost as i64,
778 ));
779 }
780 if let Some(code_action) = code_action {
781 why.push(format!(
782 "code action subject match: {} identifier terms",
783 code_action.identifier_hits
784 ));
785 }
786 if query_example_boost > 0.0 {
787 why.push("exact query_example match".to_string());
788 }
789 Some(Hit {
790 id: node.id.clone(),
791 score: rank_score,
792 lexical_score: score,
793 confidence: band_anchored(score, anchor, coverage),
794 why,
795 relation_matches,
796 anchor,
797 relation_anchor,
798 })
799 })
800 .collect()
801 };
802 apply_canonical_dominance(&mut hits, graph);
803 let title_hit: HashSet<&str> = graph
808 .nodes
809 .iter()
810 .enumerate()
811 .filter(|(i, _)| {
812 terms
813 .iter()
814 .any(|term| index.lc_titles[*i].contains(term.as_str()))
815 })
816 .map(|(_, node)| node.id.as_str())
817 .collect();
818 let title_term_hits: HashMap<&str, usize> = graph
819 .nodes
820 .iter()
821 .enumerate()
822 .map(|(i, node)| {
823 (
824 node.id.as_str(),
825 terms
826 .iter()
827 .filter(|term| index.lc_titles[i].contains(term.as_str()))
828 .count(),
829 )
830 })
831 .collect();
832 hits.sort_by(|a, b| {
833 b.score
834 .cmp(&a.score)
835 .then_with(|| {
836 title_hit
837 .contains(b.id.as_str())
838 .cmp(&title_hit.contains(a.id.as_str()))
839 })
840 .then_with(|| {
841 title_term_hits
842 .get(b.id.as_str())
843 .cmp(&title_term_hits.get(a.id.as_str()))
844 })
845 .then_with(|| a.id.cmp(&b.id))
846 });
847 calibrate(&mut hits);
848 hits.truncate(limit);
849 hits
850}
851
852fn route_score(score: f64, anchor: f64, matched_identity: usize, coverage: f64) -> f64 {
860 if anchor <= 0.0 && matched_identity == 0 && coverage < 1.0 {
861 score * coverage.max(0.25)
862 } else {
863 score
864 }
865}
866
867#[derive(Debug, Clone, Copy)]
868struct CodeActionSignal {
869 identifier_hits: usize,
870 score_boost: f64,
871}
872
873fn code_action_subject_match(node: &Node, terms: &[String]) -> Option<CodeActionSignal> {
874 if !matches!(
875 node.kind,
876 Kind::Function | Kind::Type | Kind::Trait | Kind::Module
877 ) || !query_has_code_action_intent(terms)
878 {
879 return None;
880 }
881 let subject_terms = terms
882 .iter()
883 .filter(|term| !is_code_action_control_term(term))
884 .map(|term| normalized_identifier_text(term))
885 .filter(|term| !term.is_empty())
886 .collect::<HashSet<_>>();
887 if subject_terms.is_empty() {
888 return None;
889 }
890
891 let segments = code_identifier_segments(node);
892 let identifier_hits = subject_terms
893 .iter()
894 .filter(|term| segments.contains(term.as_str()))
895 .count();
896 if identifier_hits == 0 {
897 return None;
898 }
899
900 let wants_function = terms.iter().any(|term| {
901 matches!(
902 term.as_str(),
903 "definition"
904 | "function"
905 | "fn"
906 | "implement"
907 | "implementation"
908 | "implemented"
909 | "method"
910 | "source"
911 )
912 });
913 let kind_boost = match node.kind {
914 Kind::Function => 16.0,
915 Kind::Type | Kind::Trait => 12.0,
916 Kind::Module => 8.0,
917 _ => 0.0,
918 };
919 let function_boost = if wants_function && node.kind == Kind::Function {
920 18.0
921 } else {
922 0.0
923 };
924 Some(CodeActionSignal {
925 identifier_hits,
926 score_boost: identifier_hits as f64 * 28.0 + kind_boost + function_boost,
927 })
928}
929
930fn query_has_code_action_intent(terms: &[String]) -> bool {
931 terms.iter().any(|term| is_code_action_control_term(term))
932}
933
934fn is_code_action_control_term(term: &str) -> bool {
935 matches!(
936 term,
937 "call"
938 | "caller"
939 | "callee"
940 | "change"
941 | "changing"
942 | "definition"
943 | "function"
944 | "fn"
945 | "implement"
946 | "implementation"
947 | "implemented"
948 | "method"
949 | "modify"
950 | "source"
951 | "trace"
952 | "type"
953 | "usage"
954 | "use"
955 | "used"
956 | "uses"
957 )
958}
959
960fn code_identifier_segments(node: &Node) -> HashSet<String> {
961 let mut out = HashSet::new();
962 push_identifier_segments(&mut out, &node.id);
963 push_identifier_segments(&mut out, &node.title);
964 out
965}
966
967fn push_identifier_segments(out: &mut HashSet<String>, value: &str) {
968 for segment in value.split([':', '.', '#', '/', '-']) {
969 let normalized = normalized_identifier_text(segment);
970 if !normalized.is_empty() {
971 out.insert(normalized);
972 }
973 for part in segment.split('_') {
974 let normalized_part = normalized_identifier_text(part);
975 if normalized_part.len() >= 6 {
976 out.insert(normalized_part);
977 }
978 }
979 }
980 let normalized = normalized_identifier_text(value);
981 if !normalized.is_empty() {
982 out.insert(normalized);
983 }
984}
985
986fn normalized_identifier_text(value: &str) -> String {
987 value
988 .chars()
989 .filter(char::is_ascii_alphanumeric)
990 .flat_map(char::to_lowercase)
991 .collect()
992}
993
994fn is_relation_query_term(term: &str) -> bool {
995 matches!(
996 term,
997 "call"
998 | "consume"
999 | "contract"
1000 | "depend"
1001 | "dispatch"
1002 | "implement"
1003 | "link"
1004 | "produce"
1005 | "reference"
1006 | "relate"
1007 | "require"
1008 )
1009}
1010
1011#[cfg(test)]
1012mod band_tests {
1013 use super::*;
1014 use crate::schema::{Edge, EdgeBasis, RelationProfile};
1015
1016 fn test_hit(id: &str, score: i64, confidence: Confidence) -> Hit {
1019 Hit {
1020 id: id.into(),
1021 score,
1022 lexical_score: score,
1023 confidence,
1024 why: Vec::new(),
1025 relation_matches: Vec::new(),
1026 anchor: 1.0,
1027 relation_anchor: false,
1028 }
1029 }
1030
1031 #[test]
1034 fn apply_floor_drops_trailing_fallback_only_when_a_confident_hit_exists() {
1035 let mut hits = vec![
1037 test_hit("a", 60, Confidence::Strong),
1038 test_hit("b", 20, Confidence::Fallback),
1039 test_hit("c", 10, Confidence::Fallback),
1040 ];
1041 apply_floor(&mut hits);
1042 assert_eq!(
1043 hits.iter().map(|h| h.id.as_str()).collect::<Vec<_>>(),
1044 ["a"]
1045 );
1046
1047 let mut weak = vec![
1050 test_hit("a", 30, Confidence::Weak),
1051 test_hit("b", 20, Confidence::Fallback),
1052 ];
1053 apply_floor(&mut weak);
1054 assert_eq!(weak.len(), 2, "no confident hit ⇒ list untouched");
1055 }
1056
1057 #[test]
1058 fn calibrate_demotes_strong_top_when_runner_up_is_within_ambiguity_ratio() {
1059 let mut close = vec![
1061 test_hit("a", 100, Confidence::Strong),
1062 test_hit("b", 70, Confidence::Strong),
1063 ];
1064 calibrate(&mut close);
1065 assert_eq!(close[0].confidence, Confidence::Ambiguous);
1066
1067 let mut clear = vec![
1069 test_hit("a", 100, Confidence::Strong),
1070 test_hit("b", 69, Confidence::Strong),
1071 ];
1072 calibrate(&mut clear);
1073 assert_eq!(clear[0].confidence, Confidence::Strong);
1074 }
1075
1076 #[test]
1077 fn canonical_dominance_lifts_a_parent_above_its_best_matching_child() {
1078 let graph = Graph {
1081 nodes: Vec::new(),
1082 edges: vec![Edge {
1083 from: "skill.x".into(),
1084 to: "doc.x#page".into(),
1085 relation: crate::schema::relation::HAS_KNOWLEDGE.into(),
1086 basis: EdgeBasis::Resolved,
1087 ..Default::default()
1088 }],
1089 ..Default::default()
1090 };
1091 let mut hits = vec![
1092 test_hit("doc.x#page", 80, Confidence::Strong),
1093 test_hit("skill.x", 50, Confidence::Weak),
1094 ];
1095 apply_canonical_dominance(&mut hits, &graph);
1096 let parent = hits.iter().find(|h| h.id == "skill.x").unwrap();
1097 assert_eq!(parent.score, 81, "parent lifted to best_child + 1");
1098 assert!(parent.why.iter().any(|w| w.contains("canonical")));
1099 }
1100
1101 #[test]
1102 fn adding_a_garbage_term_never_raises_a_hits_band() {
1103 let band_rank = |c: Confidence| match c {
1107 Confidence::Exact => 4,
1108 Confidence::Strong => 3,
1109 Confidence::Ambiguous => 2,
1110 Confidence::Weak => 1,
1111 Confidence::Fallback => 0,
1112 };
1113 let node = |id: &str, title: &str, summary: &str, aliases: &[&str]| Node {
1114 id: id.into(),
1115 kind: Kind::Doc,
1116 subkind: None,
1117 title: title.into(),
1118 summary: summary.into(),
1119 aliases: aliases
1120 .iter()
1121 .map(std::string::ToString::to_string)
1122 .collect(),
1123 tags: Vec::new(),
1124 query_examples: Vec::new(),
1125 source_files: vec!["f.md".into()],
1126 span: None,
1127 partition: None,
1128 };
1129 let graph = Graph {
1130 nodes: vec![
1131 node(
1132 "doc.retry",
1133 "Retry Policy",
1134 "backoff and dead letter routing",
1135 &["retry"],
1136 ),
1137 node(
1138 "doc.sched",
1139 "Scheduler",
1140 "the task scheduler loop",
1141 &["scheduler"],
1142 ),
1143 node("doc.obs", "Observability", "logs metrics traces", &[]),
1144 ],
1145 ..Default::default()
1146 };
1147
1148 for base in ["retry policy", "scheduler loop", "logs metrics", "retry"] {
1149 let with_garbage = format!("{base} zzqwxborg");
1150 let base_hits = ground(&graph, base, 5);
1151 let noisy_hits = ground(&graph, &with_garbage, 5);
1152 for bh in &base_hits {
1153 if let Some(nh) = noisy_hits.iter().find(|h| h.id == bh.id) {
1154 assert!(
1155 band_rank(nh.confidence) <= band_rank(bh.confidence),
1156 "adding a garbage term raised {}'s band from {:?} to {:?} (query {base:?})",
1157 bh.id,
1158 bh.confidence,
1159 nh.confidence
1160 );
1161 }
1162 }
1163 }
1164 }
1165
1166 #[test]
1167 fn ranker_config_is_injected_not_read_from_env() {
1168 let graph = Graph {
1173 nodes: vec![Node {
1174 id: "doc.note".to_string(),
1175 kind: Kind::Doc,
1176 subkind: None,
1177 title: "Note".to_string(),
1178 summary: "kombucha kombucha kombucha fermentation notes".to_string(),
1181 aliases: Vec::new(),
1182 tags: Vec::new(),
1183 query_examples: Vec::new(),
1184 source_files: vec!["note.md".to_string()],
1185 span: None,
1186 partition: None,
1187 }],
1188 edges: Vec::new(),
1189 ..Default::default()
1190 };
1191
1192 let default_index = GroundIndex::build(&graph);
1193 let boosted = RankerConfig {
1194 weights: [5.0, 8.0, 40.0, 6.0, 4.0, 3.0], ..RankerConfig::default()
1196 };
1197 let boosted_index = GroundIndex::build_with_config(&graph, boosted);
1198
1199 let default_score =
1200 ground_with(&graph, &default_index, "kombucha fermentation", 5)[0].lexical_score;
1201 let boosted_score =
1202 ground_with(&graph, &boosted_index, "kombucha fermentation", 5)[0].lexical_score;
1203
1204 assert_ne!(
1205 default_score, boosted_score,
1206 "an injected RankerConfig must change scoring"
1207 );
1208 let repeat =
1210 ground_with(&graph, &boosted_index, "kombucha fermentation", 5)[0].lexical_score;
1211 assert_eq!(boosted_score, repeat);
1212 }
1213
1214 #[test]
1215 fn bands_are_calibrated_to_score_distribution() {
1216 assert_eq!(
1217 band(45),
1218 Confidence::Strong,
1219 "45 is strong (clean accuracy 100% there)"
1220 );
1221 assert_eq!(band(44), Confidence::Weak);
1222 assert_eq!(band(25), Confidence::Weak);
1223 assert_eq!(band(24), Confidence::Fallback);
1224 assert_eq!(
1226 band(32),
1227 Confidence::Weak,
1228 "garbage-range scores stay below strong"
1229 );
1230 }
1231
1232 #[test]
1233 fn anchor_caps_body_only_mentions_below_strong() {
1234 assert_eq!(band_anchored(60, 0.0, 1.0), Confidence::Weak);
1237 assert_eq!(band_anchored(48, 0.0, 1.0), Confidence::Weak);
1238 assert_eq!(band_anchored(30, 0.0, 1.0), Confidence::Weak);
1239 assert_eq!(band_anchored(25, 0.0, 1.0), Confidence::Weak);
1240 assert_eq!(band_anchored(10, 0.0, 1.0), Confidence::Fallback);
1242 assert_eq!(band_anchored(30, 0.0, 0.5), Confidence::Fallback);
1243 assert_eq!(band_anchored(60, 0.5, 1.0), Confidence::Strong);
1245 assert_eq!(band_anchored(30, 0.9, 1.0), Confidence::Weak);
1246 }
1247
1248 #[test]
1249 fn code_symbol_names_require_distinctive_bare_terms() {
1250 assert!(is_distinct_code_symbol_name("load_settings"));
1251 assert!(is_distinct_code_symbol_name("config2"));
1252 assert!(!is_distinct_code_symbol_name("run"));
1253 assert!(!is_distinct_code_symbol_name("id"));
1254 assert!(!is_distinct_code_symbol_name("123456"));
1255 }
1256
1257 #[test]
1258 fn route_score_penalizes_partial_body_only_mentions() {
1259 assert_eq!(route_score(60.0, 0.0, 0, 0.5), 30.0);
1260 assert_eq!(route_score(60.0, 0.0, 0, 1.0), 60.0);
1261 assert_eq!(route_score(60.0, 0.2, 0, 0.5), 60.0);
1262 assert_eq!(route_score(60.0, 0.0, 1, 0.5), 60.0);
1264 }
1265
1266 #[test]
1267 fn anchored_typo_match_beats_partial_body_only_match() {
1268 let graph = Graph {
1269 nodes: vec![
1270 Node {
1271 id: "doc.material-inventory".to_string(),
1272 kind: Kind::Doc,
1273 subkind: None,
1274 title: "Material Inventory".to_string(),
1275 summary: "Home furniture materials, finishes, and care notes.".to_string(),
1276 aliases: Vec::new(),
1277 tags: vec!["veneer".to_string(), "desk".to_string()],
1278 query_examples: vec![
1279 "Desk: walnut veneer over plywood. Do not sand aggressively.".to_string(),
1280 ],
1281 source_files: vec!["material-inventory.md".to_string()],
1282 span: None,
1283 partition: None,
1284 },
1285 Node {
1286 id: "skill.wood-care".to_string(),
1287 kind: Kind::Skill,
1288 subkind: None,
1289 title: "wood-care".to_string(),
1290 summary: "Care and repair for wood, veneer, water rings, and scratches."
1291 .to_string(),
1292 aliases: vec![
1293 "veneer".to_string(),
1294 "scratch".to_string(),
1295 "wood repair".to_string(),
1296 ],
1297 tags: Vec::new(),
1298 query_examples: vec!["fixing small scratches in walnut furniture".to_string()],
1299 source_files: vec!["skills/wood-care/SKILL.md".to_string()],
1300 span: None,
1301 partition: None,
1302 },
1303 ],
1304 edges: Vec::new(),
1305 ..Default::default()
1306 };
1307
1308 let hits = ground(&graph, "veneer desk skratch", 5);
1309
1310 assert_eq!(
1311 hits.first().map(|hit| hit.id.as_str()),
1312 Some("skill.wood-care")
1313 );
1314 assert!(
1315 hits.iter()
1316 .any(|hit| hit.id == "doc.material-inventory"
1317 && hit.confidence == Confidence::Fallback),
1318 "body-only material doc should remain a low-confidence support hit"
1319 );
1320 }
1321
1322 #[test]
1323 fn generic_code_intent_does_not_make_body_only_helper_strong() {
1324 let graph = Graph {
1325 nodes: vec![Node {
1326 id: "fn.tests::run".to_string(),
1327 kind: Kind::Function,
1328 subkind: None,
1329 title: "run".to_string(),
1330 summary: String::new(),
1331 aliases: Vec::new(),
1332 tags: Vec::new(),
1333 query_examples: vec![
1334 "debug package eval gates code architecture docs inspect".to_string(),
1335 ],
1336 source_files: vec!["tests/cli_graph.rs".to_string()],
1337 span: None,
1338 partition: None,
1339 }],
1340 edges: Vec::new(),
1341 ..Default::default()
1342 };
1343
1344 let hits = ground(
1345 &graph,
1346 "debug package eval gates code architecture docs inspect",
1347 5,
1348 );
1349
1350 assert_eq!(hits.len(), 1);
1351 assert_eq!(hits[0].id, "fn.tests::run");
1352 assert_eq!(hits[0].confidence, Confidence::Weak);
1353 }
1354
1355 #[test]
1362 fn mostly_unknown_query_does_not_anchor_partial_name_match() {
1363 let graph = Graph {
1364 nodes: vec![
1365 Node {
1366 id: "skill.task-scheduling".to_string(),
1367 kind: Kind::Skill,
1368 subkind: None,
1369 title: "task-scheduling".to_string(),
1370 summary: "Scheduling tasks with retry, backoff, and dead-letter queues."
1371 .to_string(),
1372 aliases: vec!["task scheduler".to_string()],
1373 tags: Vec::new(),
1374 query_examples: Vec::new(),
1375 source_files: vec!["skills/task-scheduling/SKILL.md".to_string()],
1376 span: None,
1377 partition: None,
1378 },
1379 Node {
1380 id: "doc.runbook".to_string(),
1381 kind: Kind::Doc,
1382 subkind: None,
1383 title: "Operations Runbook".to_string(),
1384 summary: "Symptoms and fixes for task and worker failures.".to_string(),
1385 aliases: Vec::new(),
1386 tags: Vec::new(),
1387 query_examples: Vec::new(),
1388 source_files: vec!["runbook.md".to_string()],
1389 span: None,
1390 partition: None,
1391 },
1392 ],
1393 edges: Vec::new(),
1394 ..Default::default()
1395 };
1396
1397 let hits = ground(&graph, "task scheduler yoga meditation mindfulness", 5);
1398
1399 let top = hits.first().expect("the familiar terms still match");
1400 assert!(
1401 matches!(top.confidence, Confidence::Weak | Confidence::Fallback),
1402 "a mostly-unknown query must not answer confidently, got {:?} for {}",
1403 top.confidence,
1404 top.id
1405 );
1406 }
1407
1408 #[test]
1409 fn code_action_query_prefers_named_function_over_nearby_type_context() {
1410 let graph = Graph {
1411 nodes: vec![
1412 code_node(
1413 "type.policy::Backoff",
1414 Kind::Type,
1415 "Backoff",
1416 "Backoff configuration used by RetryPolicy delay_ms implementation.",
1417 ),
1418 code_node(
1419 "type.policy::RetryPolicy",
1420 Kind::Type,
1421 "RetryPolicy",
1422 "Retry policy configuration with max attempts and backoff.",
1423 ),
1424 code_node(
1425 "fn.policy::RetryPolicy::delay_ms",
1426 Kind::Function,
1427 "delay_ms",
1428 "Compute the delay for a retry attempt.",
1429 ),
1430 ],
1431 edges: Vec::new(),
1432 ..Default::default()
1433 };
1434
1435 let hits = ground(&graph, "RetryPolicy delay_ms implementation", 5);
1436
1437 assert_eq!(
1438 hits.first().map(|hit| hit.id.as_str()),
1439 Some("fn.policy::RetryPolicy::delay_ms")
1440 );
1441 assert!(
1442 hits[0]
1443 .why
1444 .iter()
1445 .any(|why| why.contains("code action subject match")),
1446 "top hit should explain the code-action identifier signal"
1447 );
1448 }
1449
1450 #[test]
1451 fn resolved_relation_surfaces_are_searchable_without_body_mentions() {
1452 let graph = Graph {
1453 nodes: vec![
1454 Node {
1455 id: "doc.router".to_string(),
1456 kind: Kind::Doc,
1457 subkind: None,
1458 title: "Router".to_string(),
1459 summary: "Request routing policy.".to_string(),
1460 aliases: Vec::new(),
1461 tags: Vec::new(),
1462 query_examples: Vec::new(),
1463 source_files: vec!["router.md".to_string()],
1464 span: None,
1465 partition: None,
1466 },
1467 Node {
1468 id: "doc.auth-contract".to_string(),
1469 kind: Kind::Doc,
1470 subkind: None,
1471 title: "Auth Contract".to_string(),
1472 summary: "Authentication requirements.".to_string(),
1473 aliases: vec!["login rules".to_string()],
1474 tags: Vec::new(),
1475 query_examples: Vec::new(),
1476 source_files: vec!["auth.md".to_string()],
1477 span: None,
1478 partition: None,
1479 },
1480 ],
1481 edges: vec![Edge {
1482 from: "doc.router".to_string(),
1483 to: "doc.auth-contract".to_string(),
1484 relation: "depends_on".to_string(),
1485 evidence: "frontmatter".to_string(),
1486 basis: EdgeBasis::Resolved,
1487 ..Default::default()
1488 }],
1489 ..Default::default()
1490 };
1491
1492 let hits = ground(&graph, "depends on auth contract", 5);
1493
1494 assert_eq!(hits.first().map(|hit| hit.id.as_str()), Some("doc.router"));
1495 assert!(
1496 hits[0].why.iter().any(|why| why.contains("bm25f")),
1497 "relation search should flow through normal BM25F evidence"
1498 );
1499 assert_eq!(hits[0].relation_matches.len(), 1);
1500 let relation_match = &hits[0].relation_matches[0];
1501 assert_eq!(relation_match.relation, "depends_on");
1502 assert_eq!(relation_match.direction, "forward");
1503 assert_eq!(relation_match.phrase, "depends on");
1504 assert_eq!(relation_match.endpoint_id, "doc.auth-contract");
1505 assert_eq!(relation_match.endpoint_title, "Auth Contract");
1506 assert_eq!(relation_match.endpoint_hits, 2);
1507 assert!(relation_match.score_boost > 0);
1508 }
1509
1510 #[test]
1511 fn forward_relation_query_does_not_trigger_reverse_phrase_by_stem_only() {
1512 let graph = Graph {
1513 nodes: vec![
1514 Node {
1515 id: "doc.refund-runbook".to_string(),
1516 kind: Kind::Doc,
1517 subkind: None,
1518 title: "Refund Escalation Runbook".to_string(),
1519 summary: "High value refund process.".to_string(),
1520 aliases: Vec::new(),
1521 tags: Vec::new(),
1522 query_examples: Vec::new(),
1523 source_files: vec!["refund.md".to_string()],
1524 span: None,
1525 partition: None,
1526 },
1527 Node {
1528 id: "doc.release-gate".to_string(),
1529 kind: Kind::Doc,
1530 subkind: None,
1531 title: "Refund Release Gate".to_string(),
1532 summary: "Release gate for refund changes.".to_string(),
1533 aliases: Vec::new(),
1534 tags: Vec::new(),
1535 query_examples: Vec::new(),
1536 source_files: vec!["gate.md".to_string()],
1537 span: None,
1538 partition: None,
1539 },
1540 ],
1541 edges: vec![Edge {
1542 from: "doc.refund-runbook".to_string(),
1543 to: "doc.release-gate".to_string(),
1544 relation: "blocks".to_string(),
1545 evidence: "frontmatter".to_string(),
1546 basis: EdgeBasis::Resolved,
1547 ..Default::default()
1548 }],
1549 relation_profiles: [(
1550 "blocks".to_string(),
1551 RelationProfile::new("blocks", "blocked by", 34),
1552 )]
1553 .into_iter()
1554 .collect(),
1555 };
1556
1557 let hits = ground(
1558 &graph,
1559 "make high value refund change that blocks refund release gate",
1560 5,
1561 );
1562
1563 assert_eq!(
1564 hits.first().map(|hit| hit.id.as_str()),
1565 Some("doc.refund-runbook")
1566 );
1567 assert_eq!(hits[0].relation_matches.len(), 1);
1568 assert_eq!(hits[0].relation_matches[0].direction, "forward");
1569 assert_eq!(hits[0].relation_matches[0].endpoint_id, "doc.release-gate");
1570 assert!(
1571 hits.iter()
1572 .find(|hit| hit.id == "doc.release-gate")
1573 .is_none_or(|hit| hit.relation_matches.is_empty()),
1574 "`blocks` should not satisfy the reverse phrase `blocked by` via stemming alone"
1575 );
1576 }
1577
1578 #[test]
1579 fn relation_profiles_control_search_phrases() {
1580 let graph = Graph {
1581 nodes: vec![
1582 Node {
1583 id: "doc.runbook".to_string(),
1584 kind: Kind::Doc,
1585 subkind: None,
1586 title: "Runbook".to_string(),
1587 summary: "Operational playbook.".to_string(),
1588 aliases: Vec::new(),
1589 tags: Vec::new(),
1590 query_examples: Vec::new(),
1591 source_files: vec!["runbook.md".to_string()],
1592 span: None,
1593 partition: None,
1594 },
1595 Node {
1596 id: "doc.report".to_string(),
1597 kind: Kind::Doc,
1598 subkind: None,
1599 title: "Daily Report".to_string(),
1600 summary: "Manager report.".to_string(),
1601 aliases: Vec::new(),
1602 tags: Vec::new(),
1603 query_examples: Vec::new(),
1604 source_files: vec!["report.md".to_string()],
1605 span: None,
1606 partition: None,
1607 },
1608 ],
1609 edges: vec![Edge {
1610 from: "doc.runbook".to_string(),
1611 to: "doc.report".to_string(),
1612 relation: "emits".to_string(),
1613 evidence: "frontmatter".to_string(),
1614 basis: EdgeBasis::Resolved,
1615 ..Default::default()
1616 }],
1617 relation_profiles: [(
1618 "emits".to_string(),
1619 RelationProfile::new("zorbles", "zorbeled by", 30),
1620 )]
1621 .into_iter()
1622 .collect(),
1623 };
1624
1625 let hits = ground(&graph, "zorbles", 5);
1626
1627 assert_eq!(hits.first().map(|hit| hit.id.as_str()), Some("doc.runbook"));
1628 }
1629
1630 #[test]
1631 fn a_distinctive_name_anchors_confidence_but_a_common_term_does_not() {
1632 let doc = |id: &str, title: &str, summary: &str| Node {
1635 id: id.to_string(),
1636 kind: Kind::Doc,
1637 subkind: None,
1638 title: title.to_string(),
1639 summary: summary.to_string(),
1640 aliases: Vec::new(),
1641 tags: Vec::new(),
1642 query_examples: Vec::new(),
1643 source_files: vec![format!("{id}.md")],
1644 span: None,
1645 partition: None,
1646 };
1647 let mut nodes = vec![doc(
1649 "doc.carbonara",
1650 "Carbonara",
1651 "A roman egg pasta with pecorino and guanciale.",
1652 )];
1653 for i in 0..8 {
1654 nodes.push(doc(
1655 &format!("doc.other{i}"),
1656 &format!("Recipe {i}"),
1657 "A dish that uses egg among its ingredients.",
1658 ));
1659 }
1660 let graph = Graph {
1661 nodes,
1662 edges: Vec::new(),
1663 relation_profiles: Default::default(),
1664 };
1665
1666 let hits = ground(&graph, "carbonara egg", 5);
1669 let carbonara = hits.iter().find(|h| h.id == "doc.carbonara").unwrap();
1670 assert!(
1671 matches!(
1672 carbonara.confidence,
1673 Confidence::Exact | Confidence::Strong | Confidence::Ambiguous
1674 ),
1675 "a distinctive name must anchor confidence above Weak, got {:?}",
1676 carbonara.confidence
1677 );
1678
1679 let common = ground(&graph, "egg", 5);
1681 assert!(
1682 common
1683 .first()
1684 .is_none_or(|h| matches!(h.confidence, Confidence::Weak | Confidence::Fallback)),
1685 "a common term alone must not earn a confident band: {:?}",
1686 common.first().map(|h| h.confidence)
1687 );
1688 }
1689
1690 #[test]
1691 fn relation_neighbour_does_not_outrank_an_exact_query_example() {
1692 let doc = |id: &str, title: &str, summary: &str, qe: &[&str]| Node {
1699 id: id.to_string(),
1700 kind: Kind::Doc,
1701 subkind: None,
1702 title: title.to_string(),
1703 summary: summary.to_string(),
1704 aliases: Vec::new(),
1705 tags: Vec::new(),
1706 query_examples: qe.iter().map(|s| s.to_string()).collect(),
1707 source_files: vec![format!("{id}.md")],
1708 span: None,
1709 partition: None,
1710 };
1711 let graph = Graph {
1712 nodes: vec![
1713 doc(
1714 "doc.embeddings",
1715 "Choosing an Embedding Model",
1716 "Pick by recall and latency.",
1717 &["which embedding model should I use"],
1718 ),
1719 doc("doc.evals", "Writing Evals", "Build a golden set.", &[]),
1720 ],
1721 edges: vec![Edge {
1722 from: "doc.evals".to_string(),
1723 to: "doc.embeddings".to_string(),
1724 relation: "depends_on".to_string(),
1725 evidence: "frontmatter".to_string(),
1726 basis: EdgeBasis::Resolved,
1727 ..Default::default()
1728 }],
1729 relation_profiles: [(
1730 "depends_on".to_string(),
1731 RelationProfile::new("depends on", "required by", 30),
1732 )]
1733 .into_iter()
1734 .collect(),
1735 };
1736
1737 let hits = ground(&graph, "which embedding model should I use", 5);
1738 assert_eq!(
1739 hits.first().map(|hit| hit.id.as_str()),
1740 Some("doc.embeddings"),
1741 "the doc with the verbatim query_example must outrank its relation neighbour"
1742 );
1743
1744 assert!(!is_relation_query_term("use"));
1748 assert!(is_relation_query_term("depend"));
1749 assert!(is_relation_query_term("implement"));
1750 }
1751
1752 #[test]
1753 fn exact_query_example_lifts_confidence_to_strong() {
1754 let graph = Graph {
1755 nodes: vec![Node {
1756 id: "doc.dlq".to_string(),
1757 kind: Kind::Doc,
1758 subkind: None,
1759 title: "Dead Letter Handling".to_string(),
1760 summary: String::new(),
1761 aliases: vec!["dead letter".to_string()],
1762 tags: Vec::new(),
1763 query_examples: vec!["what happens to messages that keep failing".to_string()],
1764 source_files: vec!["dlq.md".to_string()],
1765 span: None,
1766 partition: None,
1767 }],
1768 edges: Vec::new(),
1769 ..Default::default()
1770 };
1771
1772 let hits = ground(&graph, "what happens to messages that keep failing", 1);
1773
1774 assert_eq!(hits.first().map(|hit| hit.id.as_str()), Some("doc.dlq"));
1775 assert_eq!(
1776 hits.first().map(|hit| hit.confidence),
1777 Some(Confidence::Strong)
1778 );
1779 assert!(
1780 hits.first()
1781 .is_some_and(|hit| hit.why.iter().any(|why| why == "exact query_example match"))
1782 );
1783 }
1784
1785 fn code_node(id: &str, kind: Kind, title: &str, summary: &str) -> Node {
1786 Node {
1787 id: id.to_string(),
1788 kind,
1789 subkind: None,
1790 title: title.to_string(),
1791 summary: summary.to_string(),
1792 aliases: Vec::new(),
1793 tags: Vec::new(),
1794 query_examples: Vec::new(),
1795 source_files: vec![format!("{}.rs", title.to_ascii_lowercase())],
1796 span: None,
1797 partition: None,
1798 }
1799 }
1800}