1use anyhow::{Context, Result, bail};
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58use sha2::{Digest, Sha256};
59use std::collections::BTreeMap;
60
61pub const NLL_PROBABILITY_FLOOR: f64 = 1e-12;
63
64pub const DEFAULT_BINS: usize = 10;
67
68pub const REPORT_SCHEMA: &str = "jev-bridge-evaluation-v1";
70
71pub const UNLABELLED_FAMILY: &str = "unlabelled";
73
74#[derive(Debug, Clone, Deserialize)]
80pub struct PredictionLine {
81 pub id: String,
83 pub option_ids: Vec<String>,
85 #[serde(default)]
87 pub probabilities: Option<Vec<f64>>,
88 #[serde(default)]
90 pub label: Option<String>,
91}
92
93#[derive(Debug, Clone, Deserialize)]
95pub struct GoldLine {
96 pub id: String,
98 pub gold: String,
100 #[serde(default)]
102 pub family: Option<String>,
103 #[serde(default)]
108 pub positive: Option<String>,
109}
110
111#[derive(Debug, Clone)]
113pub struct Sample {
114 pub id: String,
116 pub family: String,
118 pub gold: String,
120 pub slots: Vec<String>,
122 pub predicted: Option<String>,
124 pub probs: Option<Vec<f64>>,
126 pub positive: Option<String>,
128}
129
130impl Sample {
131 fn gold_index(&self) -> Option<usize> {
133 self.slots.iter().position(|slot| *slot == self.gold)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct ReliabilityBin {
141 pub lower: f64,
143 pub upper: f64,
145 pub n: usize,
147 pub mean_confidence: f64,
149 pub accuracy: f64,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct Metrics {
160 pub n_items: usize,
162 pub n_missing: usize,
164 pub n_predicted: usize,
166 pub n_scored: usize,
168 pub accuracy: Option<f64>,
170 pub balanced_accuracy: Option<f64>,
172 pub classes_in_gold: Vec<String>,
174 pub per_class_recall: BTreeMap<String, f64>,
176 pub nll: Option<f64>,
178 pub brier_multiclass: Option<f64>,
180 pub n_binary: usize,
182 pub brier_binary: Option<f64>,
184 pub ece: Option<f64>,
186 pub reliability: Vec<ReliabilityBin>,
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct Report {
194 pub schema: String,
196 pub bins: usize,
198 pub predictions_sha256: String,
200 pub gold_sha256: String,
202 pub overall: Metrics,
204 pub by_family: BTreeMap<String, Metrics>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct Diff {
211 pub path: String,
213 pub claimed: String,
215 pub recomputed: String,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct VerifyOutcome {
222 pub checks: usize,
224 pub diffs: Vec<Diff>,
226}
227
228impl VerifyOutcome {
229 pub fn is_ok(&self) -> bool {
231 self.diffs.is_empty()
232 }
233
234 pub fn report(&self) -> String {
236 use std::fmt::Write as _;
237 let mut out = String::new();
238 let _ = writeln!(
239 out,
240 "{} checks; {} mismatch{}",
241 self.checks,
242 self.diffs.len(),
243 if self.diffs.len() == 1 { "" } else { "es" }
244 );
245 for diff in &self.diffs {
246 let _ = writeln!(
247 out,
248 " {}: claimed {} vs recomputed {}",
249 diff.path, diff.claimed, diff.recomputed
250 );
251 }
252 out
253 }
254}
255
256pub fn load_predictions(text: &str) -> Result<Vec<PredictionLine>> {
261 let mut lines = Vec::new();
262 for (index, line) in text.lines().enumerate() {
263 let line = line.trim();
264 if line.is_empty() {
265 continue;
266 }
267 let parsed: PredictionLine = serde_json::from_str(line)
268 .with_context(|| format!("predictions: line {} is not a prediction row", index + 1))?;
269 lines.push(parsed);
270 }
271 Ok(lines)
272}
273
274pub fn load_gold(text: &str) -> Result<Vec<GoldLine>> {
276 let mut lines = Vec::new();
277 for (index, line) in text.lines().enumerate() {
278 let line = line.trim();
279 if line.is_empty() {
280 continue;
281 }
282 let parsed: GoldLine = serde_json::from_str(line)
283 .with_context(|| format!("gold: line {} is not a gold row", index + 1))?;
284 lines.push(parsed);
285 }
286 Ok(lines)
287}
288
289fn argmax_first_max(values: &[f64]) -> usize {
292 let mut best = 0;
293 for (index, value) in values.iter().enumerate() {
294 if *value > values[best] {
295 best = index;
296 }
297 }
298 best
299}
300
301pub fn join(predictions: &[PredictionLine], gold: &[GoldLine]) -> Result<Vec<Sample>> {
313 let mut by_id: BTreeMap<&str, &PredictionLine> = BTreeMap::new();
314 for line in predictions {
315 if by_id.insert(line.id.as_str(), line).is_some() {
316 bail!("predictions: duplicate id {:?}", line.id);
317 }
318 }
319
320 let mut seen_gold: BTreeMap<&str, ()> = BTreeMap::new();
321 let mut samples = Vec::with_capacity(gold.len());
322 for line in gold {
323 if seen_gold.insert(line.id.as_str(), ()).is_some() {
324 bail!("gold: duplicate id {:?}", line.id);
325 }
326 let Some(prediction) = by_id.remove(line.id.as_str()) else {
327 samples.push(Sample {
330 id: line.id.clone(),
331 family: line
332 .family
333 .clone()
334 .unwrap_or_else(|| UNLABELLED_FAMILY.to_string()),
335 gold: line.gold.clone(),
336 slots: Vec::new(),
337 predicted: None,
338 probs: None,
339 positive: line.positive.clone(),
340 });
341 continue;
342 };
343
344 if !prediction.option_ids.contains(&line.gold) {
345 bail!(
346 "gold row {:?}: gold {:?} is not among the row's declared options {:?}",
347 line.id,
348 line.gold,
349 prediction.option_ids
350 );
351 }
352
353 let probs = match &prediction.probabilities {
354 None => None,
355 Some(probs) => {
356 if probs.len() != prediction.option_ids.len() {
357 bail!(
358 "predictions row {:?}: {} probabilities for {} options",
359 line.id,
360 probs.len(),
361 prediction.option_ids.len()
362 );
363 }
364 if probs.iter().any(|value| !value.is_finite() || *value < 0.0) {
365 bail!(
366 "predictions row {:?}: non-finite or negative probability",
367 line.id
368 );
369 }
370 Some(probs.clone())
371 }
372 };
373
374 let predicted = match &probs {
375 Some(probs) => {
376 let winner = argmax_first_max(probs);
377 Some(prediction.option_ids[winner].clone())
378 }
379 None => prediction.label.clone(),
380 };
381
382 let positive = line
385 .positive
386 .clone()
387 .or_else(|| (prediction.option_ids == ["true", "false"]).then(|| "true".to_string()));
388 if let Some(positive) = &positive
389 && !prediction.option_ids.iter().any(|slot| slot == positive)
390 {
391 bail!(
392 "gold row {:?}: positive {:?} is not among the row's declared options {:?}",
393 line.id,
394 positive,
395 prediction.option_ids
396 );
397 }
398
399 samples.push(Sample {
400 id: line.id.clone(),
401 family: line
402 .family
403 .clone()
404 .unwrap_or_else(|| UNLABELLED_FAMILY.to_string()),
405 gold: line.gold.clone(),
406 slots: prediction.option_ids.clone(),
407 predicted,
408 probs,
409 positive,
410 });
411 }
412
413 if !by_id.is_empty() {
414 let mut extra: Vec<&str> = by_id.keys().copied().collect();
415 extra.sort_unstable();
416 extra.truncate(5);
417 bail!(
418 "predictions: {} row(s) have no gold counterpart (first: {:?})",
419 by_id.len(),
420 extra
421 );
422 }
423
424 Ok(samples)
425}
426
427fn bin_of(confidence: f64, bins: usize) -> usize {
430 let raw = (confidence * bins as f64).floor();
431 if raw.is_nan() || raw < 0.0 {
432 0
433 } else {
434 (raw as usize).min(bins - 1)
435 }
436}
437
438pub fn summarize(samples: &[Sample], bins: usize) -> Metrics {
445 assert!(bins > 0, "bin count must be >= 1");
446
447 let n_items = samples.len();
448 let n_missing = samples
449 .iter()
450 .filter(|sample| sample.predicted.is_none() && sample.probs.is_none())
451 .count();
452 let predicted: Vec<&Sample> = samples
453 .iter()
454 .filter(|sample| sample.predicted.is_some())
455 .collect();
456 let n_predicted = predicted.len();
457
458 let accuracy = if n_predicted == 0 {
459 None
460 } else {
461 let hits = predicted
462 .iter()
463 .filter(|sample| sample.predicted.as_deref() == Some(sample.gold.as_str()))
464 .count();
465 Some(hits as f64 / n_predicted as f64)
466 };
467
468 let mut tallies: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
470 for sample in &predicted {
471 let entry = tallies.entry(sample.gold.as_str()).or_insert((0, 0));
472 entry.0 += 1;
473 if sample.predicted.as_deref() == Some(sample.gold.as_str()) {
474 entry.1 += 1;
475 }
476 }
477 let per_class_recall: BTreeMap<String, f64> = tallies
478 .iter()
479 .map(|(class, (n, ok))| ((*class).to_string(), *ok as f64 / *n as f64))
480 .collect();
481 let classes_in_gold: Vec<String> = tallies.keys().map(|key| (*key).to_string()).collect();
482 let balanced_accuracy = if per_class_recall.is_empty() {
483 None
484 } else {
485 Some(per_class_recall.values().sum::<f64>() / per_class_recall.len() as f64)
486 };
487
488 let scored: Vec<(&Sample, &[f64])> = samples
489 .iter()
490 .filter_map(|sample| {
491 sample
492 .probs
493 .as_deref()
494 .filter(|probs| probs.len() == sample.slots.len())
495 .map(|probs| (sample, probs))
496 })
497 .collect();
498 let n_scored = scored.len();
499
500 let mut nll_sum = 0.0;
501 let mut brier_multiclass_sum = 0.0;
502 let mut brier_binary_sum = 0.0;
503 let mut n_binary = 0usize;
504 let mut bin_counts = vec![(0.0f64, 0usize, 0usize); bins];
505
506 for (sample, probs) in scored.iter().copied() {
507 let Some(gold_index) = sample.gold_index() else {
511 continue;
512 };
513
514 nll_sum += -probs[gold_index].max(NLL_PROBABILITY_FLOOR).ln();
515
516 let mut squared = 0.0;
517 for (index, probability) in probs.iter().enumerate() {
518 let target = if index == gold_index { 1.0 } else { 0.0 };
519 squared += (probability - target) * (probability - target);
520 }
521 brier_multiclass_sum += squared;
522
523 if let Some(positive) = sample
524 .positive
525 .as_ref()
526 .and_then(|key| sample.slots.iter().position(|slot| slot == key))
527 {
528 let target = if positive == gold_index { 1.0 } else { 0.0 };
529 brier_binary_sum += (probs[positive] - target) * (probs[positive] - target);
530 n_binary += 1;
531 }
532
533 let top = argmax_first_max(probs);
534 let confidence = probs[top];
535 let bin = bin_of(confidence, bins);
536 bin_counts[bin].0 += confidence;
537 bin_counts[bin].1 += 1;
538 if top == gold_index {
539 bin_counts[bin].2 += 1;
540 }
541 }
542
543 let mut reliability = Vec::new();
544 let mut ece = 0.0;
545 for (index, (confidence_sum, n, correct)) in bin_counts.iter().enumerate() {
546 if *n == 0 {
547 continue;
548 }
549 let mean_confidence = confidence_sum / *n as f64;
550 let bin_accuracy = *correct as f64 / *n as f64;
551 ece += (*n as f64 / n_scored as f64) * (bin_accuracy - mean_confidence).abs();
552 reliability.push(ReliabilityBin {
553 lower: index as f64 / bins as f64,
554 upper: (index + 1) as f64 / bins as f64,
555 n: *n,
556 mean_confidence,
557 accuracy: bin_accuracy,
558 });
559 }
560
561 let divide = |sum: f64| -> Option<f64> {
562 if n_scored == 0 {
563 None
564 } else {
565 Some(sum / n_scored as f64)
566 }
567 };
568
569 Metrics {
570 n_items,
571 n_missing,
572 n_predicted,
573 n_scored,
574 accuracy,
575 balanced_accuracy,
576 classes_in_gold,
577 per_class_recall,
578 nll: divide(nll_sum),
579 brier_multiclass: divide(brier_multiclass_sum),
580 n_binary,
581 brier_binary: if n_binary == 0 {
582 None
583 } else {
584 Some(brier_binary_sum / n_binary as f64)
585 },
586 ece: if n_scored == 0 { None } else { Some(ece) },
591 reliability,
592 }
593}
594
595fn sha256_hex(text: &str) -> String {
597 let mut hasher = Sha256::new();
598 hasher.update(text.as_bytes());
599 format!("{:x}", hasher.finalize())
600}
601
602pub fn evaluate(predictions_text: &str, gold_text: &str, bins: usize) -> Result<Report> {
607 if bins == 0 {
608 bail!("bins must be >= 1");
609 }
610 let predictions = load_predictions(predictions_text)?;
611 let gold = load_gold(gold_text)?;
612 let samples = join(&predictions, &gold)?;
613
614 let mut by_family: BTreeMap<String, Vec<Sample>> = BTreeMap::new();
615 for sample in &samples {
616 by_family
617 .entry(sample.family.clone())
618 .or_default()
619 .push(sample.clone());
620 }
621
622 Ok(Report {
623 schema: REPORT_SCHEMA.to_string(),
624 bins,
625 predictions_sha256: sha256_hex(predictions_text),
626 gold_sha256: sha256_hex(gold_text),
627 overall: summarize(&samples, bins),
628 by_family: by_family
629 .iter()
630 .map(|(family, rows)| (family.clone(), summarize(rows, bins)))
631 .collect(),
632 })
633}
634
635pub fn verify(claimed: &Report, recomputed: &Report, tol: f64) -> VerifyOutcome {
642 let mut outcome = VerifyOutcome {
643 checks: 0,
644 diffs: Vec::new(),
645 };
646 let claimed_value = serde_json::to_value(claimed).expect("a Report is always serialisable");
647 let recomputed_value =
648 serde_json::to_value(recomputed).expect("a Report is always serialisable");
649 compare_values(
650 "report",
651 &claimed_value,
652 &recomputed_value,
653 tol,
654 &mut outcome,
655 );
656 outcome
657}
658
659fn compare_values(
661 path: &str,
662 claimed: &Value,
663 recomputed: &Value,
664 tol: f64,
665 outcome: &mut VerifyOutcome,
666) {
667 match (claimed, recomputed) {
668 (Value::Number(claimed), Value::Number(recomputed)) => {
669 outcome.checks += 1;
670 let matches = match (claimed.as_f64(), recomputed.as_f64()) {
671 (Some(claimed), Some(recomputed)) => {
672 (claimed - recomputed).abs() <= tol || claimed == recomputed
673 }
674 _ => false,
675 };
676 if !matches {
677 outcome.diffs.push(Diff {
678 path: path.to_string(),
679 claimed: claimed.to_string(),
680 recomputed: recomputed.to_string(),
681 });
682 }
683 }
684 (Value::Array(claimed), Value::Array(recomputed)) => {
685 if claimed.len() != recomputed.len() {
686 outcome.checks += 1;
687 outcome.diffs.push(Diff {
688 path: path.to_string(),
689 claimed: format!("{} entries", claimed.len()),
690 recomputed: format!("{} entries", recomputed.len()),
691 });
692 return;
693 }
694 for (index, (claimed, recomputed)) in claimed.iter().zip(recomputed).enumerate() {
695 compare_values(
696 &format!("{path}[{index}]"),
697 claimed,
698 recomputed,
699 tol,
700 outcome,
701 );
702 }
703 }
704 (Value::Object(claimed), Value::Object(recomputed)) => {
705 let same_keys = claimed.len() == recomputed.len()
708 && claimed.keys().all(|key| recomputed.contains_key(key));
709 if !same_keys {
710 outcome.checks += 1;
711 outcome.diffs.push(Diff {
712 path: path.to_string(),
713 claimed: format!("keys {:?}", claimed.keys().collect::<Vec<_>>()),
714 recomputed: format!("keys {:?}", recomputed.keys().collect::<Vec<_>>()),
715 });
716 return;
717 }
718 for (key, claimed) in claimed {
719 compare_values(
720 &format!("{path}.{key}"),
721 claimed,
722 &recomputed[key],
723 tol,
724 outcome,
725 );
726 }
727 }
728 (claimed, recomputed) => {
729 outcome.checks += 1;
730 if claimed != recomputed {
731 outcome.diffs.push(Diff {
732 path: path.to_string(),
733 claimed: claimed.to_string(),
734 recomputed: recomputed.to_string(),
735 });
736 }
737 }
738 }
739}
740
741pub fn render_table(report: &Report) -> String {
743 use std::fmt::Write as _;
744 let mut out = String::new();
745 let _ = writeln!(
746 out,
747 "{} rows ({} scored, {} missing) bins={}",
748 report.overall.n_items, report.overall.n_scored, report.overall.n_missing, report.bins
749 );
750 let _ = write_stratum(&mut out, "overall", &report.overall);
751 if !report.by_family.is_empty() {
752 let _ = writeln!(out, "by family:");
753 for (family, metrics) in &report.by_family {
754 let _ = write_stratum(&mut out, &format!(" {family}"), metrics);
755 }
756 }
757 out
758}
759
760fn write_stratum(out: &mut String, name: &str, metrics: &Metrics) -> std::fmt::Result {
761 use std::fmt::Write as _;
762 let show = |value: Option<f64>| match value {
763 Some(value) => format!("{value:.4}"),
764 None => "-".to_string(),
765 };
766 let _ = writeln!(
767 out,
768 "{name}: acc {} balanced {} ece {} nll {} brier {} n={}",
769 show(metrics.accuracy),
770 show(metrics.balanced_accuracy),
771 show(metrics.ece),
772 show(metrics.nll),
773 show(metrics.brier_multiclass),
774 metrics.n_scored,
775 );
776 if metrics.n_binary > 0 {
777 let _ = writeln!(
778 out,
779 " brier_binary {} (n_binary {})",
780 show(metrics.brier_binary),
781 metrics.n_binary
782 );
783 }
784 Ok(())
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 fn sample(gold: &str, probs: Option<[f64; 2]>, positive: Option<&str>) -> Sample {
792 let slots = vec!["A".to_string(), "B".to_string()];
793 let vector = probs.map(|probs| vec![probs[0], probs[1]]);
794 let predicted = vector
795 .as_ref()
796 .map(|vector| slots[argmax_first_max(vector)].clone());
797 Sample {
798 id: format!("s-{gold}-{}", vector.is_some()),
799 family: UNLABELLED_FAMILY.to_string(),
800 gold: gold.to_string(),
801 slots,
802 predicted,
803 probs: vector,
804 positive: positive.map(str::to_owned),
805 }
806 }
807
808 fn known_four() -> Vec<Sample> {
816 vec![
817 sample("A", Some([0.5, 0.5]), Some("A")),
818 sample("A", Some([0.25, 0.75]), Some("A")),
819 sample("B", Some([0.75, 0.25]), Some("A")),
820 sample("A", Some([1.0, 0.0]), Some("A")),
821 ]
822 }
823
824 #[test]
825 fn bin_assignment_clamps_the_top_edge() {
826 assert_eq!(bin_of(0.0, 10), 0);
827 assert_eq!(bin_of(0.05, 10), 0);
828 assert_eq!(bin_of(0.75, 10), 7);
829 assert_eq!(bin_of(0.999, 10), 9);
830 assert_eq!(bin_of(1.0, 10), 9);
831 assert_eq!(bin_of(1.0, 7), 6);
832 assert_eq!(bin_of(-0.25, 10), 0);
833 assert_eq!(bin_of(f64::NAN, 10), 0);
834 }
835
836 #[test]
837 fn argmax_breaks_ties_towards_the_earlier_slot() {
838 assert_eq!(argmax_first_max(&[0.5, 0.5]), 0);
839 assert_eq!(argmax_first_max(&[0.2, 0.8]), 1);
840 assert_eq!(argmax_first_max(&[0.3]), 0);
841 }
842
843 #[test]
844 fn empty_stratum_has_no_numbers() {
845 let metrics = summarize(&[], 10);
846 assert_eq!(metrics.n_items, 0);
847 assert_eq!(metrics.n_scored, 0);
848 assert_eq!(metrics.n_predicted, 0);
849 assert_eq!(metrics.n_missing, 0);
850 assert!(metrics.accuracy.is_none());
851 assert!(metrics.balanced_accuracy.is_none());
852 assert!(metrics.ece.is_none());
853 assert!(metrics.nll.is_none());
854 assert!(metrics.brier_multiclass.is_none());
855 assert!(metrics.brier_binary.is_none());
856 assert!(metrics.reliability.is_empty());
857 }
858
859 #[test]
860 fn the_known_four_row_example_reproduces_hand_computed_values() {
861 let metrics = summarize(&known_four(), 10);
862 assert_eq!(metrics.n_items, 4);
863 assert_eq!(metrics.n_predicted, 4);
864 assert_eq!(metrics.n_scored, 4);
865 assert_eq!(metrics.n_missing, 0);
866 assert_eq!(metrics.accuracy, Some(0.5));
867 assert_eq!(
869 metrics.classes_in_gold,
870 vec!["A".to_string(), "B".to_string()]
871 );
872 assert_eq!(metrics.per_class_recall["A"], 2.0 / 3.0);
873 assert_eq!(metrics.per_class_recall["B"], 0.0);
874 assert_eq!(metrics.balanced_accuracy, Some(1.0 / 3.0));
875 assert_eq!(metrics.brier_binary, Some(0.34375));
877 assert_eq!(metrics.n_binary, 4);
878 assert_eq!(metrics.brier_multiclass, Some(0.6875));
880 assert_eq!(metrics.ece, Some(0.5));
882 assert_eq!(metrics.reliability.len(), 3);
883 assert_eq!(
884 metrics.reliability[0],
885 ReliabilityBin {
886 lower: 0.5,
887 upper: 0.6,
888 n: 1,
889 mean_confidence: 0.5,
890 accuracy: 1.0,
891 }
892 );
893 assert_eq!(
894 metrics.reliability[1],
895 ReliabilityBin {
896 lower: 0.7,
897 upper: 0.8,
898 n: 2,
899 mean_confidence: 0.75,
900 accuracy: 0.0,
901 }
902 );
903 assert_eq!(
904 metrics.reliability[2],
905 ReliabilityBin {
906 lower: 0.9,
907 upper: 1.0,
908 n: 1,
909 mean_confidence: 1.0,
910 accuracy: 1.0,
911 }
912 );
913 let expected = (0.5f64.ln().abs() + 0.25f64.ln().abs() * 2.0) / 4.0;
915 assert!((metrics.nll.unwrap() - expected).abs() < 1e-15);
916 }
917
918 #[test]
919 fn a_perfect_forecast_has_zero_nll_and_zero_ece() {
920 let rows = vec![
921 sample("A", Some([1.0, 0.0]), Some("A")),
922 sample("B", Some([0.0, 1.0]), Some("A")),
923 ];
924 let metrics = summarize(&rows, 10);
925 assert_eq!(metrics.accuracy, Some(1.0));
926 assert_eq!(metrics.balanced_accuracy, Some(1.0));
927 assert_eq!(metrics.ece, Some(0.0));
928 assert_eq!(metrics.nll.unwrap(), 0.0);
929 assert_eq!(metrics.brier_multiclass, Some(0.0));
930 }
931
932 #[test]
933 fn a_maximally_wrong_confident_forecast_saturates_ece_at_one() {
934 let rows = vec![
935 sample("A", Some([0.0, 1.0]), Some("A")),
936 sample("B", Some([1.0, 0.0]), Some("A")),
937 ];
938 let metrics = summarize(&rows, 10);
939 assert_eq!(metrics.accuracy, Some(0.0));
940 assert_eq!(metrics.ece, Some(1.0));
941 assert_eq!(metrics.brier_binary, Some(1.0));
942 }
943
944 #[test]
945 fn the_nll_floor_bounds_an_impossible_gold() {
946 let rows = vec![sample("A", Some([0.0, 1.0]), Some("A"))];
948 let metrics = summarize(&rows, 10);
949 let expected = -NLL_PROBABILITY_FLOOR.ln();
950 assert!((metrics.nll.unwrap() - expected).abs() < 1e-9);
951 assert!(metrics.nll.unwrap().is_finite());
952 }
953
954 #[test]
955 fn rows_without_probabilities_are_excluded_from_nll_brier_and_ece() {
956 let mut rows = known_four();
957 let mut hard = sample("A", None, Some("A"));
958 hard.predicted = Some("A".to_string());
959 rows.push(hard);
960 let metrics = summarize(&rows, 10);
961 assert_eq!(metrics.n_predicted, 5);
963 assert_eq!(metrics.accuracy, Some(3.0 / 5.0));
964 assert_eq!(metrics.n_scored, 4);
966 assert_eq!(metrics.ece, Some(0.5));
967 assert_eq!(metrics.brier_binary, Some(0.34375));
968 }
969
970 #[test]
971 fn missing_rows_are_counted_not_silently_dropped() {
972 let rows = vec![
973 sample("A", Some([0.5, 0.5]), Some("A")),
974 Sample {
975 id: "no-answer".into(),
976 family: UNLABELLED_FAMILY.into(),
977 gold: "A".into(),
978 slots: vec!["A".into(), "B".into()],
979 predicted: None,
980 probs: None,
981 positive: None,
982 },
983 ];
984 let metrics = summarize(&rows, 10);
985 assert_eq!(metrics.n_items, 2);
986 assert_eq!(metrics.n_missing, 1);
987 assert_eq!(metrics.n_predicted, 1);
988 assert_eq!(metrics.n_scored, 1);
989 assert_eq!(metrics.accuracy, Some(1.0));
990 }
991
992 #[test]
993 fn a_bin_count_of_one_is_plain_calibration_gap() {
994 let metrics = summarize(&known_four(), 1);
995 assert_eq!(metrics.reliability.len(), 1);
998 assert_eq!(metrics.reliability[0].lower, 0.0);
999 assert_eq!(metrics.reliability[0].upper, 1.0);
1000 assert_eq!(metrics.reliability[0].n, 4);
1001 assert!((metrics.reliability[0].mean_confidence - 0.75).abs() < 1e-15);
1002 assert_eq!(metrics.reliability[0].accuracy, 0.5);
1003 assert!((metrics.ece.unwrap() - 0.25).abs() < 1e-15);
1004 }
1005
1006 #[test]
1007 fn summarize_is_a_pure_function_of_its_input() {
1008 let rows = known_four();
1009 assert_eq!(summarize(&rows, 10), summarize(&rows, 10));
1010 let finer = summarize(&rows, 25);
1011 assert_ne!(finer, summarize(&rows, 10));
1012 }
1013
1014 fn prediction(
1019 id: &str,
1020 probabilities: Option<Vec<f64>>,
1021 label: Option<&str>,
1022 ) -> PredictionLine {
1023 PredictionLine {
1024 id: id.to_string(),
1025 option_ids: vec!["yes".into(), "no".into()],
1026 probabilities,
1027 label: label.map(str::to_owned),
1028 }
1029 }
1030
1031 fn gold(id: &str, gold: &str) -> GoldLine {
1032 GoldLine {
1033 id: id.to_string(),
1034 gold: gold.to_string(),
1035 family: None,
1036 positive: None,
1037 }
1038 }
1039
1040 #[test]
1041 fn join_pairs_by_id_and_counts_gold_rows_without_predictions() {
1042 let predictions = vec![
1043 prediction("a", Some(vec![0.9, 0.1]), None),
1044 prediction("b", None, Some("no")),
1045 ];
1046 let gold = vec![gold("a", "yes"), gold("b", "no"), gold("c", "yes")];
1047 let samples = join(&predictions, &gold).unwrap();
1048
1049 assert_eq!(samples.len(), 3);
1050 assert_eq!(samples[0].predicted.as_deref(), Some("yes"));
1051 assert_eq!(samples[1].predicted.as_deref(), Some("no"));
1052 assert_eq!(samples[1].probs, None);
1053 assert_eq!(samples[2].predicted, None);
1054 assert_eq!(samples[2].probs, None);
1055 assert_eq!(samples[2].family, UNLABELLED_FAMILY);
1056 }
1057
1058 #[test]
1059 fn join_derives_the_winner_with_the_first_max_rule() {
1060 let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1061 let samples = join(&predictions, &[gold("a", "yes")]).unwrap();
1062 assert_eq!(samples[0].predicted.as_deref(), Some("yes"));
1063 }
1064
1065 #[test]
1066 fn join_defaults_noul_positive_to_true() {
1067 let predictions = vec![PredictionLine {
1068 id: "a".into(),
1069 option_ids: vec!["true".into(), "false".into()],
1070 probabilities: Some(vec![0.8, 0.2]),
1071 label: None,
1072 }];
1073 let samples = join(&predictions, &[gold("a", "true")]).unwrap();
1074 assert_eq!(samples[0].positive.as_deref(), Some("true"));
1075
1076 let predictions = vec![prediction("a", Some(vec![0.8, 0.2]), None)];
1078 let samples = join(&predictions, &[gold("a", "yes")]).unwrap();
1079 assert_eq!(samples[0].positive, None);
1080 }
1081
1082 #[test]
1083 fn join_rejects_an_unmatched_prediction() {
1084 let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1085 let error = join(&predictions, &[gold("b", "yes")]).unwrap_err();
1086 assert!(error.to_string().contains("no gold counterpart"), "{error}");
1087 }
1088
1089 #[test]
1090 fn join_rejects_duplicate_ids_on_either_side() {
1091 let predictions = vec![
1092 prediction("a", Some(vec![0.5, 0.5]), None),
1093 prediction("a", Some(vec![0.5, 0.5]), None),
1094 ];
1095 assert!(join(&predictions, &[gold("a", "yes")]).is_err());
1096
1097 let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1098 let error = join(&predictions, &[gold("a", "yes"), gold("a", "no")]).unwrap_err();
1099 assert!(error.to_string().contains("duplicate id"), "{error}");
1100 }
1101
1102 #[test]
1103 fn join_rejects_a_malformed_vector_or_an_unknown_gold() {
1104 let predictions = vec![prediction("a", Some(vec![0.5, 0.3, 0.2]), None)];
1106 assert!(join(&predictions, &[gold("a", "yes")]).is_err());
1107
1108 let predictions = vec![prediction("a", Some(vec![1.2, -0.2]), None)];
1110 assert!(join(&predictions, &[gold("a", "yes")]).is_err());
1111
1112 let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1114 let error = join(&predictions, &[gold("a", "maybe")]).unwrap_err();
1115 assert!(
1116 error
1117 .to_string()
1118 .contains("not among the row's declared options"),
1119 "{error}"
1120 );
1121 }
1122
1123 #[test]
1124 fn join_rejects_a_positive_that_is_not_a_declared_option() {
1125 let predictions = vec![prediction("a", Some(vec![0.5, 0.5]), None)];
1126 let mut line = gold("a", "yes");
1127 line.positive = Some("maybe".into());
1128 let error = join(&predictions, &[line]).unwrap_err();
1129 assert!(error.to_string().contains("positive"), "{error}");
1130 }
1131
1132 fn predictions_document() -> String {
1137 [
1138 r#"{"id":"a","option_ids":["yes","no"],"probabilities":[0.9,0.1],"label":null}"#,
1139 r#"{"id":"b","option_ids":["yes","no"],"probabilities":[0.4,0.6]}"#,
1140 ]
1141 .join("\n")
1142 }
1143
1144 fn gold_document() -> String {
1145 [
1146 r#"{"id":"a","gold":"yes","family":"one"}"#,
1147 r#"{"id":"b","gold":"no","family":"two"}"#,
1148 ]
1149 .join("\n")
1150 }
1151
1152 #[test]
1153 fn evaluate_stratifies_by_family_and_hashes_its_inputs() {
1154 let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1155
1156 assert_eq!(report.schema, REPORT_SCHEMA);
1157 assert_eq!(report.bins, 10);
1158 assert_eq!(
1159 report.predictions_sha256,
1160 sha256_hex(&predictions_document())
1161 );
1162 assert_eq!(report.gold_sha256, sha256_hex(&gold_document()));
1163 assert_eq!(report.overall.n_items, 2);
1164 assert_eq!(report.overall.accuracy, Some(1.0));
1165 assert_eq!(report.by_family.len(), 2);
1166 assert_eq!(report.by_family["one"].n_items, 1);
1167 assert_eq!(report.by_family["two"].n_items, 1);
1168 }
1169
1170 #[test]
1171 fn evaluate_rejects_zero_bins() {
1172 assert!(evaluate(&predictions_document(), &gold_document(), 0).is_err());
1173 }
1174
1175 #[test]
1176 fn evaluate_accepts_blank_lines_and_ignores_extra_fields() {
1177 let predictions = format!(
1178 "\n{}\n\n{{\"id\":\"b\",\"option_ids\":[\"yes\",\"no\"],\"probabilities\":[0.4,0.6],\
1179 \"option_logprobs\":[-0.9,-0.5],\"prompt_sha256\":\"deadbeef\",\"probe\":\"x\"}}\n",
1180 r#"{"id":"a","option_ids":["yes","no"],"probabilities":[0.9,0.1]}"#
1181 );
1182 let report = evaluate(&predictions, &gold_document(), 10).unwrap();
1183 assert_eq!(report.overall.n_items, 2);
1184 }
1185
1186 #[test]
1187 fn verify_accepts_an_identical_recomputation() {
1188 let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1189 let outcome = verify(&report, &report, 1e-12);
1190 assert!(outcome.is_ok(), "{}", outcome.report());
1191 assert!(
1192 outcome.checks > 0,
1193 "a passing verify must still count checks"
1194 );
1195 }
1196
1197 #[test]
1198 fn verify_detects_a_tampered_number() {
1199 let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1200 let mut tampered = report.clone();
1201 tampered.overall.accuracy = Some(0.25);
1202 let outcome = verify(&tampered, &report, 1e-12);
1203 assert!(!outcome.is_ok());
1204 assert_eq!(outcome.diffs.len(), 1);
1205 assert_eq!(outcome.diffs[0].path, "report.overall.accuracy");
1206 assert!(outcome.report().contains("mismatch"));
1207 }
1208
1209 #[test]
1210 fn verify_detects_a_missing_stratum_and_a_tampered_hash() {
1211 let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1212
1213 let mut dropped = report.clone();
1215 dropped.by_family.remove("two");
1216 assert!(!verify(&dropped, &report, 1e-12).is_ok());
1217
1218 let mut rewritten = report.clone();
1221 rewritten.gold_sha256 = "0".repeat(64);
1222 let outcome = verify(&rewritten, &report, 1e-12);
1223 assert!(!outcome.is_ok());
1224 assert_eq!(outcome.diffs[0].path, "report.gold_sha256");
1225 }
1226
1227 #[test]
1228 fn verify_tolerance_belongs_to_the_caller() {
1229 let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1230 let mut slightly_off = report.clone();
1231 slightly_off.overall.nll = slightly_off.overall.nll.map(|value| value + 1e-6);
1233 assert!(!verify(&slightly_off, &report, 1e-12).is_ok());
1234 assert!(verify(&slightly_off, &report, 1e-3).is_ok());
1235 }
1236
1237 #[test]
1238 fn reports_round_trip_through_json() {
1239 let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1240 let text = serde_json::to_string_pretty(&report).unwrap();
1241 let parsed: Report = serde_json::from_str(&text).unwrap();
1242 assert_eq!(parsed, report);
1243 assert!(verify(&parsed, &report, 1e-12).is_ok());
1244 }
1245
1246 #[test]
1247 fn a_report_with_an_unknown_field_is_rejected() {
1248 let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1249 let mut value = serde_json::to_value(&report).unwrap();
1250 value["overall"]["total_accuracy"] = serde_json::json!(0.99);
1251 assert!(
1252 serde_json::from_value::<Report>(value).is_err(),
1253 "a fabricated metric must fail to load rather than ride along"
1254 );
1255 }
1256
1257 #[test]
1258 fn the_table_names_the_overall_stratum_and_every_family() {
1259 let report = evaluate(&predictions_document(), &gold_document(), 10).unwrap();
1260 let table = render_table(&report);
1261 assert!(table.contains("overall"), "{table}");
1262 assert!(table.contains("one"), "{table}");
1263 assert!(table.contains("two"), "{table}");
1264 }
1265}