1use 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 #[serde(default)]
50 expect: Option<usize>,
51 #[serde(default)]
52 tolerance: Option<usize>,
53}
54
55impl Case {
56 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 fn correct_label(&self) -> String {
121 let index = if self.noul.is_some() {
122 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 fn tolerance(&self) -> usize {
140 self.tolerance.unwrap_or(0)
141 }
142}
143
144struct Outcome {
146 primitive: &'static str,
147 yes: Option<f64>,
150 faithful: bool,
154 correct: bool,
155 logprobs: BTreeMap<String, f64>,
157 correct_label: String,
158 latency: Duration,
159 truncated: bool,
160 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 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
236fn 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
254fn 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
288fn 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#[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 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 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 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 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
516fn 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
525fn 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
541fn 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
559fn markdown_table(headers: &[&str], rows: &[Vec<String>]) -> String {
565 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 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
628fn calibration_reading(best_t: f64) -> &'static str {
633 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 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 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 #[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 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 #[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 #[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}