use std::collections::HashMap;
use crate::brain::brain_sections::{Matches, Section, query_terms, split_sections, tokens};
const K1: f64 = 1.5;
const B: f64 = 0.75;
fn fold_diacritics(text: &str) -> String {
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::char::is_combining_mark;
let mut kept: Vec<char> = Vec::with_capacity(text.len());
let mut base_was_ascii = false;
for c in text.nfd() {
if is_combining_mark(c) {
if !base_was_ascii {
kept.push(c);
}
} else {
base_was_ascii = c.is_ascii_alphanumeric();
kept.push(c);
}
}
kept.into_iter().nfc().collect()
}
fn stem(word: &str) -> &str {
for suffix in ["ing", "ed", "es", "s"] {
if word.len() > suffix.len() + 3
&& let Some(stripped) = word.strip_suffix(suffix)
{
return stripped;
}
}
word
}
pub struct Ranked {
sections: Vec<Section>,
tf: Vec<HashMap<String, usize>>,
df: HashMap<String, usize>,
lens: Vec<usize>,
avg_len: f64,
max_idf: f64,
}
impl Ranked {
pub fn build(content: &str) -> Self {
Self::from_sections(split_sections(content))
}
pub fn from_sections(sections: Vec<Section>) -> Self {
let mut tf = Vec::with_capacity(sections.len());
let mut df: HashMap<String, usize> = HashMap::new();
let mut lens = Vec::with_capacity(sections.len());
for section in §ions {
let mut counts: HashMap<String, usize> = HashMap::new();
let mut len = 0usize;
for token in tokens(&fold_diacritics(§ion.text())) {
*counts.entry(stem(&token).to_string()).or_insert(0) += 1;
len += 1;
}
for term in counts.keys() {
*df.entry(term.clone()).or_insert(0) += 1;
}
tf.push(counts);
lens.push(len);
}
let avg_len = if lens.is_empty() {
0.0
} else {
lens.iter().sum::<usize>() as f64 / lens.len() as f64
};
let n = sections.len() as f64;
let max_idf = (1.0 + (n - 1.0 + 0.5) / 1.5).ln();
Self {
sections,
tf,
df,
lens,
avg_len,
max_idf,
}
}
pub fn len(&self) -> usize {
self.sections.len()
}
pub fn is_empty(&self) -> bool {
self.sections.is_empty()
}
fn idf(&self, term: &str) -> f64 {
if self.max_idf <= 0.0 {
return 0.0;
}
let n = self.sections.len() as f64;
let df = *self.df.get(term).unwrap_or(&0) as f64;
(1.0 + (n - df + 0.5) / (df + 0.5)).ln() / self.max_idf
}
fn score(&self, terms: &[String], i: usize) -> f64 {
if self.avg_len == 0.0 {
return 0.0;
}
let dl = self.lens[i] as f64;
terms
.iter()
.filter_map(|term| {
let folded = fold_diacritics(term);
let stemmed = stem(&folded);
let f = *self.tf[i].get(stemmed)? as f64;
let denom = f + K1 * (1.0 - B + B * dl / self.avg_len);
Some(self.idf(stemmed) * (f * (K1 + 1.0)) / denom)
})
.sum()
}
pub fn find_relevant(
&self,
query: &str,
max_sections: usize,
max_chars: usize,
min_score: f64,
) -> Matches {
let terms = query_terms(query);
if terms.is_empty() {
return Matches {
sections: Vec::new(),
omitted: 0,
};
}
let norm = terms.len() as f64;
let mut scored: Vec<(f64, usize)> = (0..self.sections.len())
.filter_map(|i| {
let score = self.score(&terms, i) / norm;
(score >= min_score).then_some((score, i))
})
.collect();
scored.sort_by(|a, b| {
b.0.partial_cmp(&a.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.1.cmp(&b.1))
});
let total = scored.len();
let mut sections = Vec::new();
let mut chars = 0usize;
for (_, i) in scored {
if sections.len() >= max_sections {
break;
}
let section = &self.sections[i];
let len = section.render().chars().count();
if chars + len > max_chars && !sections.is_empty() {
break;
}
chars += len;
sections.push(section.clone());
}
let omitted = total - sections.len();
Matches { sections, omitted }
}
}