use std::future::Future;
use std::sync::Arc;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use serde_json::{Value, json};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use typesafe::{Answer, Choice, Question, Usage};
use crate::cost::{self, Cost, Rates};
use crate::format::{BAD, CHOICE, bold, color_for, dim, text_of};
use crate::headless::Answered;
use crate::session::{self, Session};
#[derive(Debug, Clone, PartialEq)]
pub struct Case {
pub line: usize,
pub id: Option<String>,
pub state: Value,
pub expect: Vec<(String, Expectation)>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Expectation {
Noul { yes: bool },
Choice { label: String },
Score { level: usize },
}
impl Expectation {
pub fn kind(&self) -> &'static str {
match self {
Expectation::Noul { .. } => "noul",
Expectation::Choice { .. } => "choice",
Expectation::Score { .. } => "score",
}
}
}
pub fn parse_cases(text: &str, session: &Session) -> Result<Vec<Case>, String> {
let mut cases = Vec::new();
for (i, raw) in text.split('\n').enumerate() {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
let line = i + 1;
let one = parse_case(raw, line, session).map_err(|e| format!("cases line {line}: {e}"))?;
cases.push(one);
}
if cases.is_empty() {
return Err("the cases file holds no cases.".to_owned());
}
Ok(cases)
}
fn parse_case(text: &str, line: usize, session: &Session) -> Result<Case, String> {
let value: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
let object = value
.as_object()
.ok_or("expected a JSON object with `state` and `expect`.")?;
let id = match object.get("id") {
None => None,
Some(Value::String(s)) => Some(s.clone()),
Some(_) => return Err("`id` must be a string.".to_owned()),
};
let state = object
.get("state")
.ok_or("missing `state`: a case has to say what to judge.")?;
if session::is_empty_value(state) {
return Err("the `state` is empty: there is nothing to judge.".to_owned());
}
let wanted = object
.get("expect")
.ok_or("missing `expect`: a case has to say what the answer is.")?;
let wanted = wanted
.as_object()
.filter(|map| !map.is_empty())
.ok_or("`expect` has to name at least one question.")?;
let mut expect = Vec::with_capacity(wanted.len());
for (name, value) in wanted {
let question = session
.questions
.iter()
.find(|(n, _)| n == name)
.map(|(_, q)| q)
.ok_or_else(|| format!("no question named {name:?} on the page."))?;
expect.push((name.clone(), expected(name, question, value)?));
}
Ok(Case {
line,
id,
state: state.clone(),
expect,
})
}
fn expected(name: &str, question: &Question, value: &Value) -> Result<Expectation, String> {
match question {
Question::Noul(_) => match value {
Value::Bool(yes) => Ok(Expectation::Noul { yes: *yes }),
other => Err(format!(
"{name} is a noul: expected true or false, got {other}."
)),
},
Question::Choice(q) => {
let labels: Vec<&str> = q.criteria.keys().map(String::as_str).collect();
match value.as_str() {
Some(label) if labels.contains(&label) => Ok(Expectation::Choice {
label: label.to_owned(),
}),
_ => Err(format!(
"{name} is a choice between {}; got {value}.",
labels.join(", ")
)),
}
}
Question::Score(q) => {
let top = q.criteria.len().saturating_sub(1);
if let Value::Number(number) = value {
let n = number.as_f64().unwrap_or(f64::NAN);
if n.fract() == 0.0 && (0.0..=top as f64).contains(&n) {
return Ok(Expectation::Score { level: n as usize });
}
return Err(format!(
"{name} is a score: expected a level from 0 to {top}, got {number}."
));
}
let wanted = text_of(value);
match q.criteria.iter().position(|level| text_of(level) == wanted) {
Some(at) => Ok(Expectation::Score { level: at }),
None => Err(format!(
"{name} is a score: expected a level from 0 to {top}, or one of its levels; got {value}."
)),
}
}
_ => Err(format!(
"{name} is a raw question: raw questions cannot be scored."
)),
}
}
pub fn with_state(session: &Session, state: Value) -> Session {
Session {
state,
questions: session.questions.clone(),
model: session.model.clone(),
}
}
#[derive(Debug, Clone)]
pub enum Outcome {
Ok {
answers: Vec<Answered>,
usage: Option<Usage>,
},
Failed {
error: String,
},
}
pub async fn run<F, Fut>(
session: &Session,
cases: &[Case],
ask: F,
concurrency: usize,
) -> Vec<Outcome>
where
F: Fn(Session) -> Fut + Send + Sync + Clone + 'static,
Fut: Future<Output = Outcome> + Send + 'static,
{
let permits = Arc::new(Semaphore::new(concurrency.max(1)));
let mut workers = JoinSet::new();
for (at, one) in cases.iter().enumerate() {
let session = with_state(session, one.state.clone());
let ask = ask.clone();
let permits = Arc::clone(&permits);
workers.spawn(async move {
let _permit = permits.acquire_owned().await;
(at, ask(session).await)
});
}
let mut outcomes: Vec<Option<Outcome>> = vec![None; cases.len()];
while let Some(joined) = workers.join_next().await {
if let Ok((at, outcome)) = joined {
outcomes[at] = Some(outcome);
}
}
outcomes
.into_iter()
.map(|outcome| {
outcome.unwrap_or_else(|| Outcome::Failed {
error: "nothing was sent for this case.".to_owned(),
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq)]
pub struct SweepRow {
pub threshold: f64,
pub tp: usize,
pub fp: usize,
pub r#fn: usize,
pub tn: usize,
pub accuracy: f64,
pub precision: Option<f64>,
pub recall: Option<f64>,
pub f1: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GateRow {
pub confidence: f64,
pub coverage: f64,
pub accuracy: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Best {
pub threshold: f64,
pub f1: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum QuestionReport {
Noul {
name: String,
cases: usize,
brier: f64,
accuracy: f64,
best: Best,
sweep: Vec<SweepRow>,
},
Choice {
name: String,
cases: usize,
accuracy: f64,
labels: Vec<String>,
confusion: Vec<Vec<usize>>,
gate: Vec<GateRow>,
},
Score {
name: String,
cases: usize,
exact: f64,
within_one: f64,
mae: f64,
gate: Vec<GateRow>,
},
}
impl QuestionReport {
pub fn name(&self) -> &str {
match self {
QuestionReport::Noul { name, .. }
| QuestionReport::Choice { name, .. }
| QuestionReport::Score { name, .. } => name,
}
}
pub fn kind(&self) -> &'static str {
match self {
QuestionReport::Noul { .. } => "noul",
QuestionReport::Choice { .. } => "choice",
QuestionReport::Score { .. } => "score",
}
}
pub fn cases(&self) -> usize {
match self {
QuestionReport::Noul { cases, .. }
| QuestionReport::Choice { cases, .. }
| QuestionReport::Score { cases, .. } => *cases,
}
}
pub fn accuracy_of(&self) -> f64 {
match self {
QuestionReport::Noul { accuracy, .. } | QuestionReport::Choice { accuracy, .. } => {
*accuracy
}
QuestionReport::Score { exact, .. } => *exact,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CaseError {
pub case: usize,
pub id: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReportUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub estimated: bool,
pub cost: Option<Cost>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Report {
pub model: String,
pub threshold: f64,
pub cases: usize,
pub answered: usize,
pub errors: Vec<CaseError>,
pub questions: Vec<QuestionReport>,
pub usage: ReportUsage,
}
#[derive(Debug, Clone, Copy)]
pub struct ReportOptions<'a> {
pub model: &'a str,
pub threshold: f64,
pub rates: Option<Rates>,
}
const SWEEP: [f64; 9] = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
const CUTS: [f64; 5] = [0.0, 0.2, 0.4, 0.6, 0.8];
struct Scored<'a> {
expect: &'a [(String, Expectation)],
answers: Vec<(String, Answer)>,
usage: Option<Usage>,
}
impl Scored<'_> {
fn answer(&self, name: &str) -> Option<&Answer> {
self.answers.iter().find(|(n, _)| n == name).map(|(_, a)| a)
}
fn expects(&self, name: &str) -> Option<&Expectation> {
self.expect.iter().find(|(n, _)| n == name).map(|(_, e)| e)
}
}
pub fn report(
session: &Session,
cases: &[Case],
outcomes: &[Outcome],
options: ReportOptions<'_>,
) -> Report {
let mut errors: Vec<CaseError> = Vec::new();
let mut scored: Vec<Scored<'_>> = Vec::new();
for (at, one) in cases.iter().enumerate() {
let mut failed = |message: String| {
errors.push(CaseError {
case: one.line,
id: one.id.clone(),
message,
});
};
let (answers, usage) = match outcomes.get(at) {
None => {
failed("nothing was sent for this case.".to_owned());
continue;
}
Some(Outcome::Failed { error }) => {
failed(error.clone());
continue;
}
Some(Outcome::Ok { answers, usage }) => (answers, usage),
};
let answers: Vec<(String, Answer)> = answers
.iter()
.filter_map(|(name, answer)| answer.clone().map(|a| (name.clone(), a)))
.collect();
if let Some(message) = unscorable(&one.expect, &answers) {
failed(message);
continue;
}
scored.push(Scored {
expect: &one.expect,
answers,
usage: usage.clone(),
});
}
let mut questions = Vec::new();
for (name, question) in &session.questions {
let rows: Vec<&Scored<'_>> = scored
.iter()
.filter(|one| one.expects(name).is_some())
.collect();
if rows.is_empty() {
continue;
}
match question {
Question::Noul(_) => questions.push(noul_report(name, &rows, options.threshold)),
Question::Choice(q) => questions.push(choice_report(name, q, &rows)),
Question::Score(_) => questions.push(score_report(name, &rows)),
_ => {}
}
}
let usage = usage_of(session, cases, &scored, options.model, options.rates);
Report {
model: options.model.to_owned(),
threshold: options.threshold,
cases: cases.len(),
answered: scored.len(),
errors,
questions,
usage,
}
}
fn unscorable(expect: &[(String, Expectation)], answers: &[(String, Answer)]) -> Option<String> {
for (name, expectation) in expect {
match answers.iter().find(|(n, _)| n == name).map(|(_, a)| a) {
None => return Some(format!("no answer came back for {name}")),
Some(answer) if answer.kind() != expectation.kind() => {
return Some(format!(
"{name} came back as a {}, not a {}",
answer.kind(),
expectation.kind()
));
}
Some(_) => {}
}
}
None
}
pub fn below_bar(report: &Report, bar: f64) -> Vec<(String, f64)> {
report
.questions
.iter()
.filter(|q| q.accuracy_of() < bar)
.map(|q| (q.name().to_owned(), q.accuracy_of()))
.collect()
}
fn noul_report(name: &str, rows: &[&Scored<'_>], threshold: f64) -> QuestionReport {
let points: Vec<(f64, bool)> = rows
.iter()
.map(|row| {
let p = match row.answer(name) {
Some(Answer::Noul(a)) => a.noul,
_ => 0.0,
};
let yes = matches!(row.expects(name), Some(Expectation::Noul { yes: true }));
(p, yes)
})
.collect();
let mut thresholds: Vec<f64> = SWEEP.to_vec();
if !thresholds.contains(&threshold) {
thresholds.push(threshold);
thresholds.sort_by(f64::total_cmp);
}
let sweep: Vec<SweepRow> = thresholds
.iter()
.map(|at| sweep_row(&points, *at))
.collect();
let accuracy = sweep
.iter()
.find(|row| row.threshold == threshold)
.map(|row| row.accuracy)
.unwrap_or(0.0);
let mut best = Best {
threshold,
f1: f64::NEG_INFINITY,
};
for row in &sweep {
if row.f1 > best.f1 {
best = Best {
threshold: row.threshold,
f1: row.f1,
};
}
}
let brier = mean(
points
.iter()
.map(|(p, yes)| (p - if *yes { 1.0 } else { 0.0 }).powi(2)),
);
QuestionReport::Noul {
name: name.to_owned(),
cases: points.len(),
brier,
accuracy,
best,
sweep,
}
}
fn sweep_row(points: &[(f64, bool)], threshold: f64) -> SweepRow {
let (mut tp, mut fp, mut fneg, mut tn) = (0usize, 0usize, 0usize, 0usize);
for (p, yes) in points {
match (*p >= threshold, *yes) {
(true, true) => tp += 1,
(true, false) => fp += 1,
(false, true) => fneg += 1,
(false, false) => tn += 1,
}
}
let denominator = 2 * tp + fp + fneg;
SweepRow {
threshold,
tp,
fp,
r#fn: fneg,
tn,
accuracy: (tp + tn) as f64 / points.len() as f64,
precision: (tp + fp > 0).then(|| tp as f64 / (tp + fp) as f64),
recall: (tp + fneg > 0).then(|| tp as f64 / (tp + fneg) as f64),
f1: if denominator == 0 {
0.0
} else {
2.0 * tp as f64 / denominator as f64
},
}
}
fn choice_report(name: &str, question: &Choice, rows: &[&Scored<'_>]) -> QuestionReport {
let options: Vec<String> = question.criteria.keys().cloned().collect();
struct Point {
predicted: String,
expected: String,
confidence: f64,
right: bool,
}
let points: Vec<Point> = rows
.iter()
.map(|row| {
let (predicted, confidence) = match row.answer(name) {
Some(Answer::Choice(a)) => (a.choice.clone(), a.confidence),
_ => (String::new(), 0.0),
};
let expected = match row.expects(name) {
Some(Expectation::Choice { label }) => label.clone(),
_ => String::new(),
};
Point {
right: predicted == expected,
predicted,
expected,
confidence,
}
})
.collect();
let other = points
.iter()
.any(|point| !options.contains(&point.predicted));
let mut labels = options.clone();
if other {
labels.push("other".to_owned());
}
let confusion: Vec<Vec<usize>> = options
.iter()
.map(|expected| {
labels
.iter()
.enumerate()
.map(|(column, predicted)| {
points
.iter()
.filter(|point| {
&point.expected == expected
&& if other && column == labels.len() - 1 {
!options.contains(&point.predicted)
} else {
&point.predicted == predicted
}
})
.count()
})
.collect()
})
.collect();
QuestionReport::Choice {
name: name.to_owned(),
cases: points.len(),
accuracy: mean(points.iter().map(|point| f64::from(point.right))),
labels,
confusion,
gate: gate(points.iter().map(|point| (point.confidence, point.right))),
}
}
fn score_report(name: &str, rows: &[&Scored<'_>]) -> QuestionReport {
let points: Vec<(f64, i64)> = rows
.iter()
.map(|row| {
let (level, confidence) = match row.answer(name) {
Some(Answer::Score(a)) => (i64::from(a.rounded_level()), a.confidence),
_ => (0, 0.0),
};
let expected = match row.expects(name) {
Some(Expectation::Score { level }) => *level as i64,
_ => 0,
};
(confidence, (level - expected).abs())
})
.collect();
QuestionReport::Score {
name: name.to_owned(),
cases: points.len(),
exact: mean(points.iter().map(|(_, off)| f64::from(*off == 0))),
within_one: mean(points.iter().map(|(_, off)| f64::from(*off <= 1))),
mae: mean(points.iter().map(|(_, off)| *off as f64)),
gate: gate(points.iter().map(|(c, off)| (*c, *off == 0))),
}
}
fn gate(points: impl Iterator<Item = (f64, bool)>) -> Vec<GateRow> {
let points: Vec<(f64, bool)> = points.collect();
CUTS.iter()
.map(|confidence| {
let kept: Vec<bool> = points
.iter()
.filter(|(c, _)| c >= confidence)
.map(|(_, right)| *right)
.collect();
GateRow {
confidence: *confidence,
coverage: if points.is_empty() {
0.0
} else {
kept.len() as f64 / points.len() as f64
},
accuracy: (!kept.is_empty())
.then(|| mean(kept.iter().map(|right| f64::from(*right)))),
}
})
.collect()
}
fn usage_of(
session: &Session,
cases: &[Case],
scored: &[Scored<'_>],
model: &str,
rates: Option<Rates>,
) -> ReportUsage {
let mut input_tokens = 0u64;
let mut output_tokens = 0u64;
let mut counted = !scored.is_empty();
for one in scored {
match one
.usage
.as_ref()
.map(|u| (u.input_tokens, u.output_tokens))
{
Some((Some(input), Some(output))) => {
input_tokens += input;
output_tokens += output;
}
_ => {
counted = false;
break;
}
}
}
if !counted {
let estimate = preflight(session, cases, model, None);
input_tokens = estimate.input_tokens as u64;
output_tokens = estimate.output_tokens as u64;
}
ReportUsage {
input_tokens,
output_tokens,
estimated: !counted,
cost: rates.map(|rates| cost::price(input_tokens, output_tokens, rates)),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Preflight {
pub cases: usize,
pub input_tokens: usize,
pub output_tokens: usize,
pub cost: Option<Cost>,
}
pub fn preflight(
session: &Session,
cases: &[Case],
model: &str,
rates: Option<Rates>,
) -> Preflight {
let mut input_tokens = 0usize;
let mut output_tokens = 0usize;
for one in cases {
let estimate = cost::estimate(&with_state(session, one.state.clone()), model);
input_tokens += estimate.input_tokens;
output_tokens += estimate.output_tokens;
}
Preflight {
cases: cases.len(),
input_tokens,
output_tokens,
cost: rates.map(|rates| cost::price(input_tokens as u64, output_tokens as u64, rates)),
}
}
fn mean(values: impl Iterator<Item = f64>) -> f64 {
let mut sum = 0.0;
let mut count = 0usize;
for value in values {
sum += value;
count += 1;
}
if count == 0 { 0.0 } else { sum / count as f64 }
}
pub fn two(x: f64) -> String {
format!("{:.2}", (x * 100.0).round() / 100.0)
}
pub fn report_lines(report: &Report) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let width = report
.questions
.iter()
.map(|q| q.name().chars().count())
.max()
.unwrap_or(0);
for question in &report.questions {
if !out.is_empty() {
out.push(Line::default());
}
out.push(header_line(question, width));
match question {
QuestionReport::Noul { sweep, best, .. } => {
out.extend(sweep_lines(sweep, *best, report.threshold));
}
QuestionReport::Choice {
gate,
labels,
confusion,
..
} => {
out.extend(gate_lines(gate, "accuracy"));
out.extend(confusion_lines(labels, confusion));
}
QuestionReport::Score { gate, .. } => out.extend(gate_lines(gate, "exact")),
}
}
if !report.errors.is_empty() {
if !out.is_empty() {
out.push(Line::default());
}
for failed in &report.errors {
out.extend(error_case_lines(failed));
}
}
if !out.is_empty() {
out.push(Line::default());
}
let errors = report.errors.len();
out.push(Line::from(vec![
Span::raw(" "),
bold(format!("{} case{}", report.cases, plural(report.cases))),
dim(format!(
" · {} answered · {errors} error{}",
report.answered,
plural(errors)
)),
]));
out.push(usage_line(&report.usage));
out
}
fn header_line(question: &QuestionReport, width: usize) -> Line<'static> {
let count = format!("{} case{}", question.cases(), plural(question.cases()));
let summary = match question {
QuestionReport::Noul { brier, .. } => format!("{count} · Brier {}", two(*brier)),
QuestionReport::Choice { accuracy, .. } => format!("{count} · accuracy {}", two(*accuracy)),
QuestionReport::Score {
exact,
within_one,
mae,
..
} => format!(
"{count} · exact {} · within one {} · mae {}",
two(*exact),
two(*within_one),
two(*mae)
),
};
Line::from(vec![
Span::raw(" "),
bold(pad_end(question.name(), width)),
Span::raw(" "),
Span::styled(
pad_end(question.kind(), 8),
Style::new().fg(color_for(question.kind())),
),
dim(summary),
])
}
fn sweep_lines(sweep: &[SweepRow], best: Best, threshold: f64) -> Vec<Line<'static>> {
let mut out = vec![Line::from(vec![
Span::raw(" "),
dim(pad_end("threshold", 12)),
dim(pad_end("acc", 6)),
dim(pad_end("prec", 7)),
dim(pad_end("rec", 7)),
dim("f1"),
])];
for row in sweep {
let chosen = row.threshold == threshold;
let at = pad_end(
&format!("{}{}", two(row.threshold), if chosen { " *" } else { "" }),
12,
);
out.push(Line::from(vec![
Span::raw(" "),
if chosen { bold(at) } else { Span::raw(at) },
Span::raw(pad_end(&two(row.accuracy), 6)),
Span::raw(pad_end(&rate(row.precision), 7)),
Span::raw(pad_end(&rate(row.recall), 7)),
Span::raw(two(row.f1)),
]));
}
out.push(Line::from(vec![
Span::raw(" "),
dim(format!("best f1 at {}", two(best.threshold))),
]));
out
}
fn gate_lines(gate: &[GateRow], accuracy: &str) -> Vec<Line<'static>> {
let mut out = vec![Line::from(vec![
Span::raw(" "),
dim(pad_end("confidence ≥", 15)),
dim(pad_end("coverage", 10)),
dim(accuracy.to_owned()),
])];
for row in gate {
out.push(Line::from(vec![
Span::raw(" "),
Span::raw(pad_end(&two(row.confidence), 15)),
Span::raw(pad_end(&two(row.coverage), 10)),
Span::raw(rate(row.accuracy)),
]));
}
out
}
fn confusion_lines(labels: &[String], confusion: &[Vec<usize>]) -> Vec<Line<'static>> {
let counts: Vec<usize> = confusion
.iter()
.flatten()
.map(|n| n.to_string().len())
.collect();
let column = |label: &str| -> usize {
counts
.iter()
.copied()
.chain([label.chars().count(), 1])
.max()
.unwrap_or(1)
+ 2
};
let row_width = confusion
.iter()
.enumerate()
.map(|(at, _)| labels[at].chars().count())
.max()
.unwrap_or(0)
+ 3;
let heading: String = labels
.iter()
.map(|label| pad_end(label, column(label)))
.collect();
let mut out = vec![
Line::from(vec![
Span::raw(" "),
dim("confusion, rows expected, columns predicted"),
]),
Line::from(vec![
Span::raw(format!(" {}", " ".repeat(row_width))),
dim(heading.trim_end().to_owned()),
]),
];
for (at, row) in confusion.iter().enumerate() {
let cells: String = row
.iter()
.enumerate()
.map(|(column2, count)| pad_end(&count.to_string(), column(&labels[column2])))
.collect();
out.push(Line::from(vec![
Span::raw(" "),
Span::styled(pad_end(&labels[at], row_width), Style::new().fg(CHOICE)),
Span::raw(cells.trim_end().to_owned()),
]));
}
out
}
fn error_case_lines(failed: &CaseError) -> Vec<Line<'static>> {
let name = match &failed.id {
Some(id) => format!("case {} ({id})", failed.case),
None => format!("case {}", failed.case),
};
let mut parts = failed.message.split('\n');
let first = parts.next().unwrap_or("").trim().to_owned();
let mut out = vec![Line::from(vec![
Span::raw(" "),
Span::styled(format!("{name}: "), Style::new().fg(BAD)),
Span::raw(first),
])];
for more in parts {
out.push(Line::from(vec![
Span::raw(" "),
dim(more.trim().to_owned()),
]));
}
out
}
fn usage_line(usage: &ReportUsage) -> Line<'static> {
let money = match usage.cost {
Some(cost) => format!(" · {}", cost::usd(cost.total)),
None => String::new(),
};
let tokens = format!(
"{} in / {} out tokens{money}",
usage.input_tokens, usage.output_tokens
);
if usage.estimated {
Line::from(vec![
Span::raw(" "),
dim(format!("≈ {tokens} — estimated, nothing was counted")),
])
} else {
Line::from(vec![Span::raw(" "), dim(tokens)])
}
}
pub fn report_json(report: &Report) -> Value {
let mut questions = serde_json::Map::new();
for question in &report.questions {
questions.insert(question.name().to_owned(), question_json(question));
}
let mut usage = serde_json::Map::new();
usage.insert("inputTokens".to_owned(), json!(report.usage.input_tokens));
usage.insert("outputTokens".to_owned(), json!(report.usage.output_tokens));
usage.insert("estimated".to_owned(), json!(report.usage.estimated));
if let Some(cost) = report.usage.cost {
usage.insert("cost".to_owned(), number(cost.total));
}
let errors: Vec<Value> = report
.errors
.iter()
.map(|failed| {
let mut out = serde_json::Map::new();
out.insert("case".to_owned(), json!(failed.case));
if let Some(id) = &failed.id {
out.insert("id".to_owned(), json!(id));
}
out.insert("message".to_owned(), json!(failed.message));
Value::Object(out)
})
.collect();
json!({
"model": report.model,
"threshold": number(report.threshold),
"cases": report.cases,
"answered": report.answered,
"errors": errors,
"questions": Value::Object(questions),
"usage": Value::Object(usage),
})
}
fn question_json(question: &QuestionReport) -> Value {
match question {
QuestionReport::Noul {
cases,
brier,
accuracy,
best,
sweep,
..
} => json!({
"kind": question.kind(),
"cases": cases,
"brier": number(*brier),
"accuracy": number(*accuracy),
"best": {"threshold": number(best.threshold), "f1": number(best.f1)},
"sweep": sweep.iter().map(|row| json!({
"threshold": number(row.threshold),
"tp": row.tp,
"fp": row.fp,
"fn": row.r#fn,
"tn": row.tn,
"accuracy": number(row.accuracy),
"precision": maybe(row.precision),
"recall": maybe(row.recall),
"f1": number(row.f1),
})).collect::<Vec<_>>(),
}),
QuestionReport::Choice {
cases,
accuracy,
labels,
confusion,
gate,
..
} => json!({
"kind": question.kind(),
"cases": cases,
"accuracy": number(*accuracy),
"labels": labels,
"confusion": confusion,
"gate": gate.iter().map(gate_json).collect::<Vec<_>>(),
}),
QuestionReport::Score {
cases,
exact,
within_one,
mae,
gate,
..
} => json!({
"kind": question.kind(),
"cases": cases,
"exact": number(*exact),
"withinOne": number(*within_one),
"mae": number(*mae),
"gate": gate.iter().map(gate_json).collect::<Vec<_>>(),
}),
}
}
fn gate_json(row: &GateRow) -> Value {
json!({
"confidence": number(row.confidence),
"coverage": number(row.coverage),
"accuracy": maybe(row.accuracy),
})
}
fn number(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() < 9e15 {
return json!(x as i64);
}
json!(x)
}
fn maybe(x: Option<f64>) -> Value {
x.map_or(Value::Null, number)
}
fn rate(n: Option<f64>) -> String {
match n {
Some(n) => two(n),
None => "·".to_owned(),
}
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn pad_end(text: &str, width: usize) -> String {
let length = text.chars().count();
if length >= width {
text.to_owned()
} else {
format!("{text}{}", " ".repeat(width - length))
}
}
pub fn report_text(report: &Report) -> String {
let mut out = String::new();
for line in report_lines(report) {
for span in &line.spans {
out.push_str(span.content.as_ref());
}
out.push('\n');
}
out
}