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#[derive(Clone)]
14pub struct GroundIndex {
15 pub(super) lc_titles: Vec<String>,
16 pub(super) bm25_fields: Vec<[Vec<String>; 6]>,
19 pub(super) bm25_avglen: [f64; 6],
21 pub(super) config: RankerConfig,
24 trigram_index: TrigramIndex,
26}
27
28impl GroundIndex {
29 pub fn build(graph: &Graph) -> Self {
31 Self::build_with_config(graph, RankerConfig::default())
32 }
33
34 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 trigram_index: TrigramIndex::default(),
61 bm25_fields,
62 bm25_avglen,
63 config,
64 }
65 }
66
67 pub fn candidates(&self, terms: &[String]) -> Option<Vec<usize>> {
71 self.trigram_index.candidates(terms)
72 }
73}
74
75pub const DEFAULT_BM25F_WEIGHTS: [f64; 6] = [5.0, 8.0, 2.0, 6.0, 4.0, 3.0];
79pub const DEFAULT_BM25F_K1: f64 = 1.2;
81pub const DEFAULT_BM25F_B: f64 = 0.75;
83
84#[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
105pub(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 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 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 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#[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 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 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 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 assert_eq!(hits[0].id, "doc.a");
431 }
432}