Skip to main content

eidos_kernel/retrieval/
scoring.rs

1use std::collections::{BTreeMap, HashMap};
2
3use crate::schema::{
4    Edge, EdgeBasis, Graph, Node, RelationProfile, core_relation_profile, relation,
5};
6use crate::trigram::TrigramIndex;
7
8use super::{terms_of, within_edit1};
9
10/// Precomputed, graph-invariant inputs to grounding — the per-node BM25F token fields and
11/// lowercased titles. Built ONCE per graph via [`GroundIndex::build`]; grounding many queries against
12/// one graph reuses it instead of rebuilding it every call.
13#[derive(Clone)]
14pub struct GroundIndex {
15    pub(super) lc_titles: Vec<String>,
16    /// BM25F prototype: per-node tokenized fields
17    /// [id, title, summary, aliases, query_examples, relations].
18    pub(super) bm25_fields: Vec<[Vec<String>; 6]>,
19    /// Average token count per field across the graph — BM25 length normalization.
20    pub(super) bm25_avglen: [f64; 6],
21    /// The BM25F ranker parameters for this index. Injected at build time; defaults to the frozen
22    /// calibration. `ground_scoped` reads it — the kernel never consults the environment.
23    pub(super) config: RankerConfig,
24    /// Trigram inverted index for fast candidate narrowing.
25    trigram_index: TrigramIndex,
26}
27
28impl GroundIndex {
29    /// Precompute the ground inputs for `graph` with the FROZEN ranker calibration (one O(N) pass).
30    pub fn build(graph: &Graph) -> Self {
31        Self::build_with_config(graph, RankerConfig::default())
32    }
33
34    /// Precompute the ground inputs with an explicit [`RankerConfig`] — the injection seam for the
35    /// dev-only parameter sweep. Production/eval callers use [`GroundIndex::build`] (frozen).
36    pub fn build_with_config(graph: &Graph, config: RankerConfig) -> Self {
37        let relation_surfaces = relation_surfaces(graph);
38        let bm25_fields: Vec<[Vec<String>; 6]> = graph
39            .nodes
40            .iter()
41            .map(|node| bm25f_fields(node, &relation_surfaces))
42            .collect();
43        let n = bm25_fields.len().max(1) as f64;
44        let mut bm25_avglen = [0.0f64; 6];
45        for f in &bm25_fields {
46            for j in 0..6 {
47                bm25_avglen[j] += f[j].len() as f64;
48            }
49        }
50        for a in &mut bm25_avglen {
51            *a = (*a / n).max(1.0);
52        }
53        GroundIndex {
54            lc_titles: graph.nodes.iter().map(|n| n.title.to_lowercase()).collect(),
55            // D0.6: trigram narrowing is dormant (scoring.rs:835-841 in retrieval.rs does not
56            // read it; D3 will re-enable narrowing against the SAME tokenized forms `terms_of`
57            // produces). Skip the (large, redundant) index construction here — costs nothing in
58            // scores (candidates() is never called today) and saves an O(nodes × avg_haystack)
59            // memory+time hit per build. The module + tests are preserved for D3.
60            trigram_index: TrigramIndex::default(),
61            bm25_fields,
62            bm25_avglen,
63            config,
64        }
65    }
66
67    /// Narrow the candidate set using the trigram index.
68    /// Returns `None` if narrowing isn't possible (short terms) — caller falls back to scanning all.
69    /// Returns `Some(indices)` — the node indices that MIGHT match (superset; final scoring decides).
70    pub fn candidates(&self, terms: &[String]) -> Option<Vec<usize>> {
71        self.trigram_index.candidates(terms)
72    }
73}
74
75/// Frozen BM25F field weights `[id, title, summary, aliases, query_examples, relations]`. These
76/// are calibration constants — moving one moves the frozen eval baselines. See
77/// [`crate::calibration`] for the full calibration surface and which test pins each value.
78pub const DEFAULT_BM25F_WEIGHTS: [f64; 6] = [5.0, 8.0, 2.0, 6.0, 4.0, 3.0];
79/// Frozen BM25 term-frequency saturation. Near-optimal for short docs (measured).
80pub const DEFAULT_BM25F_K1: f64 = 1.2;
81/// Frozen BM25 length-normalization. Near-optimal for short docs (measured).
82pub const DEFAULT_BM25F_B: f64 = 0.75;
83
84/// BM25F ranker parameters. INJECTED, never read from the environment: the kernel is pure, so
85/// `Default` is the frozen calibration and any override is passed in by the caller (the dev-only
86/// parameter sweep via `eidos-eval-runner --k1/--b/--weights`). This is what makes "same inputs →
87/// same scores" hold without depending on ambient process state.
88#[derive(Clone, Copy, Debug, PartialEq)]
89pub struct RankerConfig {
90    pub k1: f64,
91    pub b: f64,
92    pub weights: [f64; 6],
93}
94
95impl Default for RankerConfig {
96    fn default() -> Self {
97        Self {
98            k1: DEFAULT_BM25F_K1,
99            b: DEFAULT_BM25F_B,
100            weights: DEFAULT_BM25F_WEIGHTS,
101        }
102    }
103}
104
105/// Back-compat alias for the internal scorer name.
106pub(super) type Bm25fParams = RankerConfig;
107
108pub(super) struct Bm25fScorer<'a> {
109    pub(super) terms: &'a [String],
110    pub(super) df: &'a HashMap<&'a str, usize>,
111    pub(super) n: usize,
112    pub(super) avglen: &'a [f64; 6],
113    pub(super) params: Bm25fParams,
114}
115
116#[derive(Clone, Copy)]
117pub(super) struct Bm25fNodeShape {
118    pub(super) is_code: bool,
119    pub(super) is_module: bool,
120}
121
122fn bm25f_fields(
123    node: &Node,
124    relation_surfaces: &BTreeMap<String, Vec<String>>,
125) -> [Vec<String>; 6] {
126    [
127        terms_of(&node.id.replace(['.', '-', '_', '/'], " ").to_lowercase()),
128        terms_of(&node.title.to_lowercase()),
129        terms_of(&node.summary.to_lowercase()),
130        terms_of(&node.aliases.join(" ").to_lowercase()),
131        terms_of(&node.query_examples.join(" ").to_lowercase()),
132        terms_of(
133            &relation_surfaces
134                .get(&node.id)
135                .map(|surfaces| surfaces.join(" "))
136                .unwrap_or_default()
137                .to_lowercase(),
138        ),
139    ]
140}
141
142pub(super) fn bm25f_score(
143    fields: &[Vec<String>; 6],
144    scorer: &Bm25fScorer<'_>,
145    shape: Bm25fNodeShape,
146) -> (f64, f64, usize, usize, usize, f64) {
147    let mut score = 0.0;
148    let mut identity = 0.0;
149    let mut matched = 0usize;
150    let mut matched_identity = 0usize;
151    let mut matched_name = 0usize;
152    // Highest IDF among query terms that EXACTLY name this node (exact hit in an id/title/alias
153    // field). A high value means a RARE, distinctive term names the node — "carbonara" names one
154    // doc; "egg" names five. Lets confidence anchor on a distinctive name even when common co-terms
155    // dilute id_coverage below the usual bar.
156    let mut max_exact_name_idf = 0.0_f64;
157    for t in scorer.terms {
158        let dft = *scorer.df.get(t.as_str()).unwrap_or(&0);
159        if dft == 0 {
160            continue;
161        }
162        let idf = ((scorer.n as f64 - dft as f64 + 0.5) / (dft as f64 + 0.5) + 1.0).ln();
163        let mut wtf = 0.0;
164        let mut id_wtf = 0.0;
165        let mut name_hit = false;
166        let mut exact_name_hit = false;
167        for (j, field) in fields.iter().enumerate() {
168            let exact_tf = field.iter().filter(|x| x.as_str() == t.as_str()).count() as f64;
169            let mut tf = exact_tf;
170            if tf == 0.0 && t.len() >= 5 {
171                tf = 0.5
172                    * field
173                        .iter()
174                        .filter(|x| x.len() >= 5 && within_edit1(t.as_bytes(), x.as_bytes()))
175                        .count() as f64;
176            }
177            if tf > 0.0 {
178                let norm = 1.0 - scorer.params.b
179                    + scorer.params.b * (field.len() as f64) / scorer.avglen[j];
180                let c = scorer.params.weights[j] * tf / norm;
181                wtf += c;
182                if j == 0 || j == 1 || (!shape.is_module && j == 2) || (!shape.is_code && j == 3) {
183                    id_wtf += c;
184                    name_hit = true;
185                }
186                // NAME-GRADE evidence: an exact (non-fuzzy) token hit in a NAME field — id,
187                // title, or (for docs) aliases. Summary hits describe; they do not name. A
188                // fuzzy edit-1 hit ("scheduler"~"schedule") is a typo hypothesis, not a name
189                // claim. This is what lets id_coverage anchor confidence without letting a
190                // description scatter or near-miss impersonate identity.
191                if exact_tf > 0.0 && (j == 0 || j == 1 || (!shape.is_code && j == 3)) {
192                    exact_name_hit = true;
193                }
194            }
195        }
196        if wtf > 0.0 {
197            matched += 1;
198            score += idf * wtf / (scorer.params.k1 + wtf);
199            if id_wtf > 0.0 {
200                identity += idf * id_wtf / (scorer.params.k1 + id_wtf);
201            }
202            if name_hit {
203                matched_identity += 1;
204            }
205            if exact_name_hit {
206                matched_name += 1;
207                max_exact_name_idf = max_exact_name_idf.max(idf);
208            }
209        }
210    }
211    (
212        score,
213        identity,
214        matched,
215        matched_identity,
216        matched_name,
217        max_exact_name_idf,
218    )
219}
220
221pub(super) fn bm25f_df<'a>(
222    terms: &'a [String],
223    fields: &[[Vec<String>; 6]],
224) -> HashMap<&'a str, usize> {
225    terms
226        .iter()
227        .map(|t| {
228            let exact = fields
229                .iter()
230                .filter(|f| f.iter().any(|fl| fl.iter().any(|tok| tok == t)))
231                .count();
232            let c = if exact > 0 || t.len() < 5 {
233                exact
234            } else {
235                fields
236                    .iter()
237                    .filter(|f| {
238                        f.iter().any(|fl| {
239                            fl.iter().any(|tok| {
240                                tok.len() >= 5 && within_edit1(t.as_bytes(), tok.as_bytes())
241                            })
242                        })
243                    })
244                    .count()
245            };
246            (t.as_str(), c)
247        })
248        .collect()
249}
250
251fn relation_surfaces(graph: &Graph) -> BTreeMap<String, Vec<String>> {
252    let nodes = graph
253        .nodes
254        .iter()
255        .map(|node| (node.id.as_str(), node))
256        .collect::<HashMap<_, _>>();
257    let mut surfaces: BTreeMap<String, Vec<String>> = BTreeMap::new();
258    for edge in &graph.edges {
259        if !relation_is_searchable(edge, &graph.relation_profiles) {
260            continue;
261        }
262        let Some(from) = nodes.get(edge.from.as_str()) else {
263            continue;
264        };
265        let Some(to) = nodes.get(edge.to.as_str()) else {
266            continue;
267        };
268        push_relation_surface(
269            &mut surfaces,
270            &edge.from,
271            relation_phrase(&edge.relation, &graph.relation_profiles),
272            to,
273        );
274        push_relation_surface(
275            &mut surfaces,
276            &edge.to,
277            reverse_relation_phrase(&edge.relation, &graph.relation_profiles),
278            from,
279        );
280    }
281    surfaces
282}
283
284fn relation_is_searchable(
285    edge: &Edge,
286    profiles: &std::collections::BTreeMap<String, RelationProfile>,
287) -> bool {
288    if edge.basis != EdgeBasis::Resolved {
289        return false;
290    }
291    // Traversal-only call-graph edges power callers/impact but must not enrich grounding surfaces.
292    if edge.evidence == crate::schema::TRAVERSAL_ONLY_EVIDENCE {
293        return false;
294    }
295    profiles
296        .get(&edge.relation)
297        .cloned()
298        .or_else(|| core_relation_profile(&edge.relation))
299        .is_none_or(|profile| profile.searchable)
300}
301
302fn push_relation_surface(
303    surfaces: &mut BTreeMap<String, Vec<String>>,
304    node_id: &str,
305    relation_phrase: String,
306    other: &Node,
307) {
308    let mut surface = format!("{relation_phrase} {} {}", other.id, other.title);
309    for alias in &other.aliases {
310        surface.push(' ');
311        surface.push_str(alias);
312    }
313    let entry = surfaces.entry(node_id.to_string()).or_default();
314    if !entry.iter().any(|existing| existing == &surface) {
315        entry.push(surface);
316    }
317}
318
319pub(super) fn relation_phrase(
320    relation: &str,
321    profiles: &std::collections::BTreeMap<String, RelationProfile>,
322) -> String {
323    profiles
324        .get(relation)
325        .cloned()
326        .or_else(|| core_relation_profile(relation))
327        .map_or_else(
328            || relation.replace('_', " "),
329            |profile| profile.forward_phrase.clone(),
330        )
331}
332
333pub(super) fn reverse_relation_phrase(
334    relation: &str,
335    profiles: &std::collections::BTreeMap<String, RelationProfile>,
336) -> String {
337    if let Some(profile) = profiles
338        .get(relation)
339        .cloned()
340        .or_else(|| core_relation_profile(relation))
341    {
342        return profile.reverse_phrase.clone();
343    }
344    match relation {
345        relation::LINKS_TO => "linked from".to_string(),
346        relation::REFERENCES => "referenced by".to_string(),
347        relation::IMPLEMENTS => "implemented by".to_string(),
348        "depends_on" => "required by".to_string(),
349        "produces" => "produced by".to_string(),
350        "consumes" => "consumed by".to_string(),
351        "uses_tool" => "used by".to_string(),
352        "requires_contract" => "required contract for".to_string(),
353        "dispatches" => "dispatched by".to_string(),
354        other => format!("{} by", other.replace('_', " ")),
355    }
356}
357
358// ─── D0.6 — trigram narrowing dormant, scores unaffected ───────────────
359
360#[cfg(test)]
361mod dormant_trigram_tests {
362    use super::*;
363    use crate::retrieval::ground_with;
364    use crate::schema::Node;
365
366    fn mk_node(id: &str, title: &str, summary: &str) -> Node {
367        Node {
368            id: id.into(),
369            kind: crate::schema::Kind::Doc,
370            partition: None,
371            subkind: None,
372            title: title.into(),
373            summary: summary.into(),
374            aliases: Vec::new(),
375            tags: Vec::new(),
376            query_examples: Vec::new(),
377            source_files: Vec::new(),
378            span: None,
379        }
380    }
381
382    fn two_node_graph() -> Graph {
383        let mut g = Graph::default();
384        g.nodes.push(mk_node("doc.a", "A", "alpha"));
385        g.nodes.push(mk_node("doc.b", "B", "beta"));
386        g
387    }
388
389    #[test]
390    fn ground_index_does_not_construct_populated_trigram() {
391        // D0.6: dormant state is observable. `trigram_index` must be the empty default,
392        // not a populated one — guarantees scoring.rs:835-841's full scan isn't shadowed
393        // by data we never read. The trigram index is populated by walking per-node
394        // haystacks; on an empty index every 3+-char term returns `Some(vec![])`
395        // (trigram doesn't exist anywhere → no candidates). A populated index would return
396        // `Some(non_empty)` for matching terms. Pin the contract: the index we build
397        // gives back the empty-candidate result.
398        let g = two_node_graph();
399        let idx = GroundIndex::build(&g);
400        let result = idx.candidates(&[String::from("alpha")]);
401        assert!(
402            matches!(result, Some(ref v) if v.is_empty()),
403            "GroundIndex::build must leave trigram_index empty (D0.6 dormant state); \
404             candidates() returned {result:?} — non-empty would mean trigram narrowing is \
405             silently providing data retrieval.rs:835-841 never reads"
406        );
407        // The same is true for any 3+-char term: no postings means no narrowing,
408        // so the scoring loop never depends on this index.
409        let result2 = idx.candidates(&[String::from("beta")]);
410        assert!(
411            matches!(result2, Some(ref v) if v.is_empty()),
412            "dormant trigram index must return Some(vec![]) for any term (no postings); got {result2:?}"
413        );
414    }
415
416    #[test]
417    fn ground_score_is_identical_to_dormant_trigram_world() {
418        // The actual byte-identical-pinning property: today's dormant state must not have
419        // changed any score. Build a graph + index, ground a query, capture the result.
420        // The frozen eval baselines will catch any deviation; this test is the local pin.
421        let g = two_node_graph();
422        let idx = GroundIndex::build(&g);
423        let hits = ground_with(&g, &idx, "alpha", 10);
424        assert!(
425            !hits.is_empty(),
426            "ground must still return at least one hit when the index is dormant"
427        );
428        // The first hit should be the alpha-node; band is unambiguous-or-better because
429        // the query is a single short term.
430        assert_eq!(hits[0].id, "doc.a");
431    }
432}