Skip to main content

rustev_eval/
compare.rs

1//! Candidate comparison over a reproduced case (spec 004, 3.4.4 to 3.4.7).
2//!
3//! A candidate plan runs against the case's snapshot, evaluation time and
4//! scope. A retained raw output is reused only for a candidate primary
5//! request whose identity equals the one it was produced under, and only
6//! when every retained output with that identity agrees. Historical runtime
7//! failures are never reused to predict a candidate's failure, and fallback
8//! execution is not simulated: a request without an equivalent successful
9//! output makes the case incomparable.
10
11use std::collections::BTreeMap;
12
13use crate::acquisition_replay::{MissingAcquisition, PageValue};
14use rustev_contract::canonical::record_canonical_bytes;
15use rustev_contract::ids::RequestId;
16use rustev_contract::judgment::Judgment;
17use rustev_contract::output::RawOutput;
18use rustev_contract::{Document, Identified};
19use rustev_core::Compiled;
20use rustev_core::evaluate::{Evaluation, Supplied};
21
22use crate::replay::{Reproduced, RetainedValue};
23
24/// Why a candidate cannot be compared on this case.
25#[derive(Debug, Clone, PartialEq)]
26pub enum CandidateIncomparable {
27    AcquisitionNotCaptured {
28        missing: Vec<MissingAcquisition>,
29        judgment: Box<Judgment>,
30    },
31    /// The candidate refuses the case's snapshot at its evaluation time.
32    SnapshotRefused { detail: String },
33    /// No retained output was produced under this request's identity.
34    RequestMismatch { step: String, instance: Vec<String> },
35    /// Only historical runtime failures share this identity.
36    HistoricalRuntimeFailure { step: String, instance: Vec<String> },
37    /// Retained outputs with this identity disagree.
38    AmbiguousOutput { step: String, instance: Vec<String> },
39    /// The candidate's core refuses the reused output; no failure is
40    /// substituted for it.
41    OutputRefused { step: String, instance: Vec<String> },
42}
43
44impl CandidateIncomparable {
45    pub fn code(&self) -> &'static str {
46        match self {
47            CandidateIncomparable::AcquisitionNotCaptured { .. } => "acquisition-not-captured",
48            CandidateIncomparable::SnapshotRefused { .. } => "snapshot-refused",
49            CandidateIncomparable::RequestMismatch { .. } => "request-mismatch",
50            CandidateIncomparable::HistoricalRuntimeFailure { .. } => "historical-runtime-failure",
51            CandidateIncomparable::AmbiguousOutput { .. } => "ambiguous-output",
52            CandidateIncomparable::OutputRefused { .. } => "output-refused",
53        }
54    }
55}
56
57/// A candidate's judgment beside the reproduced historical one. Agreement
58/// is the task adapter's to define; whole-judgment equality is not it.
59#[derive(Debug, Clone)]
60pub struct Compared {
61    pub historical: Judgment,
62    pub candidate: Judgment,
63    /// Requests the candidate issued, each served by a reused output.
64    pub reused: usize,
65    /// What was supplied to the candidate, by step and instance.
66    pub supplied: Vec<(String, Vec<String>, RawOutput)>,
67}
68
69/// Run `candidate` over the reproduced `case`, reusing retained outputs by
70/// exact request identity only.
71pub fn compare(case: &Reproduced, candidate: &Compiled) -> Result<Compared, CandidateIncomparable> {
72    let mut pool: BTreeMap<&RequestId, Vec<&RetainedValue>> = BTreeMap::new();
73    for s in &case.supplies {
74        pool.entry(&s.request).or_default().push(&s.value);
75    }
76    let mut ev =
77        Evaluation::start(candidate, &case.snapshot, case.evaluation_time).map_err(|e| {
78            CandidateIncomparable::SnapshotRefused {
79                detail: e.to_string(),
80            }
81        })?;
82    let mut reused = 0;
83    let mut supplied = vec![];
84    let mut missing = vec![];
85    loop {
86        let pages = ev.pending_acquisitions();
87        let had_pages = !pages.is_empty();
88        for p in pages {
89            let outputs: Vec<_> = case
90                .pages
91                .iter()
92                .filter_map(|(id, v)| match v {
93                    PageValue::Page(page) if id == &p.request_id => Some(page),
94                    _ => None,
95                })
96                .collect();
97            if let Some(first) = outputs.first() {
98                let bytes =
99                    first
100                        .record_canonical()
101                        .map_err(|_| CandidateIncomparable::OutputRefused {
102                            step: p.step.clone(),
103                            instance: vec![],
104                        })?;
105                if outputs
106                    .iter()
107                    .any(|v| v.record_canonical().ok().as_ref() != Some(&bytes))
108                {
109                    return Err(CandidateIncomparable::AmbiguousOutput {
110                        step: p.step,
111                        instance: vec![],
112                    });
113                }
114                ev.supply_page(&p.step, p.tier_index, p.page_index, &bytes)
115                    .map_err(|_| CandidateIncomparable::OutputRefused {
116                        step: p.step.clone(),
117                        instance: vec![],
118                    })?;
119                reused += 1;
120            } else {
121                ev.supply_acquisition_failure(
122                    &p.step,
123                    p.tier_index,
124                    p.page_index,
125                    rustev_contract::judgment::Unresolved::MissingEvidence {
126                        fields: vec![p.step.clone()],
127                    },
128                )
129                .map_err(|_| CandidateIncomparable::OutputRefused {
130                    step: p.step.clone(),
131                    instance: vec![],
132                })?;
133                missing.push(MissingAcquisition {
134                    step: p.step,
135                    tier: p.tier_index,
136                    page: p.page_index,
137                    request: p.request_id,
138                });
139            }
140        }
141        let pending = ev.pending();
142        if pending.is_empty() {
143            if had_pages {
144                continue;
145            }
146            break;
147        }
148        for r in pending {
149            let (step, instance) = (r.step.clone(), r.instance.clone());
150            let id = ev
151                .request_document(&r.step, &r.instance, 0, &case.scope)
152                .ok()
153                .and_then(|d| d.id().ok());
154            let retained = id.as_ref().and_then(|i| pool.get(i));
155            let outputs: Vec<&RawOutput> = retained
156                .into_iter()
157                .flatten()
158                .filter_map(|v| match v {
159                    RetainedValue::Output { output, .. } => Some(output),
160                    RetainedValue::Reason(_) => None,
161                })
162                .collect();
163            let Some(first) = outputs.first() else {
164                return Err(if retained.is_some_and(|v| !v.is_empty()) {
165                    CandidateIncomparable::HistoricalRuntimeFailure { step, instance }
166                } else {
167                    CandidateIncomparable::RequestMismatch { step, instance }
168                });
169            };
170            if !agree(retained.into_iter().flatten()) {
171                return Err(CandidateIncomparable::AmbiguousOutput { step, instance });
172            }
173            // The core validates the rebound raw output as the candidate's;
174            // a refusal is never turned into a supplied failure.
175            if !matches!(ev.check_output(&step, &instance, first), Ok(Ok(()))) {
176                return Err(CandidateIncomparable::OutputRefused { step, instance });
177            }
178            ev.supply(&step, &instance, Supplied::Output((*first).clone()))
179                .map_err(|_| CandidateIncomparable::RequestMismatch {
180                    step: step.clone(),
181                    instance: instance.clone(),
182                })?;
183            reused += 1;
184            supplied.push((step, instance, (*first).clone()));
185        }
186    }
187    let (candidate, _) = ev
188        .finish(&case.decision_id)
189        .expect("every pending request was supplied");
190    if !missing.is_empty() {
191        return Err(CandidateIncomparable::AcquisitionNotCaptured {
192            missing,
193            judgment: Box::new(candidate),
194        });
195    }
196    Ok(Compared {
197        historical: case.judgment.clone(),
198        candidate,
199        reused,
200        supplied,
201    })
202}
203
204/// Whether every retained output agrees: equal record-canonical bytes of the
205/// producing artifact and raw output, never of the whole output document.
206fn agree<'a>(values: impl Iterator<Item = &'a &'a RetainedValue>) -> bool {
207    let mut first: Option<Vec<u8>> = None;
208    for v in values {
209        let RetainedValue::Output { artifact, output } = v else {
210            continue;
211        };
212        let Ok(bytes) = record_canonical_bytes(&(artifact.clone(), output.clone())) else {
213            return false;
214        };
215        match &first {
216            None => first = Some(bytes),
217            Some(f) if *f == bytes => {}
218            Some(_) => return false,
219        }
220    }
221    true
222}