1use std::collections::BTreeMap;
3use std::path::Path;
4
5use rustev_contract::eval_report::Split;
6use rustev_contract::ids::ContentDigest;
7use rustev_contract::judgment::Judgment;
8use rustev_contract::live::LiveReexecution;
9use rustev_contract::plan::Plan;
10use rustev_contract::run::{CoreEvidence, RunRecord};
11use rustev_contract::{Document, Identified};
12use rustev_eval::baseline;
13use rustev_eval::comparison::{Comparison, NamedCandidate, RepeatEvidence, compare_candidates};
14use rustev_eval::config::EvaluatorConfig;
15use rustev_eval::dataset::DatasetManifest;
16use rustev_eval::metrics::TaskAdapter;
17use rustev_eval::repeat::{RepeatInputs, RepeatReport, Run, Source, repeat};
18use rustev_eval::repeat_config::{RepeatConfig, TopK};
19use rustev_eval::report::{Observation, ObservedInputs, evaluate_observed_with_cases};
20
21use crate::documents::{ItemState, LiveCheckpoint, LiveError};
22use crate::run::{RunOptions, RunSpec};
23
24pub struct ReportInputs {
27 pub manifest: DatasetManifest,
28 pub split: Split,
29 pub config: EvaluatorConfig,
30 pub adapter: Box<dyn TaskAdapter>,
31 pub repeat_config: RepeatConfig,
32 pub top_k: Option<TopK>,
33}
34
35impl ReportInputs {
36 pub(crate) fn check(&self, spec: &RunSpec) -> Result<(), LiveError> {
37 self.config.check().map_err(report_error)?;
38 self.repeat_config.check().map_err(report_error)?;
39 let split = serde_json::to_value(self.split).map_err(report_error)?;
40 if self.manifest.id().map_err(report_error)? != spec.dataset_id
41 || split.as_str() != Some(spec.split.as_str())
42 || !self
43 .manifest
44 .split(self.split)
45 .map(|c| &c.id)
46 .eq(spec.cases.iter().map(|c| &c.case_id))
47 || self.top_k != self.repeat_config.top_k
48 {
49 return Err(report_error(
50 "report inputs differ from run or repeat configuration",
51 ));
52 }
53 Ok(())
54 }
55}
56
57fn report_error(e: impl std::fmt::Display) -> LiveError {
58 LiveError::invalid("repeat-reports", e)
59}
60
61pub(crate) fn completed(
62 live: &LiveReexecution,
63 run: &RunRecord,
64 plan: &Plan,
65 judgment: Option<&Judgment>,
66) -> Result<Observation, LiveError> {
67 let recorded = match &run.core {
68 CoreEvidence::Recorded(e) => Some(e.as_ref()),
69 CoreEvidence::NotProduced => None,
70 };
71 let judgment = judgment.or_else(|| recorded.map(|e| &e.judgment));
72 if run.decision_id != live.decision_id
73 || run.plan_id != live.plan_id
74 || plan.id().map_err(report_error)? != live.plan_id
75 || live.run_digest.as_ref() != Some(&run.record_digest().map_err(report_error)?)
76 || recorded
77 .is_some_and(|e| e.snapshot_id != live.snapshot_id || Some(&e.judgment) != judgment)
78 {
79 return Err(report_error("completed item identity mismatch"));
80 }
81 let Some(judgment) = judgment else {
86 return Ok(Observation::Incomparable {
87 reason: "live-cancelled".into(),
88 scope: Some(live.scope.clone()),
89 });
90 };
91 Ok(Observation::Completed {
92 judgment: judgment.clone(),
93 run: run.clone(),
94 plan: plan.clone(),
95 scope: live.scope.clone(),
96 snapshot_id: live.snapshot_id.clone(),
97 calibrations: vec![],
100 probability_output: None,
101 })
102}
103
104pub(crate) struct Reports {
105 pub reports: BTreeMap<String, RepeatReport>,
106 pub comparison: Comparison,
107 files: Vec<(String, Vec<u8>)>,
108}
109
110pub(crate) fn build(
111 inputs: &ReportInputs,
112 spec: &RunSpec,
113 opts: &RunOptions,
114 checkpoint: &LiveCheckpoint,
115 completed_items: &BTreeMap<usize, Result<Observation, LiveError>>,
116) -> Result<Reports, LiveError> {
117 let mut sets = BTreeMap::new();
118 let mut reports = BTreeMap::new();
119 let mut files = Vec::new();
120 for (name, compiled) in &spec.candidates {
121 let mut evaluated = Vec::new();
122 for r in 1..=spec.config.repeats {
123 let mut observations = BTreeMap::new();
124 for (i, item) in checkpoint
125 .items
126 .iter()
127 .enumerate()
128 .filter(|(_, i)| &i.candidate == name && i.repeat == r)
129 {
130 let observation = match item.states.last() {
131 Some(ItemState::Completed {
132 live_document_digest,
133 run_record_digest,
134 }) => {
135 if let Some(o) = completed_items.get(&i) {
136 o.clone()?
137 } else {
138 let suffix = item
139 .states
140 .iter()
141 .filter_map(|s| match s {
142 ItemState::Rerun { k } => Some(*k),
143 _ => None,
144 })
145 .max()
146 .map(|k| format!(".rerun-{k}"))
147 .unwrap_or_default();
148 let stem = format!("{name}.{r}.{}{suffix}", item.case_id);
149 let read = |ext| {
150 std::fs::read(
151 opts.out.join("cases").join(format!("{stem}.{ext}.json")),
152 )
153 .map_err(LiveError::io)
154 };
155 let live =
156 LiveReexecution::parse(&read("live")?).map_err(report_error)?;
157 let run = RunRecord::parse(&read("run")?).map_err(report_error)?;
158 if live.record_digest().map_err(report_error)? != *live_document_digest
159 || run.record_digest().map_err(report_error)? != *run_record_digest
160 {
161 return Err(report_error("retained case differs from checkpoint"));
162 }
163 completed(&live, &run, &compiled.plan, None)?
164 }
165 }
166 Some(ItemState::Unavailable { reason }) => Observation::Incomparable {
167 reason: reason.clone(),
168 scope: None,
169 },
170 Some(ItemState::Refused { code }) => Observation::Incomparable {
171 reason: code.clone(),
172 scope: None,
173 },
174 Some(ItemState::Interrupted { .. }) => Observation::Incomparable {
175 reason: "live-interrupted".into(),
176 scope: None,
177 },
178 _ => return Err(report_error("nonterminal work item")),
179 };
180 observations.insert(item.case_id.clone(), observation);
181 }
182 evaluated.push(
183 evaluate_observed_with_cases(
184 &ObservedInputs {
185 manifest: &inputs.manifest,
186 split: inputs.split,
187 config: &inputs.config,
188 adapter: inputs.adapter.as_ref(),
189 observations: &observations,
190 plan: &compiled.plan,
191 },
192 inputs.top_k.as_ref(),
193 )
194 .map_err(report_error)?,
195 );
196 }
197 let runs: Vec<_> = evaluated
198 .iter()
199 .map(|(evaluated, cases)| Run { evaluated, cases })
200 .collect();
201 let report = repeat(&RepeatInputs {
202 source: Source::Live,
203 config: &inputs.repeat_config,
204 manifest: &inputs.manifest,
205 runs: &runs,
206 })
207 .map_err(report_error)?;
208 for (file, bytes) in [
209 (
210 "dataset.json",
211 inputs.manifest.canonical().map_err(report_error)?,
212 ),
213 (
214 "config.json",
215 inputs.config.canonical().map_err(report_error)?,
216 ),
217 (
218 "repeat-config.json",
219 inputs.repeat_config.canonical().map_err(report_error)?,
220 ),
221 (
222 "repeat.json",
223 report.record_canonical().map_err(report_error)?,
224 ),
225 ] {
226 files.push((format!("{name}/{file}"), bytes));
227 }
228 for (r, (e, c)) in evaluated.iter().enumerate() {
229 for (kind, bytes) in [
230 ("report", e.report.record_canonical().map_err(report_error)?),
231 ("detail", e.detail.record_canonical().map_err(report_error)?),
232 ("cases", c.record_canonical().map_err(report_error)?),
233 ] {
234 files.push((format!("{name}/run-{r}.{kind}.json"), bytes));
235 }
236 }
237 reports.insert(name.clone(), report);
238 sets.insert(name.clone(), evaluated);
239 }
240 let runs: BTreeMap<_, Vec<_>> = sets
241 .iter()
242 .map(|(name, set)| {
243 (
244 name,
245 set.iter()
246 .map(|(evaluated, cases)| Run { evaluated, cases })
247 .collect(),
248 )
249 })
250 .collect();
251 let evidence = |name: &String| RepeatEvidence {
252 report: &reports[name],
253 inputs: RepeatInputs {
254 source: Source::Live,
255 config: &inputs.repeat_config,
256 manifest: &inputs.manifest,
257 runs: &runs[name],
258 },
259 };
260 let first = reports
261 .keys()
262 .next()
263 .ok_or_else(|| report_error("no candidates"))?;
264 let mut reference = first.clone();
267 if reports.len() == 1 {
268 reference = "baseline".into();
269 while reports.keys().any(|n| n.eq_ignore_ascii_case(&reference)) {
270 reference.push('_');
271 }
272 }
273 let base = evidence(first);
274 let baseline = baseline::record(
275 &reference,
276 &base.inputs,
277 base.report,
278 spec.probe_time.as_ms(),
279 )
280 .map_err(report_error)?;
281 let named: Vec<_> = reports
282 .keys()
283 .skip(usize::from(reports.len() > 1))
284 .map(|name| NamedCandidate {
285 name,
286 evidence: evidence(name),
287 })
288 .collect();
289 let comparison = compare_candidates(&baseline, &base, &named).map_err(report_error)?;
290 files.push((
291 "comparison.json".into(),
292 comparison.record_canonical().map_err(report_error)?,
293 ));
294 Ok(Reports {
295 reports,
296 comparison,
297 files,
298 })
299}
300
301impl Reports {
302 pub(crate) fn digests(&self) -> Result<Vec<ContentDigest>, LiveError> {
303 self.reports
304 .values()
305 .map(|r| r.record_digest().map_err(report_error))
306 .collect()
307 }
308
309 pub(crate) fn publish(&self, out: &Path) -> Result<(), LiveError> {
313 use std::io::Write;
314 let staging = out.join(".repeat-staging");
315 if staging.try_exists().map_err(LiveError::io)? {
319 std::fs::remove_dir_all(&staging).map_err(LiveError::io)?;
320 }
321 std::fs::create_dir(&staging).map_err(LiveError::io)?;
322 let mut published = Vec::new();
323 let mut directories = Vec::new();
324 let result = (|| {
325 for (name, bytes) in &self.files {
326 let path = staging.join(name);
327 std::fs::create_dir_all(path.parent().expect("parent")).map_err(LiveError::io)?;
328 let mut file = std::fs::File::create(&path).map_err(LiveError::io)?;
329 #[cfg(feature = "fault-injection")]
330 crate::fault_injection::point("staging", name, "before-write")
331 .map_err(LiveError::io)?;
332 file.write_all(bytes).map_err(LiveError::io)?;
333 #[cfg(feature = "fault-injection")]
334 crate::fault_injection::point("staging", name, "before-sync")
335 .map_err(LiveError::io)?;
336 file.sync_all().map_err(LiveError::io)?;
337 #[cfg(feature = "fault-injection")]
338 crate::fault_injection::point("staging", name, "after-sync")
339 .map_err(LiveError::io)?;
340 let target = out.join(name);
341 if target.exists() && std::fs::read(&target).map_err(LiveError::io)? != *bytes {
342 return Err(LiveError::io(
343 "repeat output already exists with different bytes",
344 ));
345 }
346 }
347 for (name, _) in &self.files {
348 let target = out.join(name);
349 if target.exists() {
350 continue;
351 }
352 let parent = target.parent().expect("parent");
353 if !parent.exists() {
354 std::fs::create_dir(parent).map_err(LiveError::io)?;
355 directories.push(parent.to_path_buf());
356 }
357 #[cfg(feature = "fault-injection")]
358 crate::fault_injection::point("publish", name, "before-rename")
359 .map_err(LiveError::io)?;
360 std::fs::rename(staging.join(name), &target).map_err(LiveError::io)?;
361 #[cfg(feature = "fault-injection")]
362 crate::fault_injection::point("publish", name, "after-rename")
363 .map_err(LiveError::io)?;
364 published.push(target);
365 }
366 Ok(())
367 })();
368 if result.is_err() {
369 for path in published.iter().rev() {
370 let _ = std::fs::remove_file(path);
371 }
372 for path in directories.iter().rev() {
373 let _ = std::fs::remove_dir(path);
374 }
375 }
376 let _ = std::fs::remove_dir_all(staging);
377 result
378 }
379}