use crate::bitmap::Postings;
use crate::index::InfonIndex;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize)]
pub struct FacetScore {
pub facet: String,
pub tokens: usize,
pub coverage: f64,
pub maxcos: f64,
pub nearest: String,
pub gain: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct MeceReport {
pub situations: u32,
pub facets: Vec<FacetScore>,
pub mean_coverage: f64,
pub mean_maxcos: f64,
pub mean_gain: f64,
}
impl MeceReport {
pub fn failing(&self, threshold: f64) -> Vec<&FacetScore> {
self.facets.iter().filter(|f| f.gain < threshold).collect()
}
}
fn facet_incidence<B: Postings>(ix: &InfonIndex<B>, facet: &str) -> B {
let mut acc = B::empty();
for tok in facet_tokens(ix, facet) {
acc.or_inplace(&ix.post(&tok));
}
acc
}
fn facet_tokens<B: Postings>(ix: &InfonIndex<B>, facet: &str) -> Vec<String> {
let prefix = format!("{facet}/");
ix.tokens().filter(|t| *t == facet || t.starts_with(&prefix)).cloned().collect()
}
pub const MIN_SUPPORT: usize = 3;
fn facet_maxcos<B: Postings>(ix: &InfonIndex<B>, a: &[String], b: &[String], cap: usize) -> f64 {
let mut best = 0.0f64;
for ta in a.iter().take(cap) {
let pa = ix.post(ta);
let la = pa.len() as f64;
if pa.len() < MIN_SUPPORT {
continue; }
for tb in b.iter().take(cap) {
let pb = ix.post(tb);
let lb = pb.len() as f64;
if pb.len() < MIN_SUPPORT {
continue;
}
let cos = pa.and(&pb).len() as f64 / (la * lb).sqrt();
if cos > best {
best = cos;
}
}
}
best
}
pub fn report<B: Postings>(ix: &InfonIndex<B>, skip: &[&str]) -> MeceReport {
let n = ix.situations();
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for tok in ix.tokens() {
let f = tok.split('/').next().unwrap_or(tok).to_string();
*counts.entry(f).or_default() += 1;
}
let names: Vec<String> = counts.keys().filter(|f| !skip.contains(&f.as_str())).cloned().collect();
let inc: Vec<B> = names.iter().map(|f| facet_incidence(ix, f)).collect();
let lens: Vec<f64> = inc.iter().map(|b| b.len() as f64).collect();
let toks: Vec<Vec<String>> = names.iter().map(|f| facet_tokens(ix, f)).collect();
let mut facets = Vec::with_capacity(names.len());
for i in 0..names.len() {
let mut maxcos = 0.0f64;
let mut nearest = String::new();
for j in 0..names.len() {
if i == j || lens[i] == 0.0 || lens[j] == 0.0 {
continue;
}
let cos = facet_maxcos(ix, &toks[i], &toks[j], 64);
if cos > maxcos {
maxcos = cos;
nearest = names[j].clone();
}
}
let coverage = if n == 0 { 0.0 } else { lens[i] / n as f64 };
let gain = if lens[i] < 2.0 { 0.0 } else { coverage * (1.0 - maxcos) };
facets.push(FacetScore {
facet: names[i].clone(),
tokens: counts.get(&names[i]).copied().unwrap_or(0),
coverage: round3(coverage),
maxcos: round3(maxcos),
nearest,
gain: round3(gain),
});
}
facets.sort_by(|a, b| b.gain.partial_cmp(&a.gain).unwrap_or(std::cmp::Ordering::Equal));
let k = facets.len().max(1) as f64;
let mean_coverage = round3(facets.iter().map(|f| f.coverage).sum::<f64>() / k);
let mean_maxcos = round3(facets.iter().map(|f| f.maxcos).sum::<f64>() / k);
let mean_gain = round3(facets.iter().map(|f| f.gain).sum::<f64>() / k);
MeceReport { situations: n, facets, mean_coverage, mean_maxcos, mean_gain }
}
fn round3(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RoarPostings;
use std::collections::HashMap;
#[test]
fn rare_terms_cannot_fake_perfect_redundancy() {
let raw = HashMap::from([
("alpha/rare".to_string(), vec![0u32]),
("beta/rare".to_string(), vec![0u32]),
("alpha/common".to_string(), vec![0u32, 1, 2, 3]),
("beta/common".to_string(), vec![4, 5, 6, 7]),
]);
let ix: InfonIndex<RoarPostings> = InfonIndex::from_postings(raw, 8);
let r = report(&ix, &["src"]);
let by = |n: &str| r.facets.iter().find(|f| f.facet == n).unwrap().clone();
assert!(by("alpha").maxcos < 1.0, "rare overlap must not saturate: {:?}", by("alpha"));
assert!(by("alpha").gain > 0.0, "both facets earn gain: {:?}", by("alpha"));
assert!(by("beta").gain > 0.0);
}
#[test]
fn gain_rewards_coverage_and_punishes_redundancy() {
let raw = HashMap::from([
("country/japan".to_string(), vec![0u32, 1, 2, 3]),
("nation/japan".to_string(), vec![0u32, 1, 2, 3]),
("powertrain/electric".to_string(), vec![0, 2]),
("powertrain/diesel".to_string(), vec![1, 3]),
("rare/thing".to_string(), vec![0]),
]);
let ix: InfonIndex<RoarPostings> = InfonIndex::from_postings(raw, 4);
let r = report(&ix, &["src"]);
eprintln!("{}", serde_json::to_string_pretty(&r).unwrap());
let by = |n: &str| r.facets.iter().find(|f| f.facet == n).unwrap().clone();
assert_eq!(by("country").maxcos, 1.0);
assert_eq!(by("country").nearest, "nation");
assert_eq!(by("country").gain, 0.0);
assert_eq!(by("powertrain").coverage, 1.0);
assert!(by("powertrain").gain > 0.0, "disjoint well-covered facet must earn gain");
assert_eq!(r.facets[0].facet, "powertrain", "best gain = broad coverage + low redundancy");
assert!(by("powertrain").maxcos < 1.0, "term-space cosine must not saturate for co-extensive facets");
assert!(by("rare").coverage <= 0.25);
assert!(by("rare").gain < by("powertrain").gain);
let failing: Vec<&str> = r.failing(0.1).iter().map(|f| f.facet.as_str()).collect();
assert!(failing.contains(&"country") && failing.contains(&"nation"));
}
}