use crate::db::Corpus;
use serde_json::{json, Value};
fn skip_facet(f: &str) -> bool {
matches!(f, "src" | "rel" | "source")
}
fn shannon(counts: &[u64], total: u64) -> f64 {
if total == 0 {
return 0.0;
}
-counts
.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / total as f64;
p * p.ln()
})
.sum::<f64>()
}
struct FacetStat {
facet: String,
cardinality: usize,
support: u64,
score: f64,
breakdown: Value,
}
fn sniff(corpus: &Corpus, anchor: &str) -> Vec<FacetStat> {
let mut out = Vec::new();
for f in corpus.facet_names() {
if skip_facet(&f) {
continue;
}
let bd = corpus.breakdown(anchor, &f, 50);
let counts: Vec<u64> = bd
.get("partition")
.and_then(|p| p.as_array())
.map(|a| a.iter().filter_map(|x| x.get("count").and_then(|c| c.as_u64())).collect())
.unwrap_or_default();
let support: u64 = counts.iter().sum();
let card = counts.len();
if support == 0 || card == 0 {
continue;
}
let entropy = shannon(&counts, support);
let card_w = match card {
1 => 0.0,
2..=25 => 1.0,
_ => 0.35,
};
let score = support as f64 * (entropy + 0.1) * card_w;
out.push(FacetStat { facet: f, cardinality: card, support, score, breakdown: bd });
}
out.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
out
}
pub fn auto_profile(corpus: &Corpus, anchor: &str) -> Value {
let stats = sniff(corpus, anchor);
if stats.is_empty() {
return json!({ "program": "profile", "anchor": anchor, "sections": [], "note": "no informative facets in scope" });
}
let total = stats.iter().map(|s| s.support).max().unwrap_or(0);
let mut sections: Vec<Value> = Vec::new();
for s in stats.iter().take(3) {
sections.push(json!({ "kind": "breakdown", "score": s.score, "result": s.breakdown }));
}
let low: Vec<&FacetStat> = stats.iter().filter(|s| (2..=15).contains(&s.cardinality)).collect();
if low.len() >= 2 {
let ct = corpus.crosstab(anchor, &low[0].facet, &low[1].facet, 8);
sections.push(json!({ "kind": "crosstab", "result": ct }));
}
if let Some(hi) = stats.iter().find(|s| s.cardinality > 15) {
sections.push(json!({ "kind": "rank", "result": corpus.rank(&hi.facet, 6) }));
}
let clusters = corpus.s_clusters(2, 4);
if clusters.get("clusters").and_then(|c| c.as_array()).map(|a| !a.is_empty()).unwrap_or(false) {
sections.push(json!({ "kind": "clusters", "result": clusters }));
}
json!({ "program": "profile", "anchor": anchor, "scope_size": total, "sections": sections })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shannon_entropy_basics() {
assert!((shannon(&[5, 5], 10) - std::f64::consts::LN_2).abs() < 1e-9); assert!(shannon(&[10], 10).abs() < 1e-9); assert!(shannon(&[9, 1], 10) < shannon(&[5, 5], 10)); }
}