blendtutor_core/eval.rs
1//! The eval-case model: a typed model and its parse boundary.
2//!
3//! Holds [`EvalSuite`]/[`EvalCase`], the [`ExpectedVerdict`] polarity enum, the
4//! typed [`EvalParseError`], and [`parse_eval_suite`] — the pure parse that
5//! turns an instructor's `eval_<lesson>.yaml` into a typed suite. Each case
6//! pairs a synthetic learner `submission` with the polarity the grader *should*
7//! return; `ExpectedVerdict` is a dedicated sum type rather than the
8//! message-carrying runtime `llm::Verdict` (ADR-0007), so an illegal verdict is
9//! rejected here instead of travelling downstream as data.
10//!
11//! The public types are serde-free domain values; deserialization runs through
12//! the private `Raw*` boundary DTOs, mirroring the `llm` layer's
13//! `Feedback` → `Verdict` split (ADR-0006). That two-phase parse is what lets a
14//! bad verdict be reported against its *case index* rather than a YAML line.
15//!
16//! The module owns two responsibilities at different altitudes. The *data shape*
17//! and its parse are pure: reading files is the caller's concern (§2.1). Scoring
18//! ([`score_case`], [`aggregate`], [`CaseResult`], [`EvalReport`]) is the pure
19//! core (§2.3) — exact polarity equality with no model in sight. [`run_eval`] is
20//! the thin effectful shell (§2.4) that drives the suite through the same
21//! pipeline `run` uses (so the feedback evaluated is the feedback shipped, §3.2)
22//! and hands each verdict to the pure scorer.
23
24use std::error::Error;
25use std::fmt;
26
27use serde::{Deserialize, Serialize, Serializer};
28
29use crate::lesson::Lesson;
30use crate::llm::{ProviderChoice, Submission, Verdict};
31use crate::run::{RunError, run_lesson};
32
33/// The verdict an eval case expects the grader to return: a polarity, with no
34/// learner-facing message.
35///
36/// A two-variant sum type rather than a bare string, so an unknown verdict is
37/// rejected at the parse boundary instead of carried downstream as data (§1.2).
38/// Distinct from the runtime `llm::Verdict`, which also carries the feedback
39/// message; an author's *expected* outcome is pure polarity (ADR-0007).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ExpectedVerdict {
42 /// The submission is expected to be graded correct.
43 Correct,
44 /// The submission is expected to be graded incorrect.
45 Incorrect,
46}
47
48impl ExpectedVerdict {
49 /// The YAML `expected` spelling for [`ExpectedVerdict::Correct`].
50 const CORRECT_TOKEN: &'static str = "correct";
51 /// The YAML `expected` spelling for [`ExpectedVerdict::Incorrect`].
52 const INCORRECT_TOKEN: &'static str = "incorrect";
53
54 /// Map a YAML `expected` token to its variant, or `None` if unrecognized.
55 ///
56 /// The `CORRECT_TOKEN`/`INCORRECT_TOKEN` constants are the single source for
57 /// the valid spellings, and [`parse_eval_suite`]'s error path lists the same
58 /// constants on a miss, so the accepted set and the error hint cannot drift.
59 fn from_token(token: &str) -> Option<Self> {
60 match token {
61 Self::CORRECT_TOKEN => Some(ExpectedVerdict::Correct),
62 Self::INCORRECT_TOKEN => Some(ExpectedVerdict::Incorrect),
63 _ => None,
64 }
65 }
66
67 /// The canonical lowercase spelling of this polarity — the single source for
68 /// the YAML `expected` token, the serialized JSON form, and the human
69 /// rendering, so none can drift from the accepted set.
70 pub const fn token(&self) -> &'static str {
71 match self {
72 ExpectedVerdict::Correct => Self::CORRECT_TOKEN,
73 ExpectedVerdict::Incorrect => Self::INCORRECT_TOKEN,
74 }
75 }
76}
77
78impl Serialize for ExpectedVerdict {
79 /// Serialize to the same canonical token the parser accepts, so a scored
80 /// report's JSON spells a polarity exactly as an author writes it.
81 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
82 serializer.serialize_str(self.token())
83 }
84}
85
86impl From<&Verdict> for ExpectedVerdict {
87 /// Reduce a runtime verdict to its scoring polarity, dropping the
88 /// learner-facing message: eval scores *whether* the grader agreed, not the
89 /// words it chose (v0 — richer grading is deferred).
90 fn from(verdict: &Verdict) -> Self {
91 match verdict {
92 Verdict::Correct { .. } => ExpectedVerdict::Correct,
93 Verdict::Incorrect { .. } => ExpectedVerdict::Incorrect,
94 }
95 }
96}
97
98/// A single eval case: a synthetic submission and the polarity it should grade
99/// to.
100///
101/// The two fields are positional partners — `submission` and its `expected`
102/// verdict travel together in document order.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct EvalCase {
105 /// The synthetic learner submission to grade.
106 pub submission: String,
107 /// The polarity this submission is expected to grade to.
108 pub expected: ExpectedVerdict,
109}
110
111/// A lesson's eval suite: its cases in authored order.
112///
113/// Construct one only through [`parse_eval_suite`]; document order is preserved
114/// so a case's index is stable for reporting (Slice 13 scoring).
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct EvalSuite {
117 /// The eval cases, in the order authored.
118 pub cases: Vec<EvalCase>,
119}
120
121/// The boundary DTO for a suite: structural only, before verdict tokens are
122/// validated into [`ExpectedVerdict`]s. Private so callers never see the
123/// stringly `expected`.
124#[derive(Deserialize)]
125#[serde(deny_unknown_fields)]
126struct RawSuite {
127 cases: Vec<RawCase>,
128}
129
130/// The boundary DTO for one case: `expected` is still a raw token here. Unknown
131/// keys are rejected (§1.3.1) so an author's typo surfaces rather than silently
132/// dropping.
133#[derive(Deserialize)]
134#[serde(deny_unknown_fields)]
135struct RawCase {
136 submission: String,
137 expected: String,
138}
139
140/// Why a YAML document is not a valid eval suite.
141#[derive(Debug)]
142pub enum EvalParseError {
143 /// The document is not structurally an eval suite: a required field is
144 /// missing, a value has the wrong type, an unknown key is present, or the
145 /// YAML is malformed. Carries the underlying parser message, which names the
146 /// offending field.
147 Structural(String),
148 /// A case's `expected` value is not a known verdict token. Carries the
149 /// offending case so the author can find it.
150 UnknownVerdict {
151 /// The zero-based index of the offending case in document order.
152 index: usize,
153 /// The unrecognized token, quoted back to the author.
154 token: String,
155 },
156}
157
158impl fmt::Display for EvalParseError {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 match self {
161 EvalParseError::Structural(msg) => write!(f, "invalid eval suite: {msg}"),
162 EvalParseError::UnknownVerdict { index, token } => write!(
163 f,
164 "invalid eval suite: eval case {index} has unknown expected verdict \
165 {token:?} (expected {correct:?} or {incorrect:?})",
166 correct = ExpectedVerdict::CORRECT_TOKEN,
167 incorrect = ExpectedVerdict::INCORRECT_TOKEN,
168 ),
169 }
170 }
171}
172
173impl Error for EvalParseError {}
174
175/// Parse an eval suite from a YAML document.
176///
177/// A two-phase pure parse (§2.1): first deserialize the structure into the
178/// private `RawSuite` DTO — a missing field, wrong type, or unknown key yields
179/// [`EvalParseError::Structural`] — then validate each case's `expected` token
180/// into an [`ExpectedVerdict`], yielding [`EvalParseError::UnknownVerdict`]
181/// naming the offending case index on a miss. Document order is preserved. File
182/// reading is the caller's concern, so this runs over an in-memory string.
183pub fn parse_eval_suite(yaml: &str) -> Result<EvalSuite, EvalParseError> {
184 let raw: RawSuite =
185 serde_saphyr::from_str(yaml).map_err(|e| EvalParseError::Structural(e.to_string()))?;
186 let cases = raw
187 .cases
188 .into_iter()
189 .enumerate()
190 .map(|(index, case)| {
191 let RawCase {
192 submission,
193 expected,
194 } = case;
195 match ExpectedVerdict::from_token(&expected) {
196 Some(verdict) => Ok(EvalCase {
197 submission,
198 expected: verdict,
199 }),
200 None => Err(EvalParseError::UnknownVerdict {
201 index,
202 token: expected,
203 }),
204 }
205 })
206 .collect::<Result<Vec<_>, _>>()?;
207 Ok(EvalSuite { cases })
208}
209
210/// Score one case: whether the grader's `actual` polarity matched the `expected`
211/// one.
212///
213/// Pure and total (§2.3, §5.1): exact equality of the two polarities, so a wrong
214/// verdict can never be scored a match — no substring or contains slack that
215/// would let a near-miss pass.
216pub fn score_case(expected: &ExpectedVerdict, actual: &ExpectedVerdict) -> bool {
217 expected == actual
218}
219
220/// The aggregate accuracy of scored `cases`: the fraction whose polarity matched.
221///
222/// Pure and total (§2.3): an empty suite scores `0.0`, not a `0/0` NaN, so the
223/// value always serializes to a real JSON number.
224pub fn aggregate(cases: &[CaseResult]) -> f64 {
225 if cases.is_empty() {
226 return 0.0;
227 }
228 let matched = cases.iter().filter(|case| case.matched).count();
229 matched as f64 / cases.len() as f64
230}
231
232/// One scored eval case: the expected polarity, the polarity the grader actually
233/// returned, whether they matched, and the grader's verbatim feedback message.
234///
235/// `matched` is derived at construction from the two polarities ([`score_case`]),
236/// never set independently (§1.1): a result cannot claim a match its polarities
237/// contradict. The serialized shape — `expected`, `actual`, `matched`,
238/// `feedback_message` — is the per-case artifact a built site embeds without
239/// re-scoring; `feedback_message` is a plain `String` (never `Option`), so the
240/// key is always present on the wire (§1.1).
241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
242pub struct CaseResult {
243 expected: ExpectedVerdict,
244 actual: ExpectedVerdict,
245 matched: bool,
246 feedback_message: String,
247}
248
249impl CaseResult {
250 /// Score a case: reduce the runtime `actual` verdict to its polarity, derive
251 /// `matched` from it and `expected`, and capture the verdict's verbatim
252 /// learner-facing message. The only constructor, so a `matched` flag
253 /// inconsistent with the polarities is unrepresentable.
254 pub fn score(expected: ExpectedVerdict, actual: &Verdict) -> Self {
255 let feedback_message = match actual {
256 Verdict::Correct { message } | Verdict::Incorrect { message } => message.clone(),
257 };
258 let actual = ExpectedVerdict::from(actual);
259 let matched = score_case(&expected, &actual);
260 Self {
261 expected,
262 actual,
263 matched,
264 feedback_message,
265 }
266 }
267
268 /// The polarity the case's author expected the grader to return.
269 pub fn expected(&self) -> &ExpectedVerdict {
270 &self.expected
271 }
272
273 /// The polarity the grader actually returned for the submission.
274 pub fn actual(&self) -> &ExpectedVerdict {
275 &self.actual
276 }
277
278 /// Whether the actual polarity matched the expected one.
279 pub fn matched(&self) -> bool {
280 self.matched
281 }
282
283 /// The learner-facing feedback the grader produced for this case, verbatim.
284 pub fn feedback_message(&self) -> &str {
285 &self.feedback_message
286 }
287}
288
289/// The result of scoring an eval suite: every [`CaseResult`] in suite order plus
290/// the aggregate accuracy.
291///
292/// `accuracy` is derived at construction from the cases ([`aggregate`]), never
293/// set independently (§1.1). The serialized shape — `cases`, `accuracy` — is the
294/// eval artifact a built site embeds without re-scoring.
295#[derive(Debug, Clone, PartialEq, Serialize)]
296pub struct EvalReport {
297 cases: Vec<CaseResult>,
298 accuracy: f64,
299}
300
301impl EvalReport {
302 /// Assemble a report from scored `cases`, deriving the aggregate accuracy so
303 /// it cannot disagree with the per-case results.
304 pub fn new(cases: Vec<CaseResult>) -> Self {
305 let accuracy = aggregate(&cases);
306 Self { cases, accuracy }
307 }
308
309 /// The scored cases, in suite order.
310 pub fn cases(&self) -> &[CaseResult] {
311 &self.cases
312 }
313
314 /// The fraction of cases whose verdict polarity matched the expected one.
315 pub fn accuracy(&self) -> f64 {
316 self.accuracy
317 }
318}
319
320/// Why scoring an eval suite failed.
321///
322/// Either a case's submission could not be run through the pipeline — the
323/// interpreter failed to launch or the provider call failed — or a `--case N`
324/// selection was out of range. A scoring run is all-or-nothing, since a missing
325/// verdict cannot be scored as either polarity; the run failure names the
326/// offending case so an instructor can find it.
327#[derive(Debug)]
328pub enum EvalRunError {
329 /// A case's submission failed to run through the pipeline.
330 Run {
331 /// The zero-based index of the case whose run failed, in suite order.
332 index: usize,
333 /// The underlying pipeline failure.
334 source: RunError,
335 },
336 /// A `--case N` selection named a case outside the suite.
337 ///
338 /// Carries the user's requested number and the suite size; the CLI maps this
339 /// to exit 1 with a stderr message naming `suite_size`.
340 CaseOutOfRange {
341 /// The user's requested case number (1-based).
342 requested: usize,
343 /// The number of cases in the suite.
344 suite_size: usize,
345 },
346}
347
348impl fmt::Display for EvalRunError {
349 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350 match self {
351 EvalRunError::Run { index, source } => {
352 // One-based for the instructor, matching the report's `case N`
353 // rows; the field stays a zero-based index. (Slice-12's
354 // `EvalParseError` reports the zero-based logical index instead —
355 // a parse-time developer concern, its tested contract left
356 // untouched here.)
357 write!(f, "eval case {} failed to run: {}", index + 1, source)
358 }
359 EvalRunError::CaseOutOfRange {
360 requested,
361 suite_size,
362 } => write!(
363 f,
364 "eval case {requested} is out of range: the suite has {suite_size} case(s)"
365 ),
366 }
367 }
368}
369
370impl Error for EvalRunError {
371 fn source(&self) -> Option<&(dyn Error + 'static)> {
372 match self {
373 EvalRunError::Run { source, .. } => Some(source),
374 // A range error is self-contained: the request was rejected before
375 // any pipeline run, so there is no underlying failure to chain.
376 EvalRunError::CaseOutOfRange { .. } => None,
377 }
378 }
379}
380
381/// Run every case in `suite` through the same pipeline `run` uses and score each
382/// verdict against its expected polarity — or, when `case` is `Some(n)`, only
383/// the one 1-based case `n`.
384///
385/// The thin effectful shell over the pure scorer (§2.4): for each case it runs
386/// the submission through [`run_lesson`] — execute, grade, ask `provider` for a
387/// verdict — then pairs the verdict's polarity with the expected one as a
388/// [`CaseResult`]. Driving the *same* `run_lesson` is what keeps the evaluated
389/// feedback identical to the shipped feedback (§3.2). An out-of-range `case`
390/// selection is rejected up front with [`EvalRunError::CaseOutOfRange`] — never
391/// clamped — before any case runs; a run failure stops scoring and names the
392/// offending case ([`EvalRunError::Run`]). `base_url_override` points the
393/// provider at a stub in tests, exactly as `run` does, and is `None` in
394/// production.
395pub async fn run_eval(
396 lesson: &Lesson,
397 suite: &EvalSuite,
398 provider: ProviderChoice,
399 base_url_override: Option<&str>,
400 case: Option<usize>,
401) -> Result<EvalReport, EvalRunError> {
402 let selected = case
403 .map(|requested| select_case_index(suite.cases.len(), requested))
404 .transpose()?;
405 let mut cases = Vec::with_capacity(if selected.is_some() {
406 1
407 } else {
408 suite.cases.len()
409 });
410 for (index, suite_case) in suite.cases.iter().enumerate() {
411 if let Some(selected_index) = selected
412 && index != selected_index
413 {
414 continue;
415 }
416 let submission = Submission::new(suite_case.submission.clone());
417 let report = run_lesson(lesson, &submission, provider, base_url_override)
418 .await
419 .map_err(|source| EvalRunError::Run { index, source })?;
420 cases.push(CaseResult::score(
421 suite_case.expected.clone(),
422 report.verdict(),
423 ));
424 }
425 Ok(EvalReport::new(cases))
426}
427
428/// Resolve a 1-based `--case N` request against the suite size to the zero-based
429/// index to select, or reject it as out of range.
430///
431/// The single validation point for case selection (§5): `0` and any value past
432/// the last case are rejected (never clamped) with an error carrying both the
433/// user's requested number and the suite size, so the CLI can exit 1 naming the
434/// size. Pure and total, so it is unit-testable in `core` without a CLI harness.
435fn select_case_index(suite_size: usize, requested: usize) -> Result<usize, EvalRunError> {
436 if requested == 0 || requested > suite_size {
437 return Err(EvalRunError::CaseOutOfRange {
438 requested,
439 suite_size,
440 });
441 }
442 Ok(requested - 1)
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn from_token_maps_the_two_polarity_words_and_rejects_others() {
451 assert_eq!(
452 ExpectedVerdict::from_token("correct"),
453 Some(ExpectedVerdict::Correct)
454 );
455 assert_eq!(
456 ExpectedVerdict::from_token("incorrect"),
457 Some(ExpectedVerdict::Incorrect)
458 );
459 assert_eq!(ExpectedVerdict::from_token("maybe"), None);
460 }
461
462 #[test]
463 fn unknown_verdict_error_names_the_case_index_and_quotes_the_token() {
464 let err = EvalParseError::UnknownVerdict {
465 index: 2,
466 token: "maybe".to_string(),
467 };
468 let msg = err.to_string();
469 assert!(
470 msg.contains("case 2"),
471 "should name the offending case: {msg}"
472 );
473 assert!(msg.contains("maybe"), "should quote the bad token: {msg}");
474 }
475
476 #[test]
477 fn structural_error_surfaces_the_underlying_parser_message() {
478 let err = EvalParseError::Structural("line 1: boom".to_string());
479 assert!(
480 err.to_string().contains("boom"),
481 "structural error should pass through the parser message"
482 );
483 }
484
485 #[test]
486 fn parse_eval_suite_surfaces_unknown_verdict_with_its_case_index_and_token() {
487 // The structured error, not just its Display: pins the index (0) and the
488 // offending token so a mutant that drops or rewrites either is caught.
489 let yaml = "cases:\n - submission: x\n expected: maybe\n";
490 match parse_eval_suite(yaml) {
491 Err(EvalParseError::UnknownVerdict { index, token }) => {
492 assert_eq!(index, 0, "names the offending case index");
493 assert_eq!(token, "maybe", "carries the unrecognized token verbatim");
494 }
495 other => panic!("expected an UnknownVerdict error, got: {other:?}"),
496 }
497 }
498
499 #[test]
500 fn parse_rejects_unknown_key_so_author_typos_surface() {
501 // deny_unknown_fields at the boundary: a typo'd case key is a structural
502 // error naming it, not a silently dropped field (§1.3.1).
503 let yaml = "cases:\n - submision: x\n expected: correct\n";
504 let err = parse_eval_suite(yaml).expect_err("an unknown key must be rejected");
505 assert!(
506 err.to_string().contains("submision"),
507 "error should name the unknown key, got: {err}"
508 );
509 }
510
511 use crate::llm::Verdict;
512
513 fn correct() -> Verdict {
514 Verdict::Correct {
515 message: "well done".to_string(),
516 }
517 }
518
519 fn incorrect() -> Verdict {
520 Verdict::Incorrect {
521 message: "try again".to_string(),
522 }
523 }
524
525 #[test]
526 fn score_case_matches_same_polarity_and_rejects_the_opposite() {
527 // Exact polarity equality both ways, so a one-directional or constant
528 // implementation cannot pass.
529 assert!(score_case(
530 &ExpectedVerdict::Correct,
531 &ExpectedVerdict::Correct
532 ));
533 assert!(score_case(
534 &ExpectedVerdict::Incorrect,
535 &ExpectedVerdict::Incorrect
536 ));
537 assert!(!score_case(
538 &ExpectedVerdict::Correct,
539 &ExpectedVerdict::Incorrect
540 ));
541 assert!(!score_case(
542 &ExpectedVerdict::Incorrect,
543 &ExpectedVerdict::Correct
544 ));
545 }
546
547 #[test]
548 fn verdict_polarity_drops_the_feedback_message() {
549 // The runtime verdict's message is irrelevant to scoring; both polarities
550 // map so a constant mapping (always Correct/Incorrect) is caught.
551 assert_eq!(ExpectedVerdict::from(&correct()), ExpectedVerdict::Correct);
552 assert_eq!(
553 ExpectedVerdict::from(&incorrect()),
554 ExpectedVerdict::Incorrect
555 );
556 }
557
558 #[test]
559 fn case_result_derives_matched_from_score_case() {
560 // `matched` is computed at construction from the scored polarities, never
561 // set independently (§1.1): an expected-correct case graded incorrect is a
562 // mismatch, and `matched` equals `score_case` of the stored polarities.
563 let result = CaseResult::score(ExpectedVerdict::Correct, &incorrect());
564 assert_eq!(result.expected(), &ExpectedVerdict::Correct);
565 assert_eq!(result.actual(), &ExpectedVerdict::Incorrect);
566 assert!(!result.matched());
567 assert_eq!(
568 result.matched(),
569 score_case(result.expected(), result.actual())
570 );
571 }
572
573 #[test]
574 fn aggregate_is_matched_over_total_and_zero_for_an_empty_suite() {
575 let cases = vec![
576 CaseResult::score(ExpectedVerdict::Correct, &correct()),
577 CaseResult::score(ExpectedVerdict::Incorrect, &incorrect()),
578 CaseResult::score(ExpectedVerdict::Correct, &incorrect()),
579 ];
580 assert_eq!(aggregate(&cases), 2.0 / 3.0);
581 // No cases means no NaN (0/0) — an empty suite is a real, serializable 0.0.
582 assert_eq!(aggregate(&[]), 0.0);
583 }
584
585 #[test]
586 fn scores_two_of_three_and_aggregates() {
587 // The AC1 probe: two matches and one mismatch give exactly 2/3, the
588 // per-case `matched` flags are [true, true, false], and each is the
589 // derived `score_case` of its stored polarities.
590 let report = EvalReport::new(vec![
591 CaseResult::score(ExpectedVerdict::Correct, &correct()),
592 CaseResult::score(ExpectedVerdict::Incorrect, &incorrect()),
593 CaseResult::score(ExpectedVerdict::Correct, &incorrect()),
594 ]);
595
596 assert_eq!(report.accuracy(), 2.0 / 3.0);
597 let matched: Vec<bool> = report.cases().iter().map(CaseResult::matched).collect();
598 assert_eq!(matched, vec![true, true, false]);
599 for case in report.cases() {
600 assert_eq!(case.matched(), score_case(case.expected(), case.actual()));
601 }
602 }
603
604 #[test]
605 fn eval_run_error_names_the_one_based_case_and_chains_its_source() {
606 use crate::llm::FeedbackError;
607 use crate::run::RunError;
608
609 let err = EvalRunError::Run {
610 index: 2,
611 source: RunError::Feedback(FeedbackError::MissingApiKey {
612 var: "FIREWORKS_API_KEY",
613 }),
614 };
615
616 let message = err.to_string();
617 // Zero-based index 2 reads as the instructor's "case 3".
618 assert!(
619 message.contains("eval case 3 failed to run"),
620 "should name the 1-based case and the failure: {message}"
621 );
622 assert!(
623 message.contains("FIREWORKS_API_KEY"),
624 "should surface the underlying pipeline failure: {message}"
625 );
626 // The pipeline failure is chained as the error source, not swallowed.
627 assert!(
628 std::error::Error::source(&err).is_some(),
629 "the RunError must be reachable via Error::source"
630 );
631 }
632
633 #[test]
634 fn expected_verdict_serializes_to_its_canonical_token() {
635 // The JSON spelling is the same single-sourced token as the YAML one, so
636 // the wire form cannot drift from the accepted set.
637 assert_eq!(
638 serde_json::to_string(&ExpectedVerdict::Correct).unwrap(),
639 format!("{:?}", ExpectedVerdict::CORRECT_TOKEN)
640 );
641 assert_eq!(
642 serde_json::to_string(&ExpectedVerdict::Incorrect).unwrap(),
643 format!("{:?}", ExpectedVerdict::INCORRECT_TOKEN)
644 );
645 }
646
647 #[test]
648 fn eval_feedback_message_carries_the_verdict_message_verbatim() {
649 // The per-case `feedback_message` is the runtime verdict's message,
650 // captured for BOTH polarities — a message captured only for Incorrect
651 // verdicts (negative h) or a constant across cases (negative a) cannot
652 // pass.
653 let correct = CaseResult::score(ExpectedVerdict::Correct, &correct());
654 assert_eq!(correct.feedback_message(), "well done");
655 let incorrect = CaseResult::score(ExpectedVerdict::Incorrect, &incorrect());
656 assert_eq!(incorrect.feedback_message(), "try again");
657 }
658
659 #[test]
660 fn eval_feedback_message_serializes_per_case_never_null_or_omitted() {
661 // The JSON artifact must carry a String `feedback_message` on every case
662 // — never null, never omitted, verbatim. An `Option<String>` field or a
663 // `skip_serializing_if` would break this contract.
664 let report = EvalReport::new(vec![
665 CaseResult::score(ExpectedVerdict::Correct, &correct()),
666 CaseResult::score(ExpectedVerdict::Incorrect, &incorrect()),
667 ]);
668 let json = serde_json::to_value(&report).expect("an EvalReport serializes infallibly");
669 let cases = json["cases"].as_array().expect("cases is an array");
670 assert_eq!(cases.len(), 2);
671 for case in cases {
672 assert!(
673 case["feedback_message"].is_string(),
674 "feedback_message must be a non-null string: {case}"
675 );
676 }
677 assert_eq!(cases[0]["feedback_message"], "well done");
678 assert_eq!(cases[1]["feedback_message"], "try again");
679 }
680
681 #[test]
682 fn eval_no_skip_serializing_if_keeps_the_empty_message_key() {
683 // An empty message must still serialize the `feedback_message` key:
684 // `skip_serializing_if` would drop it and break the never-omitted
685 // contract (predicate 2).
686 let result = CaseResult::score(
687 ExpectedVerdict::Correct,
688 &Verdict::Correct {
689 message: String::new(),
690 },
691 );
692 let json = serde_json::to_string(&result).expect("a CaseResult serializes infallibly");
693 assert!(
694 json.contains("\"feedback_message\":\"\""),
695 "an empty feedback_message must serialize as a present key: {json}"
696 );
697 }
698
699 #[test]
700 fn case_selection_maps_one_based_requests_to_zero_based_indices() {
701 // `--case N` is 1-based: the alpha (first) case is 1, beta is 2, gamma
702 // is 3. A 0-based or clamped-to-first implementation cannot pass.
703 assert!(matches!(select_case_index(3, 1), Ok(0)));
704 assert!(matches!(select_case_index(3, 2), Ok(1)));
705 assert!(matches!(select_case_index(3, 3), Ok(2)));
706 }
707
708 #[test]
709 fn case_selection_rejects_zero_and_past_the_end_naming_the_suite_size() {
710 // Out-of-range requests are rejected (never clamped) and the error
711 // carries both the requested number and the suite size (negative d/e).
712 assert!(matches!(
713 select_case_index(3, 0),
714 Err(EvalRunError::CaseOutOfRange {
715 requested: 0,
716 suite_size: 3
717 })
718 ));
719 assert!(matches!(
720 select_case_index(3, 4),
721 Err(EvalRunError::CaseOutOfRange {
722 requested: 4,
723 suite_size: 3
724 })
725 ));
726 }
727
728 #[test]
729 fn case_out_of_range_error_names_requested_case_and_suite_size() {
730 let err = EvalRunError::CaseOutOfRange {
731 requested: 4,
732 suite_size: 3,
733 };
734 let message = err.to_string();
735 assert!(
736 message.contains("4"),
737 "should name the requested case: {message}"
738 );
739 assert!(
740 message.contains("3"),
741 "should name the suite size: {message}"
742 );
743 assert!(
744 std::error::Error::source(&err).is_none(),
745 "a range error has no underlying pipeline failure to chain"
746 );
747 }
748}