Skip to main content

cerno_bench/
main.rs

1//! Measures candidate models against a labelled dataset and writes the comparison table.
2//!
3//! Four things decide a model here, and the first one is a gate rather than a score:
4//!
5//! 1. **Label fidelity** — was the model's most likely first token one of the letters it was
6//!    offered? A model that writes prose instead is unusable for cerno at any accuracy, even
7//!    when a letter turns up further down the ranking and an answer can still be read off it.
8//! 2. **Accuracy** — did it pick the right letter.
9//! 3. **Latency** — the whole point of one forward pass.
10//! 4. **Calibration** — how far its confidence has to be flattened to stop lying.
11//!
12//! Usage:
13//!   cerno-bench --models a,b,c [--reference m] [--dataset p] [--out p] [--host url]
14//!               [--host-kind ollama|openai|vllm|llamacpp|lmstudio]
15//!               [--systemone a,b,c] [--systemone-host url]
16//!
17//! `--systemone` runs the listed models through Ollama's `/v1/systemone` via cerno's
18//! `SystemOneHost` instead of its letter prompt, at `--systemone-host` (Ollama's default address
19//! unless given). Those rows are measured for comparison and never picked as the verdict.
20//!
21//! An API key for the OpenAI-compatible hosts comes from `CERNO_HOST_API_KEY`.
22
23use cerno_core::{Engine, EngineError, labels};
24use cerno_host::{HostKind, ModelHost, OllamaHost, SystemOneHost};
25use cerno_types::{Answer, Calibration, ChoiceSpec, Question, QuestionKind, ScoreSpec};
26use serde::Deserialize;
27use std::collections::BTreeMap;
28use std::sync::Arc;
29use std::time::{Duration, Instant};
30
31#[derive(Debug, Deserialize)]
32struct Dataset {
33    cases: Vec<Case>,
34}
35
36#[derive(Debug, Deserialize)]
37struct Case {
38    id: String,
39    state: String,
40    #[serde(default)]
41    noul: Option<String>,
42    #[serde(default)]
43    expect_yes: Option<bool>,
44    #[serde(default)]
45    choice: Option<ChoiceSpec>,
46    #[serde(default)]
47    score: Option<ScoreSpec>,
48    /// Correct option index for a choice, or correct 1-based level for a score.
49    #[serde(default)]
50    expect: Option<usize>,
51    #[serde(default)]
52    tolerance: Option<usize>,
53}
54
55impl Case {
56    /// Why this case cannot be run, if it cannot. The accessors below assume a case passed, so a
57    /// dataset is checked whole before the first model is loaded, rather than panicking partway
58    /// through a run over whichever case happens to be malformed.
59    fn problem(&self) -> Option<String> {
60        let named = [
61            self.noul.is_some(),
62            self.choice.is_some(),
63            self.score.is_some(),
64        ];
65        if named.iter().filter(|n| **n).count() != 1 {
66            return Some("needs exactly one of noul, choice or score".into());
67        }
68
69        if self.noul.is_some() {
70            return self
71                .expect_yes
72                .is_none()
73                .then(|| "a noul case needs expect_yes".into());
74        }
75
76        let (offered, first) = match (&self.choice, &self.score) {
77            (Some(choice), _) => (choice.options.len(), 0),
78            (_, Some(score)) => (score.levels.count(), 1),
79            _ => unreachable!("exactly one primitive, and it is not a noul"),
80        };
81        if offered == 0 {
82            return Some(format!("this {} offers nothing to pick", self.primitive()));
83        }
84        let last = offered + first - 1;
85        match self.expect {
86            None => Some(format!("a {} case needs expect", self.primitive())),
87            Some(want) if want < first || want > last => Some(format!(
88                "expect is {want}, outside {first}..={last} for this {}",
89                self.primitive()
90            )),
91            Some(_) => None,
92        }
93    }
94
95    fn primitive(&self) -> &'static str {
96        if self.noul.is_some() {
97            "noul"
98        } else if self.choice.is_some() {
99            "choice"
100        } else {
101            "score"
102        }
103    }
104
105    fn question(&self) -> Question {
106        let kind = if let Some(q) = &self.noul {
107            QuestionKind::Noul(q.clone())
108        } else if let Some(c) = &self.choice {
109            QuestionKind::Choice(c.clone())
110        } else {
111            QuestionKind::Score(self.score.clone().expect("case has no primitive"))
112        };
113        Question {
114            id: self.id.clone(),
115            kind,
116        }
117    }
118
119    /// The label the model should have produced.
120    fn correct_label(&self) -> String {
121        let index = if self.noul.is_some() {
122            // "Yes" is label A; see `cerno_core::engine::ballot_for`.
123            if self.expect_yes.expect("noul case needs expect_yes") {
124                0
125            } else {
126                1
127            }
128        } else if self.choice.is_some() {
129            self.expect.expect("choice case needs expect")
130        } else {
131            self.expect.expect("score case needs expect") - 1
132        };
133        labels::label(index)
134            .expect("expectation within the alphabet")
135            .to_string()
136    }
137
138    /// How many levels a score answer may be off and still count as correct.
139    fn tolerance(&self) -> usize {
140        self.tolerance.unwrap_or(0)
141    }
142}
143
144/// One model's result on one case.
145struct Outcome {
146    primitive: &'static str,
147    /// The yes-probability of an answered noul case; `None` for any other primitive and for a
148    /// case that produced no answer.
149    yes: Option<f64>,
150    /// Whether the most likely first token was one of the offered labels. An answer can exist
151    /// without this: the engine reads any label in the top 20 and renormalises, so a model that
152    /// opens with `**` or `The` still gets an answer, and a confident-looking one.
153    faithful: bool,
154    correct: bool,
155    /// Logprobs per label, for refitting the calibration temperature afterwards.
156    logprobs: BTreeMap<String, f64>,
157    correct_label: String,
158    latency: Duration,
159    truncated: bool,
160    /// The label the model actually picked, for agreement against the reference.
161    picked: Option<String>,
162}
163
164fn softmax_of(logprobs: &BTreeMap<String, f64>, temperature: f64) -> BTreeMap<String, f64> {
165    let keys: Vec<&String> = logprobs.keys().collect();
166    let values: Vec<f64> = keys.iter().map(|k| logprobs[*k]).collect();
167    let probabilities = cerno_core::math::softmax(&values, temperature);
168    keys.into_iter().cloned().zip(probabilities).collect()
169}
170
171async fn run_case(engine: &Engine, model: &str, case: &Case) -> Outcome {
172    let question = case.question();
173    let correct_label = case.correct_label();
174    let started = Instant::now();
175
176    let result = engine
177        .answer_with_distribution(&case.state, &question, model, Calibration::default())
178        .await;
179    let latency = started.elapsed();
180
181    match result {
182        Ok((answer, distribution)) => {
183            let logprobs = match &answer {
184                Answer::Noul { raw_logprobs, .. }
185                | Answer::Choice { raw_logprobs, .. }
186                | Answer::Score { raw_logprobs, .. } => raw_logprobs.clone(),
187            };
188            // `raw_logprobs` is keyed by exactly the labels offered.
189            let faithful = top_token_is_a_label(&distribution.tokens, logprobs.keys());
190
191            let (picked_index, correct) = match &answer {
192                Answer::Noul { noul, .. } => {
193                    let yes = *noul >= 0.5;
194                    (if yes { 0 } else { 1 }, yes == case.expect_yes.unwrap())
195                }
196                Answer::Choice { index, .. } => (*index, *index == case.expect.unwrap()),
197                Answer::Score { score, .. } => {
198                    let got = *score as usize;
199                    let want = case.expect.unwrap();
200                    (got - 1, got.abs_diff(want) <= case.tolerance())
201                }
202            };
203
204            Outcome {
205                primitive: case.primitive(),
206                truncated: answer.truncated(),
207                yes: match &answer {
208                    Answer::Noul { noul, .. } => Some(*noul),
209                    _ => None,
210                },
211                faithful,
212                correct,
213                logprobs,
214                correct_label,
215                latency,
216                picked: labels::label(picked_index).map(str::to_string),
217            }
218        }
219        Err(err) => {
220            eprintln!("  {} {}: {}", case.id, case.primitive(), err);
221            Outcome {
222                primitive: case.primitive(),
223                yes: None,
224                faithful: false,
225                correct: false,
226                logprobs: BTreeMap::new(),
227                correct_label,
228                latency,
229                truncated: false,
230                picked: None,
231            }
232        }
233    }
234}
235
236/// Whether the highest-ranked token spells one of `offered`. `tokens` is ranked, highest first.
237fn top_token_is_a_label<'a>(
238    tokens: &[(String, f64)],
239    mut offered: impl Iterator<Item = &'a String>,
240) -> bool {
241    tokens
242        .first()
243        .is_some_and(|(top, _)| offered.any(|label| labels::matches(top, label)))
244}
245
246fn percentile(sorted: &[u128], p: f64) -> u128 {
247    if sorted.is_empty() {
248        return 0;
249    }
250    let rank = ((sorted.len() - 1) as f64 * p).round() as usize;
251    sorted[rank]
252}
253
254/// Grid-search the temperature that best explains the labelled outcomes.
255///
256/// Minimising negative log-likelihood of the correct label is the standard temperature-scaling
257/// fit. Cases where no label came back are skipped — they carry no evidence about calibration,
258/// only about fidelity, which is counted separately.
259fn fit_temperature(outcomes: &[Outcome]) -> (f64, f64) {
260    let usable: Vec<&Outcome> = outcomes.iter().filter(|o| !o.logprobs.is_empty()).collect();
261    if usable.is_empty() {
262        return (1.0, f64::NAN);
263    }
264
265    let mut best = (1.0, f64::INFINITY);
266    let mut t = 0.25;
267    while t <= 8.0 {
268        let nll: f64 = usable
269            .iter()
270            .map(|o| {
271                let p = softmax_of(&o.logprobs, t)
272                    .get(&o.correct_label)
273                    .copied()
274                    .unwrap_or(1e-12);
275                -p.max(1e-12).ln()
276            })
277            .sum::<f64>()
278            / usable.len() as f64;
279
280        if nll < best.1 {
281            best = (t, nll);
282        }
283        t += 0.05;
284    }
285    ((best.0 * 100.0).round() / 100.0, best.1)
286}
287
288/// Mean squared error between the predicted yes-probability and the truth. Noul cases only,
289/// since Brier is defined on a binary outcome.
290fn brier(outcomes: &[Outcome], cases: &[Case]) -> f64 {
291    let mut total = 0.0;
292    let mut n = 0;
293    for (outcome, case) in outcomes.iter().zip(cases) {
294        if let Some(yes) = outcome.yes {
295            let truth = if case.expect_yes.unwrap() { 1.0 } else { 0.0 };
296            total += (yes - truth).powi(2);
297            n += 1;
298        }
299    }
300    if n == 0 { f64::NAN } else { total / n as f64 }
301}
302
303/// Which path answered: cerno's engine, or Ollama's own `/v1/systemone`.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305enum Via {
306    Engine,
307    SystemOne,
308}
309
310struct Report {
311    model: String,
312    via: Via,
313    fidelity: f64,
314    accuracy: f64,
315    per_primitive: BTreeMap<&'static str, f64>,
316    p50: u128,
317    p99: u128,
318    truncation: f64,
319    brier: f64,
320    best_t: f64,
321    agreement: Option<f64>,
322    picked: Vec<Option<String>>,
323}
324
325fn summarise(model: &str, via: Via, outcomes: &[Outcome], cases: &[Case]) -> Report {
326    let n = outcomes.len() as f64;
327
328    let mut latencies: Vec<u128> = outcomes.iter().map(|o| o.latency.as_millis()).collect();
329    latencies.sort_unstable();
330
331    let mut per_primitive = BTreeMap::new();
332    for primitive in ["noul", "choice", "score"] {
333        let subset: Vec<&Outcome> = outcomes
334            .iter()
335            .filter(|o| o.primitive == primitive)
336            .collect();
337        if !subset.is_empty() {
338            let hits = subset.iter().filter(|o| o.correct).count() as f64;
339            per_primitive.insert(primitive, hits / subset.len() as f64);
340        }
341    }
342
343    let (best_t, _) = fit_temperature(outcomes);
344
345    // `/v1/systemone` returns probabilities over the offered answers only: there is no token
346    // ranking to check the top of, and no window for a label to fall out of.
347    let share = |count: usize| match via {
348        Via::Engine => count as f64 / n,
349        Via::SystemOne => f64::NAN,
350    };
351
352    Report {
353        model: model.to_string(),
354        via,
355        fidelity: share(outcomes.iter().filter(|o| o.faithful).count()),
356        accuracy: outcomes.iter().filter(|o| o.correct).count() as f64 / n,
357        per_primitive,
358        p50: percentile(&latencies, 0.50),
359        p99: percentile(&latencies, 0.99),
360        truncation: share(outcomes.iter().filter(|o| o.truncated).count()),
361        brier: brier(outcomes, cases),
362        best_t,
363        agreement: None,
364        picked: outcomes.iter().map(|o| o.picked.clone()).collect(),
365    }
366}
367
368fn arg(args: &[String], name: &str) -> Option<String> {
369    args.iter()
370        .position(|a| a == name)
371        .and_then(|i| args.get(i + 1))
372        .cloned()
373}
374
375#[tokio::main(flavor = "current_thread")]
376async fn main() -> Result<(), Box<dyn std::error::Error>> {
377    let args: Vec<String> = std::env::args().collect();
378
379    let models = list(
380        &arg(&args, "--models")
381            .unwrap_or_else(|| "gemma4:e2b-it-qat,granite4:3b,phi4-mini:3.8b".into()),
382    );
383    let systemone_models = list(&arg(&args, "--systemone").unwrap_or_default());
384    let reference = arg(&args, "--reference");
385    let dataset_path =
386        arg(&args, "--dataset").unwrap_or_else(|| "crates/cerno-bench/dataset.json".into());
387    let out_path = arg(&args, "--out").unwrap_or_else(|| "docs/model-selection.md".into());
388    let host_kind: HostKind = arg(&args, "--host-kind")
389        .unwrap_or_else(|| "ollama".into())
390        .parse()?;
391    let host_url = arg(&args, "--host").unwrap_or_else(|| host_kind.default_url().into());
392    let systemone_url =
393        arg(&args, "--systemone-host").unwrap_or_else(|| HostKind::Ollama.default_url().into());
394
395    let dataset: Dataset = serde_json::from_str(&std::fs::read_to_string(&dataset_path)?)?;
396    let problems: Vec<String> = dataset
397        .cases
398        .iter()
399        .filter_map(|case| case.problem().map(|p| format!("  {}: {p}", case.id)))
400        .collect();
401    if !problems.is_empty() {
402        eprintln!(
403            "{dataset_path} has cases that cannot run:\n{}",
404            problems.join("\n")
405        );
406        std::process::exit(1);
407    }
408    println!("{} cases from {dataset_path}", dataset.cases.len());
409
410    // The letter prompt for every model, decision models included: `connect` would send those
411    // to `/v1/systemone` and report Ollama's prompt as cerno's.
412    let host: Arc<dyn ModelHost> = match host_kind {
413        HostKind::Ollama => Arc::new(OllamaHost::new(&host_url, Duration::from_secs(120))?),
414        _ => cerno_host::connect(
415            host_kind,
416            &host_url,
417            std::env::var("CERNO_HOST_API_KEY").ok(),
418            Duration::from_secs(120),
419        )?,
420    };
421    // Keep each model resident for the length of its run; unloading between cases would measure
422    // model loading, not inference.
423    let engine = Engine::new(host, Some("5m".to_string()));
424    let systemone = if systemone_models.is_empty() {
425        None
426    } else {
427        Some(Engine::new(
428            Arc::new(SystemOneHost::new(
429                &systemone_url,
430                Duration::from_secs(120),
431            )?),
432            Some("5m".to_string()),
433        ))
434    };
435
436    let mut reports = Vec::new();
437    let mut reference_picks: Option<Vec<Option<String>>> = None;
438
439    let all: Vec<(String, Via)> = reference
440        .iter()
441        .chain(&models)
442        .map(|m| (m.clone(), Via::Engine))
443        .chain(systemone_models.iter().map(|m| (m.clone(), Via::SystemOne)))
444        .collect();
445
446    for (model, via) in &all {
447        let (model, via) = (model.as_str(), *via);
448
449        // Warm-up: the first call pays for loading the model into VRAM and would otherwise
450        // dominate the p99. It is also where a misspelt model or an unreachable host shows:
451        // measured anyway, every case would fail, the model would be reported at 0% fidelity for
452        // a fault that is not its own, and the doc would be overwritten with that. A model that
453        // answers without a letter is different — that is exactly what the run is here to count.
454        let mut outcomes = Vec::with_capacity(dataset.cases.len());
455        let (engine, name) = match via {
456            Via::Engine => (&engine, model.to_string()),
457            Via::SystemOne => (
458                systemone
459                    .as_ref()
460                    .expect("a systemone model means a systemone engine"),
461                format!("{model} via /v1/systemone"),
462            ),
463        };
464        println!("\n{name}");
465        let warm_up = engine
466            .answer(
467                "warm up",
468                &warm_up_question(),
469                model,
470                Calibration::default(),
471            )
472            .await;
473        if let Err(err @ (EngineError::Host(_) | EngineError::UnknownModel { .. })) = warm_up {
474            eprintln!("{name}: the warm-up failed, so nothing was measured or written: {err}");
475            std::process::exit(1);
476        }
477        for case in &dataset.cases {
478            outcomes.push(run_case(engine, model, case).await);
479        }
480
481        let mut report = summarise(model, via, &outcomes, &dataset.cases);
482
483        if via == Via::Engine && reference.as_deref() == Some(model) {
484            reference_picks = Some(report.picked.clone());
485        } else if let Some(reference_picks) = &reference_picks {
486            let agreed = report
487                .picked
488                .iter()
489                .zip(reference_picks)
490                .filter(|(a, b)| a.is_some() && a == b)
491                .count();
492            report.agreement = Some(agreed as f64 / dataset.cases.len() as f64);
493        }
494
495        println!(
496            "  fidelity {}  accuracy {}  p50 {}ms  p99 {}ms  best T {:.2}",
497            pct(report.fidelity),
498            pct(report.accuracy),
499            report.p50,
500            report.p99,
501            report.best_t
502        );
503        reports.push(report);
504    }
505
506    let markdown = render(&reports, &dataset, reference.as_deref());
507    if let Some(parent) = std::path::Path::new(&out_path).parent() {
508        std::fs::create_dir_all(parent)?;
509    }
510    std::fs::write(&out_path, &markdown)?;
511    println!("\nwrote {out_path}");
512
513    Ok(())
514}
515
516/// A comma-separated model list, blanks dropped, so `--models ""` means none.
517fn list(value: &str) -> Vec<String> {
518    value
519        .split(',')
520        .map(|s| s.trim().to_string())
521        .filter(|s| !s.is_empty())
522        .collect()
523}
524
525/// Sent once per model before measuring, on either path, and discarded.
526fn warm_up_question() -> Question {
527    Question {
528        id: "warmup".into(),
529        kind: QuestionKind::Noul("Is this a warm-up?".into()),
530    }
531}
532
533fn pct(v: f64) -> String {
534    if v.is_nan() {
535        "—".into()
536    } else {
537        format!("{:.0}%", v * 100.0)
538    }
539}
540
541/// The best candidate: highest accuracy among models whose top token was an offered label in
542/// every case, ties broken by median latency. Fidelity is a gate first — a model cerno cannot read is
543/// not a candidate at any accuracy — and the reference is excluded, since it is the yardstick
544/// rather than an option. A `/v1/systemone` row is never a candidate: it measured Ollama's path,
545/// and the verdict names a model for cerno's.
546fn pick_winner<'a>(reports: &'a [Report], reference: Option<&str>) -> Option<&'a Report> {
547    reports
548        .iter()
549        .filter(|r| r.via == Via::Engine)
550        .filter(|r| Some(r.model.as_str()) != reference)
551        .filter(|r| r.fidelity >= 1.0)
552        .min_by(|a, b| {
553            b.accuracy
554                .total_cmp(&a.accuracy)
555                .then_with(|| a.p50.cmp(&b.p50))
556        })
557}
558
559/// Render a markdown table with padded cells, the first column left-aligned and the rest right.
560///
561/// Markdown does not need the padding, but a generated file is read as often in a terminal as in
562/// a renderer, and an unpadded table with a column as wide and as variable as a model name is
563/// hard to scan in either. Right-aligning the numbers lines up their digits.
564fn markdown_table(headers: &[&str], rows: &[Vec<String>]) -> String {
565    // Column width in characters. Every cell here is ASCII apart from the dagger and the em
566    // dash, neither of which is double-width, so counting chars is the right measure.
567    let widths: Vec<usize> = headers
568        .iter()
569        .enumerate()
570        .map(|(i, header)| {
571            rows.iter()
572                .filter_map(|row| row.get(i))
573                .map(|cell| cell.chars().count())
574                .chain(std::iter::once(header.chars().count()))
575                .max()
576                .unwrap_or(0)
577        })
578        .collect();
579
580    let pad = |cell: &str, width: usize, right: bool| {
581        let fill = " ".repeat(width.saturating_sub(cell.chars().count()));
582        if right {
583            format!("{fill}{cell}")
584        } else {
585            format!("{cell}{fill}")
586        }
587    };
588
589    let mut out = String::new();
590
591    let header: Vec<String> = headers
592        .iter()
593        .zip(&widths)
594        .enumerate()
595        .map(|(i, (h, w))| pad(h, *w, i > 0))
596        .collect();
597    out.push_str(&format!("| {} |\n", header.join(" | ")));
598
599    // A cell occupies width + 2 columns, from the space either side of it. The rule has one
600    // alignment colon and no spaces, so it needs width + 1 dashes to line up with the rest.
601    let rule: Vec<String> = widths
602        .iter()
603        .enumerate()
604        .map(|(i, w)| {
605            let dashes = "-".repeat(w + 1);
606            if i == 0 {
607                format!(":{dashes}")
608            } else {
609                format!("{dashes}:")
610            }
611        })
612        .collect();
613    out.push_str(&format!("|{}|\n", rule.join("|")));
614
615    for row in rows {
616        let cells: Vec<String> = row
617            .iter()
618            .zip(&widths)
619            .enumerate()
620            .map(|(i, (cell, w))| pad(cell, *w, i > 0))
621            .collect();
622        out.push_str(&format!("| {} |\n", cells.join(" | ")));
623    }
624
625    out
626}
627
628/// What a best-fit temperature says about the model, in words that stay true for any value.
629///
630/// Written out rather than fixed in the template: the verdict is regenerated with every run, and
631/// a sentence that only fits one outcome turns false the first time another model wins.
632fn calibration_reading(best_t: f64) -> &'static str {
633    // The grid steps by 0.05, so anything this close to 1 is 1 within the fit's resolution.
634    if (best_t - 1.0).abs() <= 0.1 {
635        "that is close enough to 1 that the model's own probabilities can be taken as they are"
636    } else if best_t < 1.0 {
637        "a value below 1 means the model is *under*confident and would need sharpening rather \
638         than flattening"
639    } else {
640        "a value above 1 means the model is *over*confident and its probabilities need \
641         flattening before they mean anything"
642    }
643}
644
645fn render(reports: &[Report], dataset: &Dataset, reference: Option<&str>) -> String {
646    let mut out = String::new();
647    out.push_str("# Model selection\n\n");
648    out.push_str(&format!(
649        "Generated by `cargo run -p cerno-bench`. {} labelled cases: {} noul, {} choice, {} score.\n\n",
650        dataset.cases.len(),
651        dataset.cases.iter().filter(|c| c.noul.is_some()).count(),
652        dataset.cases.iter().filter(|c| c.choice.is_some()).count(),
653        dataset.cases.iter().filter(|c| c.score.is_some()).count(),
654    ));
655
656    let headers = [
657        "Model",
658        "Fidelity",
659        "Accuracy",
660        "noul",
661        "choice",
662        "score",
663        "p50 (ms)",
664        "p99 (ms)",
665        "Truncated",
666        "Brier",
667        "Best T",
668        "Agreement",
669    ];
670
671    let mut reference_seen = false;
672    let mut systemone_seen = false;
673    let rows: Vec<Vec<String>> = reports
674        .iter()
675        .map(|r| {
676            // The reference is marked with a footnote numeral rather than an inline
677            // "(reference)": the model column already holds the widest values in the table, and
678            // a parenthetical there widened it by half again and left the column ragged. Not a
679            // dagger: it reads as a cross to anyone who hasn't met it as a footnote mark.
680            let is_reference = r.via == Via::Engine && Some(r.model.as_str()) == reference;
681            let is_systemone = r.via == Via::SystemOne;
682            reference_seen |= is_reference;
683            systemone_seen |= is_systemone;
684
685            vec![
686                format!(
687                    "`{}`{}{}",
688                    r.model,
689                    if is_reference { " ¹" } else { "" },
690                    if is_systemone { " ²" } else { "" }
691                ),
692                pct(r.fidelity),
693                pct(r.accuracy),
694                pct(r.per_primitive.get("noul").copied().unwrap_or(f64::NAN)),
695                pct(r.per_primitive.get("choice").copied().unwrap_or(f64::NAN)),
696                pct(r.per_primitive.get("score").copied().unwrap_or(f64::NAN)),
697                r.p50.to_string(),
698                r.p99.to_string(),
699                pct(r.truncation),
700                format!("{:.3}", r.brier),
701                format!("{:.2}", r.best_t),
702                r.agreement.map(pct).unwrap_or_else(|| "—".into()),
703            ]
704        })
705        .collect();
706
707    out.push_str(&markdown_table(&headers, &rows));
708
709    if reference_seen {
710        out.push_str(
711            "\n¹ Reference model — the yardstick the Agreement column is measured against, \
712             not a candidate.\n",
713        );
714    }
715
716    if systemone_seen {
717        out.push_str(
718            "\n² Answered by Ollama's own `/v1/systemone` endpoint, not by cerno's engine: Ollama \
719             builds the model's prompt and returns probabilities over the offered answers only. \
720             Fidelity and Truncated do not apply, Best T is fitted to those probabilities, and \
721             the row is not a candidate for the verdict.\n",
722        );
723    }
724
725    if let Some(winner) = pick_winner(reports, reference) {
726        out.push_str(&format!(
727            "\n## Verdict\n\n\
728             **`{}`** — the most accurate model whose most likely token was an offered letter \
729             in every case, ties broken by median latency.\n\n\
730             Set it as the default with `CERNO_DEFAULT_MODEL={}`. Its best-fit calibration \
731             temperature above is {:.2}; {}. cerno ships a default of 1.0 either way — {} \
732             labelled cases is thin evidence for baking in an adjustment, and callers can always \
733             pass their own.\n",
734            winner.model,
735            winner.model,
736            winner.best_t,
737            calibration_reading(winner.best_t),
738            dataset.cases.len(),
739        ));
740    }
741
742    out.push_str(
743        "\n## Reading the table\n\n\
744         - **Fidelity** — share of cases whose most likely first token was one of the offered\n  \
745           letters. This is a gate, not a score: below 100% the model is ignoring the instruction\n  \
746           on some inputs, and cerno then reads its answer off tokens it was not going to write.\n\
747         - **Accuracy** — share of cases where the picked label was the labelled one. Score cases\n  \
748           allow the per-case tolerance in the dataset.\n\
749         - **p50 / p99** — wall-clock per question, warm model, one question per request.\n\
750         - **Truncated** — share of answers where some label fell outside the host's top-20 window,\n  \
751           making its probability an upper bound rather than an observation.\n\
752         - **Brier** — mean squared error of the yes-probability on noul cases; lower is better.\n\
753         - **Best T** — the calibration temperature that best explains the labelled outcomes. Far\n  \
754           above 1 means the model is overconfident and needs flattening.\n\
755         - **Agreement** — share of cases where the model picked the same label as the reference.\n",
756    );
757
758    out
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    /// The table is read in a terminal as often as in a renderer, so every row — the rule
766    /// included — has to be the same width. Pinned character for character.
767    fn case(json: serde_json::Value) -> Case {
768        let mut base = serde_json::json!({"id": "c", "state": "s"});
769        base.as_object_mut()
770            .unwrap()
771            .extend(json.as_object().unwrap().clone());
772        serde_json::from_value(base).unwrap()
773    }
774
775    /// The checked-in dataset is what `cargo run -p cerno-bench` uses; it has to pass its own check.
776    #[test]
777    fn every_shipped_case_can_run() {
778        let text = std::fs::read_to_string(
779            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("dataset.json"),
780        )
781        .unwrap();
782        let dataset: Dataset = serde_json::from_str(&text).unwrap();
783
784        for case in &dataset.cases {
785            assert_eq!(case.problem(), None, "{}", case.id);
786        }
787    }
788
789    #[test]
790    fn a_case_that_could_not_be_scored_is_found_before_running() {
791        let bad = [
792            serde_json::json!({}),
793            serde_json::json!({"noul": "a?", "choice": {"options": ["x", "y"]}, "expect_yes": true}),
794            serde_json::json!({"noul": "a?"}),
795            serde_json::json!({"choice": {"options": ["x", "y"]}}),
796            serde_json::json!({"choice": {"options": ["x", "y"]}, "expect": 2}),
797            serde_json::json!({"choice": {"options": []}, "expect": 0}),
798            // Score levels are 1-based; 0 would have underflowed computing the label.
799            serde_json::json!({"score": {"levels": 5}, "expect": 0}),
800            serde_json::json!({"score": {"levels": 5}, "expect": 6}),
801        ];
802        for json in bad {
803            assert!(
804                case(json.clone()).problem().is_some(),
805                "{json} was accepted"
806            );
807        }
808
809        for good in [
810            serde_json::json!({"noul": "a?", "expect_yes": false}),
811            serde_json::json!({"choice": {"options": ["x", "y"]}, "expect": 1}),
812            serde_json::json!({"score": {"levels": 5}, "expect": 5}),
813        ] {
814            assert_eq!(case(good.clone()).problem(), None, "{good}");
815        }
816    }
817
818    #[test]
819    fn table_columns_line_up_exactly() {
820        let table = markdown_table(
821            &["Model", "p50 (ms)"],
822            &[
823                vec!["`gemma4:e2b-it-qat`".into(), "37".into()],
824                vec!["`x`".into(), "1234".into()],
825            ],
826        );
827
828        assert_eq!(
829            table,
830            "\
831| Model               | p50 (ms) |
832|:--------------------|---------:|
833| `gemma4:e2b-it-qat` |       37 |
834| `x`                 |     1234 |
835"
836        );
837    }
838
839    #[test]
840    fn every_line_of_a_table_is_the_same_width() {
841        let table = markdown_table(
842            &["Model", "Brier", "Agreement"],
843            &[
844                vec![
845                    "`gemma4:26b-a4b-it-q4_K_M` ¹".into(),
846                    "0.029".into(),
847                    "—".into(),
848                ],
849                vec!["`granite4:3b`".into(), "0.168".into(), "75%".into()],
850            ],
851        );
852
853        let widths: Vec<usize> = table.lines().map(|l| l.chars().count()).collect();
854
855        assert!(
856            widths.windows(2).all(|w| w[0] == w[1]),
857            "ragged table, line widths {widths:?}:\n{table}"
858        );
859    }
860
861    /// A value wider than its header must widen the column, not overflow it.
862    /// A letter further down the ranking is enough for an answer, not for fidelity.
863    #[test]
864    fn only_a_label_at_the_top_is_faithful() {
865        let offered = ["A".to_string(), "B".to_string()];
866        let ranked = |tokens: &[(&str, f64)]| -> Vec<(String, f64)> {
867            tokens.iter().map(|(t, l)| (t.to_string(), *l)).collect()
868        };
869
870        assert!(top_token_is_a_label(
871            &ranked(&[(" b", -0.1), ("A", -3.0)]),
872            offered.iter()
873        ));
874        assert!(!top_token_is_a_label(
875            &ranked(&[("**", -0.01), ("A", -8.0)]),
876            offered.iter()
877        ));
878        assert!(!top_token_is_a_label(&[], offered.iter()));
879    }
880
881    /// The verdict is regenerated every run, so its sentence must fit whichever value comes out.
882    #[test]
883    fn the_calibration_reading_follows_the_temperature() {
884        assert!(calibration_reading(0.8).contains("*under*confident"));
885        assert!(calibration_reading(3.2).contains("*over*confident"));
886        assert!(calibration_reading(1.05).contains("close enough to 1"));
887    }
888
889    #[test]
890    fn a_long_cell_widens_its_column() {
891        let table = markdown_table(&["T"], &[vec!["a-very-long-value".into()]]);
892
893        assert!(table.starts_with("| T                 |\n"), "{table}");
894    }
895
896    #[test]
897    fn a_systemone_row_never_wins_the_verdict() {
898        let report = |model: &str, via, fidelity, accuracy| Report {
899            model: model.into(),
900            via,
901            fidelity,
902            accuracy,
903            per_primitive: BTreeMap::new(),
904            p50: 10,
905            p99: 10,
906            truncation: 0.0,
907            brier: 0.0,
908            best_t: 1.0,
909            agreement: None,
910            picked: Vec::new(),
911        };
912        let reports = [
913            report("nimble", Via::SystemOne, 1.0, 1.0),
914            report("granite4:3b", Via::Engine, 1.0, 0.5),
915        ];
916
917        let winner = pick_winner(&reports, None).unwrap();
918        assert_eq!(winner.model, "granite4:3b");
919        assert_eq!(winner.via, Via::Engine);
920    }
921}