use std::collections::HashMap;
use super::labels::LabelMap;
use super::model::{OutputColumn, ReportResult, StatValue, SummaryRow, Verdict};
pub const COMPARED_LABEL: &str = "Compared";
pub const INCORRECT_LABEL: &str = "Incorrect";
pub const ACCURACY_LABEL: &str = "Accuracy";
pub const FIXED_LABEL: &str = "Fixed";
pub const REGRESSED_LABEL: &str = "Regressed";
pub const STILL_WRONG_LABEL: &str = "Still wrong";
pub const UNCHANGED_LABEL: &str = "Unchanged";
pub const MOVEMENT_LABEL: &str = "Movement";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnMetrics {
pub header: String,
pub total: usize,
pub compared: usize,
pub correct: usize,
pub incorrect: usize,
pub matrix: Option<ConfusionMatrix>,
}
impl ColumnMetrics {
pub fn accuracy(&self) -> Option<f64> {
(self.compared > 0).then(|| self.correct as f64 / self.compared as f64)
}
pub fn accuracy_text(&self) -> Option<String> {
self.accuracy().map(|a| format!("{:.1}%", a * 100.0))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfusionMatrix {
pub axis: Vec<String>,
pub counts: Vec<Vec<usize>>,
}
impl ConfusionMatrix {
pub fn max(&self) -> usize {
self.counts
.iter()
.flat_map(|r| r.iter().copied())
.max()
.unwrap_or(0)
}
pub fn is_diagonal(&self) -> bool {
self.counts
.iter()
.enumerate()
.all(|(t, row)| row.iter().enumerate().all(|(p, &n)| t == p || n == 0))
}
pub fn total(&self) -> usize {
self.counts.iter().flat_map(|r| r.iter()).sum()
}
}
pub fn heat_rgb(n: usize, max: usize) -> ([u8; 3], bool) {
if n == 0 || max == 0 {
return ([0xff, 0xff, 0xff], false);
}
let f = n as f64 / max as f64;
let lerp = |from: f64, to: f64| (from + (to - from) * f).round() as u8;
(
[lerp(234.0, 8.0), lerp(242.0, 48.0), lerp(252.0, 107.0)],
f > 0.55,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Movement {
pub fixed: usize,
pub regressed: usize,
pub still_wrong: usize,
pub unchanged: usize,
}
impl Movement {
pub fn is_still(&self) -> bool {
self.fixed == 0 && self.regressed == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Metrics {
pub columns: Vec<ColumnMetrics>,
pub overall: Option<ColumnMetrics>,
pub movement: Option<Movement>,
}
impl Metrics {
pub fn compute(
result: &ReportResult,
columns: &[OutputColumn],
labels: &LabelMap,
) -> Option<Metrics> {
if result.verdicts.is_empty() {
return None;
}
let total = result.rows.len() - result.pending.len();
let mut out = Vec::new();
for col in columns.iter().filter(|c| c.truth.is_some()) {
let mut m = ColumnMetrics {
header: col.header.clone(),
total,
compared: 0,
correct: 0,
incorrect: 0,
matrix: None,
};
let mut axis: Vec<String> = labels.classes().to_vec();
let mut index: HashMap<String, usize> = axis
.iter()
.enumerate()
.map(|(i, c)| (c.clone(), i))
.collect();
let mut pairs: Vec<(usize, usize)> = Vec::new();
for (r, row) in result.rows.iter().enumerate() {
if result.pending.contains(&r) {
continue;
}
let key = (r, col.header.clone());
match result.verdicts.get(&key) {
Some(Verdict::Correct) => m.correct += 1,
Some(Verdict::Incorrect) => m.incorrect += 1,
_ => continue,
}
m.compared += 1;
let Some(truth) = result.truths.get(&key) else {
continue;
};
let predicted = col.value(row, &result.no_match_marker);
let mut slot = |value: &str, axis: &mut Vec<String>| -> usize {
let label = labels.label_of(value);
*index.entry(label.clone()).or_insert_with(|| {
axis.push(label);
axis.len() - 1
})
};
let t = slot(truth, &mut axis);
let p = slot(&predicted, &mut axis);
pairs.push((t, p));
}
if !labels.classes().is_empty() {
let n = axis.len();
let mut counts = vec![vec![0usize; n]; n];
for (t, p) in pairs {
counts[t][p] += 1;
}
m.matrix = Some(ConfusionMatrix { axis, counts });
}
out.push(m);
}
if out.is_empty() {
return None;
}
let overall = columns
.iter()
.any(|c| c.header == super::compare::CORRECT_COLUMN)
.then(|| row_rollup(result, total));
Some(Metrics {
columns: out,
overall,
movement: movement(result),
})
}
pub fn summary_rows(&self, columns: &[OutputColumn]) -> Vec<SummaryRow> {
let text = |m: &ColumnMetrics, which: usize| -> Option<String> {
match which {
0 => Some(format!("{} of {}", m.compared, m.total)),
1 => Some(m.incorrect.to_string()),
_ => m.accuracy_text(),
}
};
let labels = [COMPARED_LABEL, INCORRECT_LABEL, ACCURACY_LABEL];
let mut out = Vec::new();
for (which, label) in labels.iter().enumerate() {
let mut cells: Vec<Option<StatValue>> = vec![None; columns.len()];
let mut any = false;
for (ci, col) in columns.iter().enumerate() {
let m = if col.header == super::compare::CORRECT_COLUMN {
self.overall.as_ref()
} else {
self.columns.iter().find(|m| m.header == col.header)
};
if let Some(m) = m
&& let Some(t) = text(m, which)
{
any = true;
cells[ci] = Some(StatValue {
text: t,
stat: None,
numeric: false,
match_value: None,
});
}
}
if any {
if cells[0].is_some()
&& let Some(free) = cells.iter().position(Option::is_none)
{
cells[free] = Some(StatValue {
text: label.to_string(),
stat: None,
numeric: false,
match_value: None,
});
} else if let Some(first) = cells[0].as_mut() {
first.text = format!("{label}: {}", first.text);
}
out.push(SummaryRow {
label: label.to_string(),
cells,
});
}
}
out
}
}
fn movement(result: &ReportResult) -> Option<Movement> {
use super::model::Trend;
let mut m = Movement::default();
let mut any = false;
for r in 0..result.rows.len() {
if result.pending.contains(&r) {
continue;
}
let Some(t) = result.row_trend(r) else {
continue;
};
any = true;
match t {
Trend::Fixed => m.fixed += 1,
Trend::Regressed => m.regressed += 1,
Trend::StillWrong => m.still_wrong += 1,
Trend::Unchanged => m.unchanged += 1,
}
}
any.then_some(m)
}
fn row_rollup(result: &ReportResult, total: usize) -> ColumnMetrics {
let mut m = ColumnMetrics {
header: super::compare::CORRECT_COLUMN.to_string(),
total,
compared: 0,
correct: 0,
incorrect: 0,
matrix: None,
};
for (r, row) in result.rows.iter().enumerate() {
if result.pending.contains(&r) {
continue;
}
match row
.cells
.get(super::compare::CORRECT_COLUMN)
.map(String::as_str)
{
Some(v) if v == Verdict::Correct.as_str() => {
m.correct += 1;
m.compared += 1;
}
Some(v) if v == Verdict::Incorrect.as_str() => {
m.incorrect += 1;
m.compared += 1;
}
_ => {}
}
}
m
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::flow::Header;
use crate::report::model::ReportRow;
fn row(cells: &[(&str, &str)]) -> ReportRow {
ReportRow {
cells: cells
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
vars: HashMap::new(),
key: Vec::new(),
path: Vec::new(),
target: None,
}
}
fn fixture() -> (ReportResult, Vec<OutputColumn>, LabelMap) {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("Verdict", "Low Risk"), ("Correct", "correct")]),
row(&[("Verdict", "High Risk"), ("Correct", "correct")]),
row(&[("Verdict", "Low Risk"), ("Correct", "incorrect")]),
row(&[("Verdict", "Low Risk"), ("Correct", "correct")]),
row(&[("Verdict", "Low Risk")]),
];
res.column_order = vec!["Correct".into(), "Verdict".into()];
res.column_truths
.insert("Verdict".into(), "{{ e }}".to_string());
for (r, (v, truth)) in [
(Verdict::Correct, "real"),
(Verdict::Correct, "fake"),
(Verdict::Incorrect, "fake"),
(Verdict::Correct, "real"),
]
.into_iter()
.enumerate()
{
res.verdicts.insert((r, "Verdict".into()), v);
res.truths.insert((r, "Verdict".into()), truth.to_string());
}
res.verdicts
.insert((4, "Verdict".into()), Verdict::Untested);
let labels = LabelMap::parse(&[
"Pass = pass, real, low risk",
"Fail = fail, fake, high risk",
]);
let cols = res.resolved_columns(&Header::default());
(res, cols, labels)
}
#[test]
fn movement_counts_the_rows_that_moved_and_which_way() {
use crate::report::model::Trend;
let (mut res, cols, labels) = fixture();
res.trends.insert((0, "Verdict".into()), Trend::Unchanged);
res.trends.insert((1, "Verdict".into()), Trend::Fixed);
res.trends.insert((2, "Verdict".into()), Trend::StillWrong);
res.trends.insert((3, "Verdict".into()), Trend::Regressed);
let mv = Metrics::compute(&res, &cols, &labels)
.expect("metrics")
.movement
.expect("a run with trends has moved somehow");
assert_eq!(
(mv.fixed, mv.regressed, mv.still_wrong, mv.unchanged),
(1, 1, 1, 1)
);
assert!(!mv.is_still(), "one row got better and one got worse");
let mut still = res.clone();
still.trends.clear();
for r in 0..4 {
still.trends.insert((r, "Verdict".into()), Trend::Unchanged);
}
let mv = Metrics::compute(&still, &cols, &labels)
.unwrap()
.movement
.unwrap();
assert!(mv.is_still() && mv.unchanged == 4);
let (res, cols, labels) = fixture();
assert!(
Metrics::compute(&res, &cols, &labels)
.unwrap()
.movement
.is_none()
);
}
#[test]
fn accuracy_counts_only_the_rows_that_had_a_truth() {
let (res, cols, labels) = fixture();
let m = Metrics::compute(&res, &cols, &labels).expect("metrics");
let v = &m.columns[0];
assert_eq!(v.total, 5, "every row is in the table");
assert_eq!(v.compared, 4, "the unlabelled row is not evidence");
assert_eq!(v.correct, 3);
assert_eq!(v.incorrect, 1);
assert_eq!(v.accuracy_text().as_deref(), Some("75.0%"));
let overall = m.overall.as_ref().expect("the Correct column rolls up");
assert_eq!((overall.compared, overall.correct), (4, 3));
}
#[test]
fn the_confusion_matrix_takes_the_declared_axis_order() {
let (res, cols, labels) = fixture();
let m = Metrics::compute(&res, &cols, &labels).expect("metrics");
let matrix = m.columns[0].matrix.as_ref().expect("a matrix");
assert_eq!(matrix.axis, ["Pass".to_string(), "Fail".to_string()]);
assert_eq!(matrix.counts, vec![vec![2, 0], vec![1, 1]]);
assert_eq!(matrix.max(), 2);
assert_eq!(matrix.total(), 4);
assert!(!matrix.is_diagonal(), "one row was misclassified");
}
#[test]
fn no_declared_labels_means_no_matrix() {
let (res, cols, _) = fixture();
let m = Metrics::compute(&res, &cols, &LabelMap::default()).expect("metrics");
assert!(m.columns[0].matrix.is_none());
assert_eq!(m.columns[0].correct, 3);
}
#[test]
fn an_undeclared_answer_gets_its_own_axis_entry() {
let (mut res, _, labels) = fixture();
res.rows[0]
.cells
.insert("Verdict".into(), "Needs Review".into());
let cols = res.resolved_columns(&Header::default());
let m = Metrics::compute(&res, &cols, &labels).expect("metrics");
let matrix = m.columns[0].matrix.as_ref().expect("a matrix");
assert_eq!(
matrix.axis,
[
"Pass".to_string(),
"Fail".to_string(),
"needs review".to_string()
]
);
assert_eq!(matrix.total(), 4, "no scored row is lost");
}
#[test]
fn a_report_without_a_truth_has_no_metrics_at_all() {
let mut res = ReportResult::default();
res.rows = vec![row(&[("V", "a")])];
res.column_order = vec!["V".into()];
let cols = res.resolved_columns(&Header::default());
assert!(Metrics::compute(&res, &cols, &LabelMap::default()).is_none());
}
#[test]
fn a_clean_diagonal_is_recognised() {
let (mut res, cols, labels) = fixture();
res.verdicts.insert((2, "Verdict".into()), Verdict::Correct);
res.truths.insert((2, "Verdict".into()), "real".into());
let m = Metrics::compute(&res, &cols, &labels).expect("metrics");
assert!(m.columns[0].matrix.as_ref().unwrap().is_diagonal());
}
#[test]
fn footer_rows_carry_the_figures_into_the_flat_formats() {
let (res, cols, labels) = fixture();
let m = Metrics::compute(&res, &cols, &labels).expect("metrics");
let rows = m.summary_rows(&cols);
assert_eq!(rows.len(), 3);
let verdict_col = cols.iter().position(|c| c.header == "Verdict").unwrap();
assert_eq!(rows[0].text_cell(verdict_col), "4 of 5");
assert_eq!(rows[1].text_cell(verdict_col), "1");
assert_eq!(rows[2].text_cell(verdict_col), "75.0%");
assert!(
rows[0].cells.iter().flatten().all(|c| c.stat.is_none()),
"a metric is not a statistic a spreadsheet could recompute"
);
}
}