1use crate::flow::{FlowFailure, FlowOutcome};
6use crate::{
7 Answer, AnswerValue, Answers, Error, ErrorKind, IndexMap, Provider, Question, Questions,
8 Request, Result, State, ask,
9};
10use futures_util::{StreamExt, TryStreamExt, stream};
11use std::{future::Future, num::NonZeroUsize, time::Instant};
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum Decision {
15 Boolean(bool),
17 Choice(String),
19 Score(usize),
21}
22#[derive(Clone)]
24pub struct Case {
25 state: State,
26 expected: IndexMap<String, Decision>,
27}
28impl std::fmt::Debug for Case {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 f.debug_struct("Case")
31 .field("labels", &self.expected.len())
32 .finish_non_exhaustive()
33 }
34}
35impl Case {
36 pub fn new(state: State, expected: IndexMap<String, Decision>) -> Self {
38 Self { state, expected }
39 }
40 pub fn state(&self) -> &State {
42 &self.state
43 }
44 pub fn expected(&self) -> &IndexMap<String, Decision> {
46 &self.expected
47 }
48}
49#[derive(Clone, Debug, Default)]
51pub struct QuestionStats {
52 correct: usize,
53 undecided: usize,
54 total: usize,
55 p_correct: Vec<f64>,
56}
57impl QuestionStats {
58 pub fn correct(&self) -> usize {
60 self.correct
61 }
62 pub fn undecided(&self) -> usize {
64 self.undecided
65 }
66 pub fn total(&self) -> usize {
68 self.total
69 }
70 pub fn answered(&self) -> usize {
72 self.total - self.undecided
73 }
74 pub fn accuracy(&self) -> f64 {
76 ratio(self.correct, self.total)
77 }
78 pub fn accuracy_when_answered(&self) -> f64 {
80 ratio(self.correct, self.answered())
81 }
82 pub fn mean_p_correct(&self) -> f64 {
84 if self.p_correct.is_empty() {
85 0.0
86 } else {
87 accurate_sum(self.p_correct.iter().copied()).unwrap_or(0.0)
88 / self.p_correct.len() as f64
89 }
90 }
91 pub fn p_correct(&self) -> &[f64] {
93 &self.p_correct
94 }
95}
96fn ratio(n: usize, d: usize) -> f64 {
97 if d == 0 { 0.0 } else { n as f64 / d as f64 }
98}
99#[derive(Clone, Debug)]
101pub struct Miss {
102 pub case: usize,
104 pub question: String,
106 pub expected: Decision,
108 pub got: Answer,
110}
111#[derive(Clone, Debug)]
113pub struct Report {
114 questions: IndexMap<String, QuestionStats>,
115 latencies_ms: Vec<f64>,
116 total_ms: f64,
117 misses: Vec<Miss>,
118 answers: Vec<Answers>,
119 decisions: Vec<IndexMap<String, Option<Decision>>>,
120 warmup_calls: usize,
121}
122impl Report {
123 pub fn questions(&self) -> &IndexMap<String, QuestionStats> {
125 &self.questions
126 }
127 pub fn latencies_ms(&self) -> &[f64] {
129 &self.latencies_ms
130 }
131 pub fn total_ms(&self) -> f64 {
133 self.total_ms
134 }
135 pub fn misses(&self) -> &[Miss] {
137 &self.misses
138 }
139 pub fn answers(&self) -> &[Answers] {
141 &self.answers
142 }
143 pub fn decisions(&self) -> &[IndexMap<String, Option<Decision>>] {
145 &self.decisions
146 }
147 pub fn warmup_calls(&self) -> usize {
149 self.warmup_calls
150 }
151 pub fn calls(&self) -> usize {
153 self.answers.len() + self.warmup_calls
154 }
155}
156pub fn decide(question: &Question, answer: &Answer) -> Result<Option<Decision>> {
163 if matches!(question, Question::Box(_)) {
164 return Err(invalid("Box questions cannot be benchmarked"));
165 }
166 let checked = answer.revalidate(question)?;
167 Ok(match checked.value() {
168 None => None,
169 Some(AnswerValue::Boolean(v)) => Some(Decision::Boolean(*v)),
170 Some(AnswerValue::Choice(v)) => Some(Decision::Choice(v.clone())),
171 Some(AnswerValue::Score(v)) => Some(Decision::Score((v + 0.5).floor() as usize)),
172 Some(AnswerValue::Box(_)) => return Err(invalid("Box questions cannot be benchmarked")),
173 })
174}
175fn label_key<'a>(q: &'a Question, label: &'a Decision) -> Result<&'a str> {
176 match (q, label) {
177 (Question::Boolean(_), Decision::Boolean(v)) => Ok(if *v { "true" } else { "false" }),
178 (Question::Choice(q), Decision::Choice(v)) if q.options().contains_key(v) => Ok(v),
179 (Question::Score(q), Decision::Score(i)) => q
180 .levels()
181 .get(*i)
182 .map(String::as_str)
183 .ok_or_else(|| invalid("Score label is outside the rubric")),
184 _ => Err(invalid("label does not match the question outcomes")),
185 }
186}
187fn validate(questions: &Questions, cases: &[Case]) -> Result<()> {
188 if questions.keys().any(String::is_empty) {
189 return Err(invalid("question names must be nonempty"));
190 }
191 if questions.values().any(|q| matches!(q, Question::Box(_))) {
192 return Err(invalid("Box questions cannot be benchmarked"));
193 }
194 for case in cases {
195 for (name, label) in &case.expected {
196 let q = questions
197 .get(name)
198 .ok_or_else(|| invalid("case label names an unknown question"))?;
199 label_key(q, label)?;
200 }
201 }
202 Ok(())
203}
204fn check_latencies(count: usize, latencies: &[f64], total: f64) -> Result<()> {
205 if latencies.len() != count
206 || !total.is_finite()
207 || total < 0.0
208 || latencies.iter().any(|v| !v.is_finite() || *v < 0.0)
209 {
210 return Err(invalid(
211 "latencies must align with cases and be finite nonnegative milliseconds",
212 ));
213 }
214 Ok(())
215}
216pub fn score(
225 questions: &Questions,
226 cases: &[Case],
227 answers: Vec<Answers>,
228 latencies_ms: Vec<f64>,
229 total_ms: f64,
230) -> Result<Report> {
231 validate(questions, cases)?;
232 check_latencies(cases.len(), &latencies_ms, total_ms)?;
233 if answers.len() != cases.len() {
234 return Err(invalid("case and answer counts must match"));
235 }
236 let answers: Vec<Answers> = answers
237 .into_iter()
238 .map(|observed| {
239 if observed.len() != questions.len()
240 || questions.keys().any(|name| !observed.contains_key(name))
241 {
242 return Err(invalid(
243 "answer keys must exactly match benchmark questions",
244 ));
245 }
246 questions
247 .iter()
248 .map(|(name, q)| Ok((name.clone(), observed[name].revalidate(q)?)))
249 .collect()
250 })
251 .collect::<Result<_>>()?;
252 let mut stats: IndexMap<String, QuestionStats> = questions
253 .keys()
254 .map(|name| (name.clone(), QuestionStats::default()))
255 .collect();
256 let mut misses = Vec::new();
257 let mut decisions = Vec::with_capacity(cases.len());
258 for (i, (case, observed)) in cases.iter().zip(&answers).enumerate() {
259 if observed.len() != questions.len()
260 || questions.keys().any(|name| !observed.contains_key(name))
261 {
262 return Err(invalid(
263 "answer keys must exactly match benchmark questions",
264 ));
265 }
266 let row: IndexMap<String, Option<Decision>> = questions
267 .iter()
268 .map(|(name, q)| Ok((name.clone(), decide(q, &observed[name])?)))
269 .collect::<Result<_>>()?;
270 for (name, label) in &case.expected {
271 let answer = &observed[name];
272 let q = &questions[name];
273 let s = stats
274 .get_mut(name)
275 .ok_or_else(|| invalid("case label names an unknown question"))?;
276 let p = answer
277 .probabilities()
278 .get(label_key(q, label)?)
279 .ok_or_else(|| invalid("answer has no probability for label"))?;
280 s.total += 1;
281 s.p_correct.push(*p);
282 match &row[name] {
283 None => s.undecided += 1,
284 Some(value) if value == label => s.correct += 1,
285 Some(_) => misses.push(Miss {
286 case: i,
287 question: name.clone(),
288 expected: label.clone(),
289 got: answer.clone(),
290 }),
291 }
292 }
293 decisions.push(row);
294 }
295 Ok(Report {
296 questions: stats,
297 latencies_ms,
298 total_ms,
299 misses,
300 answers,
301 decisions,
302 warmup_calls: 0,
303 })
304}
305pub fn at_min_confidence(
312 report: &Report,
313 questions: &Questions,
314 cases: &[Case],
315 threshold: f64,
316) -> Result<Report> {
317 if !threshold.is_finite() || !(0.0..=1.0).contains(&threshold) {
318 return Err(invalid("threshold must be finite and from zero to one"));
319 }
320 let answers = report
321 .answers
322 .iter()
323 .map(|row| {
324 row.iter()
325 .map(|(name, answer)| {
326 Ok((name.clone(), answer.clone().with_min_confidence(threshold)?))
327 })
328 .collect::<Result<Answers>>()
329 })
330 .collect::<Result<Vec<_>>>()?;
331 let mut scored = score(
332 questions,
333 cases,
334 answers,
335 report.latencies_ms.clone(),
336 report.total_ms,
337 )?;
338 scored.warmup_calls = report.warmup_calls;
339 Ok(scored)
340}
341pub fn stability(reports: &[Report]) -> Result<IndexMap<String, f64>> {
348 let first = reports
349 .first()
350 .ok_or_else(|| invalid("stability needs at least one report"))?;
351 let count = first.decisions.len();
352 if count == 0 {
353 return Err(invalid("stability needs at least one case"));
354 }
355 let names = &first.questions;
356 for report in reports {
357 if report.decisions.len() != count
358 || report.questions.len() != names.len()
359 || names.keys().any(|n| !report.questions.contains_key(n))
360 {
361 return Err(invalid(
362 "stability reports have mismatched cases or question names",
363 ));
364 }
365 }
366 Ok(names
367 .keys()
368 .map(|name| {
369 let stable = (0..count)
370 .filter(|i| {
371 reports
372 .iter()
373 .all(|r| r.decisions[*i][name] == first.decisions[*i][name])
374 })
375 .count();
376 (name.clone(), stable as f64 / count as f64)
377 })
378 .collect())
379}
380pub fn ndcg(order: &[String], grades: &IndexMap<String, u32>, k: usize) -> Result<Option<f64>> {
387 let gains: IndexMap<String, f64> = grades
388 .iter()
389 .map(|(id, g)| {
390 let exponent =
391 i32::try_from(*g).map_err(|_| invalid("relevance grade overflows gain"))?;
392 let gain = 2.0f64.powi(exponent) - 1.0;
393 if !gain.is_finite() {
394 return Err(invalid("relevance grade overflows gain"));
395 }
396 Ok((id.clone(), gain))
397 })
398 .collect::<Result<_>>()?;
399 let dcg = |ids: &[&String]| -> Result<f64> {
400 accurate_sum(
401 ids.iter()
402 .take(k)
403 .enumerate()
404 .map(|(i, id)| gains.get(*id).copied().unwrap_or(0.0) / ((i + 2) as f64).log2()),
405 )
406 };
407 let mut ideal: Vec<_> = grades.keys().collect();
408 ideal.sort_by_key(|id| std::cmp::Reverse(grades[*id]));
409 let ideal = dcg(&ideal)?;
410 if ideal == 0.0 {
411 return Ok(None);
412 }
413 let actual: Vec<_> = order.iter().collect();
414 let result = dcg(&actual)? / ideal;
415 if !result.is_finite() {
416 return Err(invalid("NDCG is not finite"));
417 }
418 Ok(Some(result))
419}
420pub fn ndcg_default(order: &[String], grades: &IndexMap<String, u32>) -> Result<Option<f64>> {
425 ndcg(order, grades, 10)
426}
427#[derive(Clone, Debug)]
429pub struct RunOptions {
430 pub warmup: usize,
432 pub concurrency: NonZeroUsize,
434 #[cfg(feature = "images")]
436 #[cfg_attr(docsrs, doc(cfg(feature = "images")))]
437 pub assets: crate::image::Assets,
438}
439impl Default for RunOptions {
440 fn default() -> Self {
441 Self {
442 warmup: 1,
443 concurrency: NonZeroUsize::new(4).expect("four is nonzero"),
444 #[cfg(feature = "images")]
445 assets: Default::default(),
446 }
447 }
448}
449pub async fn run(
458 provider: &dyn Provider,
459 questions: &Questions,
460 cases: &[Case],
461 options: &RunOptions,
462) -> Result<Report> {
463 validate(questions, cases)?;
464 let requests = cases
465 .iter()
466 .map(|case| {
467 let request = Request::new(case.state.clone(), questions.clone())?;
468 #[cfg(feature = "images")]
469 let request = request.with_assets(options.assets.clone());
470 request.validate()?;
471 Ok(request)
472 })
473 .collect::<Result<Vec<_>>>()?;
474 let warmup = options.warmup.min(requests.len());
475 for request in requests.iter().take(warmup) {
476 ask(provider, request).await?;
477 }
478 let begin = Instant::now();
479 let mut observed: Vec<_> =
480 stream::iter(requests.iter().enumerate().map(|(i, request)| async move {
481 let start = Instant::now();
482 let answers = ask(provider, request).await?;
483 Ok::<_, Error>((i, answers, start.elapsed().as_secs_f64() * 1000.0))
484 }))
485 .buffer_unordered(options.concurrency.get())
486 .try_collect()
487 .await?;
488 let total_ms = begin.elapsed().as_secs_f64() * 1000.0;
489 observed.sort_unstable_by_key(|(i, _, _)| *i);
490 let (answers, latencies) = observed.into_iter().map(|(_, a, ms)| (a, ms)).unzip();
491 let mut report = score(questions, cases, answers, latencies, total_ms)?;
492 report.warmup_calls = warmup;
493 Ok(report)
494}
495#[derive(Clone, Debug)]
497pub struct FlowMiss<T> {
498 pub case: usize,
500 pub expected: T,
502 pub got: T,
504}
505#[derive(Clone, Debug)]
507pub struct FlowReport<T> {
508 results: Vec<FlowOutcome<T>>,
509 expected: Vec<T>,
510 latencies_ms: Vec<f64>,
511}
512impl<T: PartialEq + Clone> FlowReport<T> {
513 pub fn results(&self) -> &[FlowOutcome<T>] {
515 &self.results
516 }
517 pub fn expected(&self) -> &[T] {
519 &self.expected
520 }
521 pub fn latencies_ms(&self) -> &[f64] {
523 &self.latencies_ms
524 }
525 pub fn total(&self) -> usize {
527 self.results.len()
528 }
529 pub fn stopped(&self) -> usize {
531 self.results.iter().filter(|r| !r.decided()).count()
532 }
533 pub fn correct(&self) -> usize {
535 self.results
536 .iter()
537 .zip(&self.expected)
538 .filter(|(r, e)| r.decided() && r.value.as_ref() == Some(*e))
539 .count()
540 }
541 pub fn misses(&self) -> Vec<FlowMiss<T>> {
543 self.results
544 .iter()
545 .zip(&self.expected)
546 .enumerate()
547 .filter_map(|(i, (r, e))| match &r.value {
548 Some(v) if r.decided() && v != e => Some(FlowMiss {
549 case: i,
550 expected: e.clone(),
551 got: v.clone(),
552 }),
553 _ => None,
554 })
555 .collect()
556 }
557 pub fn accuracy_when_answered(&self) -> f64 {
559 ratio(self.correct(), self.total() - self.stopped())
560 }
561 pub fn calls(&self) -> usize {
563 self.results.iter().map(FlowOutcome::calls).sum()
564 }
565}
566pub fn score_flows<T: PartialEq + Clone>(
573 results: Vec<FlowOutcome<T>>,
574 expected: Vec<T>,
575 latencies_ms: Vec<f64>,
576) -> Result<FlowReport<T>> {
577 if results.len() != expected.len() {
578 return Err(invalid("flow outcomes and labels must align"));
579 }
580 check_latencies(results.len(), &latencies_ms, 0.0)?;
581 for result in &results {
582 validate_flow_outcome(result)?;
583 }
584 Ok(FlowReport {
585 results,
586 expected,
587 latencies_ms,
588 })
589}
590fn validate_flow_outcome<T>(result: &FlowOutcome<T>) -> Result<()> {
591 if result.decided() != result.value.is_some()
592 || result.trace.iter().any(|step| step.result.is_err())
593 {
594 return Err(invalid(
595 "flow scoring requires consistent successful or stopped outcomes",
596 ));
597 }
598 Ok(())
599}
600pub async fn run_flow<'a, I, T, F, Fut>(
608 cases: &'a [(I, T)],
609 mut flow: F,
610) -> std::result::Result<FlowReport<T>, FlowFailure>
611where
612 T: PartialEq + Clone,
613 F: FnMut(&'a I) -> Fut,
614 Fut: Future<Output = std::result::Result<FlowOutcome<T>, FlowFailure>>,
615{
616 let mut results = Vec::with_capacity(cases.len());
617 let mut expected = Vec::with_capacity(cases.len());
618 let mut latencies = Vec::with_capacity(cases.len());
619 for (input, label) in cases {
620 let start = Instant::now();
621 let outcome = flow(input).await?;
622 if let Err(error) = validate_flow_outcome(&outcome) {
623 return Err(FlowFailure {
624 error,
625 trace: outcome.trace,
626 });
627 }
628 results.push(outcome);
629 latencies.push(start.elapsed().as_secs_f64() * 1000.0);
630 expected.push(label.clone());
631 }
632 Ok(FlowReport {
635 results,
636 expected,
637 latencies_ms: latencies,
638 })
639}
640fn invalid(message: &str) -> Error {
641 Error::new(ErrorKind::InvalidArgument, message)
642}
643fn accurate_sum(values: impl IntoIterator<Item = f64>) -> Result<f64> {
646 let mut partials: Vec<f64> = Vec::new();
647 for mut x in values {
648 if !x.is_finite() {
649 return Err(invalid("benchmark sum is not finite"));
650 }
651 let mut i = 0;
652 for j in 0..partials.len() {
653 let mut y = partials[j];
654 if x.abs() < y.abs() {
655 std::mem::swap(&mut x, &mut y);
656 }
657 let hi = x + y;
658 if !hi.is_finite() {
659 return Err(invalid("benchmark sum overflow"));
660 }
661 let lo = y - (hi - x);
662 if lo != 0.0 {
663 partials[i] = lo;
664 i += 1;
665 }
666 x = hi;
667 }
668 partials.truncate(i);
669 partials.push(x);
670 }
671 let mut hi = partials.pop().unwrap_or(0.0);
672 let mut lo = 0.0;
673 while let Some(y) = partials.pop() {
674 let x = hi;
675 hi = x + y;
676 lo = y - (hi - x);
677 if lo != 0.0 {
678 break;
679 }
680 }
681 if partials
682 .last()
683 .is_some_and(|last| (lo < 0.0 && *last < 0.0) || (lo > 0.0 && *last > 0.0))
684 {
685 let y = lo * 2.0;
686 let x = hi + y;
687 if y == x - hi {
688 hi = x;
689 }
690 }
691 Ok(hi)
692}