Skip to main content

jev_repl/
evaluate.rs

1//! Scoring a rubric against states someone has already labelled.
2//!
3//! One call tells you what the model said about one state; that is what `jev run` is for. It does
4//! not tell you where to put the threshold, or how confident a choice has to be before a script
5//! may act on it — the README leaves both calls to you, and this is the module that turns them
6//! into a table. Feed it a page and a file of labelled states and it reports what the rubric got
7//! right, at every threshold worth trying.
8//!
9//! Everything here is pure: cases come in as text, the answers come from a function you pass, and
10//! the report goes out as lines and JSON. Reading files, hashing request bodies and talking to the
11//! API belong to the caller.
12
13use std::collections::HashMap;
14use std::future::Future;
15use std::sync::Arc;
16
17use ratatui::style::Style;
18use ratatui::text::{Line, Span};
19use serde_json::{Value, json};
20use tokio::sync::Semaphore;
21use tokio::task::JoinSet;
22use typesafe::{Answer, Choice, Question, Usage};
23
24use crate::cost::{self, Cost, Rates};
25use crate::format::{BAD, CHOICE, DIM, SCORE, bold, color_for, dim, text_of};
26use crate::headless::Answered;
27use crate::session::{self, Session};
28
29/// One labelled state: what to judge, and what the rubric should say about it.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Case {
32    /// 1-based line in the cases file, for messages.
33    pub line: usize,
34    pub id: Option<String>,
35    pub state: Value,
36    /// Question name → expectation, in the order the file gave them, already checked against the
37    /// session's questions. A `Vec` and not a map, because that is the shape `Session` uses for
38    /// questions and it saves a dependency.
39    pub expect: Vec<(String, Expectation)>,
40    /// For a case labelled per turn: which prefix of the conversation this is (1-based), and how
41    /// many turns the whole conversation has. Such a case is sent once per turn, and each is a case.
42    pub turn: Option<usize>,
43    pub turns: Option<usize>,
44}
45
46/// A per-turn label: the turn a noul becomes true from, or never.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ByTurn {
49    Turn(usize),
50    Never,
51}
52
53impl ByTurn {
54    /// The turn, or `None` for never — what the JSON report writes.
55    pub fn turn(self) -> Option<usize> {
56        match self {
57            ByTurn::Turn(k) => Some(k),
58            ByTurn::Never => None,
59        }
60    }
61}
62
63/// What one question is expected to answer, in the shape its kind is scored in.
64#[derive(Debug, Clone, PartialEq)]
65pub enum Expectation {
66    Noul {
67        yes: bool,
68        /// Set when the label was `{"by_turn": k}`: the turn it becomes true, or never.
69        by_turn: Option<ByTurn>,
70    },
71    Choice {
72        label: String,
73    },
74    Score {
75        level: usize,
76    },
77}
78
79impl Expectation {
80    /// The wire `type` of the answer this expectation can be compared with.
81    pub fn kind(&self) -> &'static str {
82        match self {
83            Expectation::Noul { .. } => "noul",
84            Expectation::Choice { .. } => "choice",
85            Expectation::Score { .. } => "score",
86        }
87    }
88}
89
90/// Parse JSON Lines into cases, checking every expectation against `session`.
91///
92/// Blank lines are skipped and everything else has to be a case, because a file of labels is worth
93/// nothing if a typo silently drops a row. The line number travels with the case: it is what the
94/// report names a case by, so a bad row is found by the same number that reported it.
95pub fn parse_cases(text: &str, session: &Session) -> Result<Vec<Case>, String> {
96    let mut cases = Vec::new();
97    read_cases(text, |one| {
98        let mut expect = Vec::with_capacity(one.wanted.len());
99        for (name, value) in one.wanted {
100            let question = question_of(session, name)
101                .ok_or_else(|| format!("no question named {name:?} on the page."))?;
102            expect.push((
103                name.clone(),
104                expected(name, question, value, turn_count(one.state))?,
105            ));
106        }
107        cases.extend(cases_of(&one, expect));
108        Ok(())
109    })?;
110    Ok(cases)
111}
112
113/// What the two pages of a comparison are called in its messages and its report.
114#[derive(Debug, Clone, Copy)]
115pub struct Labels<'a> {
116    pub a: &'a str,
117    pub b: &'a str,
118}
119
120/// Parse one cases file for two pages at once: a case per page, each holding the expectations for
121/// that page's questions.
122///
123/// A label may name a question on either page, which is what lets a page that adds a question be
124/// compared with one that does not. It is still checked against every page that has the question —
125/// a case one page cannot even express is not a paired observation, it is a typo.
126pub fn parse_compare_cases(
127    text: &str,
128    a: &Session,
129    b: &Session,
130    labels: Labels<'_>,
131) -> Result<(Vec<Case>, Vec<Case>), String> {
132    let mut left = Vec::new();
133    let mut right = Vec::new();
134    read_cases(text, |one| {
135        let mut expect_a = Vec::new();
136        let mut expect_b = Vec::new();
137        for (name, value) in one.wanted {
138            let on_a = question_of(a, name);
139            let on_b = question_of(b, name);
140            if on_a.is_none() && on_b.is_none() {
141                return Err(format!("no question named {name:?} on either page."));
142            }
143            for (question, into, label) in [
144                (on_a, &mut expect_a, labels.a),
145                (on_b, &mut expect_b, labels.b),
146            ] {
147                let Some(question) = question else { continue };
148                let expectation = expected(name, question, value, turn_count(one.state))
149                    .map_err(|e| format!("{label}: {e}"))?;
150                into.push((name.clone(), expectation));
151            }
152        }
153        left.extend(cases_of(&one, expect_a));
154        right.extend(cases_of(&one, expect_b));
155        Ok(())
156    })?;
157    Ok((left, right))
158}
159
160/// A line of the cases file that is a case in shape, before its labels meet a page.
161struct RawCase<'a> {
162    line: usize,
163    id: Option<String>,
164    state: &'a Value,
165    wanted: &'a serde_json::Map<String, Value>,
166}
167
168/// Hand every non-blank line to `visit` as a case, stopping at the first line that is not one or
169/// that `visit` refuses; the message that comes back already names the line.
170fn read_cases(
171    text: &str,
172    mut visit: impl FnMut(RawCase<'_>) -> Result<(), String>,
173) -> Result<(), String> {
174    let mut seen = 0usize;
175    for (i, raw) in text.split('\n').enumerate() {
176        let raw = raw.trim();
177        if raw.is_empty() {
178            continue;
179        }
180        let line = i + 1;
181        let value: Value = serde_json::from_str(raw)
182            .map_err(|e| format!("cases line {line}: not valid JSON: {e}"))?;
183        let one = read_case(&value, line).map_err(|e| format!("cases line {line}: {e}"))?;
184        visit(one).map_err(|e| format!("cases line {line}: {e}"))?;
185        seen += 1;
186    }
187    if seen == 0 {
188        return Err("the cases file holds no cases.".to_owned());
189    }
190    Ok(())
191}
192
193fn read_case(value: &Value, line: usize) -> Result<RawCase<'_>, String> {
194    let object = value
195        .as_object()
196        .ok_or("expected a JSON object with `state` and `expect`.")?;
197
198    let id = match object.get("id") {
199        None => None,
200        Some(Value::String(s)) => Some(s.clone()),
201        Some(_) => return Err("`id` must be a string.".to_owned()),
202    };
203
204    let state = object
205        .get("state")
206        .ok_or("missing `state`: a case has to say what to judge.")?;
207    if session::is_empty_value(state) {
208        return Err("the `state` is empty: there is nothing to judge.".to_owned());
209    }
210
211    let wanted = object
212        .get("expect")
213        .ok_or("missing `expect`: a case has to say what the answer is.")?;
214    let wanted = wanted
215        .as_object()
216        .filter(|map| !map.is_empty())
217        .ok_or("`expect` has to name at least one question.")?;
218    Ok(RawCase {
219        line,
220        id,
221        state,
222        wanted,
223    })
224}
225
226/// The cases one line becomes: itself, or — when a noul is labelled per turn — one case per prefix
227/// of the conversation. A per-turn noul expects `turn >= k` at every prefix; the line's other
228/// labels were written about the whole conversation, so they go on the last prefix only. A case
229/// left with nothing to score is not sent at all.
230fn cases_of(one: &RawCase<'_>, expect: Vec<(String, Expectation)>) -> Vec<Case> {
231    let named = |state: Value, expect, turn, turns| Case {
232        line: one.line,
233        id: one.id.clone(),
234        state,
235        expect,
236        turn,
237        turns,
238    };
239    if expect.is_empty() {
240        return Vec::new();
241    }
242    let per_turn = expect.iter().any(|(_, e)| {
243        matches!(
244            e,
245            Expectation::Noul {
246                by_turn: Some(_),
247                ..
248            }
249        )
250    });
251    let turns = session::turns_of(one.state);
252    let (true, Some(turns)) = (per_turn, turns) else {
253        return vec![named(one.state.clone(), expect, None, None)];
254    };
255    let n = turns.len();
256    let mut out = Vec::new();
257    for turn in 1..=n {
258        let mut at = Vec::new();
259        for (name, e) in &expect {
260            match e {
261                Expectation::Noul {
262                    by_turn: Some(by_turn),
263                    ..
264                } => {
265                    let yes = matches!(by_turn, ByTurn::Turn(k) if turn >= *k);
266                    at.push((
267                        name.clone(),
268                        Expectation::Noul {
269                            yes,
270                            by_turn: Some(*by_turn),
271                        },
272                    ));
273                }
274                _ if turn == n => at.push((name.clone(), e.clone())),
275                _ => {}
276            }
277        }
278        if at.is_empty() {
279            continue;
280        }
281        let state = session::turns_to_json(&turns[..turn]);
282        out.push(named(state, at, Some(turn), Some(n)));
283    }
284    out
285}
286
287/// How many turns a state has, when it is a conversation.
288fn turn_count(state: &Value) -> Option<usize> {
289    session::turns_of(state).map(|turns| turns.len())
290}
291
292/// A JSON value the way `JSON.stringify` writes it, whole numbers without a `.0`.
293fn compact(value: &Value) -> String {
294    match value.as_f64() {
295        Some(n) if value.is_f64() && n.fract() == 0.0 && n.abs() < 9e15 => (n as i64).to_string(),
296        _ => value.to_string(),
297    }
298}
299
300fn question_of<'a>(session: &'a Session, name: &str) -> Option<&'a Question> {
301    session
302        .questions
303        .iter()
304        .find(|(n, _)| n == name)
305        .map(|(_, q)| q)
306}
307
308/// Check one expected value against the question it names, and store it the way it is scored.
309fn expected(
310    name: &str,
311    question: &Question,
312    value: &Value,
313    turns: Option<usize>,
314) -> Result<Expectation, String> {
315    if let (Question::Choice(_) | Question::Score(_), Value::Object(object)) = (question, value)
316        && object.contains_key("by_turn")
317    {
318        let kind = if matches!(question, Question::Choice(_)) {
319            "choice"
320        } else {
321            "score"
322        };
323        return Err(format!("by_turn is for a noul, and {name} is a {kind}."));
324    }
325    match question {
326        Question::Noul(_) => match value {
327            Value::Object(object) => by_turn(name, object, value, turns),
328            Value::Bool(yes) => Ok(Expectation::Noul {
329                yes: *yes,
330                by_turn: None,
331            }),
332            other => Err(format!(
333                "{name} is a noul: expected true or false, got {other}."
334            )),
335        },
336        Question::Choice(q) => {
337            let labels: Vec<&str> = q.criteria.keys().map(String::as_str).collect();
338            match value.as_str() {
339                Some(label) if labels.contains(&label) => Ok(Expectation::Choice {
340                    label: label.to_owned(),
341                }),
342                _ => Err(format!(
343                    "{name} is a choice between {}; got {value}.",
344                    labels.join(", ")
345                )),
346            }
347        }
348        Question::Score(q) => {
349            let top = q.criteria.len().saturating_sub(1);
350            if let Value::Number(number) = value {
351                let n = number.as_f64().unwrap_or(f64::NAN);
352                if n.fract() == 0.0 && (0.0..=top as f64).contains(&n) {
353                    return Ok(Expectation::Score { level: n as usize });
354                }
355                return Err(format!(
356                    "{name} is a score: expected a level from 0 to {top}, got {number}."
357                ));
358            }
359            // A level's own text reads better in a cases file than its index does; the first wins.
360            let wanted = text_of(value);
361            match q.criteria.iter().position(|level| text_of(level) == wanted) {
362                Some(at) => Ok(Expectation::Score { level: at }),
363                None => Err(format!(
364                    "{name} is a score: expected a level from 0 to {top}, or one of its levels; got {value}."
365                )),
366            }
367        }
368        // A hand-built question object has no shape to score against, and neither has a kind this
369        // build does not know.
370        _ => Err(format!(
371            "{name} is a raw question: raw questions cannot be scored."
372        )),
373    }
374}
375
376/// `{"by_turn": k}`: false before turn `k` of the conversation and true from it on, or never true
377/// when `k` is null. It only means something over a conversation, and only for a turn it has.
378fn by_turn(
379    name: &str,
380    object: &serde_json::Map<String, Value>,
381    value: &Value,
382    turns: Option<usize>,
383) -> Result<Expectation, String> {
384    if object.len() != 1 || !object.contains_key("by_turn") {
385        return Err(format!(
386            "{name}: a per-turn expectation is {{\"by_turn\": n}}, the turn it becomes true, or null for never; got {}.",
387            compact(value)
388        ));
389    }
390    let Some(turns) = turns else {
391        return Err(format!(
392            "{name} gives by_turn, but the state is not a conversation of turns."
393        ));
394    };
395    let k = &object["by_turn"];
396    if k.is_null() {
397        return Ok(Expectation::Noul {
398            yes: false,
399            by_turn: Some(ByTurn::Never),
400        });
401    }
402    match k.as_f64() {
403        Some(n) if n.fract() == 0.0 && n >= 1.0 && n <= turns as f64 => Ok(Expectation::Noul {
404            yes: false,
405            by_turn: Some(ByTurn::Turn(n as usize)),
406        }),
407        _ => Err(format!(
408            "{name} by_turn must be a whole turn from 1 to {turns}, or null for never; got {}.",
409            compact(k)
410        )),
411    }
412}
413
414/// The session as one case sends it: the page's questions, the case's state.
415pub fn with_state(session: &Session, state: Value) -> Session {
416    Session {
417        state,
418        questions: session.questions.clone(),
419        model: session.model.clone(),
420        bars: session.bars.clone(),
421    }
422}
423
424/// What one case's request came back as.
425#[derive(Debug, Clone)]
426pub enum Outcome {
427    Ok {
428        answers: Vec<Answered>,
429        usage: Option<Usage>,
430    },
431    Failed {
432        error: String,
433    },
434}
435
436/// Send every case through `ask`, at most `concurrency` at a time; results are in case order.
437///
438/// A slow case holds up nothing but itself, and each result is written to its own slot: a file of
439/// a thousand labels keeps its order however the calls come back.
440pub async fn run<F, Fut>(
441    session: &Session,
442    cases: &[Case],
443    ask: F,
444    concurrency: usize,
445) -> Vec<Outcome>
446where
447    F: Fn(Session) -> Fut + Send + Sync + Clone + 'static,
448    Fut: Future<Output = Outcome> + Send + 'static,
449{
450    let permits = Arc::new(Semaphore::new(concurrency.max(1)));
451    let mut workers = JoinSet::new();
452    spawn_leg(&mut workers, &permits, 0, session, cases, ask).await;
453    let [outcomes] = collect(workers, [cases.len()]).await;
454    outcomes
455}
456
457/// One page's share of a comparison: its session, its cases, and how to ask it.
458pub struct Leg<'a, F> {
459    pub session: &'a Session,
460    pub cases: &'a [Case],
461    pub ask: F,
462}
463
464/// Both pages of a comparison through one pool of workers: page `a`'s cases first, then `b`'s.
465///
466/// One pool rather than one per page, so `--concurrency` still means what it says — that many
467/// requests in the air, whichever page they are for.
468pub async fn run_compare<FA, FutA, FB, FutB>(
469    a: Leg<'_, FA>,
470    b: Leg<'_, FB>,
471    concurrency: usize,
472) -> (Vec<Outcome>, Vec<Outcome>)
473where
474    FA: Fn(Session) -> FutA + Send + Sync + Clone + 'static,
475    FutA: Future<Output = Outcome> + Send + 'static,
476    FB: Fn(Session) -> FutB + Send + Sync + Clone + 'static,
477    FutB: Future<Output = Outcome> + Send + 'static,
478{
479    let permits = Arc::new(Semaphore::new(concurrency.max(1)));
480    let mut workers = JoinSet::new();
481    let sizes = [a.cases.len(), b.cases.len()];
482    spawn_leg(&mut workers, &permits, 0, a.session, a.cases, a.ask).await;
483    spawn_leg(&mut workers, &permits, 1, b.session, b.cases, b.ask).await;
484    let [left, right] = collect(workers, sizes).await;
485    (left, right)
486}
487
488/// Queue one leg's cases on the pool, in order: a case is spawned once a permit is free, so the
489/// cases go out in file order and never more than the pool allows are in the air.
490async fn spawn_leg<F, Fut>(
491    workers: &mut JoinSet<(usize, usize, Outcome)>,
492    permits: &Arc<Semaphore>,
493    leg: usize,
494    session: &Session,
495    cases: &[Case],
496    ask: F,
497) where
498    F: Fn(Session) -> Fut + Send + Sync + Clone + 'static,
499    Fut: Future<Output = Outcome> + Send + 'static,
500{
501    for (at, one) in cases.iter().enumerate() {
502        let session = with_state(session, one.state.clone());
503        let ask = ask.clone();
504        let permit = Arc::clone(permits).acquire_owned().await;
505        workers.spawn(async move {
506            let _permit = permit;
507            (leg, at, ask(session).await)
508        });
509    }
510}
511
512/// Wait for every worker and put each outcome in its leg's slot for its case.
513async fn collect<const N: usize>(
514    mut workers: JoinSet<(usize, usize, Outcome)>,
515    sizes: [usize; N],
516) -> [Vec<Outcome>; N] {
517    let mut slots: [Vec<Option<Outcome>>; N] = sizes.map(|n| vec![None; n]);
518    while let Some(joined) = workers.join_next().await {
519        // A worker that panicked leaves its slot empty; the run is not lost to one case.
520        if let Ok((leg, at, outcome)) = joined {
521            slots[leg][at] = Some(outcome);
522        }
523    }
524    slots.map(|outcomes| {
525        outcomes
526            .into_iter()
527            .map(|outcome| {
528                outcome.unwrap_or_else(|| Outcome::Failed {
529                    error: "nothing was sent for this case.".to_owned(),
530                })
531            })
532            .collect()
533    })
534}
535
536/// One row of a noul's threshold sweep: the confusion counts, and what they come to.
537#[derive(Debug, Clone, PartialEq)]
538pub struct SweepRow {
539    pub threshold: f64,
540    pub tp: usize,
541    pub fp: usize,
542    pub r#fn: usize,
543    pub tn: usize,
544    pub accuracy: f64,
545    /// `None` when nothing was predicted a yes, because a rate over nothing is not zero.
546    pub precision: Option<f64>,
547    /// `None` when nothing was expected to be a yes.
548    pub recall: Option<f64>,
549    pub f1: f64,
550}
551
552/// One cut of the confidence gate: how much of the set survives it, and how right it is.
553#[derive(Debug, Clone, PartialEq)]
554pub struct GateRow {
555    pub confidence: f64,
556    pub coverage: f64,
557    /// `None` when no case is confident enough to be counted.
558    pub accuracy: Option<f64>,
559}
560
561/// The threshold that scored best, and what it scored.
562#[derive(Debug, Clone, Copy, PartialEq)]
563pub struct Best {
564    pub threshold: f64,
565    pub f1: f64,
566}
567
568/// What one question scored, in the numbers its kind is judged by.
569#[derive(Debug, Clone, PartialEq)]
570pub enum QuestionReport {
571    Noul {
572        name: String,
573        cases: usize,
574        /// Mean squared error of the probability itself, threshold or no threshold.
575        brier: f64,
576        /// What it was read at: the page's `@threshold`, or the run's threshold when it has none.
577        threshold: f64,
578        /// Accuracy at that threshold.
579        accuracy: f64,
580        best: Best,
581        sweep: Vec<SweepRow>,
582        /// When it noticed, for the conversations labelled per turn; `None` when none were.
583        latency: Option<Latency>,
584    },
585    Choice {
586        name: String,
587        cases: usize,
588        accuracy: f64,
589        /// The page's options, plus `other` when the model answered something else.
590        labels: Vec<String>,
591        /// Rows expected, columns predicted.
592        confusion: Vec<Vec<usize>>,
593        gate: Vec<GateRow>,
594    },
595    Score {
596        name: String,
597        cases: usize,
598        exact: f64,
599        within_one: f64,
600        mae: f64,
601        gate: Vec<GateRow>,
602    },
603}
604
605impl QuestionReport {
606    pub fn name(&self) -> &str {
607        match self {
608            QuestionReport::Noul { name, .. }
609            | QuestionReport::Choice { name, .. }
610            | QuestionReport::Score { name, .. } => name,
611        }
612    }
613
614    /// The wire `type` of the question this reports on.
615    pub fn kind(&self) -> &'static str {
616        match self {
617            QuestionReport::Noul { .. } => "noul",
618            QuestionReport::Choice { .. } => "choice",
619            QuestionReport::Score { .. } => "score",
620        }
621    }
622
623    pub fn cases(&self) -> usize {
624        match self {
625            QuestionReport::Noul { cases, .. }
626            | QuestionReport::Choice { cases, .. }
627            | QuestionReport::Score { cases, .. } => *cases,
628        }
629    }
630
631    /// The accuracy `--min-accuracy` holds a question to: exact agreement at the chosen threshold.
632    pub fn accuracy_of(&self) -> f64 {
633        match self {
634            QuestionReport::Noul { accuracy, .. } | QuestionReport::Choice { accuracy, .. } => {
635                *accuracy
636            }
637            QuestionReport::Score { exact, .. } => *exact,
638        }
639    }
640}
641
642/// A case that never produced a full set of answers, and why.
643#[derive(Debug, Clone, PartialEq)]
644pub struct CaseError {
645    pub case: usize,
646    /// The prefix of a per-turn case that failed.
647    pub turn: Option<usize>,
648    pub id: Option<String>,
649    pub message: String,
650}
651
652/// The tokens the run spent, counted when the API counted them and estimated when it did not.
653#[derive(Debug, Clone, PartialEq)]
654pub struct ReportUsage {
655    pub input_tokens: u64,
656    pub output_tokens: u64,
657    pub estimated: bool,
658    pub cost: Option<Cost>,
659}
660
661/// Everything the run found out, with the numbers unrounded.
662#[derive(Debug, Clone, PartialEq)]
663pub struct Report {
664    pub model: String,
665    pub threshold: f64,
666    pub cases: usize,
667    pub answered: usize,
668    pub errors: Vec<CaseError>,
669    pub questions: Vec<QuestionReport>,
670    pub usage: ReportUsage,
671}
672
673/// What the run was asked for, which the report repeats back.
674#[derive(Debug, Clone, Copy)]
675pub struct ReportOptions<'a> {
676    pub model: &'a str,
677    pub threshold: f64,
678    pub rates: Option<Rates>,
679}
680
681/// The thresholds a sweep always covers; the chosen one joins them when it is not one of these.
682const SWEEP: [f64; 9] = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
683
684/// The cuts the confidence gate is read at.
685const CUTS: [f64; 5] = [0.0, 0.2, 0.4, 0.6, 0.8];
686
687/// One case that answered everything it was labelled for.
688struct Scored<'a> {
689    line: usize,
690    id: Option<&'a str>,
691    turn: Option<usize>,
692    turns: Option<usize>,
693    expect: &'a [(String, Expectation)],
694    answers: Vec<(String, Answer)>,
695    usage: Option<Usage>,
696}
697
698impl Scored<'_> {
699    fn answer(&self, name: &str) -> Option<&Answer> {
700        self.answers.iter().find(|(n, _)| n == name).map(|(_, a)| a)
701    }
702
703    fn expects(&self, name: &str) -> Option<&Expectation> {
704        self.expect.iter().find(|(n, _)| n == name).map(|(_, e)| e)
705    }
706}
707
708/// Score the outcomes against the cases.
709///
710/// A case either answered everything it was labelled for or it counts as an error: a half-answered
711/// case would quietly skew whichever question it did answer, and a rubric is being judged here.
712pub fn report(
713    session: &Session,
714    cases: &[Case],
715    outcomes: &[Outcome],
716    options: ReportOptions<'_>,
717) -> Report {
718    let (errors, scored) = scored_of(cases, outcomes);
719    let mut questions = Vec::new();
720    for (name, question) in &session.questions {
721        let rows: Vec<&Scored<'_>> = scored
722            .iter()
723            .filter(|one| one.expects(name).is_some())
724            .collect();
725        if rows.is_empty() {
726            continue;
727        }
728        match question {
729            Question::Noul(_) => questions.push(noul_report(
730                name,
731                &rows,
732                session.threshold_of(name, options.threshold),
733            )),
734            Question::Choice(q) => questions.push(choice_report(name, q, &rows)),
735            Question::Score(_) => questions.push(score_report(name, &rows)),
736            _ => {}
737        }
738    }
739
740    let usage = usage_of(session, cases, &scored, options.model, options.rates);
741    Report {
742        model: options.model.to_owned(),
743        threshold: options.threshold,
744        cases: cases.len(),
745        answered: scored.len(),
746        errors,
747        questions,
748        usage,
749    }
750}
751
752/// Split the outcomes into the cases that can be scored and the ones that are errors.
753fn scored_of<'a>(cases: &'a [Case], outcomes: &[Outcome]) -> (Vec<CaseError>, Vec<Scored<'a>>) {
754    let mut errors: Vec<CaseError> = Vec::new();
755    let mut scored: Vec<Scored<'a>> = Vec::new();
756    for (at, one) in cases.iter().enumerate() {
757        let mut failed = |message: String| {
758            errors.push(CaseError {
759                case: one.line,
760                turn: one.turn,
761                id: one.id.clone(),
762                message,
763            });
764        };
765        let (answers, usage) = match outcomes.get(at) {
766            None => {
767                failed("nothing was sent for this case.".to_owned());
768                continue;
769            }
770            Some(Outcome::Failed { error }) => {
771                failed(error.clone());
772                continue;
773            }
774            Some(Outcome::Ok { answers, usage }) => (answers, usage),
775        };
776        let answers: Vec<(String, Answer)> = answers
777            .iter()
778            .filter_map(|(name, answer)| answer.clone().map(|a| (name.clone(), a)))
779            .collect();
780        if let Some(message) = unscorable(&one.expect, &answers) {
781            failed(message);
782            continue;
783        }
784        scored.push(Scored {
785            line: one.line,
786            id: one.id.as_deref(),
787            turn: one.turn,
788            turns: one.turns,
789            expect: &one.expect,
790            answers,
791            usage: usage.clone(),
792        });
793    }
794    (errors, scored)
795}
796
797/// Why this case cannot be scored, if it cannot: the first label that got no answer, or one whose
798/// answer came back as another kind.
799fn unscorable(expect: &[(String, Expectation)], answers: &[(String, Answer)]) -> Option<String> {
800    for (name, expectation) in expect {
801        match answers.iter().find(|(n, _)| n == name).map(|(_, a)| a) {
802            None => return Some(format!("no answer came back for {name}")),
803            Some(answer) if answer.kind() != expectation.kind() => {
804                return Some(format!(
805                    "{name} came back as a {}, not a {}",
806                    answer.kind(),
807                    expectation.kind()
808                ));
809            }
810            Some(_) => {}
811        }
812    }
813    None
814}
815
816/// Questions whose accuracy is below `bar`, for --min-accuracy.
817pub fn below_bar(report: &Report, bar: f64) -> Vec<(String, f64)> {
818    report
819        .questions
820        .iter()
821        .filter(|q| q.accuracy_of() < bar)
822        .map(|q| (q.name().to_owned(), q.accuracy_of()))
823        .collect()
824}
825
826fn noul_report(name: &str, rows: &[&Scored<'_>], threshold: f64) -> QuestionReport {
827    let points: Vec<(f64, bool)> = rows
828        .iter()
829        .map(|row| {
830            let p = match row.answer(name) {
831                Some(Answer::Noul(a)) => a.noul,
832                _ => 0.0,
833            };
834            let yes = matches!(row.expects(name), Some(Expectation::Noul { yes: true, .. }));
835            (p, yes)
836        })
837        .collect();
838
839    let mut thresholds: Vec<f64> = SWEEP.to_vec();
840    if !thresholds.contains(&threshold) {
841        thresholds.push(threshold);
842        thresholds.sort_by(f64::total_cmp);
843    }
844    let sweep: Vec<SweepRow> = thresholds
845        .iter()
846        .map(|at| sweep_row(&points, *at))
847        .collect();
848    let accuracy = sweep
849        .iter()
850        .find(|row| row.threshold == threshold)
851        .map(|row| row.accuracy)
852        .unwrap_or(0.0);
853    // The sweep is in ascending order and the comparison is strict, so a tie keeps the lowest.
854    let mut best = Best {
855        threshold,
856        f1: f64::NEG_INFINITY,
857    };
858    for row in &sweep {
859        if row.f1 > best.f1 {
860            best = Best {
861                threshold: row.threshold,
862                f1: row.f1,
863            };
864        }
865    }
866    let brier = mean(
867        points
868            .iter()
869            .map(|(p, yes)| (p - if *yes { 1.0 } else { 0.0 }).powi(2)),
870    );
871    QuestionReport::Noul {
872        name: name.to_owned(),
873        cases: points.len(),
874        brier,
875        threshold,
876        accuracy,
877        best,
878        sweep,
879        latency: latency_of(name, rows, threshold),
880    }
881}
882
883/// One conversation labelled per turn: the turn it should have said yes, and the turn it did.
884#[derive(Debug, Clone, PartialEq)]
885pub struct ThreadLatency {
886    pub case: usize,
887    pub id: Option<String>,
888    /// `by_turn`; `None` when it should never have said yes.
889    pub expected: Option<usize>,
890    /// The first turn at or above the threshold; `None` when there was none.
891    pub detected: Option<usize>,
892    /// `detected − expected`, negative when early; `None` unless both are known.
893    pub latency: Option<i64>,
894}
895
896/// How early or late a noul notices, over the conversations labelled per turn.
897#[derive(Debug, Clone, PartialEq)]
898pub struct Latency {
899    pub threads: usize,
900    pub on_time: usize,
901    pub early: usize,
902    pub late: usize,
903    pub missed: usize,
904    pub false_alarms: usize,
905    /// Mean latency over the threads that expected a yes and got one; `None` when none did.
906    pub mean: Option<f64>,
907    pub cases: Vec<ThreadLatency>,
908}
909
910/// Detection latency: for each conversation labelled per turn, the first turn the noul said yes,
911/// against the turn it should have. A thread with a prefix that errored is left out, because its
912/// first yes might be the one that is missing.
913fn latency_of(name: &str, rows: &[&Scored<'_>], threshold: f64) -> Option<Latency> {
914    let mut threads: Vec<(usize, Vec<&Scored<'_>>)> = Vec::new();
915    for row in rows {
916        let per_turn = matches!(
917            row.expects(name),
918            Some(Expectation::Noul {
919                by_turn: Some(_),
920                ..
921            })
922        );
923        if !per_turn || row.turn.is_none() {
924            continue;
925        }
926        match threads.iter_mut().find(|(line, _)| *line == row.line) {
927            Some((_, thread)) => thread.push(row),
928            None => threads.push((row.line, vec![row])),
929        }
930    }
931    if threads.is_empty() {
932        return None;
933    }
934    let mut cases = Vec::new();
935    let (mut on_time, mut early, mut late, mut missed, mut false_alarms) = (0, 0, 0, 0, 0);
936    let mut lags: Vec<f64> = Vec::new();
937    for (line, mut thread) in threads {
938        let first = thread[0];
939        if Some(thread.len()) != first.turns {
940            continue;
941        }
942        thread.sort_by_key(|row| row.turn);
943        let expected = match first.expects(name) {
944            Some(Expectation::Noul {
945                by_turn: Some(by_turn),
946                ..
947            }) => by_turn.turn(),
948            _ => None,
949        };
950        let detected = thread
951            .iter()
952            .find(|row| matches!(row.answer(name), Some(Answer::Noul(a)) if a.noul >= threshold))
953            .and_then(|row| row.turn);
954        let lag = match (expected, detected) {
955            (Some(k), Some(d)) => Some(d as i64 - k as i64),
956            _ => None,
957        };
958        match (expected, lag) {
959            (None, _) => {
960                if detected.is_some() {
961                    false_alarms += 1;
962                }
963            }
964            (Some(_), None) => missed += 1,
965            (Some(_), Some(lag)) => {
966                lags.push(lag as f64);
967                match lag.cmp(&0) {
968                    std::cmp::Ordering::Equal => on_time += 1,
969                    std::cmp::Ordering::Less => early += 1,
970                    std::cmp::Ordering::Greater => late += 1,
971                }
972            }
973        }
974        cases.push(ThreadLatency {
975            case: line,
976            id: first.id.map(str::to_owned),
977            expected,
978            detected,
979            latency: lag,
980        });
981    }
982    Some(Latency {
983        threads: cases.len(),
984        on_time,
985        early,
986        late,
987        missed,
988        false_alarms,
989        mean: (!lags.is_empty()).then(|| mean(lags.iter().copied())),
990        cases,
991    })
992}
993
994fn sweep_row(points: &[(f64, bool)], threshold: f64) -> SweepRow {
995    let (mut tp, mut fp, mut fneg, mut tn) = (0usize, 0usize, 0usize, 0usize);
996    for (p, yes) in points {
997        match (*p >= threshold, *yes) {
998            (true, true) => tp += 1,
999            (true, false) => fp += 1,
1000            (false, true) => fneg += 1,
1001            (false, false) => tn += 1,
1002        }
1003    }
1004    let denominator = 2 * tp + fp + fneg;
1005    SweepRow {
1006        threshold,
1007        tp,
1008        fp,
1009        r#fn: fneg,
1010        tn,
1011        accuracy: (tp + tn) as f64 / points.len() as f64,
1012        precision: (tp + fp > 0).then(|| tp as f64 / (tp + fp) as f64),
1013        recall: (tp + fneg > 0).then(|| tp as f64 / (tp + fneg) as f64),
1014        f1: if denominator == 0 {
1015            0.0
1016        } else {
1017            2.0 * tp as f64 / denominator as f64
1018        },
1019    }
1020}
1021
1022fn choice_report(name: &str, question: &Choice, rows: &[&Scored<'_>]) -> QuestionReport {
1023    let options: Vec<String> = question.criteria.keys().cloned().collect();
1024    struct Point {
1025        predicted: String,
1026        expected: String,
1027        confidence: f64,
1028        right: bool,
1029    }
1030    let points: Vec<Point> = rows
1031        .iter()
1032        .map(|row| {
1033            let (predicted, confidence) = match row.answer(name) {
1034                Some(Answer::Choice(a)) => (a.choice.clone(), a.confidence),
1035                _ => (String::new(), 0.0),
1036            };
1037            let expected = match row.expects(name) {
1038                Some(Expectation::Choice { label }) => label.clone(),
1039                _ => String::new(),
1040            };
1041            Point {
1042                right: predicted == expected,
1043                predicted,
1044                expected,
1045                confidence,
1046            }
1047        })
1048        .collect();
1049
1050    // A label the page never offered still has to land somewhere, or the matrix loses cases.
1051    let other = points
1052        .iter()
1053        .any(|point| !options.contains(&point.predicted));
1054    let mut labels = options.clone();
1055    if other {
1056        labels.push("other".to_owned());
1057    }
1058    let confusion: Vec<Vec<usize>> = options
1059        .iter()
1060        .map(|expected| {
1061            labels
1062                .iter()
1063                .enumerate()
1064                .map(|(column, predicted)| {
1065                    points
1066                        .iter()
1067                        .filter(|point| {
1068                            &point.expected == expected
1069                                && if other && column == labels.len() - 1 {
1070                                    !options.contains(&point.predicted)
1071                                } else {
1072                                    &point.predicted == predicted
1073                                }
1074                        })
1075                        .count()
1076                })
1077                .collect()
1078        })
1079        .collect();
1080
1081    QuestionReport::Choice {
1082        name: name.to_owned(),
1083        cases: points.len(),
1084        accuracy: mean(points.iter().map(|point| f64::from(point.right))),
1085        labels,
1086        confusion,
1087        gate: gate(points.iter().map(|point| (point.confidence, point.right))),
1088    }
1089}
1090
1091fn score_report(name: &str, rows: &[&Scored<'_>]) -> QuestionReport {
1092    let points: Vec<(f64, i64)> = rows
1093        .iter()
1094        .map(|row| {
1095            let (level, confidence) = match row.answer(name) {
1096                Some(Answer::Score(a)) => (i64::from(a.rounded_level()), a.confidence),
1097                _ => (0, 0.0),
1098            };
1099            let expected = match row.expects(name) {
1100                Some(Expectation::Score { level }) => *level as i64,
1101                _ => 0,
1102            };
1103            (confidence, (level - expected).abs())
1104        })
1105        .collect();
1106    QuestionReport::Score {
1107        name: name.to_owned(),
1108        cases: points.len(),
1109        exact: mean(points.iter().map(|(_, off)| f64::from(*off == 0))),
1110        within_one: mean(points.iter().map(|(_, off)| f64::from(*off <= 1))),
1111        mae: mean(points.iter().map(|(_, off)| *off as f64)),
1112        gate: gate(points.iter().map(|(c, off)| (*c, *off == 0))),
1113    }
1114}
1115
1116/// Coverage and accuracy at each cut: what you buy by only acting on confident answers.
1117fn gate(points: impl Iterator<Item = (f64, bool)>) -> Vec<GateRow> {
1118    gate_at(points, &CUTS)
1119}
1120
1121/// The gate at any set of cuts: the report reads five, calibration twenty.
1122fn gate_at(points: impl Iterator<Item = (f64, bool)>, cuts: &[f64]) -> Vec<GateRow> {
1123    let points: Vec<(f64, bool)> = points.collect();
1124    cuts.iter()
1125        .map(|confidence| {
1126            let kept: Vec<bool> = points
1127                .iter()
1128                .filter(|(c, _)| c >= confidence)
1129                .map(|(_, right)| *right)
1130                .collect();
1131            GateRow {
1132                confidence: *confidence,
1133                coverage: if points.is_empty() {
1134                    0.0
1135                } else {
1136                    kept.len() as f64 / points.len() as f64
1137                },
1138                accuracy: (!kept.is_empty())
1139                    .then(|| mean(kept.iter().map(|right| f64::from(*right)))),
1140            }
1141        })
1142        .collect()
1143}
1144
1145/// Counted tokens when every answered case carried them; the estimate, marked as one, otherwise.
1146fn usage_of(
1147    session: &Session,
1148    cases: &[Case],
1149    scored: &[Scored<'_>],
1150    model: &str,
1151    rates: Option<Rates>,
1152) -> ReportUsage {
1153    let mut input_tokens = 0u64;
1154    let mut output_tokens = 0u64;
1155    let mut counted = !scored.is_empty();
1156    for one in scored {
1157        match one
1158            .usage
1159            .as_ref()
1160            .map(|u| (u.input_tokens, u.output_tokens))
1161        {
1162            Some((Some(input), Some(output))) => {
1163                input_tokens += input;
1164                output_tokens += output;
1165            }
1166            _ => {
1167                counted = false;
1168                break;
1169            }
1170        }
1171    }
1172    if !counted {
1173        let estimate = preflight(session, cases, model, None);
1174        input_tokens = estimate.input_tokens as u64;
1175        output_tokens = estimate.output_tokens as u64;
1176    }
1177    ReportUsage {
1178        input_tokens,
1179        output_tokens,
1180        estimated: !counted,
1181        cost: rates.map(|rates| cost::price(input_tokens, output_tokens, rates)),
1182    }
1183}
1184
1185/// What a whole run would send, before any of it is sent.
1186#[derive(Debug, Clone, PartialEq)]
1187pub struct Preflight {
1188    pub cases: usize,
1189    pub input_tokens: usize,
1190    pub output_tokens: usize,
1191    pub cost: Option<Cost>,
1192}
1193
1194/// The preflight estimate: tokens summed over every case, priced when rates are known.
1195pub fn preflight(
1196    session: &Session,
1197    cases: &[Case],
1198    model: &str,
1199    rates: Option<Rates>,
1200) -> Preflight {
1201    let mut input_tokens = 0usize;
1202    let mut output_tokens = 0usize;
1203    for one in cases {
1204        let estimate = cost::estimate(&with_state(session, one.state.clone()), model);
1205        input_tokens += estimate.input_tokens;
1206        output_tokens += estimate.output_tokens;
1207    }
1208    Preflight {
1209        cases: cases.len(),
1210        input_tokens,
1211        output_tokens,
1212        cost: rates.map(|rates| cost::price(input_tokens as u64, output_tokens as u64, rates)),
1213    }
1214}
1215
1216fn mean(values: impl Iterator<Item = f64>) -> f64 {
1217    let mut sum = 0.0;
1218    let mut count = 0usize;
1219    for value in values {
1220        sum += value;
1221        count += 1;
1222    }
1223    if count == 0 { 0.0 } else { sum / count as f64 }
1224}
1225
1226/// Two decimals, the way the TypeScript port's `toFixed(2)` writes them.
1227///
1228/// Rust rounds an exact tie to even, so `0.125` would print `0.12` here and `0.13` there; every
1229/// rate and probability in a report goes through this so the two ports' reports can be compared
1230/// byte for byte.
1231pub fn two(x: f64) -> String {
1232    to_fixed(x, 2)
1233}
1234
1235/// Three decimals, the way `toFixed(3)` writes them; see [`two`].
1236pub fn three(x: f64) -> String {
1237    to_fixed(x, 3)
1238}
1239
1240/// `Number.prototype.toFixed`: the double's exact decimal value, rounded to `digits` places with an
1241/// exact tie going away from zero.
1242///
1243/// Scaling by a power of ten and rounding is not the same thing — `0.475 * 100` lands on `47.5`
1244/// although `0.475` is a hair below it, so it would print `0.48` where `toFixed` prints `0.47`.
1245/// Rust's `{:.N}` is already exact and differs only on a true tie, which is the one case handled
1246/// by hand.
1247fn to_fixed(x: f64, digits: usize) -> String {
1248    if !x.is_finite() {
1249        return format!("{x}");
1250    }
1251    // Wide enough to hold every digit of any double's exact expansion.
1252    let exact = format!("{:.1100}", x.abs());
1253    let point = exact.find('.').unwrap_or(exact.len());
1254    let tail = exact.get(point + 1 + digits..).unwrap_or("");
1255    let tie = tail.starts_with('5') && tail[1..].bytes().all(|b| b == b'0');
1256    let magnitude = if tie {
1257        let mut kept: Vec<u8> = exact[..point + 1 + digits].bytes().collect();
1258        let mut at = kept.len();
1259        loop {
1260            if at == 0 {
1261                kept.insert(0, b'1');
1262                break;
1263            }
1264            at -= 1;
1265            match kept[at] {
1266                b'.' => continue,
1267                b'9' => kept[at] = b'0',
1268                digit => {
1269                    kept[at] = digit + 1;
1270                    break;
1271                }
1272            }
1273        }
1274        let mut text = String::from_utf8(kept).unwrap_or_default();
1275        if digits == 0 {
1276            text.pop();
1277        }
1278        text
1279    } else {
1280        format!("{:.digits$}", x.abs())
1281    };
1282    if x < 0.0 {
1283        format!("-{magnitude}")
1284    } else {
1285        magnitude
1286    }
1287}
1288
1289/// The text report, as lines the terminal draws.
1290///
1291/// One block per question, in the page's order: what it scored, the sweep or the gate that says
1292/// where to set the dial, and — for a choice — the matrix that says what it confuses with what.
1293pub fn report_lines(report: &Report) -> Vec<Line<'static>> {
1294    let mut out: Vec<Line<'static>> = Vec::new();
1295    let width = report
1296        .questions
1297        .iter()
1298        .map(|q| q.name().chars().count())
1299        .max()
1300        .unwrap_or(0);
1301    for question in &report.questions {
1302        if !out.is_empty() {
1303            out.push(Line::default());
1304        }
1305        out.push(header_line(question, width));
1306        match question {
1307            QuestionReport::Noul {
1308                sweep,
1309                best,
1310                threshold,
1311                latency,
1312                ..
1313            } => {
1314                out.extend(sweep_lines(sweep, *best, *threshold));
1315                if let Some(latency) = latency {
1316                    out.push(latency_line(latency));
1317                }
1318            }
1319            QuestionReport::Choice {
1320                gate,
1321                labels,
1322                confusion,
1323                ..
1324            } => {
1325                out.extend(gate_lines(gate, "accuracy"));
1326                out.extend(confusion_lines(labels, confusion));
1327            }
1328            QuestionReport::Score { gate, .. } => out.extend(gate_lines(gate, "exact")),
1329        }
1330    }
1331
1332    if !report.errors.is_empty() {
1333        if !out.is_empty() {
1334            out.push(Line::default());
1335        }
1336        for failed in &report.errors {
1337            out.extend(error_case_lines(failed, ""));
1338        }
1339    }
1340
1341    if !out.is_empty() {
1342        out.push(Line::default());
1343    }
1344    let errors = report.errors.len();
1345    out.push(Line::from(vec![
1346        Span::raw("  "),
1347        bold(format!("{} case{}", report.cases, plural(report.cases))),
1348        dim(format!(
1349            " · {} answered · {errors} error{}",
1350            report.answered,
1351            plural(errors)
1352        )),
1353    ]));
1354    out.push(usage_line(&report.usage));
1355    out
1356}
1357
1358fn header_line(question: &QuestionReport, width: usize) -> Line<'static> {
1359    let count = format!("{} case{}", question.cases(), plural(question.cases()));
1360    let summary = match question {
1361        QuestionReport::Noul { brier, .. } => format!("{count} · Brier {}", two(*brier)),
1362        QuestionReport::Choice { accuracy, .. } => format!("{count} · accuracy {}", two(*accuracy)),
1363        QuestionReport::Score {
1364            exact,
1365            within_one,
1366            mae,
1367            ..
1368        } => format!(
1369            "{count} · exact {} · within one {} · mae {}",
1370            two(*exact),
1371            two(*within_one),
1372            two(*mae)
1373        ),
1374    };
1375    Line::from(vec![
1376        Span::raw("  "),
1377        bold(pad_end(question.name(), width)),
1378        Span::raw("  "),
1379        Span::styled(
1380            pad_end(question.kind(), 8),
1381            Style::new().fg(color_for(question.kind())),
1382        ),
1383        dim(summary),
1384    ])
1385}
1386
1387/// The sweep: what the threshold buys, row by row, with a `*` on the one this run used.
1388fn sweep_lines(sweep: &[SweepRow], best: Best, threshold: f64) -> Vec<Line<'static>> {
1389    let mut out = vec![Line::from(vec![
1390        Span::raw("    "),
1391        dim(pad_end("threshold", 12)),
1392        dim(pad_end("acc", 6)),
1393        dim(pad_end("prec", 7)),
1394        dim(pad_end("rec", 7)),
1395        dim("f1"),
1396    ])];
1397    for row in sweep {
1398        let chosen = row.threshold == threshold;
1399        let at = pad_end(
1400            &format!("{}{}", two(row.threshold), if chosen { " *" } else { "" }),
1401            12,
1402        );
1403        out.push(Line::from(vec![
1404            Span::raw("    "),
1405            if chosen { bold(at) } else { Span::raw(at) },
1406            Span::raw(pad_end(&two(row.accuracy), 6)),
1407            Span::raw(pad_end(&rate(row.precision), 7)),
1408            Span::raw(pad_end(&rate(row.recall), 7)),
1409            Span::raw(two(row.f1)),
1410        ]));
1411    }
1412    out.push(Line::from(vec![
1413        Span::raw("    "),
1414        dim(format!("best f1 at {}", two(best.threshold))),
1415    ]));
1416    out
1417}
1418
1419/// The one line that says when a noul noticed, over the conversations labelled per turn.
1420fn latency_line(latency: &Latency) -> Line<'static> {
1421    let counted = |n: usize, word: &str| format!("{n} {word}{}", plural(n));
1422    let mean = match latency.mean {
1423        None => "·".to_owned(),
1424        Some(m) => format!(
1425            "{} turn{}",
1426            signed(m),
1427            if m.abs() == 1.0 { "" } else { "s" }
1428        ),
1429    };
1430    Line::from(vec![
1431        Span::raw("    "),
1432        dim("by turn  "),
1433        Span::raw(
1434            [
1435                counted(latency.threads, "thread"),
1436                format!("{} on time", latency.on_time),
1437                format!("{} early", latency.early),
1438                format!("{} late", latency.late),
1439                format!("{} missed", latency.missed),
1440                counted(latency.false_alarms, "false alarm"),
1441                format!("mean latency {mean}"),
1442            ]
1443            .join(" · "),
1444        ),
1445    ])
1446}
1447
1448fn gate_lines(gate: &[GateRow], accuracy: &str) -> Vec<Line<'static>> {
1449    let mut out = vec![Line::from(vec![
1450        Span::raw("    "),
1451        dim(pad_end("confidence ≥", 15)),
1452        dim(pad_end("coverage", 10)),
1453        dim(accuracy.to_owned()),
1454    ])];
1455    for row in gate {
1456        out.push(Line::from(vec![
1457            Span::raw("    "),
1458            Span::raw(pad_end(&two(row.confidence), 15)),
1459            Span::raw(pad_end(&two(row.coverage), 10)),
1460            Span::raw(rate(row.accuracy)),
1461        ]));
1462    }
1463    out
1464}
1465
1466/// The matrix, which is where a rubric's real confusions show: what it calls what.
1467fn confusion_lines(labels: &[String], confusion: &[Vec<usize>]) -> Vec<Line<'static>> {
1468    let counts: Vec<usize> = confusion
1469        .iter()
1470        .flatten()
1471        .map(|n| n.to_string().len())
1472        .collect();
1473    let column = |label: &str| -> usize {
1474        counts
1475            .iter()
1476            .copied()
1477            .chain([label.chars().count(), 1])
1478            .max()
1479            .unwrap_or(1)
1480            + 2
1481    };
1482    let row_width = confusion
1483        .iter()
1484        .enumerate()
1485        .map(|(at, _)| labels[at].chars().count())
1486        .max()
1487        .unwrap_or(0)
1488        + 3;
1489
1490    let heading: String = labels
1491        .iter()
1492        .map(|label| pad_end(label, column(label)))
1493        .collect();
1494    let mut out = vec![
1495        Line::from(vec![
1496            Span::raw("    "),
1497            dim("confusion, rows expected, columns predicted"),
1498        ]),
1499        Line::from(vec![
1500            Span::raw(format!("    {}", " ".repeat(row_width))),
1501            dim(heading.trim_end().to_owned()),
1502        ]),
1503    ];
1504    for (at, row) in confusion.iter().enumerate() {
1505        let cells: String = row
1506            .iter()
1507            .enumerate()
1508            .map(|(column2, count)| pad_end(&count.to_string(), column(&labels[column2])))
1509            .collect();
1510        out.push(Line::from(vec![
1511            Span::raw("    "),
1512            Span::styled(pad_end(&labels[at], row_width), Style::new().fg(CHOICE)),
1513            Span::raw(cells.trim_end().to_owned()),
1514        ]));
1515    }
1516    out
1517}
1518
1519fn error_case_lines(failed: &CaseError, prefix: &str) -> Vec<Line<'static>> {
1520    let name = format!(
1521        "{prefix}{}",
1522        case_name(failed.case, failed.id.as_deref(), failed.turn)
1523    );
1524    let mut parts = failed.message.split('\n');
1525    let first = parts.next().unwrap_or("").trim().to_owned();
1526    let mut out = vec![Line::from(vec![
1527        Span::raw("  "),
1528        Span::styled(format!("{name}: "), Style::new().fg(BAD)),
1529        Span::raw(first),
1530    ])];
1531    for more in parts {
1532        out.push(Line::from(vec![
1533            Span::raw("    "),
1534            dim(more.trim().to_owned()),
1535        ]));
1536    }
1537    out
1538}
1539
1540fn usage_line(usage: &ReportUsage) -> Line<'static> {
1541    let money = match usage.cost {
1542        Some(cost) => format!(" · {}", cost::usd(cost.total)),
1543        None => String::new(),
1544    };
1545    let tokens = format!(
1546        "{} in / {} out tokens{money}",
1547        usage.input_tokens, usage.output_tokens
1548    );
1549    if usage.estimated {
1550        Line::from(vec![
1551            Span::raw("  "),
1552            dim(format!("≈ {tokens} — estimated, nothing was counted")),
1553        ])
1554    } else {
1555        Line::from(vec![Span::raw("  "), dim(tokens)])
1556    }
1557}
1558
1559/// The JSON report, ready for `to_string_pretty`. Numbers keep their precision; what is undefined
1560/// is null.
1561pub fn report_json(report: &Report) -> Value {
1562    let mut questions = serde_json::Map::new();
1563    for question in &report.questions {
1564        questions.insert(question.name().to_owned(), question_json(question));
1565    }
1566    let mut usage = serde_json::Map::new();
1567    usage.insert("inputTokens".to_owned(), json!(report.usage.input_tokens));
1568    usage.insert("outputTokens".to_owned(), json!(report.usage.output_tokens));
1569    usage.insert("estimated".to_owned(), json!(report.usage.estimated));
1570    if let Some(cost) = report.usage.cost {
1571        usage.insert("cost".to_owned(), number(cost.total));
1572    }
1573    let errors: Vec<Value> = report
1574        .errors
1575        .iter()
1576        .map(|failed| {
1577            let mut out = serde_json::Map::new();
1578            out.insert("case".to_owned(), json!(failed.case));
1579            if let Some(turn) = failed.turn {
1580                out.insert("turn".to_owned(), json!(turn));
1581            }
1582            if let Some(id) = &failed.id {
1583                out.insert("id".to_owned(), json!(id));
1584            }
1585            out.insert("message".to_owned(), json!(failed.message));
1586            Value::Object(out)
1587        })
1588        .collect();
1589    json!({
1590        "model": report.model,
1591        "threshold": number(report.threshold),
1592        "cases": report.cases,
1593        "answered": report.answered,
1594        "errors": errors,
1595        "questions": Value::Object(questions),
1596        "usage": Value::Object(usage),
1597    })
1598}
1599
1600fn question_json(question: &QuestionReport) -> Value {
1601    match question {
1602        QuestionReport::Noul {
1603            cases,
1604            brier,
1605            threshold,
1606            accuracy,
1607            best,
1608            sweep,
1609            latency,
1610            ..
1611        } => {
1612            let mut out = json!({
1613            "kind": question.kind(),
1614            "cases": cases,
1615            "brier": number(*brier),
1616            "threshold": number(*threshold),
1617            "accuracy": number(*accuracy),
1618            "best": {"threshold": number(best.threshold), "f1": number(best.f1)},
1619            "sweep": sweep.iter().map(|row| json!({
1620                "threshold": number(row.threshold),
1621                "tp": row.tp,
1622                "fp": row.fp,
1623                "fn": row.r#fn,
1624                "tn": row.tn,
1625                "accuracy": number(row.accuracy),
1626                "precision": maybe(row.precision),
1627                "recall": maybe(row.recall),
1628                "f1": number(row.f1),
1629            })).collect::<Vec<_>>(),
1630            });
1631            if let (Some(latency), Value::Object(object)) = (latency, &mut out) {
1632                object.insert("latency".to_owned(), latency_json(latency));
1633            }
1634            out
1635        }
1636        QuestionReport::Choice {
1637            cases,
1638            accuracy,
1639            labels,
1640            confusion,
1641            gate,
1642            ..
1643        } => json!({
1644            "kind": question.kind(),
1645            "cases": cases,
1646            "accuracy": number(*accuracy),
1647            "labels": labels,
1648            "confusion": confusion,
1649            "gate": gate.iter().map(gate_json).collect::<Vec<_>>(),
1650        }),
1651        QuestionReport::Score {
1652            cases,
1653            exact,
1654            within_one,
1655            mae,
1656            gate,
1657            ..
1658        } => json!({
1659            "kind": question.kind(),
1660            "cases": cases,
1661            "exact": number(*exact),
1662            "withinOne": number(*within_one),
1663            "mae": number(*mae),
1664            "gate": gate.iter().map(gate_json).collect::<Vec<_>>(),
1665        }),
1666    }
1667}
1668
1669fn latency_json(latency: &Latency) -> Value {
1670    let cases: Vec<Value> = latency
1671        .cases
1672        .iter()
1673        .map(|one| {
1674            let mut out = serde_json::Map::new();
1675            out.insert("case".to_owned(), json!(one.case));
1676            if let Some(id) = &one.id {
1677                out.insert("id".to_owned(), json!(id));
1678            }
1679            out.insert("expected".to_owned(), json!(one.expected));
1680            out.insert("detected".to_owned(), json!(one.detected));
1681            out.insert("latency".to_owned(), json!(one.latency));
1682            Value::Object(out)
1683        })
1684        .collect();
1685    json!({
1686        "threads": latency.threads,
1687        "onTime": latency.on_time,
1688        "early": latency.early,
1689        "late": latency.late,
1690        "missed": latency.missed,
1691        "falseAlarms": latency.false_alarms,
1692        "mean": maybe(latency.mean),
1693        "cases": cases,
1694    })
1695}
1696
1697fn gate_json(row: &GateRow) -> Value {
1698    json!({
1699        "confidence": number(row.confidence),
1700        "coverage": number(row.coverage),
1701        "accuracy": maybe(row.accuracy),
1702    })
1703}
1704
1705/// A number written the way `JSON.stringify` writes it: a whole float loses its `.0`, so the two
1706/// ports' JSON reports can be compared byte for byte the way their tables can.
1707fn number(x: f64) -> Value {
1708    if x.fract() == 0.0 && x.abs() < 9e15 {
1709        return json!(x as i64);
1710    }
1711    json!(x)
1712}
1713
1714/// The same, for a rate that was never defined: `null`, so the key is always there.
1715fn maybe(x: Option<f64>) -> Value {
1716    x.map_or(Value::Null, number)
1717}
1718
1719/// A rate that was never defined is a dot, not a zero: nothing was measured.
1720fn rate(n: Option<f64>) -> String {
1721    match n {
1722        Some(n) => two(n),
1723        None => "·".to_owned(),
1724    }
1725}
1726
1727fn plural(n: usize) -> &'static str {
1728    if n == 1 { "" } else { "s" }
1729}
1730
1731fn pad_end(text: &str, width: usize) -> String {
1732    let length = text.chars().count();
1733    if length >= width {
1734        text.to_owned()
1735    } else {
1736        format!("{text}{}", " ".repeat(width - length))
1737    }
1738}
1739
1740/// Styled lines as the plain text a pipe wants.
1741pub fn report_text(report: &Report) -> String {
1742    lines_text(report_lines(report))
1743}
1744
1745fn lines_text(lines: Vec<Line<'static>>) -> String {
1746    let mut out = String::new();
1747    for line in lines {
1748        for span in &line.spans {
1749            out.push_str(span.content.as_ref());
1750        }
1751        out.push('\n');
1752    }
1753    out
1754}
1755
1756// ---- two pages over the same cases ------------------------------------------------------------
1757
1758/// One page's run, as a comparison needs it.
1759#[derive(Debug, Clone, Copy)]
1760pub struct Side<'a> {
1761    /// What the page is called: the path it was read from.
1762    pub label: &'a str,
1763    pub session: &'a Session,
1764    pub cases: &'a [Case],
1765    pub outcomes: &'a [Outcome],
1766    pub model: &'a str,
1767}
1768
1769/// What the exact McNemar test made of the discordant pairs.
1770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1771pub enum Verdict {
1772    TooFew,
1773    Better,
1774    Worse,
1775    Same,
1776}
1777
1778impl Verdict {
1779    /// The word the report and the JSON use.
1780    pub fn as_str(self) -> &'static str {
1781        match self {
1782            Verdict::TooFew => "too few",
1783            Verdict::Better => "better",
1784            Verdict::Worse => "worse",
1785            Verdict::Same => "same",
1786        }
1787    }
1788}
1789
1790#[derive(Debug, Clone, Copy, PartialEq)]
1791pub struct McNemar {
1792    /// Cases one page got right and the other wrong: `fixed + broke`.
1793    pub discordant: usize,
1794    /// Two-sided exact p-value; 1 when there is nothing to test.
1795    pub p: f64,
1796    pub verdict: Verdict,
1797}
1798
1799/// A case the two pages answered differently.
1800#[derive(Debug, Clone, PartialEq)]
1801pub struct Flip {
1802    pub case: usize,
1803    pub turn: Option<usize>,
1804    pub id: Option<String>,
1805    /// What the case expects, and what each page predicted, in the question's own terms.
1806    pub expected: Value,
1807    pub a: Value,
1808    pub b: Value,
1809    /// `fixed`: `b` put right what `a` got wrong. `broke`: the reverse. `changed`: both wrong.
1810    pub status: &'static str,
1811}
1812
1813/// One metric on both sides, and what moved.
1814#[derive(Debug, Clone, PartialEq)]
1815pub struct Metric {
1816    /// The JSON key.
1817    pub key: &'static str,
1818    /// The row name in the text report.
1819    pub label: &'static str,
1820    pub a: f64,
1821    pub b: f64,
1822    /// Whether a delta means anything: a threshold is a setting, not a result.
1823    pub delta: bool,
1824}
1825
1826/// A question both pages ask the same way, measured on the cases both pages scored.
1827#[derive(Debug, Clone, PartialEq)]
1828pub struct Shared {
1829    pub name: String,
1830    pub kind: &'static str,
1831    pub paired: usize,
1832    pub metrics: Vec<Metric>,
1833    pub fixed: usize,
1834    pub broke: usize,
1835    pub changed: usize,
1836    pub mcnemar: McNemar,
1837    pub flips: Vec<Flip>,
1838}
1839
1840/// A name both pages use for questions of different kinds.
1841#[derive(Debug, Clone, PartialEq, Eq)]
1842pub struct Mismatch {
1843    pub name: String,
1844    pub a: &'static str,
1845    pub b: &'static str,
1846}
1847
1848/// One page's report, with the name it goes by.
1849#[derive(Debug, Clone, PartialEq)]
1850pub struct Labelled {
1851    pub label: String,
1852    pub report: Report,
1853}
1854
1855/// Everything a comparison found, with the numbers unrounded.
1856#[derive(Debug, Clone, PartialEq)]
1857pub struct Comparison {
1858    pub a: Labelled,
1859    pub b: Labelled,
1860    pub questions: Vec<Shared>,
1861    pub only_a: Vec<String>,
1862    pub only_b: Vec<String>,
1863    pub mismatched: Vec<Mismatch>,
1864    pub unpaired: Vec<String>,
1865    /// Distinct cases across both pages.
1866    pub cases: usize,
1867    pub usage: ReportUsage,
1868}
1869
1870/// What a comparison is read at, which both reports repeat back.
1871#[derive(Debug, Clone, Copy)]
1872pub struct CompareOptions {
1873    pub threshold: f64,
1874    pub rates: Option<Rates>,
1875}
1876
1877/// The level McNemar's test is read at. Not a flag: a comparison should mean the same everywhere.
1878pub const ALPHA: f64 = 0.05;
1879
1880/// Below this many discordant pairs no two-sided exact p can reach [`ALPHA`]: 2 / 2^5 > 0.05.
1881pub const MIN_DISCORDANT: usize = 6;
1882
1883/// The exact McNemar test on the discordant pairs of a paired comparison.
1884///
1885/// Under "no difference" each discordant pair is a fair coin, so the p-value is a binomial tail.
1886/// It is summed in log space because `2^n` stops being a number long before a cases file stops
1887/// being a reasonable size.
1888pub fn mcnemar(fixed: usize, broke: usize) -> McNemar {
1889    let n = fixed + broke;
1890    let mut p = 1.0;
1891    if n > 0 {
1892        let low = fixed.min(broke);
1893        let ln2n = n as f64 * std::f64::consts::LN_2;
1894        let mut ln_choose = 0.0;
1895        let mut tail = (-ln2n).exp();
1896        for k in 1..=low {
1897            ln_choose += ((n - k + 1) as f64).ln() - (k as f64).ln();
1898            tail += (ln_choose - ln2n).exp();
1899        }
1900        p = (2.0 * tail).min(1.0);
1901    }
1902    let verdict = if n < MIN_DISCORDANT {
1903        Verdict::TooFew
1904    } else if p < ALPHA && fixed > broke {
1905        Verdict::Better
1906    } else if p < ALPHA && broke > fixed {
1907        Verdict::Worse
1908    } else {
1909        Verdict::Same
1910    };
1911    McNemar {
1912        discordant: n,
1913        p,
1914        verdict,
1915    }
1916}
1917
1918/// The wire `type` of a question, with anything that is not a noul, a choice or a score as `raw`.
1919fn kind_of(question: &Question) -> &'static str {
1920    match question {
1921        Question::Noul(_) => "noul",
1922        Question::Choice(_) => "choice",
1923        Question::Score(_) => "score",
1924        _ => "raw",
1925    }
1926}
1927
1928/// Put two runs of the same cases side by side.
1929///
1930/// Only the cases both pages scored count, so each delta is measured on the same states: a page
1931/// that errored on the hard cases must not look better for it. The full reports, over everything
1932/// each page scored, travel along for the JSON.
1933pub fn compare(a: Side<'_>, b: Side<'_>, options: CompareOptions) -> Comparison {
1934    let report_of = |side: &Side<'_>| {
1935        report(
1936            side.session,
1937            side.cases,
1938            side.outcomes,
1939            ReportOptions {
1940                model: side.model,
1941                threshold: options.threshold,
1942                rates: options.rates,
1943            },
1944        )
1945    };
1946    let (_, left) = scored_of(a.cases, a.outcomes);
1947    let (_, right) = scored_of(b.cases, b.outcomes);
1948    let right: HashMap<(usize, Option<usize>), &Scored<'_>> =
1949        right.iter().map(|one| (key_of(one), one)).collect();
1950    let twin_of = |one: &Scored<'_>| right.get(&key_of(one)).copied();
1951
1952    let kind_in_b = |name: &str| question_of(b.session, name).map(kind_of);
1953    let mut questions = Vec::new();
1954    let mut mismatched = Vec::new();
1955    let mut unpaired = Vec::new();
1956    for (name, question) in &a.session.questions {
1957        let Some(other) = kind_in_b(name) else {
1958            continue;
1959        };
1960        let kind = kind_of(question);
1961        if other != kind || kind == "raw" {
1962            if other != kind {
1963                mismatched.push(Mismatch {
1964                    name: name.clone(),
1965                    a: kind,
1966                    b: other,
1967                });
1968            }
1969            continue;
1970        }
1971        let pairs: Vec<(&Scored<'_>, &Scored<'_>)> = left
1972            .iter()
1973            .filter_map(|one| {
1974                let twin = twin_of(one)?;
1975                (one.expects(name).is_some() && twin.expects(name).is_some()).then_some((one, twin))
1976            })
1977            .collect();
1978        if pairs.is_empty() {
1979            // A question nobody labelled is left out, as eval leaves it out; one that was labelled
1980            // and still has no pair is worth saying so about.
1981            let labelled = a
1982                .cases
1983                .iter()
1984                .chain(b.cases)
1985                .any(|one| one.expect.iter().any(|(n, _)| n == name));
1986            if labelled {
1987                unpaired.push(name.clone());
1988            }
1989            continue;
1990        }
1991        questions.push(shared(
1992            name,
1993            kind,
1994            &pairs,
1995            a.session.threshold_of(name, options.threshold),
1996            b.session.threshold_of(name, options.threshold),
1997        ));
1998    }
1999
2000    let mut keys: Vec<(usize, Option<usize>)> =
2001        a.cases.iter().chain(b.cases).map(case_key).collect();
2002    keys.sort_unstable();
2003    keys.dedup();
2004    let report_a = report_of(&a);
2005    let report_b = report_of(&b);
2006    let usage = sum_usage(&report_a.usage, &report_b.usage, options.rates);
2007    Comparison {
2008        a: Labelled {
2009            label: a.label.to_owned(),
2010            report: report_a,
2011        },
2012        b: Labelled {
2013            label: b.label.to_owned(),
2014            report: report_b,
2015        },
2016        questions,
2017        only_a: a
2018            .session
2019            .questions
2020            .iter()
2021            .filter(|(name, _)| question_of(b.session, name).is_none())
2022            .map(|(name, _)| name.clone())
2023            .collect(),
2024        only_b: b
2025            .session
2026            .questions
2027            .iter()
2028            .filter(|(name, _)| question_of(a.session, name).is_none())
2029            .map(|(name, _)| name.clone())
2030            .collect(),
2031        mismatched,
2032        unpaired,
2033        cases: keys.len(),
2034        usage,
2035    }
2036}
2037
2038/// Which case a scored row came from, so the same case can be found on the other page.
2039fn key_of(one: &Scored<'_>) -> (usize, Option<usize>) {
2040    (one.line, one.turn)
2041}
2042
2043/// The same key, for a case that has not been scored.
2044fn case_key(one: &Case) -> (usize, Option<usize>) {
2045    (one.line, one.turn)
2046}
2047
2048fn sum_usage(a: &ReportUsage, b: &ReportUsage, rates: Option<Rates>) -> ReportUsage {
2049    let input_tokens = a.input_tokens + b.input_tokens;
2050    let output_tokens = a.output_tokens + b.output_tokens;
2051    ReportUsage {
2052        input_tokens,
2053        output_tokens,
2054        estimated: a.estimated || b.estimated,
2055        cost: rates.map(|rates| cost::price(input_tokens, output_tokens, rates)),
2056    }
2057}
2058
2059/// One paired observation: what each side predicted, and whether it was right.
2060struct Pair<'a> {
2061    one: &'a Scored<'a>,
2062    expected: Value,
2063    a: Value,
2064    b: Value,
2065    right_a: bool,
2066    right_b: bool,
2067}
2068
2069fn shared(
2070    name: &str,
2071    kind: &'static str,
2072    pairs: &[(&Scored<'_>, &Scored<'_>)],
2073    threshold_a: f64,
2074    threshold_b: f64,
2075) -> Shared {
2076    let noul_of = |row: &Scored<'_>| match row.answer(name) {
2077        Some(Answer::Noul(answer)) => answer.noul,
2078        _ => 0.0,
2079    };
2080    let yes_of =
2081        |row: &Scored<'_>| matches!(row.expects(name), Some(Expectation::Noul { yes: true, .. }));
2082    let choice_of = |row: &Scored<'_>| match row.answer(name) {
2083        Some(Answer::Choice(answer)) => answer.choice.clone(),
2084        _ => String::new(),
2085    };
2086    let level_of = |row: &Scored<'_>| match row.answer(name) {
2087        Some(Answer::Score(answer)) => answer.rounded_level() as usize,
2088        _ => 0,
2089    };
2090    let (metrics, observed): (Vec<Metric>, Vec<Pair<'_>>) = match kind {
2091        "noul" => {
2092            let left: Vec<(f64, bool)> = pairs
2093                .iter()
2094                .map(|(one, _)| (noul_of(one), yes_of(one)))
2095                .collect();
2096            let right: Vec<(f64, bool)> = pairs
2097                .iter()
2098                .map(|(_, twin)| (noul_of(twin), yes_of(twin)))
2099                .collect();
2100            let brier = |list: &[(f64, bool)]| {
2101                mean(
2102                    list.iter()
2103                        .map(|(p, yes)| (p - if *yes { 1.0 } else { 0.0 }).powi(2)),
2104                )
2105            };
2106            let row_a = sweep_row(&left, threshold_a);
2107            let row_b = sweep_row(&right, threshold_b);
2108            let metrics = vec![
2109                metric("threshold", "threshold", threshold_a, threshold_b, false),
2110                metric("brier", "brier", brier(&left), brier(&right), true),
2111                metric("accuracy", "accuracy", row_a.accuracy, row_b.accuracy, true),
2112                metric("f1", "f1", row_a.f1, row_b.f1, true),
2113            ];
2114            let observed = pairs
2115                .iter()
2116                .zip(left.iter().zip(&right))
2117                .map(|((one, _), ((pa, yes), (pb, _)))| {
2118                    let (pred_a, pred_b) = (*pa >= threshold_a, *pb >= threshold_b);
2119                    Pair {
2120                        one,
2121                        expected: Value::Bool(*yes),
2122                        a: Value::Bool(pred_a),
2123                        b: Value::Bool(pred_b),
2124                        right_a: pred_a == *yes,
2125                        right_b: pred_b == *yes,
2126                    }
2127                })
2128                .collect();
2129            (metrics, observed)
2130        }
2131        "choice" => {
2132            let observed: Vec<Pair<'_>> = pairs
2133                .iter()
2134                .map(|(one, twin)| {
2135                    let expected = match one.expects(name) {
2136                        Some(Expectation::Choice { label }) => label.clone(),
2137                        _ => String::new(),
2138                    };
2139                    let (a, b) = (choice_of(one), choice_of(twin));
2140                    Pair {
2141                        one,
2142                        right_a: a == expected,
2143                        right_b: b == expected,
2144                        expected: Value::String(expected),
2145                        a: Value::String(a),
2146                        b: Value::String(b),
2147                    }
2148                })
2149                .collect();
2150            let metrics = vec![metric(
2151                "accuracy",
2152                "accuracy",
2153                mean(observed.iter().map(|pair| f64::from(pair.right_a))),
2154                mean(observed.iter().map(|pair| f64::from(pair.right_b))),
2155                true,
2156            )];
2157            (metrics, observed)
2158        }
2159        _ => {
2160            let mut offs: Vec<(usize, usize)> = Vec::new();
2161            let observed: Vec<Pair<'_>> = pairs
2162                .iter()
2163                .map(|(one, twin)| {
2164                    let expected = match one.expects(name) {
2165                        Some(Expectation::Score { level }) => *level,
2166                        _ => 0,
2167                    };
2168                    let (a, b) = (level_of(one), level_of(twin));
2169                    offs.push((a.abs_diff(expected), b.abs_diff(expected)));
2170                    Pair {
2171                        one,
2172                        expected: json!(expected),
2173                        a: json!(a),
2174                        b: json!(b),
2175                        right_a: a == expected,
2176                        right_b: b == expected,
2177                    }
2178                })
2179                .collect();
2180            let both = |f: &dyn Fn(usize) -> f64| {
2181                (
2182                    mean(offs.iter().map(|(a, _)| f(*a))),
2183                    mean(offs.iter().map(|(_, b)| f(*b))),
2184                )
2185            };
2186            let (exact_a, exact_b) = both(&|off| f64::from(off == 0));
2187            let (within_a, within_b) = both(&|off| f64::from(off <= 1));
2188            let (mae_a, mae_b) = both(&|off| off as f64);
2189            let metrics = vec![
2190                metric("exact", "exact", exact_a, exact_b, true),
2191                metric("withinOne", "within one", within_a, within_b, true),
2192                metric("mae", "mae", mae_a, mae_b, true),
2193            ];
2194            (metrics, observed)
2195        }
2196    };
2197
2198    let mut flips = Vec::new();
2199    let (mut fixed, mut broke, mut changed) = (0, 0, 0);
2200    for pair in observed {
2201        if pair.a == pair.b {
2202            continue;
2203        }
2204        let status = if !pair.right_a && pair.right_b {
2205            fixed += 1;
2206            "fixed"
2207        } else if pair.right_a {
2208            broke += 1;
2209            "broke"
2210        } else {
2211            changed += 1;
2212            "changed"
2213        };
2214        flips.push(Flip {
2215            case: pair.one.line,
2216            turn: pair.one.turn,
2217            id: pair.one.id.map(str::to_owned),
2218            expected: pair.expected,
2219            a: pair.a,
2220            b: pair.b,
2221            status,
2222        });
2223    }
2224    Shared {
2225        name: name.to_owned(),
2226        kind,
2227        paired: pairs.len(),
2228        metrics,
2229        fixed,
2230        broke,
2231        changed,
2232        mcnemar: mcnemar(fixed, broke),
2233        flips,
2234    }
2235}
2236
2237fn metric(key: &'static str, label: &'static str, a: f64, b: f64, delta: bool) -> Metric {
2238    Metric {
2239        key,
2240        label,
2241        a,
2242        b,
2243        delta,
2244    }
2245}
2246
2247/// The questions `b` is significantly worse at, for `--fail-on-regression`.
2248pub fn regressions(comparison: &Comparison) -> Vec<&Shared> {
2249    comparison
2250        .questions
2251        .iter()
2252        .filter(|q| q.mcnemar.verdict == Verdict::Worse)
2253        .collect()
2254}
2255
2256/// How many flipped cases the text report lists per question before it points at the JSON.
2257const FLIPS_SHOWN: usize = 10;
2258
2259/// The comparison as lines: a legend, a block per shared question, what could not be compared.
2260pub fn compare_lines(comparison: &Comparison) -> Vec<Line<'static>> {
2261    let mut out: Vec<Line<'static>> = Vec::new();
2262    let sides = [("a", &comparison.a), ("b", &comparison.b)];
2263    let label_width = sides
2264        .iter()
2265        .map(|(_, side)| side.label.chars().count())
2266        .max()
2267        .unwrap_or(0);
2268    for (letter, side) in sides {
2269        let n = side.report.cases;
2270        out.push(Line::from(vec![
2271            Span::raw("  "),
2272            bold(letter),
2273            Span::raw("  "),
2274            Span::raw(pad_end(&side.label, label_width)),
2275            Span::raw("  "),
2276            dim(format!("{} · {n} case{}", side.report.model, plural(n))),
2277        ]));
2278    }
2279
2280    let width = comparison
2281        .questions
2282        .iter()
2283        .map(|q| q.name.chars().count())
2284        .max()
2285        .unwrap_or(0);
2286    for question in &comparison.questions {
2287        out.push(Line::default());
2288        out.extend(shared_lines(question, width));
2289    }
2290
2291    let mut lists: Vec<Line<'static>> = Vec::new();
2292    if !comparison.only_a.is_empty() {
2293        lists.push(Line::from(vec![
2294            Span::raw("  "),
2295            dim("only in a: "),
2296            Span::raw(comparison.only_a.join(", ")),
2297        ]));
2298    }
2299    if !comparison.only_b.is_empty() {
2300        lists.push(Line::from(vec![
2301            Span::raw("  "),
2302            dim("only in b: "),
2303            Span::raw(comparison.only_b.join(", ")),
2304        ]));
2305    }
2306    for odd in &comparison.mismatched {
2307        lists.push(Line::from(vec![
2308            Span::raw("  "),
2309            dim("mismatched: "),
2310            Span::raw(format!(
2311                "{} is a {} in a and a {} in b",
2312                odd.name, odd.a, odd.b
2313            )),
2314        ]));
2315    }
2316    for name in &comparison.unpaired {
2317        lists.push(Line::from(vec![
2318            Span::raw("  "),
2319            dim("unpaired: "),
2320            Span::raw(format!("{name} — no case was scored for it on both pages")),
2321        ]));
2322    }
2323    if !lists.is_empty() {
2324        out.push(Line::default());
2325        out.extend(lists);
2326    }
2327
2328    let mut failures: Vec<Line<'static>> = Vec::new();
2329    for (letter, side) in sides {
2330        for failed in &side.report.errors {
2331            failures.extend(error_case_lines(failed, &format!("{letter} ")));
2332        }
2333    }
2334    if !failures.is_empty() {
2335        out.push(Line::default());
2336        out.extend(failures);
2337    }
2338
2339    out.push(Line::default());
2340    let tally = |report: &Report| {
2341        let errors = report.errors.len();
2342        format!(
2343            "{} answered, {errors} error{}",
2344            report.answered,
2345            plural(errors)
2346        )
2347    };
2348    out.push(Line::from(vec![
2349        Span::raw("  "),
2350        bold(format!(
2351            "{} case{}",
2352            comparison.cases,
2353            plural(comparison.cases)
2354        )),
2355        dim(format!(
2356            " · a {} · b {}",
2357            tally(&comparison.a.report),
2358            tally(&comparison.b.report)
2359        )),
2360    ]));
2361    out.push(usage_line(&comparison.usage));
2362    out
2363}
2364
2365fn shared_lines(question: &Shared, width: usize) -> Vec<Line<'static>> {
2366    let n = question.paired;
2367    let mut out = vec![
2368        Line::from(vec![
2369            Span::raw("  "),
2370            bold(pad_end(&question.name, width)),
2371            Span::raw("  "),
2372            Span::styled(
2373                pad_end(question.kind, 8),
2374                Style::new().fg(color_for(question.kind)),
2375            ),
2376            dim(format!("{n} paired case{}", plural(n))),
2377        ]),
2378        Line::from(vec![
2379            Span::raw(format!("    {}", " ".repeat(14))),
2380            dim(format!("{}{}Δ", pad_end("a", 8), pad_end("b", 8))),
2381        ]),
2382    ];
2383    for metric in &question.metrics {
2384        let mut cells = vec![two(metric.a), two(metric.b)];
2385        if metric.delta {
2386            cells.push(signed(metric.b - metric.a));
2387        }
2388        let cells: String = cells.iter().map(|cell| pad_end(cell, 8)).collect();
2389        out.push(Line::from(vec![
2390            Span::raw("    "),
2391            Span::raw(pad_end(metric.label, 14)),
2392            Span::raw(cells.trim_end().to_owned()),
2393        ]));
2394    }
2395    out.push(Line::from(vec![
2396        Span::raw("    "),
2397        Span::raw(format!(
2398            "{} fixed · {} broke · {} changed",
2399            question.fixed, question.broke, question.changed
2400        )),
2401    ]));
2402    let verdict = question.mcnemar.verdict;
2403    let text = mcnemar_text(&question.mcnemar);
2404    out.push(Line::from(vec![
2405        Span::raw("    "),
2406        match verdict {
2407            Verdict::Better => Span::styled(text, Style::new().fg(SCORE)),
2408            Verdict::Worse => Span::styled(text, Style::new().fg(BAD)),
2409            _ => dim(text),
2410        },
2411    ]));
2412
2413    let shown = &question.flips[..question.flips.len().min(FLIPS_SHOWN)];
2414    let names: Vec<String> = shown
2415        .iter()
2416        .map(|flip| case_name(flip.case, flip.id.as_deref(), flip.turn))
2417        .collect();
2418    let moves: Vec<String> = shown
2419        .iter()
2420        .map(|flip| {
2421            format!(
2422                "{} → {}",
2423                reading(question.kind, &flip.a),
2424                reading(question.kind, &flip.b)
2425            )
2426        })
2427        .collect();
2428    let name_width = names.iter().map(|n| n.chars().count()).max().unwrap_or(0);
2429    let move_width = moves.iter().map(|m| m.chars().count()).max().unwrap_or(0);
2430    for ((flip, name), change) in shown.iter().zip(&names).zip(&moves) {
2431        let color = match flip.status {
2432            "fixed" => SCORE,
2433            "broke" => BAD,
2434            _ => DIM,
2435        };
2436        out.push(Line::from(vec![
2437            Span::raw("    "),
2438            Span::raw(pad_end(name, name_width)),
2439            Span::raw("   "),
2440            Span::raw(pad_end(change, move_width)),
2441            Span::raw("   "),
2442            Span::styled(flip.status, Style::new().fg(color)),
2443        ]));
2444    }
2445    let more = question.flips.len() - shown.len();
2446    if more > 0 {
2447        out.push(Line::from(vec![
2448            Span::raw("    "),
2449            dim(format!("… {more} more flipped; --json lists them all")),
2450        ]));
2451    }
2452    out
2453}
2454
2455/// The significance line, which says in words what the p-value allows and what it does not.
2456fn mcnemar_text(test: &McNemar) -> String {
2457    if test.discordant == 0 {
2458        return "McNemar: no discordant pairs, nothing to test".to_owned();
2459    }
2460    if test.verdict == Verdict::TooFew {
2461        return format!(
2462            "McNemar: too few discordant pairs to call ({}; {MIN_DISCORDANT} are needed for p < {ALPHA})",
2463            test.discordant
2464        );
2465    }
2466    let head = format!(
2467        "McNemar p {} over {} discordant pairs: ",
2468        three(test.p),
2469        test.discordant
2470    );
2471    match test.verdict {
2472        Verdict::Better => format!("{head}b is significantly better"),
2473        Verdict::Worse => format!("{head}b is significantly worse"),
2474        _ => format!("{head}no significant difference"),
2475    }
2476}
2477
2478/// A prediction as the report says it: yes or no, a label, a level.
2479fn reading(kind: &str, value: &Value) -> String {
2480    match (kind, value) {
2481        ("noul", Value::Bool(true)) => "yes".to_owned(),
2482        ("noul", _) => "no".to_owned(),
2483        ("score", other) => format!("level {other}"),
2484        (_, Value::String(label)) => label.clone(),
2485        (_, other) => other.to_string(),
2486    }
2487}
2488
2489/// `case 7 turn 2 (t-007)`: the line in the cases file, the prefix of a case labelled per turn, and
2490/// the id when the case has one.
2491fn case_name(line: usize, id: Option<&str>, turn: Option<usize>) -> String {
2492    let turn = turn.map(|t| format!(" turn {t}")).unwrap_or_default();
2493    let id = id.map(|id| format!(" ({id})")).unwrap_or_default();
2494    format!("case {line}{turn}{id}")
2495}
2496
2497/// A change, signed either way, so a regression reads as one; `-0.00` is no change, so `+0.00`.
2498pub fn signed(n: f64) -> String {
2499    let text = two(n);
2500    if text == "-0.00" {
2501        return "+0.00".to_owned();
2502    }
2503    if text.starts_with('-') {
2504        text
2505    } else {
2506        format!("+{text}")
2507    }
2508}
2509
2510/// The comparison as JSON, ready for `to_string_pretty`: both reports whole, and what moved
2511/// between them.
2512pub fn compare_json(comparison: &Comparison) -> Value {
2513    let side = |one: &Labelled| {
2514        let mut out = serde_json::Map::new();
2515        out.insert("page".to_owned(), json!(one.label));
2516        if let Value::Object(report) = report_json(&one.report) {
2517            out.extend(report);
2518        }
2519        Value::Object(out)
2520    };
2521    let mut questions = serde_json::Map::new();
2522    for question in &comparison.questions {
2523        let pick = |f: &dyn Fn(&Metric) -> Option<f64>| {
2524            let mut out = serde_json::Map::new();
2525            for metric in &question.metrics {
2526                if let Some(value) = f(metric) {
2527                    out.insert(metric.key.to_owned(), number(value));
2528                }
2529            }
2530            Value::Object(out)
2531        };
2532        let flips: Vec<Value> = question
2533            .flips
2534            .iter()
2535            .map(|flip| {
2536                let mut out = serde_json::Map::new();
2537                out.insert("case".to_owned(), json!(flip.case));
2538                if let Some(turn) = flip.turn {
2539                    out.insert("turn".to_owned(), json!(turn));
2540                }
2541                if let Some(id) = &flip.id {
2542                    out.insert("id".to_owned(), json!(id));
2543                }
2544                out.insert("expected".to_owned(), flip.expected.clone());
2545                out.insert("a".to_owned(), flip.a.clone());
2546                out.insert("b".to_owned(), flip.b.clone());
2547                out.insert("status".to_owned(), json!(flip.status));
2548                Value::Object(out)
2549            })
2550            .collect();
2551        questions.insert(
2552            question.name.clone(),
2553            json!({
2554                "kind": question.kind,
2555                "paired": question.paired,
2556                "a": pick(&|m| Some(m.a)),
2557                "b": pick(&|m| Some(m.b)),
2558                "delta": pick(&|m| m.delta.then_some(m.b - m.a)),
2559                "fixed": question.fixed,
2560                "broke": question.broke,
2561                "changed": question.changed,
2562                "mcnemar": {
2563                    "discordant": question.mcnemar.discordant,
2564                    "p": number(question.mcnemar.p),
2565                    "verdict": question.mcnemar.verdict.as_str(),
2566                },
2567                "flips": flips,
2568            }),
2569        );
2570    }
2571    let mut usage = serde_json::Map::new();
2572    usage.insert(
2573        "inputTokens".to_owned(),
2574        json!(comparison.usage.input_tokens),
2575    );
2576    usage.insert(
2577        "outputTokens".to_owned(),
2578        json!(comparison.usage.output_tokens),
2579    );
2580    usage.insert("estimated".to_owned(), json!(comparison.usage.estimated));
2581    if let Some(cost) = comparison.usage.cost {
2582        usage.insert("cost".to_owned(), number(cost.total));
2583    }
2584    json!({
2585        "a": side(&comparison.a),
2586        "b": side(&comparison.b),
2587        "questions": Value::Object(questions),
2588        "onlyA": comparison.only_a,
2589        "onlyB": comparison.only_b,
2590        "mismatched": comparison.mismatched.iter().map(|odd| json!({
2591            "name": odd.name,
2592            "a": odd.a,
2593            "b": odd.b,
2594        })).collect::<Vec<_>>(),
2595        "unpaired": comparison.unpaired,
2596        "regressions": regressions(comparison).iter().map(|q| q.name.clone()).collect::<Vec<_>>(),
2597        "usage": Value::Object(usage),
2598    })
2599}
2600
2601/// The comparison as the plain text a pipe wants.
2602pub fn compare_text(comparison: &Comparison) -> String {
2603    lines_text(compare_lines(comparison))
2604}
2605
2606// ---- writing the bars back --------------------------------------------------------------------
2607
2608/// The accuracy a choice's or score's bar has to reach when `--target-accuracy` is not given.
2609pub const DEFAULT_TARGET: f64 = 0.9;
2610
2611/// The confidence bars calibration tries, `k / 20` for `k` from 0 to 19: finer than the report's
2612/// gate, and computed by division so each prints as the short decimal it is.
2613pub fn calibration_cuts() -> Vec<f64> {
2614    (0..20).map(|k| f64::from(k) / 20.0).collect()
2615}
2616
2617/// What calibration made of one question: the bar it found, or why it left the question alone.
2618#[derive(Debug, Clone, PartialEq)]
2619pub struct CalibratedQuestion {
2620    pub name: String,
2621    pub kind: &'static str,
2622    /// The new bar; `None` when the question is left alone.
2623    pub bar: Option<f64>,
2624    /// The bar the page had before.
2625    pub was: Option<f64>,
2626    /// For a noul: the F1 at the new threshold.
2627    pub f1: Option<f64>,
2628    /// For a choice or a score: the accuracy over the cases that clear the new bar, and how many
2629    /// do.
2630    pub accuracy: Option<f64>,
2631    pub coverage: Option<f64>,
2632    /// Why the question was left alone.
2633    pub reason: Option<String>,
2634}
2635
2636#[derive(Debug, Clone, PartialEq)]
2637pub struct Calibration {
2638    pub target: f64,
2639    pub questions: Vec<CalibratedQuestion>,
2640    /// Only the bars that changed: what `sketch::set_bars` has to write.
2641    pub changed: Vec<(String, f64)>,
2642}
2643
2644/// The bars a run supports, one per scored question.
2645///
2646/// A noul gets the threshold with the best F1, which the report has already found. A choice or a
2647/// score gets the lowest confidence bar at which the answers it lets through are right at least
2648/// `target` of the time: the lowest, because every step up sends more of the work to a person.
2649pub fn calibrate(
2650    session: &Session,
2651    cases: &[Case],
2652    outcomes: &[Outcome],
2653    scored_report: &Report,
2654    target: f64,
2655) -> Calibration {
2656    let (_, scored) = scored_of(cases, outcomes);
2657    let mut questions = Vec::new();
2658    let mut changed = Vec::new();
2659    for question in &scored_report.questions {
2660        let name = question.name();
2661        let mut found = CalibratedQuestion {
2662            name: name.to_owned(),
2663            kind: question.kind(),
2664            bar: None,
2665            was: session.bar(name),
2666            f1: None,
2667            accuracy: None,
2668            coverage: None,
2669            reason: None,
2670        };
2671        if let QuestionReport::Noul { best, .. } = question {
2672            if best.f1 > 0.0 {
2673                found.bar = Some(best.threshold);
2674                found.f1 = Some(best.f1);
2675            } else {
2676                found.reason = Some("no threshold gives an F1 above 0".to_owned());
2677            }
2678        } else {
2679            let points = scored.iter().filter_map(|one| {
2680                let expectation = one.expects(name)?;
2681                match (one.answer(name)?, expectation) {
2682                    (Answer::Choice(answer), Expectation::Choice { label }) => {
2683                        Some((answer.confidence, answer.choice == *label))
2684                    }
2685                    (Answer::Score(answer), Expectation::Score { level }) => {
2686                        Some((answer.confidence, answer.rounded_level() as usize == *level))
2687                    }
2688                    _ => None,
2689                }
2690            });
2691            let rows = gate_at(points, &calibration_cuts());
2692            let reached = rows
2693                .iter()
2694                .find(|row| row.accuracy.is_some_and(|accuracy| accuracy >= target));
2695            match reached {
2696                Some(row) => {
2697                    found.bar = Some(row.confidence);
2698                    found.accuracy = row.accuracy;
2699                    found.coverage = Some(row.coverage);
2700                }
2701                None => {
2702                    let mut best: Option<&GateRow> = None;
2703                    for row in &rows {
2704                        if let Some(accuracy) = row.accuracy
2705                            && best.is_none_or(|b| accuracy > b.accuracy.unwrap_or(0.0))
2706                        {
2707                            best = Some(row);
2708                        }
2709                    }
2710                    found.reason = Some(match best {
2711                        None => format!("no confidence bar reaches accuracy {}", two(target)),
2712                        Some(row) => format!(
2713                            "no confidence bar reaches accuracy {} (best {} at {})",
2714                            two(target),
2715                            two(row.accuracy.unwrap_or(0.0)),
2716                            two(row.confidence)
2717                        ),
2718                    });
2719                }
2720            }
2721        }
2722        if let Some(bar) = found.bar
2723            && found.was != Some(bar)
2724        {
2725            changed.push((found.name.clone(), bar));
2726        }
2727        questions.push(found);
2728    }
2729    Calibration {
2730        target,
2731        questions,
2732        changed,
2733    }
2734}
2735
2736/// Why a run with errors writes nothing back: the bars would be fitted to the cases that worked.
2737pub fn not_calibrating(errors: usize) -> String {
2738    format!(
2739        "not calibrating: {errors} case{} back with errors, so the numbers are incomplete.",
2740        if errors == 1 { " came" } else { "s came" }
2741    )
2742}
2743
2744/// The directive a question's bar is written with.
2745fn directive_of(kind: &str) -> &'static str {
2746    if kind == "noul" {
2747        "@threshold"
2748    } else {
2749        "@confidence"
2750    }
2751}
2752
2753/// What calibration changed, as lines for under the report: one per question, the new directive as
2754/// it now reads on the page, what it replaced, and the evidence for it.
2755pub fn calibration_lines(calibration: &Calibration, page: &str) -> Vec<Line<'static>> {
2756    let mut out = vec![Line::from(vec![
2757        Span::raw("  "),
2758        bold("calibration"),
2759        dim(format!("  target accuracy {}", two(calibration.target))),
2760    ])];
2761    let width = calibration
2762        .questions
2763        .iter()
2764        .map(|q| q.name.chars().count())
2765        .max()
2766        .unwrap_or(0);
2767    for question in &calibration.questions {
2768        let mut spans = vec![
2769            Span::raw("    "),
2770            bold(pad_end(&question.name, width)),
2771            Span::raw("  "),
2772        ];
2773        match question.bar {
2774            None => {
2775                spans.push(dim(pad_end("left alone", 19)));
2776                spans.push(dim(question.reason.clone().unwrap_or_default()));
2777            }
2778            Some(bar) => {
2779                let was = match question.was {
2780                    Some(was) if was == bar => "unchanged".to_owned(),
2781                    Some(was) => format!("was {was}"),
2782                    None => "was none".to_owned(),
2783                };
2784                let evidence = if question.kind == "noul" {
2785                    format!("f1 {}", two(question.f1.unwrap_or(0.0)))
2786                } else {
2787                    format!(
2788                        "accuracy {} over {} of cases",
2789                        two(question.accuracy.unwrap_or(0.0)),
2790                        two(question.coverage.unwrap_or(0.0))
2791                    )
2792                };
2793                spans.push(Span::styled(
2794                    pad_end(&format!("{} {bar}", directive_of(question.kind)), 19),
2795                    Style::new().fg(color_for(question.kind)),
2796                ));
2797                spans.push(dim(pad_end(&was, 11)));
2798                spans.push(Span::raw(evidence));
2799            }
2800        }
2801        out.push(Line::from(spans));
2802    }
2803    let n = calibration.changed.len();
2804    out.push(Line::from(vec![
2805        Span::raw("  "),
2806        if n == 0 {
2807            dim(format!("nothing to write: {page} already holds these bars"))
2808        } else {
2809            Span::raw(format!("wrote {n} bar{} to {page}", plural(n)))
2810        },
2811    ]));
2812    out
2813}
2814
2815/// The calibration as plain text, for under the report.
2816pub fn calibration_text(calibration: &Calibration, page: &str) -> String {
2817    lines_text(calibration_lines(calibration, page))
2818}
2819
2820/// The calibration as JSON, for the report's `calibration` key.
2821pub fn calibration_json(calibration: &Calibration, page: &str) -> serde_json::Map<String, Value> {
2822    let mut questions = serde_json::Map::new();
2823    for question in &calibration.questions {
2824        let mut out = serde_json::Map::new();
2825        out.insert("kind".to_owned(), json!(question.kind));
2826        out.insert("bar".to_owned(), maybe(question.bar));
2827        out.insert("was".to_owned(), maybe(question.was));
2828        if let Some(f1) = question.f1 {
2829            out.insert("f1".to_owned(), number(f1));
2830        }
2831        if let Some(accuracy) = question.accuracy {
2832            out.insert("accuracy".to_owned(), number(accuracy));
2833        }
2834        if let Some(coverage) = question.coverage {
2835            out.insert("coverage".to_owned(), number(coverage));
2836        }
2837        if let Some(reason) = &question.reason {
2838            out.insert("reason".to_owned(), json!(reason));
2839        }
2840        questions.insert(question.name.clone(), Value::Object(out));
2841    }
2842    let mut out = serde_json::Map::new();
2843    out.insert("page".to_owned(), json!(page));
2844    out.insert("target".to_owned(), number(calibration.target));
2845    out.insert("written".to_owned(), json!(!calibration.changed.is_empty()));
2846    out.insert("questions".to_owned(), Value::Object(questions));
2847    out
2848}