pub const TERMS: usize = 16;
const UNITS: usize = 2 * TERMS - 1;
const MIN_PAGES: u32 = 3;
#[derive(Clone, Copy, Debug)]
pub struct Facets {
about: u32,
head: u32,
tf: [u16; UNITS],
len: u32,
}
impl Default for Facets {
fn default() -> Self {
Self {
about: 0,
head: 0,
tf: [0; UNITS],
len: 0,
}
}
}
fn count(query: &pagesift_extract::Query, text: &str, tf: &mut [u16; UNITS]) -> u32 {
let n = query.len().min(TERMS);
let mut prev: Option<usize> = None;
let mut len = 0u32;
query.for_each_term(text, |t| {
len = len.saturating_add(1);
let t = t.filter(|&i| i < n);
if let Some(i) = t {
tf[i] = tf[i].saturating_add(1);
if prev.is_some_and(|p| p + 1 == i) {
tf[n + i - 1] = tf[n + i - 1].saturating_add(1);
}
}
prev = t;
});
len
}
fn mask(tf: &[u16; UNITS]) -> u32 {
tf.iter()
.enumerate()
.filter(|(_, c)| **c > 0)
.fold(0u32, |m, (k, _)| m | (1 << k))
}
impl Facets {
pub fn of(query: &pagesift_extract::Query, title: &str, locator: &str, markdown: &str) -> Self {
let mut about = [0u16; UNITS];
count(query, title, &mut about);
count(query, locator, &mut about);
let mut head = [0u16; UNITS];
for line in markdown.lines() {
let line = line.trim_start();
if line.starts_with('#') {
count(query, line.trim_start_matches('#'), &mut head);
}
}
let mut tf = [0u16; UNITS];
let len = count(query, markdown, &mut tf);
Self {
about: mask(&about),
head: mask(&head),
tf,
len,
}
}
pub fn is_empty(&self) -> bool {
self.len == 0 && self.about == 0
}
fn present(&self) -> u32 {
self.about | self.head | mask(&self.tf)
}
}
fn collocations(pairs: &[(f64, f64)], delta: pagesift_stat::Delta) -> Vec<bool> {
let p: Vec<f64> = pairs
.iter()
.map(|&(k, lambda)| {
if k <= 0.0 {
1.0
} else {
pagesift_stat::special::gamma_p(k, lambda)
}
})
.collect();
pagesift_stat::verdict::benjamini_hochberg(&p, delta)
}
pub struct FacetStats {
terms: usize,
pages: u32,
df: [u32; UNITS],
tokens: u64,
cf: [u64; UNITS],
weights: [f32; UNITS],
}
impl FacetStats {
pub fn new(terms: usize) -> Self {
let terms = terms.min(TERMS);
let mut s = Self {
terms,
pages: 0,
df: [0; UNITS],
tokens: 0,
cf: [0; UNITS],
weights: [0.0; UNITS],
};
s.weights[..terms].fill(1.0);
s
}
pub fn observe(&mut self, f: &Facets) {
self.pages += 1;
let present = f.present();
for k in 0..UNITS {
if present & (1 << k) != 0 {
self.df[k] += 1;
}
self.cf[k] += u64::from(f.tf[k]);
}
self.tokens += u64::from(f.len);
if self.pages < MIN_PAGES {
return;
}
let n = f64::from(self.pages);
let idf = |df: u32| {
if df == 0 {
0.0
} else {
((n + 1.0) / (f64::from(df) + 0.5)).ln().max(0.0) as f32
}
};
let t = self.terms;
for k in 0..t {
self.weights[k] = idf(self.df[k]);
}
let tokens = self.tokens.max(1) as f64;
let pairs: Vec<(f64, f64)> = (1..t)
.map(|i| {
let expected = self.cf[i - 1] as f64 * self.cf[i] as f64 / tokens;
(self.cf[t + i - 1] as f64, expected)
})
.collect();
for (i, phrase) in collocations(&pairs, pagesift_stat::Delta::DEFAULT)
.into_iter()
.enumerate()
{
let k = t + i;
self.weights[k] = if phrase { idf(self.df[k]) } else { 0.0 };
}
}
pub fn relevance(&self, f: &Facets) -> f32 {
let avg = if self.pages > 0 && self.tokens > 0 {
self.tokens as f32 / self.pages as f32
} else {
f.len.max(1) as f32
};
let (mut num, mut den) = (0.0f32, 0.0f32);
for (k, &w) in self.weights.iter().enumerate() {
if w <= 0.0 {
continue;
}
let bit = 1 << k;
let tf = f32::from(f.tf[k]);
let body = tf / (tf + 1.0 + f.len as f32 / avg);
let about = if f.about & bit != 0 { 1.0 } else { 0.0 };
let head = if f.head & bit != 0 { 1.0 } else { 0.0 };
num += w * (about + head + body) / 3.0;
den += w;
}
if den > 0.0 { num / den } else { 0.0 }
}
pub fn shares(&self) -> impl Iterator<Item = f32> + '_ {
let total: f32 = self.weights.iter().sum();
self.weights
.iter()
.map(move |&w| if total > 0.0 { w / total } else { 0.0 })
}
}
#[cfg(test)]
mod tests {
use super::*;
use pagesift_extract::Query;
fn page(q: &Query, title: &str, body: &str) -> Facets {
Facets::of(q, title, "", body)
}
#[test]
fn a_page_about_the_rare_facet_beats_one_that_collects_common_words() {
let q = Query::new("computer science department faculty research");
let mut s = FacetStats::new(q.len());
let filler = "The department and faculty support research across the university.";
let pages: Vec<Facets> = (0..20)
.map(|i| {
if i % 5 == 0 {
page(
&q,
"Computer Science",
&format!("{filler} The computer science department lists its faculty."),
)
} else {
page(
&q,
"Biology",
&format!("{filler} Biology research and faculty news."),
)
}
})
.collect();
for p in &pages {
s.observe(p);
}
let cs = s.relevance(&pages[0]);
let bio = s.relevance(&pages[1]);
assert!(cs > 2.0 * bio, "cs {cs} bio {bio}");
assert!((0.0..=1.0).contains(&cs) && (0.0..=1.0).contains(&bio));
}
#[test]
fn collocation_is_a_poisson_tail_test_with_bh() {
let d = pagesift_stat::Delta::DEFAULT;
assert_eq!(collocations(&[(6.0, 2.0)], d), vec![true]);
assert_eq!(collocations(&[(5.0, 2.0)], d), vec![false]);
assert_eq!(collocations(&[(0.0, 0.0)], d), vec![false]);
assert_eq!(
collocations(&[(6.0, 2.0), (5.0, 2.0)], d),
vec![true, false]
);
assert_eq!(
collocations(&[(5.0, 2.0), (5.0, 2.0)], d),
vec![false, false]
);
}
#[test]
fn adjacent_query_words_that_go_together_on_the_site_become_a_phrase() {
let q = Query::new("student housing");
let mut s = FacetStats::new(q.len());
for i in 0..12 {
let body = if i % 2 == 0 {
"Apply for student housing. Student housing costs and student housing halls."
} else {
"Every student gets news. Housing market report for the city."
};
s.observe(&page(&q, "", body));
}
assert!(s.weights[2] > 0.0, "{:?}", &s.weights[..3]);
let q = Query::new("library hours");
let mut s = FacetStats::new(q.len());
for _ in 0..12 {
s.observe(&page(&q, "", "The library is large. Office hours vary."));
}
assert_eq!(s.weights[2], 0.0);
}
#[test]
fn before_enough_pages_terms_count_equally() {
let q = Query::new("refund policy");
let s = FacetStats::new(q.len());
let both = page(&q, "Refund policy", "Our refund policy.");
let one = page(&q, "", "A refund.");
assert!(s.relevance(&both) > s.relevance(&one));
assert!(s.relevance(&page(&q, "", "nothing")) == 0.0);
}
}