Skip to main content

eidos_kernel/
retrieval.rs

1//! Retrieval — lexical scoring + CALIBRATED confidence.
2//!
3//! Ports the Python scorer (haystack = id/title/summary/aliases; exact + partial field
4//! checks; per-term bonus + coverage; bands strong≥45 / weak≥25 / fallback). Adds the
5//! calibration the Python version lacks: the top hit is demoted `strong → ambiguous` when
6//! a runner-up scores within 0.7× of it — so a coin-flip query stops reporting `strong`.
7
8use 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    /// Route score: lexical relevance adjusted by reliability signals such as identity anchoring
43    /// and canonical-dominance boosting — drives ranking/route.
44    pub score: i64,
45    /// Raw lexical relevance, BEFORE route reliability penalties or dominance boosts. Lets context
46    /// expansion keep useful body evidence even when route ranking prefers anchored front doors.
47    pub lexical_score: i64,
48    pub confidence: Confidence,
49    pub why: Vec<String>,
50    /// Structured relation reasons that influenced this hit's ranking.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub relation_matches: Vec<RelationMatch>,
53    /// Fraction of the query's IDF weight that lands in the node's curated identity (id/title/
54    /// aliases/summary) vs only its body. Calibration input, not serialized.
55    #[serde(skip)]
56    pub anchor: f64,
57    /// True when the route was anchored by a directed relation phrase + endpoint match, not just
58    /// local node text. Focus uses this to preserve source-side relation answers.
59    #[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
74/// How close a runner-up must be (as a fraction of the top score) to make the top hit "ambiguous"
75/// rather than "strong". Housed in the calibration registry — see [`crate::calibration`].
76use crate::calibration::{AMBIGUITY_RATIO, DISTINCTIVE_NAME_IDF};
77
78/// Common English stopwords — near-zero retrieval signal, so they are dropped from query terms.
79/// Without this, a query like "the project findings notes" lets any node containing "the" score
80/// (and canonical dominance then promotes it over the relevant node). Whole-query exact/partial
81/// field checks are unaffected, so a stopword-y phrase that IS an exact alias still matches.
82const 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
94/// True if `a` and `b` differ by at most one edit (insert / delete / substitute). A conservative
95/// typo matcher — byte-wise (English-light), O(len), short-circuits on the second edit.
96fn 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; // substitution
115            }
116            j += 1; // advance the longer (or both, for substitution)
117        }
118    }
119    true
120}
121
122/// Conservative light stemmer: strip a single common inflectional suffix so an inflected query
123/// term reaches a base-form node ("baking"→"bak", "tests"→"test"). Substring matching downstream
124/// means a shortened stem still matches the full word. Guards `-ss/-us/-is` and short words to
125/// avoid mangling (e.g. `status`, `class`, `this`). Deterministic, English-light.
126fn 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
140/// After stripping `-ing`/`-ed`, restore a silent `e` elided before it when the stem ends in a
141/// consonant-vowel-consonant pattern (Porter's rule): `bak`→`bake`, `cod`→`code`, `writ`→`write` —
142/// so a gerund and its base form converge to the same token (token matching, unlike the old
143/// substring matching, needs them to be equal). Skips double consonants (`runn`) and `w/x/y` endings.
144fn 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
179/// Confidence bands, calibrated to the measured score distribution (see `band_tests`): clean
180/// retrieval accuracy is 100% down to score 45 and garbage tops out ~32, so `strong ≥ 45` is
181/// discriminating yet keeps strong high-accuracy and never labels garbage strong.
182fn 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
192/// Anchor-aware confidence: `band(score)` reflects how MANY terms matched and how distinctive
193/// they are, but not WHERE they matched. Field testing found a doc that merely *mentions* the
194/// query terms in its body (e.g. a brain-dump that lists "VLANs" as a TODO) scoring "strong" —
195/// relevant, but not what the doc is *about*. So when NO distinctive query weight lands in the
196/// node's curated identity (id/title/aliases/summary), confidence is capped: it's a mention, not
197/// a definitional match. Ranking is untouched — only the confidence LABEL is calibrated.
198///
199/// The cap is tiered by coverage: if ALL query terms matched (coverage == 1.0) AND the score is
200/// decent (>= 25), the document genuinely contains the answer — cap to Weak (not Fallback).
201/// A body-only match with full term coverage and a real score is "found something relevant but
202/// not highly confident." Fallback is reserved for low-score stretches where even the body match
203/// is marginal.
204fn band_anchored(score: i64, anchor: f64, coverage: f64) -> Confidence {
205    let base = band(score);
206    if anchor <= 0.0 {
207        // NO distinctive query weight in the curated identity → a body-only match.
208        // Tier the cap by coverage and score:
209        //   - Full coverage + decent score → Weak (the doc genuinely contains all query terms)
210        //   - Low score → Fallback (the match is marginal, a body coincidence)
211        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
224/// A bare code symbol name is only identity-grade evidence when it is distinctive enough to avoid
225/// common short words like `id`, `run`, or `get` dominating task-shaped searches.
226fn 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        // "show code/docs" is a broad class request, not evidence that every code symbol is a
248        // relevant answer. A generically-named helper (`run`, `get`, `id`) with no identity
249        // evidence keeps a small route nudge but never becomes a strong seed. A distinctively
250        // named symbol (`LoginForm`) keeps the full intent boost: its name alone is evidence.
251        return (6.0, 0.0);
252    }
253    let boost = intents.len() as f64 * 20.0;
254    (boost, boost)
255}
256
257/// Demote the top hit `strong → ambiguous` when the runner-up is within `AMBIGUITY_RATIO`
258/// of it — the calibration that stops bare ambiguous queries reporting false confidence.
259fn 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
269/// Adaptive confidence floor — a **presentation** filter, applied at the surface (MCP/CLI), NOT in
270/// the retrieval/eval path. The engine always returns the full ranked list so the eval can measure
271/// rank-2+ recall honestly; the surface uses this to drop trailing noise before showing results.
272///
273/// When a confident hit exists (`Exact`/`Strong`/`Ambiguous`), the trailing `Fallback`-band hits are
274/// noise, not answers — drop them (the "1 strong + 7 fallback" cleanup). When nothing is confident
275/// (a genuinely weak query, or garbage), the ranked list is kept untouched so a weak-but-real answer
276/// still surfaces and garbage still reads as rejected. The TOP hit's band is never changed.
277pub 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
289/// Canonical dominance: a `has_knowledge` PARENT (a skill) is the front door to its own
290/// wiki/trigger pages. When the parent AND some of its children both match, lift the parent's
291/// effective score to just above its best-matching child — so the skill ranks first and the
292/// specific page follows. Derived from the data (best child + 1), no tuning constant.
293/// Without this, subordinate docs out-score their skill on term coverage (real-vault p@1 45%).
294fn 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
314/// Ground a query against the graph: ranked hits with calibrated confidence. Builds a fresh
315/// [`GroundIndex`] each call — convenient for one-shot use. To ground many queries against one graph,
316/// build the index once and call [`ground_with`] (the eval harness and a warm server do this).
317pub fn ground(graph: &Graph, query: &str, limit: usize) -> Vec<Hit> {
318    ground_with(graph, &GroundIndex::build(graph), query, limit)
319}
320
321/// Which nodes are candidates for grounding. `Docs` excludes code symbols
322/// (Function/Type/Trait/Module); `Code` keeps only them; `Section` keeps only sub-doc heading blocks;
323/// `Subkind` keeps only nodes whose open domain `subkind` matches (e.g. "skill", "agent").
324/// `All` is every CANONICAL unit — it deliberately EXCLUDES sections (they're a sub-granularity that
325/// would flood the candidate set and displace skills/docs; opt in with `Section`).
326#[derive(Clone, PartialEq, Eq, Debug, Default)]
327pub enum Scope {
328    #[default]
329    All,
330    Docs,
331    Code,
332    /// Only sub-doc `Section` nodes (heading blocks) — opt-in granularity.
333    Section,
334    /// Only nodes whose `subkind` equals this value.
335    Subkind(String),
336}
337
338impl Scope {
339    /// Parse a scope name. `all`/empty → All, `docs`/`doc` → Docs, `code` → Code,
340    /// `section`/`sections` → Section; anything else is a `Subkind` filter (so `scope=skill` keeps
341    /// only skill nodes). Always `Some`.
342    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    /// Does this scope admit `node` as a candidate?
353    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            // canonical scopes exclude sections (sub-granularity); opt in via Scope::Section.
362            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
517/// Ground using a precomputed [`GroundIndex`] — the hot path. Byte-for-byte identical results to
518/// [`ground`]; the only difference is that the graph-invariant inputs are supplied, not rebuilt.
519pub fn ground_with(graph: &Graph, index: &GroundIndex, query: &str, limit: usize) -> Vec<Hit> {
520    ground_scoped(graph, index, query, limit, Scope::All)
521}
522
523/// Like [`ground_with`], restricted to the candidates a `scope` admits (docs-only / code-only).
524/// `Scope::All` is identical to [`ground_with`]. Scope is a pre-filter on which nodes are scored;
525/// IDF/df stays corpus-wide (distinctiveness is a global property of the graph).
526pub 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    // A blank query grounds nothing — without this guard every `field.contains("")` is true,
535    // so an empty query would match (and strongly score) every node.
536    if q.trim().is_empty() {
537        return Vec::new();
538    }
539    let terms = terms_of(&q);
540    let n = graph.nodes.len();
541
542    // TRIGRAM CANDIDATE NARROWING: disabled temporarily — it's causing a regression.
543    // The trigram index is built but we're still investigating why it misses candidates.
544    // TODO: fix the trigram candidate coverage, then re-enable.
545    // let mut candidate_indices: Vec<usize> =
546    //     index.candidates(&terms).unwrap_or_else(|| (0..n).collect());
547    // candidate_indices.sort_unstable();
548    let candidate_indices: Vec<usize> = (0..n).collect();
549
550    let mut hits: Vec<Hit> = {
551        // BM25F: field-weighted BM25 over the precomputed surfaces, length-normalized. This is
552        // THE ranker — the legacy substring scorer (env-gated `EIDOS_RANKER=legacy`) was deleted
553        // in D0.8 after its byte-identical migration was long proven.
554        let bdf = bm25f_df(&terms, &index.bm25_fields);
555        // Ranker params come from the index (injected at build; frozen calibration by default).
556        // The kernel never reads the environment — same inputs → same scores.
557        let params = index.config;
558        // ADAPTIVE BANDS: per-query normalization ceiling = the max BM25F a perfect identity match
559        // could reach (the query terms' IDF at field-weight saturation). Dividing the score by it
560        // makes the score — and therefore the confidence bands — CORPUS-INDEPENDENT: no absolute
561        // threshold tied to a particular vault's IDF scale. Self-tunes to any corpus.
562        let sat = params.weights[1] / (params.k1 + params.weights[1]); // top field (title) saturation
563        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        // Fraction of query terms that exist ANYWHERE in the corpus. When most of a query's
583        // vocabulary is absent (df == 0), the query is about something this brain does not know —
584        // the garbage-near-node class ("task scheduler yoga meditation mindfulness"). A partial
585        // identity match must not anchor confidence on such a query: matching the 2 familiar words
586        // says nothing about the 3 unknown ones the query is actually about.
587        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                // exact-match boosts on the normalized 0..100 scale (cheap, high-precision).
612                // Computed BEFORE the s<=0 filter: a whole-query id/title match (e.g. "skill.a",
613                // which de-slugifies away from the token "skill") must still register even when the
614                // tokenized BM25F score is 0.
615                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 // whole query == an identity field: the strongest signal, must DOMINATE
626                // (clear the ambiguity ratio even when a near-dupe shares the term)
627                } else if titlel.contains(&q)
628                    || node.aliases.iter().any(|a| a.to_lowercase().contains(&q))
629                {
630                    6.0 // partial substring — small; must NOT rival an exact match (calibration)
631                } 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                // IDENTITY coverage: fraction of query terms matching the node's NAME (id/title/
644                // aliases), not its body — the garbage guard. "load user router model payload cache"
645                // lands `load`+`user` in load_user's name but the rest only in its body (2/6 identity).
646                let id_coverage = matched_identity as f64 / terms.len().max(1) as f64;
647                // KIND/INTENT PRIOR (the graph-signal layer): when the query asks for a particular
648                // kind — "which expert owns…" (agent), "…implemented in code" (code) — boost nodes of
649                // that kind. Reuses the legacy intent detection so BM25F is ≥ legacy on intent too.
650                let aliases: Vec<String> = node.aliases.iter().map(|a| a.to_lowercase()).collect();
651                let intents = query_intent_matches(node, &aliases, &terms);
652                // EXACT SYMBOL-NAME, kind-gated: a query term that IS the node's whole title ("hit" for
653                // type Hit) prefers the symbol literally named — but for CODE only when the query also
654                // carries a structural keyword matching the node's kind ("the Hit STRUCT"→Type, "the
655                // retrieval MODULE"→Module). This is legacy's structural-lookup context: it tells "the
656                // Hit struct" (boost Hit) from "profile config settings" (no kind word → a generic term
657                // matching a module's name must NOT dominate the config symbol the query is about). For
658                // docs a bare title-equal term is already an unambiguous signal, so no keyword needed.
659                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                // normalized relevance in ~[0,100] (s/qmax) → adaptive, corpus-independent bands.
707                // NO coverage factor here: ranking keeps the full score so a distinctive single term
708                // ("waybar" in "waybar data system build") still outranks generic-term accumulation.
709                // + matched_identity + name_boost as tiebreakers: when two nodes saturate the
710                // normalization ceiling, the one matching MORE query terms in its NAME, or whose whole
711                // name a term equals, wins (kind/specificity disambiguation).
712                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                // Coverage gates CONFIDENCE, not ranking: a high-scoring hit covering <60% of the
723                // query is the garbage-near-node class ("rust gardening clippy soup"→skill.rust). Zero
724                // its anchor so band_anchored caps it to Weak — and since canonical dominance ALSO
725                // recomputes via band_anchored(score, anchor), the cap survives the dominance lift. An
726                // exact identity OR intent match is always anchored.
727                // name_boost anchors too: a kind-confirmed exact name match ("retrieval module"→
728                // mod.retrieval) IS the answer. Without it a module — whose summary no longer anchors
729                // it — would never be confident even when the query explicitly names it.
730                // DISTINCTIVE-NAME anchor: a RARE query term (high IDF) that EXACTLY names this node
731                // unambiguously identifies it, even when common co-terms dilute id_coverage below the
732                // 0.4 bar. "why did my CARBONARA turn into scrambled eggs" → carbonara (df=1) names
733                // doc.carbonara; egg/scrambled do not. Gated by known_coverage so an out-of-vocabulary
734                // garbage query ("carbonara kubernetes payload") can't anchor on the one real term.
735                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    // A node whose TITLE carries more query terms is more specific than one that only matches in
804    // body/summary text (a symbol NAMED for the concept beats one that merely mentions it).
805    // The score boost handles real relevance gaps; this tie-break keeps equal-score ordering
806    // deterministic and specificity-preserving. (Titles are precomputed in the index.)
807    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
852/// Route-rank penalty for PARTIAL BODY-ONLY mentions: a hit with no anchor and ZERO identity
853/// evidence (`matched_identity == 0` — nothing landed in id/title/summary/aliases) that also
854/// fails to cover the whole query is a scatter of body words, not an answer. Scale its route
855/// rank by coverage so anchored/identity-backed hits outrank it. A hit with ANY identity
856/// evidence keeps its full lexical rank even when unanchored: partial identity plus a dominant
857/// lexical score (e.g. a runbook section matching 4 of 6 task words) is honest relevance, and
858/// the confidence band — not the rank — is where its uncertainty is reported.
859fn 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    // Bands recalibrated to the post-IDF/stemming score distribution (measured): clean accuracy is
1017    // 100% down to score 45, garbage tops out ~32 — so strong≥45 is discriminating AND safe.
1018    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    // D4.2: pins for scoring signals the audit flagged with zero direct coverage.
1032
1033    #[test]
1034    fn apply_floor_drops_trailing_fallback_only_when_a_confident_hit_exists() {
1035        // Confident top present → trailing Fallback noise is dropped.
1036        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        // No confident hit → the full ranked list is kept (a weak-but-real answer must survive,
1048        // and garbage must still read as rejected rather than empty).
1049        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        // Runner-up ≥ 0.7 × top → the top is a coin-flip, demote Strong → Ambiguous.
1060        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        // Runner-up below the ratio → the top stays Strong (a clear winner).
1068        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        // skill.x --has_knowledge--> doc.x#page ; the child out-scores the parent on term
1079        // coverage. Dominance lifts the parent to best_child + 1 so the front door ranks first.
1080        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        // D4.5 monotonicity invariant — the one the 2026-07-04 gate incident violated: appending
1104        // an absent (garbage) term to a query dilutes coverage and can only LOWER or keep a hit's
1105        // confidence, never raise it. Checked across a small spread of node shapes.
1106        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        // D4: the ranker params flow through GroundIndex (built with a RankerConfig), NOT the
1169        // environment. A body-heavy node scored with a body-weight-boosted config must produce a
1170        // DIFFERENT lexical score than the frozen default — proving the injected config reaches
1171        // the scorer. (Also the purity guarantee: same graph+query+config → same score.)
1172        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                // Body (summary) carries the query term; a config that boosts the summary field
1179                // weight scores it higher.
1180                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], // 20x the summary-field weight
1195            ..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        // Determinism: same config → same score.
1209        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        // safety: the garbage ceiling (~32) must never reach 'strong'.
1225        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        // A body-only hit (anchor=0.0) with full coverage + decent score → Weak (not Fallback).
1235        // The doc genuinely contains all query terms — it's a real-but-weak answer.
1236        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        // Low score or partial coverage → still Fallback (marginal match)
1241        assert_eq!(band_anchored(10, 0.0, 1.0), Confidence::Fallback);
1242        assert_eq!(band_anchored(30, 0.0, 0.5), Confidence::Fallback);
1243        // An identity-anchored hit keeps its score-based band.
1244        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        // ANY identity evidence exempts a hit from the body-only penalty.
1263        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    /// The garbage-near-node class: a query whose vocabulary is MOSTLY absent from the corpus
1356    /// ("yoga meditation mindfulness" on a task-scheduler brain) must not produce a confident
1357    /// answer just because the two familiar words match a node's name. Matching what you know
1358    /// says nothing about the part of the query you don't. Regression: the id_coverage >= 0.4
1359    /// anchor arm requires known_coverage >= 0.6, so the top hit stays Weak/Fallback and the
1360    /// routing directive rejects instead of answering.
1361    #[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        // Band conservatism on common vocabulary: a RARE query term that names a node should anchor
1633        // it even when common co-terms dilute id_coverage — but the common terms alone must NOT.
1634        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        // "carbonara" names one doc (rare → high IDF); "egg" is shared by many (common → low IDF).
1648        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        // Distinctive name + common co-term: the distinctive term anchors, so the hit is NOT capped
1667        // to the body-only Weak/Fallback floor.
1668        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        // A common term ALONE must not anchor — "egg" names nothing distinctively.
1680        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        // Dogfood regression: "which embedding model should I use" is a verbatim query_example on
1693        // doc.embeddings (strong identity). doc.evals only DEPENDS ON embeddings, so the topic
1694        // terms land in its relation surface — but a relation-context match on a zero-identity
1695        // neighbour must NOT outrank the doc that IS the answer. (Root cause: the ubiquitous verb
1696        // "use" was a relation-intent trigger, firing the +12/term relation boost on a topical
1697        // query and inflating the neighbour above the identity hit.)
1698        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        // The direct fix: the ubiquitous verb "use" is NOT a relation-intent trigger (unlike the
1745        // specific technical verbs), so a topical "which X should I use" query does not fire the
1746        // relation-context boost that caused the inversion.
1747        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}