Skip to main content

rustev_eval/
replay.rs

1//! Historical reproduction from a replay bundle (spec 004, 3.3).
2//!
3//! Scope first, before any byte is resolved; then capture completeness,
4//! lifetimes, bounds, integrity and every cross-reference; then the plan is
5//! loaded with its retained dependencies and the pure core is driven with
6//! the retained snapshot, the recorded evaluation time and the retained
7//! values in actual supply order. `reproduced` means the record-canonical
8//! judgment bytes equal the retained run record's; a valid, complete replay
9//! with other bytes is `diverged`; anything else is `incomparable` with a
10//! typed reason and location. No backend is called: there is none here.
11//!
12//! Nothing digests the bundle's own scope field: relabeling is caught
13//! because every supply's request identity is recomputed under the claimed
14//! scope. A bundle with no supplies has no identity to contradict a
15//! relabeled scope; a bundle is evidence of what the host retained, never
16//! an attestation (spec 004, 3.1.1).
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::fmt;
20
21use rustev_contract::calibration::CalibrationArtifact;
22use rustev_contract::descriptor::BackendDescriptor;
23use rustev_contract::ids::{ArtifactId, RequestId};
24use rustev_contract::judgment::{Judgment, Unresolved};
25use rustev_contract::output::{BackendOutputDoc, RawOutput};
26use rustev_contract::plan::{Plan, PlanExecution, PlanStepDetail};
27use rustev_contract::reason::RuntimeReasonDoc;
28use rustev_contract::replay::{
29    BundleError, CaptureStatus, ItemLocation, MAX_BUNDLE_ITEMS, ReplayBundle,
30};
31use rustev_contract::retention::Availability;
32use rustev_contract::run::{CoreEvidence, RequestResult, RunRecord, Termination};
33use rustev_contract::scope::Scope;
34use rustev_contract::snapshot::Snapshot;
35use rustev_contract::time::Timestamp;
36use rustev_contract::{Document, Identified, schema};
37use rustev_core::evaluate::{Evaluation, Supplied};
38use rustev_core::{Compiled, LoadError};
39
40use crate::acquisition_replay::{MissingAcquisition, PageValue, fill_missing, supply_page};
41
42use crate::resolve::{Budget, ItemFailure, Resolver, check_bytes, item_bytes};
43
44/// The caller's trusted replay configuration.
45#[derive(Debug, Clone)]
46pub struct ReplayConfig {
47    /// The scope the caller is entitled to replay under; it must equal the
48    /// bundle's exactly, mode included.
49    pub scope: Scope,
50    /// The caller's wall-clock time for expiry, in host milliseconds.
51    pub now_ms: Timestamp,
52    /// A host cap on bundle lifetime, tighter than the hard 30 days.
53    pub host_cap_ms: Option<u64>,
54}
55
56/// A cross-reference that does not hold.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum Inconsistency {
59    DecisionId,
60    PlanId,
61    SnapshotId,
62    EvaluationTime,
63    /// More supplies than the plan can issue.
64    TooManySupplies {
65        max: u64,
66    },
67    /// The entry's request is not pending when its turn comes.
68    NotPending,
69    /// The run record has no such request, or did not supply it.
70    NotInRun,
71    /// The entry's target or attempt is not the run record's.
72    Origin,
73    /// The target is not bound by the plan for this step.
74    UnknownTarget,
75    /// The recomputed request identity differs from the entry's.
76    RequestIdentity,
77    /// An output where the run supplied a reason, or the reverse, or a
78    /// reason other than the recorded one.
79    Value,
80    /// The output document names another step, instance or artifact.
81    OutputBinding,
82    /// The core refuses the retained output the run supplied.
83    OutputRefused,
84    /// A request the run supplied has no entry.
85    MissingSupply {
86        step: String,
87        instance: Vec<String>,
88    },
89    /// The requests were not listed in the recorded order.
90    RequestOrder,
91    /// The core refused to start on the retained snapshot.
92    StartRefused,
93    /// The core could not finish once every entry was supplied.
94    FinishRefused,
95    /// A judgment has no record canonical form.
96    NoRecordForm,
97}
98
99/// Why a case cannot be reproduced or compared.
100#[derive(Debug, Clone, PartialEq)]
101pub enum Incomparable {
102    AcquisitionNotCaptured {
103        missing: Vec<MissingAcquisition>,
104        judgment: Box<Judgment>,
105    },
106    ScopeMismatch,
107    InvalidBundle(BundleError),
108    CaptureIncomplete(CaptureStatus),
109    Unavailable {
110        location: ItemLocation,
111        availability: Availability,
112    },
113    Oversized {
114        location: ItemLocation,
115    },
116    /// The run was cancelled or produced no judgment.
117    NoExpectedJudgment,
118    CompilerChanged {
119        recorded_compiler: String,
120        current_compiler: String,
121        recorded_registry: String,
122        current_registry: String,
123    },
124    /// The plan binds a descriptor or calibration the bundle does not carry.
125    MissingDependency {
126        detail: String,
127    },
128    /// Same compiler, but the plan does not recompile identically.
129    PlanMismatch,
130    Inconsistent {
131        location: ItemLocation,
132        what: Inconsistency,
133    },
134}
135
136impl Incomparable {
137    /// A stable name for counting cases by reason.
138    pub fn code(&self) -> String {
139        match self {
140            Incomparable::AcquisitionNotCaptured { .. } => "acquisition-not-captured".into(),
141            Incomparable::ScopeMismatch => "scope-mismatch".into(),
142            Incomparable::InvalidBundle(_) => "invalid-bundle".into(),
143            Incomparable::CaptureIncomplete(_) => "capture-incomplete".into(),
144            Incomparable::Unavailable {
145                location: ItemLocation::AcquisitionPage(_),
146                availability: Availability::Missing,
147            } => "content-not-retained".into(),
148            Incomparable::Unavailable { availability, .. } => availability.name().into(),
149            Incomparable::Oversized { .. } => "oversized".into(),
150            Incomparable::NoExpectedJudgment => "no-expected-judgment".into(),
151            Incomparable::CompilerChanged { .. } => "compiler-changed".into(),
152            Incomparable::MissingDependency { .. } => "missing-dependency".into(),
153            Incomparable::PlanMismatch => "plan-mismatch".into(),
154            Incomparable::Inconsistent { .. } => "inconsistent".into(),
155        }
156    }
157}
158
159impl fmt::Display for Incomparable {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            Incomparable::Unavailable {
163                location,
164                availability,
165            } => write!(f, "{location} is {availability}"),
166            Incomparable::Oversized { location } => write!(f, "{location} is oversized"),
167            Incomparable::Inconsistent { location, what } => {
168                write!(f, "inconsistent at {location}: {what:?}")
169            }
170            Incomparable::InvalidBundle(e) => write!(f, "invalid bundle: {e}"),
171            other => f.write_str(&other.code()),
172        }
173    }
174}
175
176/// A retained value, as reproduction supplied it.
177#[derive(Debug, Clone, PartialEq)]
178pub enum RetainedValue {
179    Output {
180        artifact: ArtifactId,
181        output: RawOutput,
182    },
183    /// A historical runtime observation, never a backend output.
184    Reason(Unresolved),
185}
186
187/// One checked supply entry.
188#[derive(Debug, Clone, PartialEq)]
189pub struct RetainedSupply {
190    pub step: String,
191    pub instance: Vec<String>,
192    pub request: RequestId,
193    pub target: u32,
194    pub value: RetainedValue,
195}
196
197/// A case whose judgment reproduced byte for byte, with what candidate
198/// comparison needs. Historical timing, attempts and cost stay in the run
199/// record: copied observations, never remeasured. Only [`reproduce`]
200/// constructs one, so a candidate is only ever compared on a reproduced
201/// case (spec 004, 3.4.4).
202#[derive(Debug, Clone)]
203pub struct Reproduced {
204    pub(crate) decision_id: String,
205    pub(crate) scope: Scope,
206    pub(crate) plan: Plan,
207    pub(crate) calibrations: Vec<CalibrationArtifact>,
208    pub(crate) snapshot: Snapshot,
209    pub(crate) evaluation_time: Timestamp,
210    pub(crate) judgment: Judgment,
211    pub(crate) run: RunRecord,
212    pub(crate) supplies: Vec<RetainedSupply>,
213    pub(crate) pages: Vec<(rustev_contract::ids::AcquisitionRequestId, PageValue)>,
214}
215
216impl Reproduced {
217    pub fn decision_id(&self) -> &str {
218        &self.decision_id
219    }
220    pub fn scope(&self) -> &Scope {
221        &self.scope
222    }
223    /// The retained plan and its calibrations, as loaded.
224    pub fn plan(&self) -> &Plan {
225        &self.plan
226    }
227    pub fn calibrations(&self) -> &[CalibrationArtifact] {
228        &self.calibrations
229    }
230    pub fn snapshot(&self) -> &Snapshot {
231        &self.snapshot
232    }
233    pub fn evaluation_time(&self) -> Timestamp {
234        self.evaluation_time
235    }
236    /// The reproduced judgment, byte-equal to the retained one.
237    pub fn judgment(&self) -> &Judgment {
238        &self.judgment
239    }
240    /// The retained run record: historical observations.
241    pub fn run(&self) -> &RunRecord {
242        &self.run
243    }
244    /// The checked supply entries, in supply order.
245    pub fn supplies(&self) -> &[RetainedSupply] {
246        &self.supplies
247    }
248}
249
250#[derive(Debug, Clone)]
251pub enum ReplayOutcome {
252    Reproduced(Box<Reproduced>),
253    Diverged {
254        expected: Box<Judgment>,
255        actual: Box<Judgment>,
256    },
257    Incomparable(Incomparable),
258}
259
260fn inconsistent(location: ItemLocation, what: Inconsistency) -> Incomparable {
261    Incomparable::Inconsistent { location, what }
262}
263
264fn item_failure(location: ItemLocation, f: ItemFailure) -> Incomparable {
265    match f {
266        ItemFailure::Unavailable(availability) => Incomparable::Unavailable {
267            location,
268            availability,
269        },
270        ItemFailure::Oversized => Incomparable::Oversized { location },
271    }
272}
273
274/// Reproduce one retained decision.
275pub fn reproduce(
276    bundle: &ReplayBundle,
277    resolver: &dyn Resolver,
278    config: &ReplayConfig,
279) -> ReplayOutcome {
280    match reproduce_inner(bundle, resolver, config) {
281        Ok(o) => o,
282        Err(i) => ReplayOutcome::Incomparable(i),
283    }
284}
285
286struct Resolved {
287    plan: Plan,
288    descriptors: Vec<BackendDescriptor>,
289    calibrations: Vec<CalibrationArtifact>,
290    snapshot: Snapshot,
291    run: RunRecord,
292    values: Vec<Value>,
293    sources: Vec<rustev_contract::source::SourceDescriptor>,
294    pages: Vec<PageValue>,
295}
296
297enum Value {
298    Output(BackendOutputDoc),
299    Reason(RuntimeReasonDoc),
300}
301
302fn resolve_all(
303    bundle: &ReplayBundle,
304    resolver: &dyn Resolver,
305    now: Timestamp,
306) -> Result<Resolved, Incomparable> {
307    let mut budget = Budget::default();
308    let mut get = |location: ItemLocation, item| {
309        item_bytes(item, now, resolver, &mut budget)
310            .map_err(|f| item_failure(location, f))
311            .map(|b| (location, item, b))
312    };
313    fn typed<T: Document + PartialEq>(
314        (location, item, bytes): (
315            ItemLocation,
316            &rustev_contract::retention::RetentionItem,
317            Vec<u8>,
318        ),
319    ) -> Result<T, Incomparable> {
320        check_bytes::<T>(item, &bytes).map_err(|f| item_failure(location, f))
321    }
322    let plan: Plan = typed(get(ItemLocation::Plan, &bundle.plan)?)?;
323    let mut descriptors = vec![];
324    for (i, d) in bundle.descriptors.iter().enumerate() {
325        descriptors.push(typed(get(ItemLocation::Descriptor(i), d)?)?);
326    }
327    let mut calibrations = vec![];
328    for (i, c) in bundle.calibrations.iter().enumerate() {
329        calibrations.push(typed(get(ItemLocation::Calibration(i), c)?)?);
330    }
331    let snapshot: Snapshot = typed(get(ItemLocation::Snapshot, &bundle.snapshot)?)?;
332    let run: RunRecord = typed(get(ItemLocation::Run, &bundle.run)?)?;
333    let mut values = vec![];
334    for (i, s) in bundle.supplies.iter().enumerate() {
335        let location = ItemLocation::Supply(i);
336        let got = get(location, &s.value)?;
337        values.push(if s.value.document_schema == schema::RUNTIME_REASON {
338            let r: RuntimeReasonDoc = typed(got)?;
339            if r.check().is_err() {
340                return Err(Incomparable::Unavailable {
341                    location,
342                    availability: Availability::Mismatched,
343                });
344            }
345            Value::Reason(r)
346        } else {
347            Value::Output(typed(got)?)
348        });
349    }
350    let mut sources = vec![];
351    for (i, source) in bundle.sources.iter().flatten().enumerate() {
352        sources.push(typed(get(ItemLocation::Source(i), source)?)?);
353    }
354    let mut pages = vec![];
355    for (i, page) in bundle.acquisition_pages.iter().flatten().enumerate() {
356        let got = get(ItemLocation::AcquisitionPage(i), &page.value)?;
357        pages.push(if page.value.document_schema == schema::RUNTIME_REASON {
358            PageValue::Reason(typed::<RuntimeReasonDoc>(got)?.reason)
359        } else {
360            PageValue::Page(typed(got)?)
361        });
362    }
363    Ok(Resolved {
364        sources,
365        pages,
366        plan,
367        descriptors,
368        calibrations,
369        snapshot,
370        run,
371        values,
372    })
373}
374
375/// The target binding of a step: artifact of target 0 or of a fallback.
376fn target_artifact(plan: &Plan, step: &str, target: u32) -> Option<ArtifactId> {
377    let Some(PlanStepDetail::Semantic(b)) =
378        plan.steps.iter().find(|s| s.id == step).map(|s| &s.detail)
379    else {
380        return None;
381    };
382    if target == 0 {
383        return Some(b.artifact.clone());
384    }
385    match &plan.execution {
386        PlanExecution::Declared(d) => d
387            .fallbacks
388            .iter()
389            .find(|f| f.step == step && f.target == target)
390            .map(|f| f.artifact.clone()),
391        PlanExecution::None => None,
392    }
393}
394
395/// Append newly pending requests in the core's listing order.
396fn list_new(
397    ev: &Evaluation<'_>,
398    listed: &mut Vec<(String, Vec<String>)>,
399    seen: &mut BTreeSet<(String, Vec<String>)>,
400) {
401    for r in ev.pending() {
402        let key = (r.step.clone(), r.instance.clone());
403        if seen.insert(key.clone()) {
404            listed.push(key);
405        }
406    }
407}
408
409fn reproduce_inner(
410    bundle: &ReplayBundle,
411    resolver: &dyn Resolver,
412    config: &ReplayConfig,
413) -> Result<ReplayOutcome, Incomparable> {
414    // 1. Scope, before anything is resolved.
415    if config.scope != bundle.scope {
416        return Err(Incomparable::ScopeMismatch);
417    }
418    bundle
419        .check(config.host_cap_ms)
420        .map_err(Incomparable::InvalidBundle)?;
421    if bundle.capture != CaptureStatus::Complete {
422        return Err(Incomparable::CaptureIncomplete(bundle.capture));
423    }
424    // 2. Every retained document, under its own limits and the case cap.
425    let r = resolve_all(bundle, resolver, config.now_ms)?;
426    let run = &r.run;
427    let run_at = ItemLocation::Run;
428    if run.decision_id != bundle.decision_id {
429        return Err(inconsistent(run_at, Inconsistency::DecisionId));
430    }
431    if run.plan_id != bundle.plan_id {
432        return Err(inconsistent(run_at, Inconsistency::PlanId));
433    }
434    let evidence = match (&run.termination, &run.core) {
435        (Termination::Judged, CoreEvidence::Recorded(e)) => e,
436        _ => return Err(Incomparable::NoExpectedJudgment),
437    };
438    if evidence.decision_id != bundle.decision_id {
439        return Err(inconsistent(run_at, Inconsistency::DecisionId));
440    }
441    if evidence.plan_id != bundle.plan_id || evidence.judgment.plan_id != bundle.plan_id {
442        return Err(inconsistent(run_at, Inconsistency::PlanId));
443    }
444    if evidence.snapshot_id != bundle.snapshot_id {
445        return Err(inconsistent(run_at, Inconsistency::SnapshotId));
446    }
447    if evidence.evaluation_time_ms != bundle.evaluation_time_ms {
448        return Err(inconsistent(run_at, Inconsistency::EvaluationTime));
449    }
450    if r.plan.id().ok().as_ref() != Some(&bundle.plan_id) {
451        return Err(inconsistent(ItemLocation::Plan, Inconsistency::PlanId));
452    }
453    if r.snapshot.id().ok().as_ref() != Some(&bundle.snapshot_id) {
454        return Err(inconsistent(
455            ItemLocation::Snapshot,
456            Inconsistency::SnapshotId,
457        ));
458    }
459    let max = r
460        .plan
461        .definition
462        .limits
463        .max_semantic_requests
464        .min(MAX_BUNDLE_ITEMS as u64);
465    if bundle.supplies.len() as u64 > max {
466        return Err(inconsistent(
467            ItemLocation::Bundle,
468            Inconsistency::TooManySupplies { max },
469        ));
470    }
471    let max_pages = r
472        .plan
473        .definition
474        .limits
475        .acquisition
476        .as_ref()
477        .map_or(0, |l| l.max_calls);
478    if r.pages.len() as u64 > max_pages {
479        return Err(inconsistent(
480            ItemLocation::Bundle,
481            Inconsistency::TooManySupplies { max: max_pages },
482        ));
483    }
484    // 3. The plan, with its retained dependencies only.
485    let compiled =
486        Compiled::load_checked_with_sources(&r.plan, &r.descriptors, &r.calibrations, &r.sources)
487            .map_err(|e| match e {
488            LoadError::CompilerChanged {
489                recorded_compiler,
490                current_compiler,
491                recorded_registry,
492                current_registry,
493            } => Incomparable::CompilerChanged {
494                recorded_compiler,
495                current_compiler: current_compiler.into(),
496                recorded_registry,
497                current_registry: current_registry.into(),
498            },
499            e @ (LoadError::MissingDescriptor { .. } | LoadError::MissingCalibration { .. }) => {
500                Incomparable::MissingDependency {
501                    detail: e.to_string(),
502                }
503            }
504            LoadError::PlanMismatch { .. } => Incomparable::PlanMismatch,
505        })?;
506    // 4. The pure core, fed in actual supply order.
507    let mut ev = Evaluation::start(&compiled, &r.snapshot, bundle.evaluation_time_ms)
508        .map_err(|_| inconsistent(ItemLocation::Snapshot, Inconsistency::StartRefused))?;
509    let by_key: BTreeMap<(&str, &[String]), usize> = run
510        .requests
511        .iter()
512        .enumerate()
513        .map(|(i, q)| ((q.step.as_str(), q.instance.as_slice()), i))
514        .collect();
515    let mut listed = vec![];
516    let mut seen = BTreeSet::new();
517    list_new(&ev, &mut listed, &mut seen);
518    let mut supplies = vec![];
519    let pages = bundle.acquisition_pages.as_deref().unwrap_or(&[]);
520    let mut missing = vec![];
521    let (mut semantic_index, mut page_index) = (0, 0);
522    loop {
523        fill_missing(&mut ev, &pages[page_index..], &mut missing)?;
524        list_new(&ev, &mut listed, &mut seen);
525        let semantic = bundle.supplies.get(semantic_index);
526        let page = pages.get(page_index);
527        if let Some(page) = page
528            && semantic.is_none_or(|s| page.sequence < s.sequence)
529        {
530            // Missing pages can make a later retained tier unreachable.
531            if missing.is_empty()
532                || ev.pending_acquisitions().iter().any(|p| {
533                    p.step == page.step && p.tier_index == page.tier && p.page_index == page.page
534                })
535            {
536                supply_page(&mut ev, page, &r.pages[page_index], run, page_index)?;
537            }
538            page_index += 1;
539            continue;
540        }
541        let Some(entry) = semantic else {
542            break;
543        };
544        let i = semantic_index;
545        let value = &r.values[i];
546        semantic_index += 1;
547        if !missing.is_empty()
548            && !ev
549                .pending()
550                .iter()
551                .any(|p| p.step == entry.step && p.instance == entry.instance)
552        {
553            continue;
554        }
555        let at = ItemLocation::Supply(i);
556        let bad = |what| Err(inconsistent(at, what));
557        let Some(q) = by_key
558            .get(&(entry.step.as_str(), entry.instance.as_slice()))
559            .map(|&k| &run.requests[k])
560        else {
561            return bad(Inconsistency::NotInRun);
562        };
563        let Some(from) = q.supplied_from() else {
564            return bad(Inconsistency::NotInRun);
565        };
566        // An output reused from a cache entry names the earlier attempt that
567        // produced it; the record itself made no attempt (spec 017, 3.8).
568        let origin = from
569            .attempt
570            .map(|a| a.attempt_id.as_str())
571            .or(from.reused_from);
572        let output_target = from
573            .attempt
574            .map(|a| a.target)
575            .or(from.reused_from.map(|_| from.target));
576        if from.target != entry.target
577            || origin != entry.attempt_id.as_deref()
578            || matches!(q.result, RequestResult::Output { .. })
579                && output_target != Some(entry.target)
580        {
581            return bad(Inconsistency::Origin);
582        }
583        let doc =
584            match ev.request_document(&entry.step, &entry.instance, entry.target, &bundle.scope) {
585                Ok(d) => d,
586                Err(rustev_core::RequestIdentityError::UnknownTarget { .. }) => {
587                    return bad(Inconsistency::UnknownTarget);
588                }
589                Err(_) => return bad(Inconsistency::NotPending),
590            };
591        if doc.id().ok().as_ref() != Some(&entry.request) {
592            return bad(Inconsistency::RequestIdentity);
593        }
594        let (supplied, retained) = match (value, &q.result) {
595            (Value::Output(o), RequestResult::Output { target }) if *target == entry.target => {
596                let bound = target_artifact(&r.plan, &entry.step, entry.target);
597                if o.step != entry.step
598                    || o.instance != entry.instance
599                    || bound.as_ref() != Some(&o.artifact)
600                    || from.attempt.is_some_and(|a| a.artifact != o.artifact)
601                {
602                    return bad(Inconsistency::OutputBinding);
603                }
604                // The run supplied it, so the same core accepts it.
605                match ev.check_output(&entry.step, &entry.instance, &o.output) {
606                    Ok(Ok(())) => {}
607                    _ => return bad(Inconsistency::OutputRefused),
608                }
609                (
610                    Supplied::Output(o.output.clone()),
611                    RetainedValue::Output {
612                        artifact: o.artifact.clone(),
613                        output: o.output.clone(),
614                    },
615                )
616            }
617            (Value::Reason(d), RequestResult::Failed(u)) if &d.reason == u => (
618                Supplied::Failed(d.reason.clone()),
619                RetainedValue::Reason(d.reason.clone()),
620            ),
621            _ => return bad(Inconsistency::Value),
622        };
623        if let Some(context) = entry.failure_context {
624            ev.note_failure(
625                &entry.step,
626                &entry.instance,
627                rustev_core::FailureContext {
628                    origin: context.origin,
629                    determinism: context.determinism,
630                },
631            );
632        }
633        if ev.supply(&entry.step, &entry.instance, supplied).is_err() {
634            return bad(Inconsistency::NotPending);
635        }
636        list_new(&ev, &mut listed, &mut seen);
637        supplies.push(RetainedSupply {
638            step: entry.step.clone(),
639            instance: entry.instance.clone(),
640            request: entry.request.clone(),
641            target: entry.target,
642            value: retained,
643        });
644    }
645    if let Some(p) = ev.pending().first() {
646        return Err(inconsistent(
647            ItemLocation::Bundle,
648            Inconsistency::MissingSupply {
649                step: p.step.clone(),
650                instance: p.instance.clone(),
651            },
652        ));
653    }
654    let recorded: Vec<(String, Vec<String>)> = run
655        .requests
656        .iter()
657        .map(|q| (q.step.clone(), q.instance.clone()))
658        .collect();
659    if missing.is_empty() && recorded != listed {
660        return Err(inconsistent(run_at, Inconsistency::RequestOrder));
661    }
662    // 5. The verdict.
663    let (judgment, _) = ev
664        .finish(&bundle.decision_id)
665        .map_err(|_| inconsistent(ItemLocation::Bundle, Inconsistency::FinishRefused))?;
666    if !missing.is_empty() {
667        return Err(Incomparable::AcquisitionNotCaptured {
668            missing,
669            judgment: Box::new(judgment),
670        });
671    }
672    let expected = &evidence.judgment;
673    let same = match (judgment.record_canonical(), expected.record_canonical()) {
674        (Ok(a), Ok(b)) => a == b,
675        _ => {
676            return Err(inconsistent(ItemLocation::Run, Inconsistency::NoRecordForm));
677        }
678    };
679    if !same {
680        return Ok(ReplayOutcome::Diverged {
681            expected: Box::new(expected.clone()),
682            actual: Box::new(judgment),
683        });
684    }
685    Ok(ReplayOutcome::Reproduced(Box::new(Reproduced {
686        decision_id: bundle.decision_id.clone(),
687        scope: bundle.scope.clone(),
688        plan: r.plan.clone(),
689        calibrations: r.calibrations.clone(),
690        snapshot: r.snapshot,
691        evaluation_time: bundle.evaluation_time_ms,
692        judgment,
693        run: r.run.clone(),
694        supplies,
695        pages: pages
696            .iter()
697            .zip(&r.pages)
698            .map(|(p, v)| (p.request.clone(), v.clone()))
699            .collect(),
700    })))
701}