1use std::collections::{HashMap, HashSet};
13
14use super::store::SearchHit;
15
16pub const RRF_K_DEFAULT: f64 = 20.0;
20
21pub fn rrf_fuse(lists: Vec<Vec<SearchHit>>, limit: usize, k: f64) -> Vec<SearchHit> {
23 let k = if k == 0.0 { RRF_K_DEFAULT } else { k };
24 let mut scores: HashMap<String, f64> = HashMap::new();
25 let mut meta: HashMap<String, SearchHit> = HashMap::new();
26 let num = lists.len() as f64;
27 for list in lists {
28 for (rank, hit) in list.into_iter().enumerate() {
29 *scores.entry(hit.slug.clone()).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
30 meta.entry(hit.slug.clone()).or_insert(hit);
31 }
32 }
33 if scores.is_empty() {
34 return vec![];
35 }
36 let max = num / (k + 1.0);
37 let mut out: Vec<SearchHit> = scores
38 .into_iter()
39 .map(|(slug, s)| {
40 let m = meta.remove(&slug).unwrap();
41 let norm = if max > 0.0 { (s / max).min(1.0) } else { 0.0 };
42 SearchHit { slug, score: norm, excerpt: m.excerpt, source: "fused".into() }
43 })
44 .collect();
45 out.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
46 if out.len() > limit {
47 out.truncate(limit);
48 }
49 out
50}
51
52pub fn rsf_fuse(lists: Vec<Vec<SearchHit>>, limit: usize, weights: Option<Vec<f64>>) -> Vec<SearchHit> {
54 let n = lists.len();
55 let w = weights.unwrap_or_else(|| vec![if n == 0 { 0.0 } else { 1.0 / n as f64 }; n]);
56 if w.len() != n {
57 return rsf_fuse(lists, limit, Some(vec![if n == 0 { 0.0 } else { 1.0 / n as f64 }; n]));
59 }
60 let mut scores: HashMap<String, f64> = HashMap::new();
61 let mut meta: HashMap<String, SearchHit> = HashMap::new();
62 for (i, list) in lists.into_iter().enumerate() {
63 if list.is_empty() {
64 continue;
65 }
66 let mut lo = f64::INFINITY;
67 let mut hi = f64::NEG_INFINITY;
68 for h in &list {
69 if h.score < lo { lo = h.score; }
70 if h.score > hi { hi = h.score; }
71 }
72 let span = hi - lo;
73 for h in list {
74 let norm = if span > 0.0 { (h.score - lo) / span } else { 1.0 };
75 *scores.entry(h.slug.clone()).or_insert(0.0) += w[i] * norm;
76 meta.entry(h.slug.clone()).or_insert(h);
77 }
78 }
79 let mut out: Vec<SearchHit> = scores
80 .into_iter()
81 .map(|(slug, s)| {
82 let m = meta.remove(&slug).unwrap();
83 SearchHit { slug, score: s, excerpt: m.excerpt, source: "fused".into() }
84 })
85 .collect();
86 out.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
87 if out.len() > limit {
88 out.truncate(limit);
89 }
90 out
91}
92
93#[derive(Debug, Clone, Copy)]
95pub struct QueryCharacteristics {
96 pub token_count: usize,
97 pub is_phrase: bool,
98 pub is_boolean: bool,
99}
100
101pub fn characterize(query: &str) -> QueryCharacteristics {
103 let t = query.trim();
104 let is_phrase = (t.starts_with('"') && t.ends_with('"') && t.len() > 2)
105 || (t.starts_with('\'') && t.ends_with('\'') && t.len() > 2);
106 let mut is_boolean = false;
107 for kw in ["AND", "OR", "NOT"] {
108 for m in t.match_indices(kw) {
110 let before_ok = m.0 == 0 || !t.as_bytes()[m.0 - 1].is_ascii_alphanumeric();
111 let after_idx = m.0 + kw.len();
112 let after_ok = after_idx == t.len()
113 || !t.as_bytes()[after_idx].is_ascii_alphanumeric();
114 if before_ok && after_ok {
115 is_boolean = true;
116 break;
117 }
118 }
119 if is_boolean { break; }
120 }
121 if !is_boolean {
122 let mut prev_ws = false;
124 for c in t.chars() {
125 if c == '-' && prev_ws { is_boolean = true; break; }
126 prev_ws = c.is_whitespace();
127 }
128 }
129 let token_count = t.split_whitespace().count();
130 QueryCharacteristics { token_count, is_phrase, is_boolean }
131}
132
133pub fn select_rrf_k(q: QueryCharacteristics) -> i32 {
135 if q.is_phrase { return 10; }
136 if q.is_boolean { return 15; }
137 if q.token_count <= 2 { return 15; }
138 if q.token_count >= 10 { return 40; }
139 20
140}
141
142#[derive(Debug, Clone, Copy)]
144pub struct FusionWeights { pub fts: f64, pub semantic: f64 }
145
146pub fn select_weights(q: QueryCharacteristics) -> FusionWeights {
148 if q.is_phrase { return FusionWeights { fts: 0.8, semantic: 0.2 }; }
149 if q.is_boolean { return FusionWeights { fts: 0.7, semantic: 0.3 }; }
150 if q.token_count <= 2 { return FusionWeights { fts: 0.65, semantic: 0.35 }; }
151 if q.token_count >= 10 { return FusionWeights { fts: 0.3, semantic: 0.7 }; }
152 FusionWeights { fts: 0.5, semantic: 0.5 }
153}
154
155pub fn cosine(a: &[f64], b: &[f64]) -> f64 {
159 if a.len() != b.len() { return 0.0; }
160 let mut dot = 0.0;
161 let mut na = 0.0;
162 let mut nb = 0.0;
163 for i in 0..a.len() {
164 dot += a[i] * b[i];
165 na += a[i] * a[i];
166 nb += b[i] * b[i];
167 }
168 let d = na.sqrt() * nb.sqrt();
169 if d == 0.0 { 0.0 } else { dot / d }
170}
171
172#[derive(Debug, Clone)]
174pub struct MmrInput {
175 pub hit: SearchHit,
176 pub embedding: Option<Vec<f64>>,
177}
178
179pub fn mmr_rerank(hits: Vec<MmrInput>, lambda: f64, limit: usize) -> Vec<MmrInput> {
181 let limit = if limit == 0 { hits.len() } else { limit };
182 let (mut embedded, orphans): (Vec<_>, Vec<_>) =
183 hits.into_iter().partition(|h| h.embedding.as_ref().map(|e| !e.is_empty()).unwrap_or(false));
184 let mut selected: Vec<MmrInput> = Vec::with_capacity(limit);
185 while selected.len() < limit && !embedded.is_empty() {
186 let mut best_idx: Option<usize> = None;
187 let mut best_score = f64::NEG_INFINITY;
188 for (i, c) in embedded.iter().enumerate() {
189 let rel = c.hit.score;
190 let mut max_sim = 0.0_f64;
191 for s in &selected {
192 let sim = cosine(
193 c.embedding.as_ref().unwrap(),
194 s.embedding.as_ref().unwrap(),
195 );
196 if sim > max_sim { max_sim = sim; }
197 }
198 let mmr = lambda * rel - (1.0 - lambda) * max_sim;
199 if mmr > best_score {
200 best_score = mmr;
201 best_idx = Some(i);
202 }
203 }
204 match best_idx {
205 Some(i) => selected.push(embedded.remove(i)),
206 None => break,
207 }
208 }
209 for o in orphans {
210 if selected.len() >= limit { break; }
211 selected.push(o);
212 }
213 selected
214}
215
216fn chain_of(slug: &str) -> String {
219 let mut s = slug.to_string();
220 if let Some(idx) = s.rfind("#chunk-") {
222 if s[idx + 7..].chars().all(|c| c.is_ascii_digit()) {
223 s.truncate(idx);
224 }
225 }
226 if let Some(idx) = s.rfind("::") {
228 if s[idx + 2..].chars().all(|c| c.is_ascii_digit()) {
229 s.truncate(idx);
230 }
231 }
232 s
233}
234
235pub fn dedup_hits(hits: Vec<SearchHit>, per_chain: usize) -> Vec<SearchHit> {
237 let per_chain = if per_chain == 0 { 1 } else { per_chain };
238 let mut buckets: HashMap<String, Vec<SearchHit>> = HashMap::new();
239 for h in hits {
240 buckets.entry(chain_of(&h.slug)).or_default().push(h);
241 }
242 let mut out = Vec::new();
243 for mut lst in buckets.into_values() {
244 lst.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
245 out.extend(lst.into_iter().take(per_chain));
246 }
247 out.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
248 out
249}
250
251fn is_cjk(cp: u32) -> bool {
254 (0x4E00..=0x9FFF).contains(&cp)
255 || (0x3400..=0x4DBF).contains(&cp)
256 || (0x3040..=0x30FF).contains(&cp)
257 || (0xAC00..=0xD7AF).contains(&cp)
258}
259
260fn is_emoji(cp: u32) -> bool {
261 (0x2600..=0x27BF).contains(&cp) || (0x1F300..=0x1FAFF).contains(&cp)
262}
263
264pub fn has_cjk(s: &str) -> bool { s.chars().any(|c| is_cjk(c as u32)) }
266
267pub fn has_emoji(s: &str) -> bool { s.chars().any(|c| is_emoji(c as u32)) }
269
270#[derive(Debug, Clone)]
272pub struct ScriptReport {
273 pub primary: String,
274 pub fractions: HashMap<String, f64>,
275 pub has_cjk: bool,
276 pub has_emoji: bool,
277}
278
279pub fn detect_script(s: &str) -> ScriptReport {
281 let keys = ["latin", "cjk", "emoji", "cyrillic", "arabic", "hebrew", "greek", "devanagari", "other"];
282 let mut counts: HashMap<String, usize> = keys.iter().map(|k| (k.to_string(), 0)).collect();
283 let mut total = 0usize;
284 for c in s.chars() {
285 let cp = c as u32;
286 if (0x0030..=0x0039).contains(&cp) { continue; }
287 if let Some(k) = classify_cp(cp) {
288 *counts.entry(k.to_string()).or_insert(0) += 1;
289 total += 1;
290 }
291 }
292 let mut primary = "other".to_string();
293 let mut max = 0usize;
294 for &k in &keys {
295 if counts[k] > max {
296 max = counts[k];
297 primary = k.to_string();
298 }
299 }
300 let mut fractions: HashMap<String, f64> = HashMap::new();
301 for &k in &keys {
302 let v = counts[k];
303 fractions.insert(k.to_string(), if total > 0 { v as f64 / total as f64 } else { 0.0 });
304 }
305 let has_cjk = counts["cjk"] > 0;
306 let has_emoji = counts["emoji"] > 0;
307 ScriptReport { primary, fractions, has_cjk, has_emoji }
308}
309
310fn classify_cp(cp: u32) -> Option<&'static str> {
311 if is_cjk(cp) { return Some("cjk"); }
312 if is_emoji(cp) { return Some("emoji"); }
313 if (0x0041..=0x005A).contains(&cp) || (0x0061..=0x007A).contains(&cp) || (0x00C0..=0x024F).contains(&cp) {
314 return Some("latin");
315 }
316 if (0x0370..=0x03FF).contains(&cp) { return Some("greek"); }
317 if (0x0400..=0x04FF).contains(&cp) { return Some("cyrillic"); }
318 if (0x0590..=0x05FF).contains(&cp) { return Some("hebrew"); }
319 if (0x0600..=0x06FF).contains(&cp) { return Some("arabic"); }
320 if (0x0900..=0x097F).contains(&cp) { return Some("devanagari"); }
321 if cp <= 0x002F
322 || (0x003A..=0x0040).contains(&cp)
323 || (0x005B..=0x0060).contains(&cp)
324 || (0x007B..=0x007E).contains(&cp)
325 {
326 return None;
327 }
328 Some("other")
329}
330
331pub fn cjk_bigrams(text: &str) -> Vec<String> {
335 let mut out = Vec::new();
336 let mut cjk_buf = String::new();
337 let mut latin_buf = String::new();
338 let flush_cjk = |buf: &mut String, out: &mut Vec<String>| {
339 if buf.is_empty() { return; }
340 let runes: Vec<char> = buf.chars().collect();
341 if runes.len() == 1 {
342 out.push(runes[0].to_string());
343 } else {
344 for i in 0..runes.len() - 1 {
345 out.push(runes[i..i + 2].iter().collect::<String>());
346 }
347 }
348 buf.clear();
349 };
350 let flush_latin = |buf: &mut String, out: &mut Vec<String>| {
351 if !buf.is_empty() {
352 out.push(buf.clone());
353 buf.clear();
354 }
355 };
356 for c in text.chars() {
357 if is_cjk(c as u32) {
358 flush_latin(&mut latin_buf, &mut out);
359 cjk_buf.push(c);
360 } else if c.is_whitespace() {
361 flush_cjk(&mut cjk_buf, &mut out);
362 flush_latin(&mut latin_buf, &mut out);
363 } else {
364 flush_cjk(&mut cjk_buf, &mut out);
365 latin_buf.push(c);
366 }
367 }
368 flush_cjk(&mut cjk_buf, &mut out);
369 flush_latin(&mut latin_buf, &mut out);
370 out
371}
372
373pub fn emoji_trigrams(text: &str) -> Vec<String> {
375 let chars: Vec<char> = text.chars().collect();
376 let mut out = Vec::new();
377 for i in 0..chars.len() {
378 if !is_emoji(chars[i] as u32) { continue; }
379 let mut s = String::new();
380 s.push(chars[i]);
381 if i + 1 < chars.len() { s.push(chars[i + 1]); }
382 if i + 2 < chars.len() { s.push(chars[i + 2]); }
383 out.push(s);
384 }
385 out
386}
387
388#[derive(Debug, Clone, Default, PartialEq, Eq)]
390pub struct ParsedQuery {
391 pub required: Vec<String>,
392 pub excluded: Vec<String>,
393 pub optional: Vec<Vec<String>>,
394 pub phrases: Vec<String>,
395}
396
397pub fn parse_websearch(query: &str) -> ParsedQuery {
399 let tokens = tokenize_with_phrases(query);
400 let mut out = ParsedQuery::default();
401 let mut i = 0;
402 while i < tokens.len() {
403 let (kind, val) = &tokens[i];
404 if kind == "phrase" {
405 out.phrases.push(val.clone());
406 out.required.push(val.clone());
407 i += 1;
408 continue;
409 }
410 if i + 1 < tokens.len() && tokens[i + 1].0 == "word" && tokens[i + 1].1 == "OR" {
411 let mut group = vec![val.clone()];
412 let mut j = i + 1;
413 while j + 1 < tokens.len() && tokens[j].0 == "word" && tokens[j].1 == "OR" {
414 group.push(tokens[j + 1].1.clone());
415 j += 2;
416 }
417 out.optional.push(group);
418 i = j;
419 continue;
420 }
421 if val.starts_with('-') && val.len() > 1 {
422 out.excluded.push(val[1..].to_string());
423 i += 1;
424 continue;
425 }
426 out.required.push(val.clone());
427 i += 1;
428 }
429 out
430}
431
432fn tokenize_with_phrases(q: &str) -> Vec<(String, String)> {
433 let mut out = Vec::new();
434 let chars: Vec<char> = q.chars().collect();
435 let mut i = 0;
436 while i < chars.len() {
437 if chars[i].is_whitespace() { i += 1; continue; }
438 if chars[i] == '"' {
439 let mut j = i + 1;
440 while j < chars.len() && chars[j] != '"' { j += 1; }
441 out.push(("phrase".to_string(), chars[i + 1..j].iter().collect()));
442 i = if j < chars.len() { j + 1 } else { j };
443 continue;
444 }
445 let mut j = i;
446 while j < chars.len() && !chars[j].is_whitespace() { j += 1; }
447 out.push(("word".to_string(), chars[i..j].iter().collect()));
448 i = j;
449 }
450 out
451}
452
453pub fn to_fts5_match(p: &ParsedQuery) -> String {
455 let mut parts: Vec<String> = Vec::new();
456 for r in &p.required { parts.push(quote_fts5(r)); }
457 for group in &p.optional {
458 let alts: Vec<String> = group.iter().map(|g| quote_fts5(g)).collect();
459 parts.push(format!("({})", alts.join(" OR ")));
460 }
461 let mut s = parts.join(" AND ");
462 for e in &p.excluded {
463 s.push_str(&format!(" NOT {}", quote_fts5(e)));
464 }
465 s.trim().to_string()
466}
467
468fn quote_fts5(term: &str) -> String {
469 let simple = !term.is_empty() && term.chars().all(|c| c.is_alphanumeric() || c == '_');
470 if !simple || term.contains(' ') {
471 let escaped = term.replace('"', "\"\"");
472 format!("\"{}\"", escaped)
473 } else {
474 term.to_string()
475 }
476}
477
478#[derive(Debug, Clone)]
482pub struct EmbeddingModel {
483 pub slug: String,
484 pub dim: usize,
485 pub mrl_dims: Vec<usize>,
486 pub prefix_query: Option<String>,
487 pub prefix_passage: Option<String>,
488 pub family: Option<String>,
489}
490
491fn embed_registry() -> &'static std::sync::Mutex<HashMap<String, EmbeddingModel>> {
492 use std::sync::OnceLock;
493 static REG: OnceLock<std::sync::Mutex<HashMap<String, EmbeddingModel>>> = OnceLock::new();
494 REG.get_or_init(|| {
495 let mut m = HashMap::new();
496 m.insert("ollama:nomic-embed-text".into(), EmbeddingModel {
497 slug: "ollama:nomic-embed-text".into(), dim: 768,
498 mrl_dims: vec![128, 256, 512, 768],
499 prefix_query: None, prefix_passage: None,
500 family: Some("nomic".into()),
501 });
502 m.insert("intfloat/e5-large-v2".into(), EmbeddingModel {
503 slug: "intfloat/e5-large-v2".into(), dim: 1024,
504 mrl_dims: vec![],
505 prefix_query: Some("query: ".into()), prefix_passage: Some("passage: ".into()),
506 family: Some("e5".into()),
507 });
508 m.insert("openai:text-embedding-3-small".into(), EmbeddingModel {
509 slug: "openai:text-embedding-3-small".into(), dim: 1536,
510 mrl_dims: vec![256, 512, 768, 1024, 1536],
511 prefix_query: None, prefix_passage: None,
512 family: Some("openai".into()),
513 });
514 m.insert("openai:text-embedding-3-large".into(), EmbeddingModel {
515 slug: "openai:text-embedding-3-large".into(), dim: 3072,
516 mrl_dims: vec![256, 512, 1024, 2048, 3072],
517 prefix_query: None, prefix_passage: None,
518 family: Some("openai".into()),
519 });
520 std::sync::Mutex::new(m)
521 })
522}
523
524pub fn register_embedding_model(m: EmbeddingModel) {
526 embed_registry().lock().unwrap().insert(m.slug.clone(), m);
527}
528
529pub fn get_embedding_model(slug: &str) -> Option<EmbeddingModel> {
531 embed_registry().lock().unwrap().get(slug).cloned()
532}
533
534pub fn list_embedding_models() -> Vec<EmbeddingModel> {
536 embed_registry().lock().unwrap().values().cloned().collect()
537}
538
539pub fn prefix_for(m: &EmbeddingModel, task: &str, text: &str) -> String {
541 if task == "symmetric" || (m.prefix_query.is_none() && m.prefix_passage.is_none()) {
542 return text.to_string();
543 }
544 match task {
545 "query" => format!("{}{}", m.prefix_query.clone().unwrap_or_default(), text),
546 _ => format!("{}{}", m.prefix_passage.clone().unwrap_or_default(), text),
547 }
548}
549
550pub fn l2_normalize(v: &mut [f64]) {
552 let mut s = 0.0_f64;
553 for x in v.iter() { s += x * x; }
554 let n = s.sqrt();
555 if n == 0.0 { return; }
556 for x in v.iter_mut() { *x /= n; }
557}
558
559pub fn mrl_truncate(embedding: &[f64], target_dim: usize) -> Vec<f64> {
561 assert!(target_dim > 0, "target_dim must be positive");
562 let mut copy = if target_dim >= embedding.len() {
563 embedding.to_vec()
564 } else {
565 embedding[..target_dim].to_vec()
566 };
567 l2_normalize(&mut copy);
568 copy
569}
570
571pub fn coarse_dim(m: &EmbeddingModel) -> usize {
573 if m.mrl_dims.is_empty() { return m.dim; }
574 let target = (m.dim as f64) / 8.0;
575 for &d in &m.mrl_dims {
576 if (d as f64) >= target { return d; }
577 }
578 *m.mrl_dims.last().unwrap_or(&m.dim)
579}
580
581pub fn v7_floor(epoch_ms: i64) -> String { v7(epoch_ms, false) }
585
586pub fn v7_ceiling(epoch_ms: i64) -> String { v7(epoch_ms, true) }
588
589fn v7(epoch_ms: i64, ceiling: bool) -> String {
590 let ts = if epoch_ms < 0 { 0u64 } else { epoch_ms as u64 };
591 let hex = format!("{:012x}", ts);
592 let hex = &hex[hex.len() - 12..];
593 let (th, tl) = (&hex[..8], &hex[8..12]);
594 if ceiling {
595 format!("{}-{}-7fff-bfff-ffffffffffff", th, tl)
596 } else {
597 format!("{}-{}-7000-8000-000000000000", th, tl)
598 }
599}
600
601#[derive(Debug, Clone)]
604pub struct CaptionSegment {
605 pub start_secs: f64,
606 pub end_secs: f64,
607 pub text: String,
608 pub speaker: Option<String>,
609}
610
611pub fn render_vtt(segs: &[CaptionSegment]) -> String {
613 let mut s = String::from("WEBVTT\n\n");
614 for (i, sg) in segs.iter().enumerate() {
615 s.push_str(&format!("{}\n", i + 1));
616 s.push_str(&format!("{} --> {}\n", fmt_time(sg.start_secs, '.'), fmt_time(sg.end_secs, '.')));
617 match &sg.speaker {
618 Some(sp) => s.push_str(&format!("<v {}>{}</v>\n\n", sp, sg.text)),
619 None => s.push_str(&format!("{}\n\n", sg.text)),
620 }
621 }
622 s
623}
624
625pub fn render_srt(segs: &[CaptionSegment]) -> String {
627 let mut s = String::new();
628 for (i, sg) in segs.iter().enumerate() {
629 s.push_str(&format!("{}\n", i + 1));
630 s.push_str(&format!("{} --> {}\n", fmt_time(sg.start_secs, ','), fmt_time(sg.end_secs, ',')));
631 match &sg.speaker {
632 Some(sp) => s.push_str(&format!("{}: {}\n\n", sp, sg.text)),
633 None => s.push_str(&format!("{}\n\n", sg.text)),
634 }
635 }
636 s
637}
638
639pub fn render_rttm(segs: &[CaptionSegment], uri: &str) -> String {
641 let uri = if uri.is_empty() { "audio" } else { uri };
642 let mut s = String::new();
643 for sg in segs {
644 if let Some(sp) = &sg.speaker {
645 let dur = sg.end_secs - sg.start_secs;
646 s.push_str(&format!(
647 "SPEAKER {} 1 {:.3} {:.3} <NA> <NA> {} <NA> <NA>\n",
648 uri, sg.start_secs, dur, sp,
649 ));
650 }
651 }
652 s
653}
654
655fn fmt_time(secs: f64, ms_sep: char) -> String {
656 let ms = ((secs - secs.floor()) * 1000.0) as u32;
657 let total = secs.floor() as u32;
658 let h = total / 3600;
659 let m = (total % 3600) / 60;
660 let s = total % 60;
661 format!("{:02}:{:02}:{:02}{}{:03}", h, m, s, ms_sep, ms)
662}
663
664pub fn estimate_tokens(text: &str) -> usize {
668 let mut total = 0usize;
669 let mut ascii_run = String::new();
670 let flush = |buf: &mut String, total: &mut usize| {
671 if buf.is_empty() { return; }
672 for w in buf.split_whitespace() {
673 *total += std::cmp::max(1, (w.chars().count() + 3) / 4);
674 }
675 buf.clear();
676 };
677 for c in text.chars() {
678 if is_cjk(c as u32) || is_emoji(c as u32) {
679 flush(&mut ascii_run, &mut total);
680 total += 1;
681 } else {
682 ascii_run.push(c);
683 }
684 }
685 flush(&mut ascii_run, &mut total);
686 total
687}
688
689pub fn truncate_to_tokens(text: &str, max: usize) -> String {
691 if estimate_tokens(text) <= max { return text.to_string(); }
692 let chars: Vec<char> = text.chars().collect();
693 let (mut lo, mut hi) = (0usize, chars.len());
694 while lo < hi {
695 let mid = (lo + hi + 1) / 2;
696 let s: String = chars[..mid].iter().collect();
697 if estimate_tokens(&s) <= max { lo = mid; } else { hi = mid - 1; }
698 }
699 chars[..lo].iter().collect()
700}
701
702#[derive(Debug, Clone)]
706pub struct QueryEval {
707 pub predicted: Vec<String>,
708 pub relevant: HashMap<String, i32>,
709}
710
711fn rel_set(q: &QueryEval) -> HashSet<&String> {
712 q.relevant.iter().filter_map(|(k, v)| if *v > 0 { Some(k) } else { None }).collect()
713}
714
715pub fn reciprocal_rank(q: &QueryEval) -> f64 {
716 let rel = rel_set(q);
717 for (i, p) in q.predicted.iter().enumerate() {
718 if rel.contains(p) { return 1.0 / (i + 1) as f64; }
719 }
720 0.0
721}
722
723pub fn mean_reciprocal_rank(qs: &[QueryEval]) -> f64 {
724 if qs.is_empty() { return 0.0; }
725 qs.iter().map(reciprocal_rank).sum::<f64>() / qs.len() as f64
726}
727
728pub fn recall_at_k(q: &QueryEval, k: usize) -> f64 {
729 let rel = rel_set(q);
730 if rel.is_empty() { return 0.0; }
731 let hits = q.predicted.iter().take(k).filter(|p| rel.contains(p)).count();
732 hits as f64 / rel.len() as f64
733}
734
735pub fn precision_at_k(q: &QueryEval, k: usize) -> f64 {
736 let head: Vec<_> = q.predicted.iter().take(k).collect();
737 if head.is_empty() { return 0.0; }
738 let rel = rel_set(q);
739 let hits = head.iter().filter(|p| rel.contains(**p)).count();
740 hits as f64 / head.len() as f64
741}
742
743pub fn ndcg_at_k(q: &QueryEval, k: usize) -> f64 {
744 let dcg = |slugs: &[&String]| {
745 let mut s = 0.0;
746 for (i, slug) in slugs.iter().enumerate() {
747 let g = q.relevant.get(*slug).copied().unwrap_or(0) as f64;
748 s += (2f64.powf(g) - 1.0) / (((i + 2) as f64).log2());
749 }
750 s
751 };
752 let mut sorted: Vec<(&String, i32)> = q.relevant.iter().map(|(k, v)| (k, *v)).collect();
753 sorted.sort_by(|a, b| b.1.cmp(&a.1));
754 let ideal: Vec<&String> = sorted.iter().take(k).map(|(k, _)| *k).collect();
755 let idcg = dcg(&ideal);
756 let head: Vec<&String> = q.predicted.iter().take(k).collect();
757 let actual = dcg(&head);
758 if idcg == 0.0 { 0.0 } else { actual / idcg }
759}
760
761const EARTH_KM: f64 = 6371.0088;
764
765pub fn haversine_km(lat_a: f64, lng_a: f64, lat_b: f64, lng_b: f64) -> f64 {
766 let d_lat = (lat_b - lat_a).to_radians();
767 let d_lng = (lng_b - lng_a).to_radians();
768 let r1 = lat_a.to_radians();
769 let r2 = lat_b.to_radians();
770 let x = (d_lat / 2.0).sin().powi(2)
771 + (d_lng / 2.0).sin().powi(2) * r1.cos() * r2.cos();
772 2.0 * EARTH_KM * x.sqrt().asin()
773}
774
775#[derive(Debug, Clone, Copy)]
776pub struct BBox { pub min_lat: f64, pub min_lng: f64, pub max_lat: f64, pub max_lng: f64 }
777
778pub fn bbox_around(lat: f64, lng: f64, radius_km: f64) -> BBox {
779 let d_lat = radius_km / 111.0;
780 let d_lng = radius_km / (111.0 * lat.to_radians().cos());
781 BBox { min_lat: lat - d_lat, min_lng: lng - d_lng, max_lat: lat + d_lat, max_lng: lng + d_lng }
782}
783
784pub fn in_box(lat: f64, lng: f64, b: BBox) -> bool {
785 lat >= b.min_lat && lat <= b.max_lat && lng >= b.min_lng && lng <= b.max_lng
786}
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub enum RangeOutcome {
792 Ok { start: i64, end: i64 },
793 Unsatisfiable,
794 Invalid,
795}
796
797pub fn parse_range(header: &str, total: i64) -> RangeOutcome {
798 if !header.starts_with("bytes=") { return RangeOutcome::Invalid; }
799 let spec = header[6..].split(',').next().unwrap_or("").trim();
800 if spec.is_empty() { return RangeOutcome::Invalid; }
801 if let Some(rest) = spec.strip_prefix('-') {
802 match rest.parse::<i64>() {
803 Ok(suffix) if suffix > 0 => {
804 let start = (total - suffix).max(0);
805 return RangeOutcome::Ok { start, end: total - 1 };
806 }
807 _ => return RangeOutcome::Invalid,
808 }
809 }
810 let parts: Vec<&str> = spec.splitn(2, '-').collect();
811 let start = match parts[0].parse::<i64>() { Ok(v) => v, Err(_) => return RangeOutcome::Invalid };
812 let end = if parts.len() == 2 && !parts[1].is_empty() {
813 match parts[1].parse::<i64>() { Ok(v) => v, Err(_) => return RangeOutcome::Invalid }
814 } else {
815 total - 1
816 };
817 if start > end || start >= total { return RangeOutcome::Unsatisfiable; }
818 RangeOutcome::Ok { start, end: end.min(total - 1) }
819}
820
821pub fn content_range(start: i64, end: i64, total: i64) -> String {
822 format!("bytes {}-{}/{}", start, end, total)
823}
824
825const BASE58: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
828const VERSION_V1: u8 = 0x01;
829
830fn digest32(data: &[u8]) -> [u8; 32] {
831 blake3::hash(data).into()
836}
837
838pub fn encode_address(public_key: &[u8], prefix: Option<&str>) -> Result<String, &'static str> {
839 if public_key.len() != 32 { return Err("public key must be 32 bytes"); }
840 let prefix = prefix.unwrap_or("hanzo");
841 let h = digest32(public_key);
842 let mut versioned = Vec::with_capacity(21);
843 versioned.push(VERSION_V1);
844 versioned.extend_from_slice(&h[..20]);
845 let cs = digest32(&versioned);
846 let mut payload = versioned.clone();
847 payload.extend_from_slice(&cs[..4]);
848 Ok(format!("{}:{}", prefix, base58_encode(&payload)))
849}
850
851#[derive(Debug, Clone)]
852pub struct DecodedAddress {
853 pub prefix: String,
854 pub version: u8,
855 pub hash: [u8; 20],
856}
857
858pub fn decode_address(addr: &str) -> Result<DecodedAddress, &'static str> {
859 let colon = match addr.find(':') { Some(i) => i, None => return Err("address: missing prefix") };
860 let body = &addr[colon + 1..];
861 let decoded = base58_decode(body)?;
862 if decoded.len() != 25 { return Err("address: wrong length"); }
863 let mut hash = [0u8; 20];
864 hash.copy_from_slice(&decoded[1..21]);
865 let expected = digest32(&decoded[..21]);
866 if decoded[21..25] != expected[..4] { return Err("address: bad checksum"); }
867 Ok(DecodedAddress { prefix: addr[..colon].to_string(), version: decoded[0], hash })
868}
869
870fn base58_encode(b: &[u8]) -> String {
871 if b.is_empty() { return String::new(); }
872 let zeros = b.iter().take_while(|&&c| c == 0).count();
873 let bytes_to_int = |b: &[u8]| -> Vec<u8> { b.to_vec() };
874 let mut n = bytes_to_int(b);
875 let mut out = String::new();
876 loop {
877 let mut remainder: u32 = 0;
878 let mut new = Vec::with_capacity(n.len());
879 let mut started = false;
880 for &byte in &n {
881 let cur = remainder * 256 + byte as u32;
882 let q = cur / 58;
883 remainder = cur % 58;
884 if started || q > 0 { new.push(q as u8); started = true; }
885 }
886 out.insert(0, BASE58.as_bytes()[remainder as usize] as char);
887 if new.is_empty() { break; }
888 n = new;
889 }
890 for _ in 0..zeros { out.insert(0, BASE58.as_bytes()[0] as char); }
891 out
892}
893
894fn base58_decode(s: &str) -> Result<Vec<u8>, &'static str> {
895 if s.is_empty() { return Ok(vec![]); }
896 let zeros = s.chars().take_while(|c| *c == BASE58.chars().next().unwrap()).count();
897 let mut n: Vec<u8> = vec![0];
898 for c in s.chars() {
899 let idx = BASE58.find(c).ok_or("base58: invalid char")? as u32;
900 let mut carry = idx;
901 for b in n.iter_mut() {
902 carry += (*b as u32) * 58;
903 *b = (carry & 0xff) as u8;
904 carry >>= 8;
905 }
906 while carry > 0 { n.push((carry & 0xff) as u8); carry >>= 8; }
907 }
908 let mut out = vec![0u8; zeros];
909 let mut rev: Vec<u8> = n.iter().rev().copied().collect();
910 while rev.first().copied() == Some(0) && rev.len() > 1 { rev.remove(0); }
911 out.extend_from_slice(&rev);
912 Ok(out)
913}
914
915#[derive(Debug, Clone)]
918pub struct WeightedEdge {
919 pub source: String,
920 pub target: String,
921 pub weight: f64,
922}
923
924pub fn normalize_edges(edges: &[WeightedEdge]) -> Vec<WeightedEdge> {
925 if edges.is_empty() { return vec![]; }
926 let lo = edges.iter().map(|e| e.weight).fold(f64::INFINITY, f64::min);
927 let hi = edges.iter().map(|e| e.weight).fold(f64::NEG_INFINITY, f64::max);
928 let span = hi - lo;
929 edges.iter().map(|e| WeightedEdge {
930 source: e.source.clone(),
931 target: e.target.clone(),
932 weight: if span > 0.0 { (e.weight - lo) / span } else { 1.0 },
933 }).collect()
934}
935
936pub fn snn_score(edges: &[WeightedEdge], k: usize) -> Vec<WeightedEdge> {
937 let mut adj: HashMap<String, Vec<(String, f64)>> = HashMap::new();
938 for e in edges {
939 adj.entry(e.source.clone()).or_default().push((e.target.clone(), e.weight));
940 adj.entry(e.target.clone()).or_default().push((e.source.clone(), e.weight));
941 }
942 let mut nbrs: HashMap<String, HashSet<String>> = HashMap::new();
943 for (node, lst) in adj.iter_mut() {
944 lst.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
945 let set: HashSet<String> = lst.iter().take(k).map(|(t, _)| t.clone()).collect();
946 nbrs.insert(node.clone(), set);
947 }
948 edges.iter().map(|e| {
949 let a = nbrs.get(&e.source).cloned().unwrap_or_default();
950 let b = nbrs.get(&e.target).cloned().unwrap_or_default();
951 let inter = a.intersection(&b).count();
952 let union = a.len() + b.len() - inter;
953 let w = if union > 0 { inter as f64 / union as f64 } else { 0.0 };
954 WeightedEdge { source: e.source.clone(), target: e.target.clone(), weight: w }
955 }).collect()
956}
957
958pub fn pfnet_infinity(edges: &[WeightedEdge]) -> Vec<WeightedEdge> {
959 let mut adj: HashMap<String, HashMap<String, f64>> = HashMap::new();
960 for e in edges {
961 adj.entry(e.source.clone()).or_default().entry(e.target.clone())
962 .and_modify(|v| { if e.weight > *v { *v = e.weight; } })
963 .or_insert(e.weight);
964 }
965 let mut keep = Vec::new();
966 for e in edges {
967 let mut dominated = false;
968 if let Some(srcs) = adj.get(&e.source) {
969 for (x, w_ux) in srcs {
970 if x == &e.target { continue; }
971 if let Some(w_xv) = adj.get(x).and_then(|m| m.get(&e.target)) {
972 let path = (*w_ux).min(*w_xv);
973 if path > e.weight { dominated = true; break; }
974 }
975 }
976 }
977 if !dominated { keep.push(e.clone()); }
978 }
979 keep
980}
981
982pub fn louvain(edges: &[WeightedEdge], passes: usize) -> HashMap<String, i32> {
983 let passes = if passes == 0 { 10 } else { passes };
984 let mut nodes: HashSet<String> = HashSet::new();
985 for e in edges {
986 nodes.insert(e.source.clone());
987 nodes.insert(e.target.clone());
988 }
989 let mut community: HashMap<String, i32> = HashMap::new();
990 for (i, n) in nodes.iter().enumerate() {
991 community.insert(n.clone(), i as i32);
992 }
993 let mut adj: HashMap<String, Vec<(String, f64)>> = HashMap::new();
994 let mut total = 0.0;
995 for e in edges {
996 adj.entry(e.source.clone()).or_default().push((e.target.clone(), e.weight));
997 adj.entry(e.target.clone()).or_default().push((e.source.clone(), e.weight));
998 total += e.weight;
999 }
1000 let mut deg: HashMap<String, f64> = HashMap::new();
1001 for (n, lst) in &adj {
1002 let d: f64 = lst.iter().map(|(_, w)| w).sum();
1003 deg.insert(n.clone(), d);
1004 }
1005 let m = total;
1006 for _ in 0..passes {
1007 let mut improved = false;
1008 let order: Vec<String> = nodes.iter().cloned().collect();
1009 for n in &order {
1010 let cur = community[n];
1011 let mut w_to: HashMap<i32, f64> = HashMap::new();
1012 for (nb, w) in adj.get(n).cloned().unwrap_or_default() {
1013 let c = community[&nb];
1014 *w_to.entry(c).or_insert(0.0) += w;
1015 }
1016 let mut best = cur;
1017 let mut best_gain = 0.0;
1018 let kn = deg.get(n).copied().unwrap_or(0.0);
1019 for (c, wnc) in &w_to {
1020 if *c == cur { continue; }
1021 let sigma_tot: f64 = community.iter()
1022 .filter(|(other, comm)| *comm == c && other.as_str() != n.as_str())
1023 .map(|(other, _)| deg.get(other).copied().unwrap_or(0.0))
1024 .sum();
1025 let gain = wnc - (kn * sigma_tot) / (2.0 * m).max(1e-9);
1026 if gain > best_gain { best_gain = gain; best = *c; }
1027 }
1028 if best != cur {
1029 community.insert(n.clone(), best);
1030 improved = true;
1031 }
1032 }
1033 if !improved { break; }
1034 }
1035 let mut id_map: HashMap<i32, i32> = HashMap::new();
1037 let mut next = 0i32;
1038 for c in community.values() {
1039 id_map.entry(*c).or_insert_with(|| { let v = next; next += 1; v });
1040 }
1041 for (_, c) in community.iter_mut() {
1042 *c = id_map[c];
1043 }
1044 community
1045}
1046
1047const KNOWN_PROVIDERS: &[&str] = &[
1050 "ollama", "openai", "openrouter", "llamacpp",
1051 "anthropic", "google", "azure", "groq", "together", "mock",
1052];
1053
1054#[derive(Debug, Clone, PartialEq, Eq)]
1055pub struct ParsedSlug { pub provider: String, pub model: String }
1056
1057pub fn parse_slug(slug: &str, default_provider: &str) -> ParsedSlug {
1058 let colon = match slug.find(':') {
1059 Some(i) => i,
1060 None => return ParsedSlug { provider: default_provider.into(), model: slug.into() },
1061 };
1062 let head = &slug[..colon];
1063 if KNOWN_PROVIDERS.iter().any(|&k| k == head) {
1064 return ParsedSlug { provider: head.into(), model: slug[colon + 1..].into() };
1065 }
1066 ParsedSlug { provider: default_provider.into(), model: slug.into() }
1067}
1068
1069pub fn format_slug(p: &ParsedSlug) -> String { format!("{}:{}", p.provider, p.model) }
1070
1071#[derive(Debug, Default)]
1072pub struct RuntimeConfig {
1073 pub defaults: HashMap<String, String>,
1074 pub env: HashMap<String, String>,
1075 overrides: HashMap<String, String>,
1076}
1077
1078impl RuntimeConfig {
1079 pub fn new(defaults: HashMap<String, String>, env: HashMap<String, String>) -> Self {
1080 Self { defaults, env, overrides: HashMap::new() }
1081 }
1082 pub fn get(&self, key: &str) -> Option<String> {
1083 if let Some(v) = self.overrides.get(key) { return Some(v.clone()); }
1084 if let Some(v) = self.env.get(key) { return Some(v.clone()); }
1085 self.defaults.get(key).cloned()
1086 }
1087 pub fn source(&self, key: &str) -> &'static str {
1088 if self.overrides.contains_key(key) { return "db_override"; }
1089 if self.env.contains_key(key) { return "env"; }
1090 if self.defaults.contains_key(key) { return "default"; }
1091 "absent"
1092 }
1093 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
1094 self.overrides.insert(key.into(), value.into());
1095 }
1096 pub fn clear(&mut self, key: &str) { self.overrides.remove(key); }
1097}
1098
1099pub const LINK_TYPES: &[&str] = &[
1100 "mentions", "founded", "invested_in", "advises", "works_at",
1101 "attended", "authored", "cites", "succeeded_by", "located_in", "related",
1102];
1103
1104pub fn classify_link_rule(evidence: &str) -> &'static str {
1106 let e = evidence.to_lowercase();
1107 if e.contains("founded") { return "founded"; }
1108 if let Some(idx) = e.find("invested") {
1109 if e[idx..].contains("in") { return "invested_in"; }
1110 }
1111 if e.contains("advisor") || e.contains("advises") || e.contains("advising") { return "advises"; }
1112 if e.contains("works at") || e.contains("works for") || e.contains("work at") || e.contains("work for") { return "works_at"; }
1113 if e.contains("attended") { return "attended"; }
1114 if e.contains("wrote") || e.contains("authored") { return "authored"; }
1115 if e.contains("cites") || e.contains("cite ") { return "cites"; }
1116 if e.contains("succeeded by") { return "succeeded_by"; }
1117 if e.contains("located in") { return "located_in"; }
1118 "mentions"
1119}
1120
1121#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use crate::brain::store::SearchHit;
1127
1128 fn hit(slug: &str, score: f64) -> SearchHit {
1129 SearchHit { slug: slug.into(), score, excerpt: slug.into(), source: "keyword".into() }
1130 }
1131
1132 #[test]
1133 fn test_rrf_top_one() {
1134 let r = rrf_fuse(vec![vec![hit("a", 1.0), hit("b", 0.5)]], 10, 0.0);
1135 assert_eq!(r[0].slug, "a");
1136 assert!((r[0].score - 1.0).abs() < 0.01);
1137 }
1138
1139 #[test]
1140 fn test_rsf_preserves_magnitude() {
1141 let r = rsf_fuse(vec![vec![hit("a", 100.0), hit("b", 50.0)], vec![hit("a", 1.0), hit("c", 0.5)]], 10, None);
1142 assert_eq!(r[0].slug, "a");
1143 assert_eq!(r.len(), 3);
1144 }
1145
1146 #[test]
1147 fn test_select_rrf_k() {
1148 assert_eq!(select_rrf_k(characterize("\"hello world\"")), 10);
1149 assert_eq!(select_rrf_k(characterize("foo AND bar")), 15);
1150 assert_eq!(select_rrf_k(characterize("rust")), 15);
1151 assert_eq!(select_rrf_k(characterize("a b c d e f g h i j")), 40);
1152 }
1153
1154 #[test]
1155 fn test_select_weights() {
1156 let short = select_weights(characterize("rust"));
1157 assert!(short.fts > short.semantic);
1158 let long = select_weights(characterize("how do retrieval augmented generation systems typically work in production scale"));
1159 assert!(long.semantic > long.fts);
1160 }
1161
1162 #[test]
1163 fn test_cosine() {
1164 assert!((cosine(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-6);
1165 assert!(cosine(&[1.0, 0.0], &[0.0, 1.0]).abs() < 1e-6);
1166 }
1167
1168 #[test]
1169 fn test_mmr() {
1170 let hits = vec![
1171 MmrInput { hit: hit("a", 0.9), embedding: Some(vec![1.0, 0.0]) },
1172 MmrInput { hit: hit("b", 0.85), embedding: Some(vec![1.0, 0.01]) },
1173 MmrInput { hit: hit("c", 0.6), embedding: Some(vec![0.0, 1.0]) },
1174 ];
1175 let out = mmr_rerank(hits, 0.2, 2);
1176 assert_eq!(out[0].hit.slug, "a");
1177 assert_eq!(out[1].hit.slug, "c");
1178 }
1179
1180 #[test]
1181 fn test_dedup() {
1182 let out = dedup_hits(vec![
1183 hit("page/foo#chunk-0", 0.5),
1184 hit("page/foo#chunk-1", 0.8),
1185 hit("page/bar", 0.6),
1186 ], 1);
1187 assert_eq!(out.len(), 2);
1188 }
1189
1190 #[test]
1191 fn test_detect_script() {
1192 assert_eq!(detect_script("こんにちは世界").primary, "cjk");
1193 assert_eq!(detect_script("Hello world").primary, "latin");
1194 assert_eq!(detect_script("Привет").primary, "cyrillic");
1195 }
1196
1197 #[test]
1198 fn test_cjk_bigrams() {
1199 let out = cjk_bigrams("hello 世界 こんにちは");
1200 assert!(out.contains(&"hello".to_string()));
1201 assert!(out.contains(&"世界".to_string()));
1202 assert!(out.contains(&"こん".to_string()));
1203 }
1204
1205 #[test]
1206 fn test_emoji_trigrams_emit() {
1207 let out = emoji_trigrams("hi 🚀🌌🌟");
1208 assert!(!out.is_empty());
1209 }
1210
1211 #[test]
1212 fn test_parse_websearch() {
1213 let p = parse_websearch("\"hello world\" foo OR bar -baz qux");
1214 assert_eq!(p.phrases, vec!["hello world"]);
1215 assert_eq!(p.optional[0], vec!["foo", "bar"]);
1216 assert_eq!(p.excluded, vec!["baz"]);
1217 assert!(p.required.contains(&"qux".to_string()));
1218 }
1219
1220 #[test]
1221 fn test_to_fts5_match() {
1222 let sql = to_fts5_match(&parse_websearch("apple OR orange -spoil"));
1223 assert!(sql.contains("apple OR orange"));
1224 assert!(sql.contains("NOT spoil"));
1225 }
1226
1227 #[test]
1228 fn test_embed_registry() {
1229 assert_eq!(get_embedding_model("ollama:nomic-embed-text").unwrap().dim, 768);
1230 assert_eq!(get_embedding_model("openai:text-embedding-3-small").unwrap().dim, 1536);
1231 }
1232
1233 #[test]
1234 fn test_prefix_for() {
1235 let e5 = get_embedding_model("intfloat/e5-large-v2").unwrap();
1236 assert_eq!(prefix_for(&e5, "query", "x"), "query: x");
1237 assert_eq!(prefix_for(&e5, "passage", "x"), "passage: x");
1238 let nomic = get_embedding_model("ollama:nomic-embed-text").unwrap();
1239 assert_eq!(prefix_for(&nomic, "query", "x"), "x");
1240 }
1241
1242 #[test]
1243 fn test_mrl_truncate() {
1244 let v: Vec<f64> = (1..=8).map(|x| x as f64).collect();
1245 let t = mrl_truncate(&v, 4);
1246 assert_eq!(t.len(), 4);
1247 let norm: f64 = t.iter().map(|x| x * x).sum::<f64>().sqrt();
1248 assert!((norm - 1.0).abs() < 1e-6);
1249 }
1250
1251 #[test]
1252 fn test_coarse_dim() {
1253 let m = get_embedding_model("openai:text-embedding-3-large").unwrap();
1254 let cd = coarse_dim(&m);
1255 assert!((256..=512).contains(&cd));
1256 }
1257
1258 #[test]
1259 fn test_l2_zero_vector() {
1260 let mut v = vec![0.0; 3];
1261 l2_normalize(&mut v);
1262 assert_eq!(v, vec![0.0; 3]);
1263 }
1264
1265 #[test]
1266 fn test_v7_floor_ceiling_order() {
1267 let t = 1_700_000_000_000i64;
1268 assert!(v7_floor(t) < v7_ceiling(t));
1269 }
1270
1271 #[test]
1272 fn test_captions() {
1273 let segs = vec![
1274 CaptionSegment { start_secs: 0.0, end_secs: 1.5, text: "hi".into(), speaker: Some("S0".into()) },
1275 CaptionSegment { start_secs: 1.5, end_secs: 3.0, text: "world".into(), speaker: Some("S1".into()) },
1276 ];
1277 assert!(render_vtt(&segs).starts_with("WEBVTT"));
1278 assert!(render_srt(&segs).contains("00:00:00,000 --> 00:00:01,500"));
1279 assert!(render_rttm(&segs, "").starts_with("SPEAKER"));
1280 }
1281
1282 #[test]
1283 fn test_estimate_tokens() {
1284 assert!(estimate_tokens("hi there friend") > estimate_tokens("hi"));
1285 assert_eq!(estimate_tokens("こんにちは"), 5);
1286 }
1287
1288 #[test]
1289 fn test_truncate_to_tokens() {
1290 let long = "alpha ".repeat(100);
1291 let t = truncate_to_tokens(&long, 20);
1292 assert!(estimate_tokens(&t) <= 20);
1293 }
1294
1295 fn qe() -> QueryEval {
1296 let mut r = HashMap::new();
1297 r.insert("c".into(), 1);
1298 r.insert("d".into(), 1);
1299 QueryEval { predicted: vec!["a", "b", "c", "d"].into_iter().map(String::from).collect(), relevant: r }
1300 }
1301
1302 #[test]
1303 fn test_reciprocal_rank() {
1304 assert!((reciprocal_rank(&qe()) - 1.0 / 3.0).abs() < 1e-6);
1305 }
1306
1307 #[test]
1308 fn test_recall_at_k() {
1309 let q = qe();
1310 assert_eq!(recall_at_k(&q, 2), 0.0);
1311 assert!((recall_at_k(&q, 4) - 1.0).abs() < 1e-6);
1312 }
1313
1314 #[test]
1315 fn test_precision_at_k() {
1316 assert!((precision_at_k(&qe(), 4) - 0.5).abs() < 1e-6);
1317 }
1318
1319 #[test]
1320 fn test_ndcg_graded() {
1321 let mut r = HashMap::new();
1322 r.insert("a".into(), 3);
1323 r.insert("b".into(), 1);
1324 let g = QueryEval { predicted: vec!["a", "b"].into_iter().map(String::from).collect(), relevant: r };
1325 assert!(ndcg_at_k(&g, 2) > 0.9);
1326 }
1327
1328 #[test]
1329 fn test_mrr() {
1330 assert!(mean_reciprocal_rank(&[qe()]) > 0.0);
1331 }
1332
1333 #[test]
1334 fn test_haversine() {
1335 assert!(haversine_km(0.0, 0.0, 0.0, 0.0).abs() < 1e-6);
1336 let d = haversine_km(40.7128, -74.006, 34.0522, -118.2437);
1337 assert!((d - 3935.0).abs() < 50.0);
1338 }
1339
1340 #[test]
1341 fn test_bbox() {
1342 let center = (37.77, -122.42);
1343 let box_ = bbox_around(center.0, center.1, 10.0);
1344 assert!(in_box(center.0, center.1, box_));
1345 assert!(!in_box(0.0, 0.0, box_));
1346 }
1347
1348 #[test]
1349 fn test_range_parsing() {
1350 assert_eq!(parse_range("bytes=0-99", 1000), RangeOutcome::Ok { start: 0, end: 99 });
1351 assert_eq!(parse_range("bytes=-100", 1000), RangeOutcome::Ok { start: 900, end: 999 });
1352 assert_eq!(parse_range("bytes=2000-3000", 1000), RangeOutcome::Unsatisfiable);
1353 assert_eq!(content_range(0, 99, 1000), "bytes 0-99/1000");
1354 }
1355
1356 #[test]
1357 fn test_address_round_trip() {
1358 let pk: Vec<u8> = (0..32u8).collect();
1359 let addr = encode_address(&pk, None).unwrap();
1360 assert!(addr.starts_with("hanzo:"));
1361 let dec = decode_address(&addr).unwrap();
1362 assert_eq!(dec.prefix, "hanzo");
1363 assert_eq!(dec.version, 1);
1364 }
1365
1366 #[test]
1367 fn test_address_bad_checksum() {
1368 assert!(decode_address("hanzo:11111111111111111111111111").is_err());
1369 }
1370
1371 #[test]
1372 fn test_normalize_edges() {
1373 let out = normalize_edges(&[
1374 WeightedEdge { source: "a".into(), target: "b".into(), weight: 10.0 },
1375 WeightedEdge { source: "b".into(), target: "c".into(), weight: 5.0 },
1376 ]);
1377 assert!((out[0].weight - 1.0).abs() < 1e-6);
1378 assert!(out[1].weight.abs() < 1e-6);
1379 }
1380
1381 #[test]
1382 fn test_snn_bounds() {
1383 let edges = vec![
1384 WeightedEdge { source: "a".into(), target: "b".into(), weight: 0.9 },
1385 WeightedEdge { source: "a".into(), target: "c".into(), weight: 0.8 },
1386 WeightedEdge { source: "b".into(), target: "c".into(), weight: 0.7 },
1387 ];
1388 for e in snn_score(&edges, 2) {
1389 assert!((0.0..=1.0).contains(&e.weight));
1390 }
1391 }
1392
1393 #[test]
1394 fn test_pfnet_drops_dominated() {
1395 let edges = vec![
1396 WeightedEdge { source: "a".into(), target: "b".into(), weight: 0.9 },
1397 WeightedEdge { source: "b".into(), target: "c".into(), weight: 0.9 },
1398 WeightedEdge { source: "a".into(), target: "c".into(), weight: 0.5 },
1399 ];
1400 for e in pfnet_infinity(&edges) {
1401 assert!(!(e.source == "a" && e.target == "c"));
1402 }
1403 }
1404
1405 #[test]
1406 fn test_louvain_size() {
1407 let edges = vec![
1408 WeightedEdge { source: "a".into(), target: "b".into(), weight: 1.0 },
1409 WeightedEdge { source: "b".into(), target: "c".into(), weight: 1.0 },
1410 WeightedEdge { source: "a".into(), target: "c".into(), weight: 1.0 },
1411 ];
1412 let c = louvain(&edges, 0);
1413 assert_eq!(c.len(), 3);
1414 }
1415
1416 #[test]
1417 fn test_parse_slug() {
1418 let p = parse_slug("openai:gpt-4o", "ollama");
1419 assert_eq!(p, ParsedSlug { provider: "openai".into(), model: "gpt-4o".into() });
1420 let p = parse_slug("qwen3:8b", "ollama");
1421 assert_eq!(p, ParsedSlug { provider: "ollama".into(), model: "qwen3:8b".into() });
1422 assert_eq!(format_slug(&ParsedSlug { provider: "openai".into(), model: "gpt-4o".into() }), "openai:gpt-4o");
1423 }
1424
1425 #[test]
1426 fn test_runtime_config_precedence() {
1427 let mut rc = RuntimeConfig::new(
1428 HashMap::from([("K".into(), "default".into())]),
1429 HashMap::from([("K".into(), "env".into())]),
1430 );
1431 assert_eq!(rc.get("K").as_deref(), Some("env"));
1432 rc.set("K", "override");
1433 assert_eq!(rc.get("K").as_deref(), Some("override"));
1434 assert_eq!(rc.source("K"), "db_override");
1435 rc.clear("K");
1436 assert_eq!(rc.get("K").as_deref(), Some("env"));
1437 }
1438
1439 #[test]
1440 fn test_classify_link_rule() {
1441 assert_eq!(classify_link_rule("Alice founded Acme"), "founded");
1442 assert_eq!(classify_link_rule("Alice invested in Acme"), "invested_in");
1443 assert_eq!(classify_link_rule("worked together"), "mentions");
1444 }
1445}