1use 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#[derive(Debug, Clone, PartialEq)]
31pub struct Case {
32 pub line: usize,
34 pub id: Option<String>,
35 pub state: Value,
36 pub expect: Vec<(String, Expectation)>,
40 pub turn: Option<usize>,
43 pub turns: Option<usize>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ByTurn {
49 Turn(usize),
50 Never,
51}
52
53impl ByTurn {
54 pub fn turn(self) -> Option<usize> {
56 match self {
57 ByTurn::Turn(k) => Some(k),
58 ByTurn::Never => None,
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub enum Expectation {
66 Noul {
67 yes: bool,
68 by_turn: Option<ByTurn>,
70 },
71 Choice {
72 label: String,
73 },
74 Score {
75 level: usize,
76 },
77}
78
79impl Expectation {
80 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
90pub 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#[derive(Debug, Clone, Copy)]
115pub struct Labels<'a> {
116 pub a: &'a str,
117 pub b: &'a str,
118}
119
120pub 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
160struct RawCase<'a> {
162 line: usize,
163 id: Option<String>,
164 state: &'a Value,
165 wanted: &'a serde_json::Map<String, Value>,
166}
167
168fn 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
226fn 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
287fn turn_count(state: &Value) -> Option<usize> {
289 session::turns_of(state).map(|turns| turns.len())
290}
291
292fn 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
308fn 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 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 _ => Err(format!(
371 "{name} is a raw question: raw questions cannot be scored."
372 )),
373 }
374}
375
376fn 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
414pub 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#[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
436pub 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
457pub struct Leg<'a, F> {
459 pub session: &'a Session,
460 pub cases: &'a [Case],
461 pub ask: F,
462}
463
464pub 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
488async 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
512async 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 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#[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 pub precision: Option<f64>,
547 pub recall: Option<f64>,
549 pub f1: f64,
550}
551
552#[derive(Debug, Clone, PartialEq)]
554pub struct GateRow {
555 pub confidence: f64,
556 pub coverage: f64,
557 pub accuracy: Option<f64>,
559}
560
561#[derive(Debug, Clone, Copy, PartialEq)]
563pub struct Best {
564 pub threshold: f64,
565 pub f1: f64,
566}
567
568#[derive(Debug, Clone, PartialEq)]
570pub enum QuestionReport {
571 Noul {
572 name: String,
573 cases: usize,
574 brier: f64,
576 threshold: f64,
578 accuracy: f64,
580 best: Best,
581 sweep: Vec<SweepRow>,
582 latency: Option<Latency>,
584 },
585 Choice {
586 name: String,
587 cases: usize,
588 accuracy: f64,
589 labels: Vec<String>,
591 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 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 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#[derive(Debug, Clone, PartialEq)]
644pub struct CaseError {
645 pub case: usize,
646 pub turn: Option<usize>,
648 pub id: Option<String>,
649 pub message: String,
650}
651
652#[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#[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#[derive(Debug, Clone, Copy)]
675pub struct ReportOptions<'a> {
676 pub model: &'a str,
677 pub threshold: f64,
678 pub rates: Option<Rates>,
679}
680
681const SWEEP: [f64; 9] = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
683
684const CUTS: [f64; 5] = [0.0, 0.2, 0.4, 0.6, 0.8];
686
687struct 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
708pub 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
752fn 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
797fn 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
816pub 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 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#[derive(Debug, Clone, PartialEq)]
885pub struct ThreadLatency {
886 pub case: usize,
887 pub id: Option<String>,
888 pub expected: Option<usize>,
890 pub detected: Option<usize>,
892 pub latency: Option<i64>,
894}
895
896#[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 pub mean: Option<f64>,
907 pub cases: Vec<ThreadLatency>,
908}
909
910fn 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 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
1116fn gate(points: impl Iterator<Item = (f64, bool)>) -> Vec<GateRow> {
1118 gate_at(points, &CUTS)
1119}
1120
1121fn 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
1145fn 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#[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
1194pub 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
1226pub fn two(x: f64) -> String {
1232 to_fixed(x, 2)
1233}
1234
1235pub fn three(x: f64) -> String {
1237 to_fixed(x, 3)
1238}
1239
1240fn to_fixed(x: f64, digits: usize) -> String {
1248 if !x.is_finite() {
1249 return format!("{x}");
1250 }
1251 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
1289pub 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
1387fn 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
1419fn 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
1466fn 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
1559pub 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
1705fn 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
1714fn maybe(x: Option<f64>) -> Value {
1716 x.map_or(Value::Null, number)
1717}
1718
1719fn 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
1740pub 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#[derive(Debug, Clone, Copy)]
1760pub struct Side<'a> {
1761 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1771pub enum Verdict {
1772 TooFew,
1773 Better,
1774 Worse,
1775 Same,
1776}
1777
1778impl Verdict {
1779 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 pub discordant: usize,
1794 pub p: f64,
1796 pub verdict: Verdict,
1797}
1798
1799#[derive(Debug, Clone, PartialEq)]
1801pub struct Flip {
1802 pub case: usize,
1803 pub turn: Option<usize>,
1804 pub id: Option<String>,
1805 pub expected: Value,
1807 pub a: Value,
1808 pub b: Value,
1809 pub status: &'static str,
1811}
1812
1813#[derive(Debug, Clone, PartialEq)]
1815pub struct Metric {
1816 pub key: &'static str,
1818 pub label: &'static str,
1820 pub a: f64,
1821 pub b: f64,
1822 pub delta: bool,
1824}
1825
1826#[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#[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#[derive(Debug, Clone, PartialEq)]
1850pub struct Labelled {
1851 pub label: String,
1852 pub report: Report,
1853}
1854
1855#[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 pub cases: usize,
1867 pub usage: ReportUsage,
1868}
1869
1870#[derive(Debug, Clone, Copy)]
1872pub struct CompareOptions {
1873 pub threshold: f64,
1874 pub rates: Option<Rates>,
1875}
1876
1877pub const ALPHA: f64 = 0.05;
1879
1880pub const MIN_DISCORDANT: usize = 6;
1882
1883pub 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
1918fn 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
1928pub 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 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
2038fn key_of(one: &Scored<'_>) -> (usize, Option<usize>) {
2040 (one.line, one.turn)
2041}
2042
2043fn 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
2059struct 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
2247pub fn regressions(comparison: &Comparison) -> Vec<&Shared> {
2249 comparison
2250 .questions
2251 .iter()
2252 .filter(|q| q.mcnemar.verdict == Verdict::Worse)
2253 .collect()
2254}
2255
2256const FLIPS_SHOWN: usize = 10;
2258
2259pub 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
2455fn 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
2478fn 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
2489fn 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
2497pub 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
2510pub 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
2601pub fn compare_text(comparison: &Comparison) -> String {
2603 lines_text(compare_lines(comparison))
2604}
2605
2606pub const DEFAULT_TARGET: f64 = 0.9;
2610
2611pub fn calibration_cuts() -> Vec<f64> {
2614 (0..20).map(|k| f64::from(k) / 20.0).collect()
2615}
2616
2617#[derive(Debug, Clone, PartialEq)]
2619pub struct CalibratedQuestion {
2620 pub name: String,
2621 pub kind: &'static str,
2622 pub bar: Option<f64>,
2624 pub was: Option<f64>,
2626 pub f1: Option<f64>,
2628 pub accuracy: Option<f64>,
2631 pub coverage: Option<f64>,
2632 pub reason: Option<String>,
2634}
2635
2636#[derive(Debug, Clone, PartialEq)]
2637pub struct Calibration {
2638 pub target: f64,
2639 pub questions: Vec<CalibratedQuestion>,
2640 pub changed: Vec<(String, f64)>,
2642}
2643
2644pub 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
2736pub 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
2744fn directive_of(kind: &str) -> &'static str {
2746 if kind == "noul" {
2747 "@threshold"
2748 } else {
2749 "@confidence"
2750 }
2751}
2752
2753pub 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
2815pub fn calibration_text(calibration: &Calibration, page: &str) -> String {
2817 lines_text(calibration_lines(calibration, page))
2818}
2819
2820pub 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}