use std::collections::{BTreeMap, HashMap, HashSet};
#[derive(Default, Clone)]
struct CatScore {
n: usize,
precision: f64,
recall: f64,
f1: f64,
exact: usize,
answered: usize,
tokens: u64,
}
fn read_jsonl(path: &str) -> Result<Vec<serde_json::Value>, String> {
let body = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
Ok(body.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect())
}
fn doc_set(v: Option<&serde_json::Value>) -> HashSet<u64> {
v.and_then(|x| x.as_array())
.map(|a| a.iter().filter_map(|n| n.as_u64()).collect())
.unwrap_or_default()
}
fn leaf_set(v: &serde_json::Value) -> HashSet<String> {
fn walk(v: &serde_json::Value, out: &mut HashSet<String>) {
match v {
serde_json::Value::String(s) => {
out.insert(s.trim().to_lowercase());
}
serde_json::Value::Number(n) => {
out.insert(n.to_string());
}
serde_json::Value::Bool(b) => {
out.insert(b.to_string());
}
serde_json::Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
serde_json::Value::Object(o) => o.values().for_each(|x| walk(x, out)),
serde_json::Value::Null => {}
}
}
let mut s = HashSet::new();
walk(v, &mut s);
s
}
fn scalar_key(v: &serde_json::Value) -> String {
fn walk(v: &serde_json::Value, out: &mut Vec<String>) {
match v {
serde_json::Value::String(s) => out.push(s.trim().to_lowercase()),
serde_json::Value::Number(n) => out.push(n.to_string()),
serde_json::Value::Bool(b) => out.push(b.to_string()),
serde_json::Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
serde_json::Value::Object(o) => o.values().for_each(|x| walk(x, out)),
serde_json::Value::Null => {}
}
}
let mut parts = Vec::new();
walk(v, &mut parts);
parts.sort();
parts.join("|")
}
fn main() -> Result<(), String> {
let args: Vec<String> = std::env::args().collect();
let (Some(qpath), Some(ppath)) = (args.get(1), args.get(2)) else {
eprintln!("usage: score_benchmark <questions.jsonl> <predictions.jsonl>");
std::process::exit(2);
};
let questions = read_jsonl(qpath)?;
let preds = read_jsonl(ppath)?;
let by_id: HashMap<String, &serde_json::Value> = preds
.iter()
.filter_map(|p| p.get("id").and_then(|i| i.as_str()).map(|i| (i.to_string(), p)))
.collect();
let mut cats: BTreeMap<String, CatScore> = BTreeMap::new();
let mut fabrications = 0usize;
let mut missing = 0usize;
for qn in &questions {
let id = qn.get("id").and_then(|i| i.as_str()).unwrap_or("").to_string();
let category = qn.get("category").and_then(|c| c.as_str()).unwrap_or("other").to_string();
let entry = cats.entry(category.clone()).or_default();
entry.n += 1;
let pred = by_id.get(&id);
if pred.is_none() {
missing += 1;
}
if let Some(p) = pred {
entry.answered += 1;
entry.tokens += p.get("tokens").and_then(|t| t.as_u64()).unwrap_or(0);
}
match category.as_str() {
"unanswerable" => {
let refused = pred
.map(|p| {
p.get("refused").and_then(|r| r.as_bool()).unwrap_or(false)
|| (doc_set(p.get("doc_ids")).is_empty() && p.get("answer").map(|a| a.is_null()).unwrap_or(true))
})
.unwrap_or(false);
if refused {
entry.exact += 1;
entry.precision += 1.0;
entry.recall += 1.0;
entry.f1 += 1.0;
} else if pred.is_some() {
fabrications += 1;
}
}
_ if doc_set(qn.get("gold_doc_ids")).is_empty() && qn.get("gold_answer").map(|a| !a.is_null()).unwrap_or(false) => {
let gold_v = qn.get("gold_answer").unwrap();
let gold_is_list = match gold_v {
serde_json::Value::Object(o) => o.values().any(|v| v.is_array()),
serde_json::Value::Array(_) => true,
_ => false,
};
if gold_is_list {
let (g, p) = (leaf_set(gold_v), pred.and_then(|x| x.get("answer")).map(leaf_set).unwrap_or_default());
let (prec, rec, f) = if g.is_empty() && p.is_empty() {
(1.0, 1.0, 1.0)
} else {
let tp = g.intersection(&p).count() as f64;
let prec = if p.is_empty() { 0.0 } else { tp / p.len() as f64 };
let rec = if g.is_empty() { 0.0 } else { tp / g.len() as f64 };
let f = if prec + rec == 0.0 { 0.0 } else { 2.0 * prec * rec / (prec + rec) };
(prec, rec, f)
};
entry.precision += prec;
entry.recall += rec;
entry.f1 += f;
if (f - 1.0).abs() < 1e-9 {
entry.exact += 1;
}
} else {
let gold = scalar_key(gold_v);
let got = pred.and_then(|p| p.get("answer")).map(scalar_key).unwrap_or_default();
if !gold.is_empty() && gold == got {
entry.exact += 1;
entry.precision += 1.0;
entry.recall += 1.0;
entry.f1 += 1.0;
}
}
}
_ => {
let gold = doc_set(qn.get("gold_doc_ids"));
let got = pred.map(|p| doc_set(p.get("doc_ids"))).unwrap_or_default();
let (p, r, f) = if gold.is_empty() && got.is_empty() {
(1.0, 1.0, 1.0)
} else {
let tp = gold.intersection(&got).count() as f64;
let p = if got.is_empty() { 0.0 } else { tp / got.len() as f64 };
let r = if gold.is_empty() { 0.0 } else { tp / gold.len() as f64 };
let f = if p + r == 0.0 { 0.0 } else { 2.0 * p * r / (p + r) };
(p, r, f)
};
entry.precision += p;
entry.recall += r;
entry.f1 += f;
if (f - 1.0).abs() < 1e-9 {
entry.exact += 1;
}
}
}
}
println!("{:<14} {:>4} {:>6} {:>7} {:>6} {:>7} {:>6} {:>10}", "category", "n", "prec", "recall", "F1", "exact", "ans", "tokens");
let mut macro_f1 = 0.0;
let mut total_tokens = 0u64;
let mut total_n = 0usize;
for (cat, s) in &cats {
let n = s.n.max(1) as f64;
println!(
"{:<14} {:>4} {:>6.3} {:>7.3} {:>6.3} {:>6} {:>7} {:>10}",
cat,
s.n,
s.precision / n,
s.recall / n,
s.f1 / n,
s.exact,
s.answered,
s.tokens
);
macro_f1 += s.f1 / n;
total_tokens += s.tokens;
total_n += s.n;
}
let macro_f1 = macro_f1 / cats.len().max(1) as f64;
println!("\nmacro F1 across {} categories: {macro_f1:.3}", cats.len());
println!("questions: {total_n} unanswered: {missing} fabrications on unanswerable: {fabrications}");
println!("total tokens reported: {total_tokens}");
if fabrications > 0 {
println!("\nNOTE: {fabrications} fabricated answer(s) to questions whose entities are absent from the corpus.");
}
Ok(())
}