Skip to main content

veredictum/exec/
player.rs

1// SPDX-FileCopyrightText: Veredictum contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! The transcript player — the runner-verification pack's part 1: replay a
5//! fixed transcript and reproduce the adjudicated verdicts.
6//!
7//! The transcript is itself a specified artifact (`transcript.schema.json`):
8//! an ordered sequence per case × format × row of recorded exchanges with
9//! adjudicated expected verdicts; the player answers the Nth matching request
10//! with the Nth recorded response (matching = method + path suffix), so a
11//! fixture file fully determines what any conformant runner must conclude.
12//!
13//! A replay judges every assertion a recorded exchange plus the catalogue's
14//! own corpus decides ([`crate::exec::assertions::eval_from_exchange`]) and
15//! REFUSES the entry for any family it cannot, on both assertion seams: no
16//! verdict is ever reproduced over an assertion nobody evaluated.
17
18#![expect(
19    clippy::disallowed_types,
20    reason = "dev/verification tooling over JSON artifacts (the catalogue, results, wire \
21              exchanges), whose shapes belong to the artifacts and the SUT"
22)]
23
24use std::collections::BTreeMap;
25
26use reqwest::StatusCode;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30use crate::exec::assertions::{CorpusGround, ExchangeFacts, ReplayJudgement, replay_judgement};
31use crate::exec::outcome::StepJudgement;
32use crate::exec::resolve::Resolver;
33use crate::exec::state::{Captured, VarStore};
34use crate::exec::{StepDriver, StepObservation, outcome};
35use crate::ids::{CaptureName, CaseId, CorpusKey, SmOperationRef, ViewName};
36use crate::model::assertion::{Assertion, PostconditionRole};
37use crate::model::binding::{HeaderMatcher, WireExpectation, WireFrom};
38use crate::model::case::{CaseCore, FlowStep};
39use crate::vocab::{FormatName, OutcomeKind};
40
41/// One recorded response in a transcript entry.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct RecordedResponse {
45    /// The HTTP status code the recorded server answered with.
46    pub status: u16,
47    /// The recorded response headers, lower-cased names.
48    pub headers: BTreeMap<String, String>,
49    /// The recorded response body, when it carried one.
50    #[serde(default)]
51    pub body: Option<Value>,
52}
53
54/// One recorded request key (matching = method + path suffix + step).
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct RecordedRequest {
58    /// The HTTP method of the recorded request.
59    pub method: String,
60    /// The request path as recorded (matching is by suffix).
61    pub path: String,
62    /// Digest of the request body, so a replay can tell two shapes apart.
63    #[serde(default)]
64    pub body_digest: Option<String>,
65    /// The `Accept` the step negotiated, when a binding's header expectation
66    /// needs it.
67    ///
68    /// A `negotiated` matcher compares the served `Content-Type` against what
69    /// the request asked for, and a recording that omits the ask cannot judge
70    /// it. Optional so an entry whose expectation declares no such matcher
71    /// carries nothing it does not need; an entry that DOES declare one and
72    /// omits this is refused rather than passed, by the player's own
73    /// ungrounded-header guard. Named in prose rather than linked: the guard is
74    /// private and a public item may not link to it.
75    #[serde(default)]
76    pub accept: Option<String>,
77}
78
79/// The adjudicated expectation for one step exchange.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct TranscriptStep {
83    /// The flow step this exchange belongs to.
84    pub step: u32,
85    /// The request the step is expected to issue.
86    pub request: RecordedRequest,
87    /// The response the player answers it with.
88    pub response: RecordedResponse,
89}
90
91/// One case×format×row sequence with its adjudicated verdict.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct TranscriptEntry {
95    /// The case this sequence belongs to.
96    pub case: CaseId,
97    /// The format axis the sequence was recorded on, when parameterized.
98    #[serde(default)]
99    pub format: Option<FormatName>,
100    /// The 0-based parameter row the sequence belongs to.
101    pub row: usize,
102    /// The recorded exchanges, in step order.
103    pub steps: Vec<TranscriptStep>,
104    /// The adjudicated per-row verdict the runner MUST reproduce.
105    pub expected_verdict: ExpectedVerdict,
106    /// The adjudication citation (spec text, register entry).
107    pub adjudication_ref: String,
108}
109
110/// The adjudicated verdict vocabulary of the pack.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum ExpectedVerdict {
114    /// Every assertion of the row must hold.
115    Passed,
116    /// The row must fail an assertion (ISO/IEC 9646 fail).
117    Failed,
118    /// The row must be inconclusive — the exchange itself broke.
119    Errored,
120    /// The row must be excluded by selection, with a citation.
121    NotApplicable,
122    /// The row must be skipped, with a citation.
123    Skipped,
124}
125
126/// A whole transcript document.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct Transcript {
130    /// The schedule release the adjudications were made against.
131    pub schedule_release: String,
132    /// Every recorded sequence of the document.
133    pub entries: Vec<TranscriptEntry>,
134}
135
136/// The catalogue's own corpus, as the replay's [`CorpusGround`].
137///
138/// A replay is driven over the same artifact root the live run drove, so a
139/// `${ds:…}` comparand is read off disk through the run's own resolver. The
140/// wrapper exists because [`CorpusGround`] is the narrow contract the pure
141/// evaluators see: the corpus, and no row state, no provisioning, no ixit.
142struct PlayerCorpus<'a> {
143    resolver: Resolver<'a>,
144    manifest: &'a crate::model::corpus::CorpusManifest,
145}
146
147impl CorpusGround for PlayerCorpus<'_> {
148    fn data_set(&mut self, key: &CorpusKey, view: Option<&ViewName>) -> Result<Value, String> {
149        match view {
150            None => self.resolver.data_set(key).map_err(|e| e.to_string()),
151            Some(view) => self.resolver.view(key, view).map_err(|e| e.to_string()),
152        }
153    }
154
155    fn corpus_format(&self, key: &CorpusKey) -> Option<crate::vocab::CorpusFormat> {
156        self.manifest.get(key).map(|entry| entry.format)
157    }
158}
159
160/// The player: a [`StepDriver`] answering from recorded exchanges.
161#[derive(Debug)]
162pub struct TranscriptPlayer<'a> {
163    set: &'a crate::artifacts::ArtifactSet,
164    entry: &'a TranscriptEntry,
165    cursor: usize,
166}
167
168impl<'a> TranscriptPlayer<'a> {
169    /// A player over one transcript entry.
170    #[must_use]
171    pub fn new(set: &'a crate::artifacts::ArtifactSet, entry: &'a TranscriptEntry) -> Self {
172        Self {
173            set,
174            entry,
175            cursor: 0,
176        }
177    }
178
179    /// The corpus ground for this replay, or [`None`] when the artifact set
180    /// carries no corpus at all.
181    fn corpus(&self) -> Option<PlayerCorpus<'a>> {
182        let (_, manifest) = self.set.corpus.as_ref()?;
183        let corpus_dir = self.set.corpus_dir.as_deref()?;
184        Some(PlayerCorpus {
185            resolver: Resolver::new(manifest, corpus_dir, None),
186            manifest,
187        })
188    }
189
190    /// Selects the operation binding under the same variant discipline as the
191    /// live driver (`HttpDriver::binding_for_variant`): a step's `variant`
192    /// selects the binding declaring it, and a variant-less step — or a variant
193    /// with no dedicated binding — resolves the variant-less binding.
194    fn binding_for(
195        &self,
196        case: &CaseCore,
197        call: &str,
198        variant: Option<&str>,
199    ) -> Option<&'a crate::model::binding::OperationBinding> {
200        let op = if call.contains('.') {
201            SmOperationRef::parse(call).ok()?
202        } else {
203            case.sm_operation.as_ref()?.sibling(call)
204        };
205        let mut bindings = self.set.bindings.iter().map(|(_, b)| b);
206        if let Some(v) = variant
207            && let Some(exact) = bindings
208                .clone()
209                .find(|b| b.sm_operation == op && b.variant.as_deref() == Some(v))
210        {
211            return Some(exact);
212        }
213        bindings.find(|b| b.sm_operation == op && b.variant.is_none())
214    }
215
216    /// Refuses the entry when the outcome's expectation declares a header
217    /// matcher this recording cannot ground.
218    ///
219    /// `negotiated` compares the served `Content-Type` against the `Accept` the
220    /// request carried. A recording that omits the ask makes that comparison
221    /// unsound, and the evaluator answers "no failure" for an absent ask, so
222    /// evaluating anyway would let a wrong media type reproduce a pass. The
223    /// entry is refused by name instead, the same discipline
224    /// [`Self::refuse_unrecorded`] applies to an assertion family.
225    fn refuse_ungrounded_headers(
226        case: &CaseCore,
227        step: &FlowStep,
228        expectation: &WireExpectation,
229        recorded: &TranscriptStep,
230    ) -> Result<(), String> {
231        if recorded.request.accept.is_some() {
232            return Ok(());
233        }
234        let ungrounded: Vec<&str> = expectation
235            .headers
236            .as_deref()
237            .unwrap_or_default()
238            .iter()
239            .filter(|(_, declarations)| {
240                declarations
241                    .all()
242                    .iter()
243                    .any(|h| matches!(h.matcher, HeaderMatcher::Negotiated))
244            })
245            .map(|(name, _)| name.as_str())
246            .collect();
247        if ungrounded.is_empty() {
248            return Ok(());
249        }
250        Err(format!(
251            "case {} step {} declares a negotiated matcher on {}, and the recorded request \
252             carries no `accept`; a pack entry may not claim a verdict over a header the replay \
253             cannot judge",
254            case.id,
255            step.step,
256            ungrounded.join(", ")
257        ))
258    }
259
260    /// Refuses the entry when `step` asserts a family the replay cannot judge.
261    ///
262    /// A transcript records the response side of the flow's own exchanges and
263    /// nothing else: no payload committed earlier in the row, no versioned
264    /// read, no instance posture. A [`ReplayJudgement::Unrecorded`] assertion
265    /// is unevaluable from that plus the catalogue's corpus, so the entry is
266    /// refused by name rather than claiming a verdict over an assertion nobody
267    /// ran.
268    fn refuse_unrecorded(case: &CaseCore, step: &FlowStep) -> Result<(), String> {
269        let unjudgeable: Vec<&str> = step
270            .assertions
271            .iter()
272            .filter(|assertion| replay_judgement(assertion) == ReplayJudgement::Unrecorded)
273            .map(Assertion::family)
274            .collect();
275        if unjudgeable.is_empty() {
276            return Ok(());
277        }
278        Err(format!(
279            "case {} step {} asserts families the transcript replay cannot judge ({}); a pack \
280             entry may not claim a verdict over assertions the replay never evaluated",
281            case.id,
282            step.step,
283            unjudgeable.join(", ")
284        ))
285    }
286
287    /// Binds `step`'s captures from the recorded response, mirroring the live
288    /// driver's closed capture grammar.
289    ///
290    /// Only the captures declared for the classified outcome `kind` bind. A
291    /// `commit_time` capture answers from the cursor, since a transcript
292    /// carries no clock.
293    fn bind_recorded_captures(
294        &self,
295        step: &FlowStep,
296        binding: &crate::model::binding::OperationBinding,
297        recorded: &TranscriptStep,
298        kind: OutcomeKind,
299        vars: &mut VarStore,
300    ) {
301        for (name, source) in step.captures() {
302            if source.outcome != kind {
303                continue;
304            }
305            match &source.field {
306                crate::refgrammar::CaptureField::Body => {
307                    if let Some(b) = &recorded.response.body {
308                        vars.set(name.clone(), Captured::Body(b.clone()));
309                    }
310                }
311                crate::refgrammar::CaptureField::CommitTime => {
312                    #[expect(
313                        clippy::expect_used,
314                        reason = "the cursor indexes the transcript's in-memory step vector, so \
315                                  it is bounded orders of magnitude below i64::MAX / 1000; a \
316                                  failed widening or multiplication is logically impossible and \
317                                  should fail loud, never substitute an instant"
318                    )]
319                    let ms = i64::try_from(self.cursor)
320                        .ok()
321                        .and_then(|seconds| seconds.checked_mul(1_000))
322                        .expect("the transcript cursor should fit an i64 millisecond instant");
323                    vars.set(name.clone(), Captured::InstantMs { lo: ms, hi: ms });
324                }
325                crate::refgrammar::CaptureField::Field { name: field, list } => {
326                    let Some(spec) = binding
327                        .captures
328                        .as_deref()
329                        .unwrap_or_default()
330                        .iter()
331                        .find(|(n, _)| n == field)
332                        .map(|(_, s)| s)
333                    else {
334                        continue;
335                    };
336                    if *list {
337                        if let (Some(body), WireFrom::Body { path }) =
338                            (&recorded.response.body, &spec.from)
339                        {
340                            let items = extract_list(body, path);
341                            vars.set(name.clone(), Captured::List(items));
342                        }
343                    } else if let Some(value) = extract_scalar(&recorded.response, spec, vars) {
344                        vars.set(name.clone(), Captured::Scalar(value));
345                    }
346                }
347            }
348        }
349    }
350}
351
352/// Whether any flow `with`/`scope` template or any assertion of `case`
353/// references `handle` as a capture.
354fn case_reads_capture(case: &CaseCore, handle: &CaptureName) -> bool {
355    let matches_handle = |reference: &crate::refgrammar::ValueRef| matches!(reference, crate::refgrammar::ValueRef::Capture { name, .. } if name == handle);
356    case.flow.iter().any(|step| {
357        step.with_entries()
358            .iter()
359            .any(|(_, value)| value.refs().iter().any(|r| matches_handle(r)))
360            || step
361                .scope_templates()
362                .iter()
363                .any(|template| template.refs().iter().any(|r| matches_handle(r)))
364            || step.assertions.iter().any(|a| {
365                crate::model::assertion::assertion_refs(a)
366                    .iter()
367                    .any(matches_handle)
368            })
369    }) || case.postconditions.iter().any(|a| {
370        crate::model::assertion::assertion_refs(a)
371            .iter()
372            .any(matches_handle)
373    })
374}
375
376impl StepDriver for TranscriptPlayer<'_> {
377    fn perform(
378        &mut self,
379        case: &CaseCore,
380        step: &FlowStep,
381        expected: OutcomeKind,
382        _row: usize,
383        vars: &mut VarStore,
384    ) -> Result<StepObservation, String> {
385        let Some(recorded) = self.entry.steps.get(self.cursor) else {
386            return Ok(StepObservation::transport(
387                "transcript exhausted before the flow ended".to_owned(),
388            ));
389        };
390        self.cursor += 1;
391
392        let Some(binding) = self.binding_for(case, &step.call, step.variant.as_deref()) else {
393            return Err(format!("no binding declares operation {}", step.call));
394        };
395        let selectors = self.set.selectors.as_ref().map(|(_, s)| s);
396        let observation =
397            outcome::classify_status(binding, selectors, recorded.response.status, expected);
398
399        if let outcome::Observation::Kind(kind) = observation {
400            self.bind_recorded_captures(step, binding, recorded, kind, vars);
401        }
402        // Law b aborts the row on a mismatch and `run_case` never reads the
403        // assertion list then, so the replay must be able to judge the
404        // assertions exactly when the observation met the expectation.
405        if !matches!(
406            outcome::judge(expected, &observation),
407            StepJudgement::Continue
408        ) {
409            return Ok(StepObservation {
410                observation,
411                assertion_failures: Vec::new(),
412                advisories: Vec::new(),
413            });
414        }
415        Self::refuse_unrecorded(case, step)?;
416        let Ok(status) = StatusCode::from_u16(recorded.response.status) else {
417            return Ok(StepObservation::transport(format!(
418                "recorded status {} is not an HTTP status code",
419                recorded.response.status
420            )));
421        };
422        let body = recorded.response.body.as_ref().unwrap_or(&Value::Null);
423        let facts = ExchangeFacts {
424            status,
425            body,
426            media_type: recorded
427                .response
428                .headers
429                .iter()
430                .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
431                .map(|(_, value)| value.as_str()),
432        };
433        let mut no_corpus = crate::exec::assertions::NoCorpus;
434        let mut corpus = self.corpus();
435        let ground: &mut dyn CorpusGround = match corpus.as_mut() {
436            Some(loaded) => loaded,
437            None => &mut no_corpus,
438        };
439        // Binding header matchers are EXECUTED expectations, not documentation
440        // (#473): before this the player judged status, captures and assertions
441        // and never these, so an entry could reproduce a pass over a recording
442        // whose headers violated its own binding.
443        let mut assertion_failures = Vec::new();
444        if let outcome::Observation::Kind(kind) = observation
445            && let Some(expectation) = binding.outcome(kind)
446        {
447            Self::refuse_ungrounded_headers(case, step, expectation, recorded)?;
448            // `last_version_uid` and `spec_versions` are None because a pack
449            // recording tracks neither: the uid matcher then asserts presence
450            // only and a dated rule is out of scope, which is what both
451            // evaluators already do for a party that declares nothing.
452            let header_ctx = crate::exec::headers::RequestContext {
453                accept: recorded.request.accept.as_deref(),
454                last_version_uid: None,
455                spec_versions: None,
456            };
457            assertion_failures.extend(
458                crate::exec::headers::evaluate(
459                    expectation,
460                    &recorded.response.headers,
461                    recorded.response.body.as_ref(),
462                    &header_ctx,
463                    vars,
464                )
465                .into_iter()
466                .map(crate::exec::assertions::AssertionOutcome::Mismatch),
467            );
468        }
469        let mut advisories = Vec::new();
470        for assertion in &step.assertions {
471            match crate::exec::assertions::eval_from_exchange(assertion, facts, ground) {
472                Ok(recorded_advisories) => advisories.extend(recorded_advisories),
473                Err(outcome) => assertion_failures.push(outcome),
474            }
475        }
476        Ok(StepObservation {
477            observation,
478            assertion_failures,
479            advisories,
480        })
481    }
482
483    fn provision(
484        &mut self,
485        case: &CaseCore,
486        _row: usize,
487        _vars: &mut VarStore,
488    ) -> Result<crate::exec::Provisioned, String> {
489        // The transcript records no provisioned handles, so a case that READS
490        // one would resolve against a value the exchanges never used.
491        let minted = case.requires.minted_handles();
492        if minted.is_empty() {
493            return Ok(crate::exec::Provisioned::Ready);
494        }
495        let read: Vec<String> = minted
496            .iter()
497            .filter(|handle| case_reads_capture(case, handle))
498            .map(ToString::to_string)
499            .collect();
500        if read.is_empty() {
501            return Ok(crate::exec::Provisioned::Ready);
502        }
503        Err(format!(
504            "case {} reads provisioned handle(s) {} the transcript does not record; a pack \
505             entry may not resolve a requires handle against a value the recorded exchanges \
506             never used",
507            case.id,
508            read.join(", ")
509        ))
510    }
511
512    /// Refuses any judged postcondition instead of reproducing a verdict it
513    /// never checked.
514    ///
515    /// A postcondition runs after the flow, with no exchange of its own to
516    /// read, so every [`PostconditionRole::Judged`] family is unevaluable here
517    /// and the entry is refused by name. The aggregate family is law e
518    /// ([`TranscriptPlayer::aggregates`]) and the informative families are
519    /// never pass/fail, so neither blocks a replay.
520    fn postconditions(
521        &mut self,
522        case: &CaseCore,
523        _row: usize,
524        _vars: &mut VarStore,
525    ) -> Result<crate::exec::PostconditionOutcomes, String> {
526        let unjudgeable: Vec<&str> = case
527            .postconditions
528            .iter()
529            .filter(|a| matches!(a.postcondition_role(), PostconditionRole::Judged))
530            .map(Assertion::family)
531            .collect();
532        if unjudgeable.is_empty() {
533            return Ok(crate::exec::PostconditionOutcomes::default());
534        }
535        Err(format!(
536            "case {} carries postconditions the transcript replay cannot judge ({}); a pack entry \
537             may not claim a verdict over assertions the replay never evaluated",
538            case.id,
539            unjudgeable.join(", ")
540        ))
541    }
542
543    fn aggregates(
544        &mut self,
545        case: &CaseCore,
546        all_rows: &[VarStore],
547    ) -> Result<Vec<String>, String> {
548        let mut failures = Vec::new();
549        for assertion in &case.postconditions {
550            if let Assertion::Unique { over, .. } = assertion
551                && let crate::refgrammar::ValueRef::Capture { name, .. } = &over.0
552                && let Err(crate::exec::assertions::AssertionFailure(m)) =
553                    crate::exec::assertions::eval_unique(name, all_rows)
554            {
555                failures.push(m);
556            }
557        }
558        Ok(failures)
559    }
560}
561
562fn extract_scalar(
563    response: &RecordedResponse,
564    spec: &crate::model::binding::WireCapture,
565    vars: &VarStore,
566) -> Option<String> {
567    let from_source = |source: &WireFrom| -> Option<String> {
568        match source {
569            WireFrom::Header { name, last_segment } => {
570                let value = response
571                    .headers
572                    .iter()
573                    .find(|(k, _)| k.eq_ignore_ascii_case(name))
574                    .map(|(_, v)| v.clone())?;
575                if *last_segment {
576                    value.rsplit('/').next().map(ToOwned::to_owned)
577                } else {
578                    Some(value)
579                }
580            }
581            WireFrom::Body { path } => {
582                let mut current = response.body.as_ref()?;
583                for seg in path.split('.') {
584                    current = current.get(seg)?;
585                }
586                match current {
587                    Value::String(s) => Some(s.clone()),
588                    other => Some(other.to_string()),
589                }
590            }
591            WireFrom::Capture(name) => vars.scalar(name).map(ToOwned::to_owned),
592        }
593    };
594    let mut value =
595        from_source(&spec.from).or_else(|| spec.fallback.as_ref().and_then(from_source))?;
596    if matches!(
597        spec.strip,
598        Some(crate::model::binding::StripRule::WeakQuotes)
599    ) {
600        value = value.trim_start_matches("W/").trim_matches('"').to_owned();
601    }
602    // One implementation of the closed transform grammar, so a replay cannot
603    // judge a capture differently from the live run that recorded it.
604    if let Some(transform) = spec.transform {
605        value = transform.apply(&value)?;
606    }
607    Some(value)
608}
609
610fn extract_list(body: &Value, path: &str) -> Vec<String> {
611    let mut current = vec![body];
612    for seg in path.split('.') {
613        let (attr, star) = match seg.strip_suffix("[*]") {
614            Some(attr) => (attr, true),
615            None => (seg, false),
616        };
617        let mut next = Vec::new();
618        for v in current {
619            let v = if attr.is_empty() {
620                Some(v)
621            } else {
622                v.get(attr)
623            };
624            if let Some(v) = v {
625                if star {
626                    if let Some(items) = v.as_array() {
627                        next.extend(items.iter());
628                    }
629                } else {
630                    next.push(v);
631                }
632            }
633        }
634        current = next;
635    }
636    current
637        .into_iter()
638        .filter_map(|v| match v {
639            Value::String(s) => Some(s.clone()),
640            other => other.as_str().map(ToOwned::to_owned),
641        })
642        .collect()
643}
644
645/// Replay one transcript entry against its case and judge whether the
646/// runner reproduces the adjudicated verdict.
647///
648/// # Errors
649/// Interpreter defects only (unknown case, unresolvable binding).
650pub fn replay_entry(
651    set: &crate::artifacts::ArtifactSet,
652    entry: &TranscriptEntry,
653) -> Result<(ExpectedVerdict, crate::exec::RowOutcome), String> {
654    let case = set
655        .cases
656        .iter()
657        .map(|(_, c)| c)
658        .find(|c| c.id == entry.case)
659        .ok_or_else(|| format!("transcript case {} is not in the catalogue", entry.case))?;
660    // A transcript entry records ONE case×format×row sequence: slice the
661    // case to that row so the recorded steps line up 1:1 with the flow.
662    let mut sliced = case.clone();
663    if let Some(parameters) = &mut sliced.parameters {
664        if let Some(matrix) = &mut parameters.matrix {
665            let row =
666                matrix.rows.get(entry.row).cloned().ok_or_else(|| {
667                    format!("case {} has no matrix row {}", entry.case, entry.row)
668                })?;
669            matrix.rows = vec![row];
670        }
671        if let Some(fixtures) = &mut parameters.fixture_set {
672            let fixture = fixtures
673                .get(entry.row)
674                .cloned()
675                .ok_or_else(|| format!("case {} has no fixture row {}", entry.case, entry.row))?;
676            *fixtures = vec![fixture];
677        }
678    }
679    let mut player = TranscriptPlayer::new(set, entry);
680    let record = crate::exec::run_case(&sliced, entry.format, &mut player)?;
681    let row = record
682        .rows
683        .first()
684        .cloned()
685        .ok_or_else(|| format!("case {} produced no row", entry.case))?;
686    Ok((entry.expected_verdict, row))
687}
688
689/// Whether a produced row outcome reproduces the adjudicated verdict.
690#[must_use]
691pub fn verdict_matches(expected: ExpectedVerdict, produced: &crate::exec::RowOutcome) -> bool {
692    matches!(
693        (expected, produced),
694        (ExpectedVerdict::Passed, crate::exec::RowOutcome::Passed)
695            | (
696                ExpectedVerdict::Failed,
697                crate::exec::RowOutcome::Failed { .. }
698            )
699            | (
700                ExpectedVerdict::Errored,
701                crate::exec::RowOutcome::Errored { .. }
702            )
703            | (
704                ExpectedVerdict::NotApplicable,
705                crate::exec::RowOutcome::NotApplicable { .. }
706            )
707            | (
708                ExpectedVerdict::Skipped,
709                crate::exec::RowOutcome::Skipped { .. }
710            )
711    )
712}