1use serde_json::Value as Json;
5
6use crate::fts::split_ws;
7
8pub const RRF_K: f64 = 60.0;
10pub const HYBRID_PASS_LIMIT: usize = 200;
12pub const RESOLVE_DEFAULT_LIMIT: usize = 10;
14pub const PREVIEW_WORDS: usize = 12;
16
17pub const TITLE_BOOST: f64 = 1.25;
18pub const HEADING_BOOST: f64 = 1.15;
19pub const PATH_BOOST: f64 = 1.10;
20
21#[must_use]
24pub fn layer_boost(layer: &str) -> Option<f64> {
25 match layer {
26 "canon" => Some(1.3),
27 "working" => Some(1.15),
28 "draft" => Some(0.85),
29 _ => None,
30 }
31}
32
33#[derive(Clone, Copy, Debug, Default, PartialEq)]
35pub struct Boosts {
36 pub title: Option<f64>,
37 pub heading: Option<f64>,
38 pub path: Option<f64>,
39 pub layer: Option<f64>,
40 pub recency: Option<f64>,
42}
43
44impl Boosts {
45 #[must_use]
47 pub fn to_json(&self) -> Json {
48 let mut m = serde_json::Map::new();
49 for (k, v) in [
50 ("title", self.title),
51 ("heading", self.heading),
52 ("path", self.path),
53 ("layer", self.layer),
54 ("recency", self.recency),
55 ] {
56 if let Some(v) = v {
57 m.insert(k.to_owned(), Json::from(v));
58 }
59 }
60 Json::Object(m)
61 }
62}
63
64#[derive(Clone, Debug, Default, PartialEq, Eq)]
66pub struct BoostFacts {
67 pub title: String,
70 pub layer: String,
72 pub path: String,
73 pub headings: Vec<String>,
75}
76
77#[must_use]
79pub fn default_terms(text: Option<&str>) -> Vec<String> {
80 text.map(|t| split_ws(t).map(str::to_owned).collect())
81 .unwrap_or_default()
82}
83
84#[must_use]
86pub fn lower_terms(terms: &[String]) -> Vec<String> {
87 terms
88 .iter()
89 .map(|t| t.to_lowercase())
90 .filter(|t| !t.is_empty())
91 .collect()
92}
93
94#[must_use]
98pub fn property_to_string(value: Option<&Json>) -> String {
99 fn element(v: &Json) -> String {
100 match v {
101 Json::Null => String::new(),
102 Json::Array(items) => items.iter().map(element).collect::<Vec<_>>().join(","),
103 other => scalar(other),
104 }
105 }
106 fn scalar(v: &Json) -> String {
107 match v {
108 Json::Null => String::new(),
109 Json::Bool(b) => b.to_string(),
110 Json::Number(n) => n.as_f64().map_or_else(|| n.to_string(), js_number),
111 Json::String(s) => s.clone(),
112 Json::Array(items) => items.iter().map(element).collect::<Vec<_>>().join(","),
113 Json::Object(_) => "[object Object]".to_owned(),
114 }
115 }
116 value.map_or_else(String::new, scalar)
117}
118
119fn js_number(n: f64) -> String {
122 if n.is_nan() {
123 return "NaN".to_owned();
124 }
125 if n.is_infinite() {
126 return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_owned();
127 }
128 if n == n.trunc() && n.abs() < 1e21 {
129 return format!("{}", n as i128);
130 }
131 format!("{n}")
132}
133
134#[must_use]
136pub fn compute_boosts(facts: &BoostFacts, lower_terms: &[String]) -> Boosts {
137 let mut b = Boosts::default();
138 let title = facts.title.to_lowercase();
139 if !title.is_empty() && lower_terms.iter().any(|t| title.contains(t.as_str())) {
140 b.title = Some(TITLE_BOOST);
141 }
142 if facts.headings.iter().any(|h| {
143 let h = h.to_lowercase();
144 lower_terms.iter().any(|t| h.contains(t.as_str()))
145 }) {
146 b.heading = Some(HEADING_BOOST);
147 }
148 let path = facts.path.to_lowercase();
149 if lower_terms.iter().any(|t| path.contains(t.as_str())) {
150 b.path = Some(PATH_BOOST);
151 }
152 b.layer = layer_boost(&facts.layer);
153 b
154}
155
156#[must_use]
158pub fn rrf_score(fts_rank: Option<usize>, vector_rank: Option<usize>) -> f64 {
159 let term = |r: Option<usize>| r.map_or(0.0, |r| 1.0 / (RRF_K + r as f64));
160 term(fts_rank) + term(vector_rank)
161}
162
163#[must_use]
166pub fn apply_boosts(rrf: f64, boosts: &Boosts) -> f64 {
167 rrf * boosts.title.unwrap_or(1.0)
168 * boosts.heading.unwrap_or(1.0)
169 * boosts.path.unwrap_or(1.0)
170 * boosts.layer.unwrap_or(1.0)
171 * boosts.recency.unwrap_or(1.0)
172}
173
174#[derive(Clone, Debug, PartialEq)]
176pub struct Candidate {
177 pub block_id: String,
178 pub fts_rank: Option<usize>,
180 pub vector_rank: Option<usize>,
182 pub cosine: Option<f64>,
183 pub rrf: f64,
184}
185
186#[must_use]
191pub fn fuse(fts: &[String], vector: &[(String, f64)]) -> Vec<Candidate> {
192 let mut out: Vec<Candidate> = Vec::new();
193 for (i, id) in fts.iter().enumerate() {
194 if out.iter().any(|c| &c.block_id == id) {
195 continue;
196 }
197 out.push(Candidate {
198 block_id: id.clone(),
199 fts_rank: Some(i + 1),
200 vector_rank: None,
201 cosine: None,
202 rrf: 0.0,
203 });
204 }
205 for (i, (id, cos)) in vector.iter().enumerate() {
206 match out.iter_mut().find(|c| &c.block_id == id) {
207 Some(c) => {
208 if c.vector_rank.is_none() {
209 c.vector_rank = Some(i + 1);
210 c.cosine = Some(*cos);
211 }
212 }
213 None => out.push(Candidate {
214 block_id: id.clone(),
215 fts_rank: None,
216 vector_rank: Some(i + 1),
217 cosine: Some(*cos),
218 rrf: 0.0,
219 }),
220 }
221 }
222 for c in &mut out {
223 c.rrf = rrf_score(c.fts_rank, c.vector_rank);
224 }
225 out
226}
227
228#[derive(Clone, Debug, PartialEq)]
230pub struct Evidence {
231 pub fts_rank: Option<usize>,
232 pub vector_rank: Option<usize>,
233 pub cosine: Option<f64>,
234 pub rrf: f64,
235 pub boosts: Boosts,
236}
237
238impl Evidence {
239 #[must_use]
241 pub fn to_json(&self) -> Json {
242 let mut m = serde_json::Map::new();
243 if let Some(r) = self.fts_rank {
244 m.insert("fts_rank".to_owned(), Json::from(r));
245 }
246 if let Some(r) = self.vector_rank {
247 m.insert("vector_rank".to_owned(), Json::from(r));
248 }
249 if let Some(c) = self.cosine {
250 m.insert("cosine".to_owned(), Json::from(c));
251 }
252 m.insert("rrf".to_owned(), Json::from(self.rrf));
253 m.insert("boosts".to_owned(), self.boosts.to_json());
254 Json::Object(m)
255 }
256}
257
258pub fn sort_by_score<T>(hits: &mut [T], score: impl Fn(&T) -> f64, id: impl Fn(&T) -> &str) {
260 hits.sort_by(|a, b| {
261 score(b)
262 .partial_cmp(&score(a))
263 .unwrap_or(std::cmp::Ordering::Equal)
264 .then_with(|| id(a).as_bytes().cmp(id(b).as_bytes()))
265 });
266}
267
268#[must_use]
270pub fn locator(path: &str, block_type: &str, ordinal: i64) -> String {
271 format!("{path}#{block_type}[{ordinal}]")
272}
273
274#[must_use]
277pub fn preview(text: &str, words: usize) -> String {
278 let w: Vec<&str> = split_ws(text).collect();
279 if w.len() <= words {
280 w.join(" ")
281 } else {
282 format!("{}\u{2026}", w[..words].join(" "))
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use serde_json::json;
290
291 fn s(v: &[&str]) -> Vec<String> {
292 v.iter().map(|x| (*x).to_owned()).collect()
293 }
294
295 #[test]
296 fn rrf_arithmetic() {
297 assert_eq!(rrf_score(None, None), 0.0);
298 assert_eq!(rrf_score(Some(1), None), 1.0 / 61.0);
299 assert_eq!(rrf_score(Some(1), Some(1)), 2.0 / 61.0);
300 assert_eq!(rrf_score(Some(3), Some(200)), 1.0 / 63.0 + 1.0 / 260.0);
301 }
302
303 #[test]
304 fn fusion_ranks_first_occurrence() {
305 let fts = s(&["b_1", "b_2", "b_1", "b_3"]);
306 let vec = vec![("b_3".to_owned(), 0.9), ("b_4".to_owned(), 0.5)];
307 let c = fuse(&fts, &vec);
308 assert_eq!(c.len(), 4);
309 assert_eq!(
310 (c[0].block_id.as_str(), c[0].fts_rank, c[0].vector_rank),
311 ("b_1", Some(1), None)
312 );
313 assert_eq!((c[1].block_id.as_str(), c[1].fts_rank), ("b_2", Some(2)));
314 assert_eq!(
315 (
316 c[2].block_id.as_str(),
317 c[2].fts_rank,
318 c[2].vector_rank,
319 c[2].cosine
320 ),
321 ("b_3", Some(4), Some(1), Some(0.9))
322 );
323 assert_eq!(c[2].rrf, 1.0 / 64.0 + 1.0 / 61.0);
324 assert_eq!(
325 (c[3].block_id.as_str(), c[3].fts_rank, c[3].vector_rank),
326 ("b_4", None, Some(2))
327 );
328 assert_eq!(c[3].rrf, 1.0 / 62.0);
329 let c = fuse(&[], &[("b_9".to_owned(), 0.9), ("b_9".to_owned(), 0.1)]);
331 assert_eq!(c.len(), 1);
332 assert_eq!((c[0].vector_rank, c[0].cosine), (Some(1), Some(0.9)));
333 }
334
335 #[test]
336 fn boosts_each_and_product() {
337 let facts = BoostFacts {
338 title: "Guides Index".to_owned(),
339 layer: "canon".to_owned(),
340 path: "guides/onboarding.md".to_owned(),
341 headings: s(&["Setup", "First Steps"]),
342 };
343 let b = compute_boosts(&facts, &lower_terms(&s(&["STEPS"])));
344 assert_eq!(
345 b,
346 Boosts {
347 title: None,
348 heading: Some(1.15),
349 path: None,
350 layer: Some(1.3),
351 recency: None
352 }
353 );
354 let b = compute_boosts(&facts, &lower_terms(&s(&["guides"])));
355 assert_eq!((b.title, b.heading, b.path), (Some(1.25), None, Some(1.1)));
356 assert_eq!(apply_boosts(0.5, &b), 0.5 * 1.25 * 1.0 * 1.1 * 1.3 * 1.0);
357 assert_eq!(apply_boosts(0.5, &Boosts::default()), 0.5);
358 assert_eq!(
359 b.to_json(),
360 json!({"title": 1.25, "path": 1.1, "layer": 1.3})
361 );
362 let facts = BoostFacts {
364 layer: "proposed".to_owned(),
365 ..BoostFacts::default()
366 };
367 assert_eq!(
368 compute_boosts(&facts, &lower_terms(&s(&[""]))),
369 Boosts::default()
370 );
371 assert_eq!(layer_boost("draft"), Some(0.85));
372 assert_eq!(layer_boost("working"), Some(1.15));
373 assert_eq!(layer_boost("weird"), None);
374 }
375
376 #[test]
377 fn terms_and_strings() {
378 assert_eq!(
379 default_terms(Some(" Foo bar\tBAZ ")),
380 s(&["Foo", "bar", "BAZ"])
381 );
382 assert_eq!(default_terms(None), Vec::<String>::new());
383 assert_eq!(lower_terms(&s(&["Foo", "", "É"])), s(&["foo", "é"]));
384 assert_eq!(property_to_string(None), "");
385 assert_eq!(property_to_string(Some(&json!(null))), "");
386 assert_eq!(property_to_string(Some(&json!("T"))), "T");
387 assert_eq!(property_to_string(Some(&json!(3))), "3");
388 assert_eq!(property_to_string(Some(&json!(1.5))), "1.5");
389 assert_eq!(property_to_string(Some(&json!(true))), "true");
390 assert_eq!(
391 property_to_string(Some(&json!(["a", null, 2, ["x", "y"]]))),
392 "a,,2,x,y"
393 );
394 assert_eq!(
395 property_to_string(Some(&json!({"a": 1}))),
396 "[object Object]"
397 );
398 }
399
400 #[test]
401 fn evidence_json_and_sort() {
402 let e = Evidence {
403 fts_rank: Some(2),
404 vector_rank: None,
405 cosine: None,
406 rrf: 1.0 / 62.0,
407 boosts: Boosts::default(),
408 };
409 assert_eq!(
410 e.to_json(),
411 json!({"fts_rank": 2, "rrf": 1.0 / 62.0, "boosts": {}})
412 );
413 let mut hits = vec![("b_2", 0.5), ("b_1", 0.5), ("b_0", 0.7)];
414 sort_by_score(&mut hits, |h| h.1, |h| h.0);
415 assert_eq!(hits, vec![("b_0", 0.7), ("b_1", 0.5), ("b_2", 0.5)]);
416 }
417
418 #[test]
419 fn resolve_shaping() {
420 assert_eq!(locator("a.md", "paragraph", 3), "a.md#paragraph[3]");
421 let twelve = "1 2 3 4 5 6 7 8 9 10 11 12";
422 assert_eq!(preview(twelve, PREVIEW_WORDS), twelve);
423 assert_eq!(
424 preview(&format!("{twelve} 13"), PREVIEW_WORDS),
425 format!("{twelve}…")
426 );
427 assert_eq!(preview(" a \n b ", PREVIEW_WORDS), "a b");
428 assert_eq!(preview("", PREVIEW_WORDS), "");
429 }
430}