metrale_bench/benchmarks/bfcl/aggregate.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Combining BFCL shard results into the score the gate judges.
4//!
5//! Owner: bench, BFCL benchmark.
6//! Invariants: `aggregate` depends only on each subset's summed `(hits, n)`,
7//! so every partition of the same rows gives the same result through `union`.
8//!
9//! # Why shard scores cannot simply be averaged
10//!
11//! `score.py` does not compute a flat mean. It aggregates hierarchically, and
12//! the weights are the whole difficulty:
13//!
14//! ```text
15//! non_live = mean[ mean(simple_python, simple_java, simple_javascript),
16//! multiple, parallel, parallel_multiple ] <- four terms
17//! live = sample-weighted mean over its subsets
18//! hallucination = mean(irrelevance, live_irrelevance) <- unweighted
19//! normalized = mean(the categories present)
20//! overall = flat mean over every scored sample
21//! ```
22//!
23//! So `simple_javascript` (31 rows in the golden draw) weighs the same as
24//! `simple_python` (248 rows), a third of one term each, and `irrelevance` (24
25//! rows) counts equally with `live_irrelevance` (88). A mean of the shards'
26//! `normalized_single_turn_score` is therefore not the whole-set value, and
27//! `overall_accuracy` survives only a sample-count-weighted mean, to the 2
28//! decimal places each shard's JSON is rounded to.
29//!
30//! `score.py` also builds each category from the subsets present, so a subset
31//! missing from a shard changes that category's divisor. `live_parallel` is 16
32//! rows in the golden draw; a quarter of it is four.
33//!
34//! # What this does instead
35//!
36//! Shards report per-subset `(hits, n)` integer counts (`subset_totals`); every
37//! sample scores exactly 0.0 or 1.0 in `score.py`, so nothing is lost. Summing
38//! them across shards and aggregating once applies the weighting to the union,
39//! never to a partial view.
40//!
41//! The arithmetic below is a second implementation of `score.py`'s. A test in
42//! `provision.rs` runs the real `score.py` on a 12-sample fixture and checks
43//! `aggregate` against its output.
44
45use std::collections::BTreeMap;
46
47/// 2026-09-26: `score.py`'s `non_live` subsets, in its order.
48pub const NON_LIVE: [&str; 6] = [
49 "simple_python",
50 "simple_java",
51 "simple_javascript",
52 "multiple",
53 "parallel",
54 "parallel_multiple",
55];
56/// 2026-09-26: `score.py`'s `live` subsets.
57pub const LIVE: [&str; 4] = [
58 "live_simple",
59 "live_multiple",
60 "live_parallel",
61 "live_parallel_multiple",
62];
63/// 2026-09-26: `score.py`'s `hallucination` subsets.
64pub const HALLUCINATION: [&str; 2] = ["irrelevance", "live_irrelevance"];
65/// 2026-09-26: The three `simple_*` subsets collapse to one term inside
66/// `non_live`.
67pub const SIMPLE_AST: [&str; 3] = ["simple_python", "simple_java", "simple_javascript"];
68
69/// 2026-09-26: Hits and sample count for one subset.
70#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
71pub struct Tally {
72 /// 2026-09-26: Samples scored 1.0.
73 pub hits: u64,
74 /// 2026-09-26: Samples scored at all.
75 pub n: u64,
76}
77
78impl Tally {
79 fn mean(self) -> Option<f64> {
80 (self.n > 0).then(|| self.hits as f64 / self.n as f64)
81 }
82}
83
84/// 2026-09-26: The numbers the gate judges.
85#[derive(Clone, Debug, PartialEq)]
86pub struct Aggregate {
87 /// 2026-09-26: Flat mean over every scored sample, x100, rounded to 2dp.
88 pub overall_accuracy: f64,
89 /// 2026-09-26: Unweighted mean of the categories present, x100, rounded to
90 /// 2dp.
91 pub normalized_single_turn_score: f64,
92 /// 2026-09-26: Per category present, x100, rounded to 2dp.
93 pub category_scores: BTreeMap<String, f64>,
94 /// 2026-09-26: Total samples scored.
95 pub total_samples: u64,
96}
97
98fn round2(x: f64) -> f64 {
99 (x * 100.0).round() / 100.0
100}
101
102fn mean(v: &[f64]) -> f64 {
103 if v.is_empty() {
104 0.0
105 } else {
106 v.iter().sum::<f64>() / v.len() as f64
107 }
108}
109
110/// 2026-09-26: Aggregates per-subset tallies with `score.py`'s weighting. Given
111/// the union of every shard's tallies it scores the whole draw; given one
112/// run's tallies it scores that run, which is how the `provision.rs` test
113/// compares it with `score.py`.
114pub fn aggregate(tallies: &BTreeMap<String, Tally>) -> Aggregate {
115 let subset_mean: BTreeMap<&str, f64> = tallies
116 .iter()
117 .filter_map(|(k, t)| t.mean().map(|m| (k.as_str(), m)))
118 .collect();
119
120 let mut category_scores_raw: BTreeMap<String, f64> = BTreeMap::new();
121
122 // 2026-09-26: hallucination: unweighted mean over the subsets present.
123 let present: Vec<f64> = HALLUCINATION
124 .iter()
125 .filter_map(|s| subset_mean.get(s).copied())
126 .collect();
127 if !present.is_empty() {
128 category_scores_raw.insert("hallucination".into(), mean(&present));
129 }
130
131 // 2026-09-26: live: sample-weighted, i.e. the flat mean over live samples.
132 let live: Vec<(&str, f64)> = LIVE
133 .iter()
134 .filter_map(|s| subset_mean.get(s).map(|m| (*s, *m)))
135 .collect();
136 if !live.is_empty() {
137 let total: u64 = live.iter().map(|(s, _)| tallies[*s].n).sum();
138 if total > 0 {
139 let num: f64 = live
140 .iter()
141 .map(|(s, m)| m * tallies[*s].n as f64)
142 .sum::<f64>();
143 category_scores_raw.insert("live".into(), num / total as f64);
144 }
145 }
146
147 // 2026-09-26: non_live: the simple_* subsets present collapse to one term,
148 // then an unweighted mean over that term plus each other subset present.
149 let non_live_present: Vec<&str> = NON_LIVE
150 .iter()
151 .copied()
152 .filter(|s| subset_mean.contains_key(s))
153 .collect();
154 if !non_live_present.is_empty() {
155 let simple: Vec<f64> = SIMPLE_AST
156 .iter()
157 .filter_map(|s| subset_mean.get(s).copied())
158 .collect();
159 let mut top: Vec<f64> = Vec::new();
160 if !simple.is_empty() {
161 top.push(mean(&simple));
162 }
163 for s in &non_live_present {
164 if !SIMPLE_AST.contains(s) {
165 top.push(subset_mean[s]);
166 }
167 }
168 if !top.is_empty() {
169 category_scores_raw.insert("non_live".into(), mean(&top));
170 }
171 }
172
173 let normalized = if category_scores_raw.is_empty() {
174 0.0
175 } else {
176 mean(&category_scores_raw.values().copied().collect::<Vec<_>>())
177 };
178
179 let hits: u64 = tallies.values().map(|t| t.hits).sum();
180 let n: u64 = tallies.values().map(|t| t.n).sum();
181 let overall = if n > 0 { hits as f64 / n as f64 } else { 0.0 };
182
183 Aggregate {
184 overall_accuracy: round2(overall * 100.0),
185 normalized_single_turn_score: round2(normalized * 100.0),
186 category_scores: category_scores_raw
187 .into_iter()
188 .map(|(k, v)| (k, round2(v * 100.0)))
189 .collect(),
190 total_samples: n,
191 }
192}
193
194/// 2026-09-26: Sums shard tallies per subset. A missing or duplicated shard is
195/// not detected here.
196pub fn union(shards: &[BTreeMap<String, Tally>]) -> BTreeMap<String, Tally> {
197 let mut out: BTreeMap<String, Tally> = BTreeMap::new();
198 for shard in shards {
199 for (k, t) in shard {
200 let e = out.entry(k.clone()).or_default();
201 e.hits += t.hits;
202 e.n += t.n;
203 }
204 }
205 out
206}
207
208/// 2026-09-26: Recovers per-subset tallies from a gate record's metrics, the
209/// `subset.<name>.hits` and `subset.<name>.n` keys that `report.rs` writes.
210/// `None` when the record has no tallies, or a subset has hits but no `n`. A
211/// caller treats `None` as "cannot be aggregated", never as an empty
212/// contribution, which would score the group over fewer samples than the draw.
213pub fn tallies_from_metrics(metrics: &BTreeMap<String, f64>) -> Option<BTreeMap<String, Tally>> {
214 let mut out: BTreeMap<String, Tally> = BTreeMap::new();
215 for (k, v) in metrics {
216 let Some(rest) = k.strip_prefix("subset.") else {
217 continue;
218 };
219 if let Some(name) = rest.strip_suffix(".hits") {
220 out.entry(name.to_string()).or_default().hits = *v as u64;
221 } else if let Some(name) = rest.strip_suffix(".n") {
222 out.entry(name.to_string()).or_default().n = *v as u64;
223 }
224 }
225 if out.is_empty() {
226 return None;
227 }
228 // 2026-09-26: Hits with no `n` is malformed; refuse rather than divide by
229 // zero.
230 if out.values().any(|t| t.n == 0 && t.hits > 0) {
231 return None;
232 }
233 Some(out)
234}