Skip to main content

rustev_live/
reports.rs

1//! Repeat output assembly for completed live runs (spec 043, 3.11.2).
2use 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
24/// Optional pure evaluation inputs. The adapter remains host-owned behavior;
25/// its surface-layer document is not available to this crate.
26pub 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    // A run that ended without a judgment is evidence of the attempt, not a
82    // comparable outcome (3.11.3). The engine classifies a cancelled run as
83    // interrupted when it raised the signal; this covers any other path to
84    // a judgment-less record rather than failing the whole report.
85    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        // Run records retain output digests, not raw probability outputs.
98        // Their absence is reported as unknown by the observed evaluator.
99        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    // The first candidate in name order supplies the reference measurement.
265    // A single candidate also serves as its own separately named reference.
266    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    /// Stage and sync every file before publishing any. On a publication
310    /// failure remove only files published by this invocation. Existing files
311    /// are accepted only when identical, allowing a completed run to resume.
312    pub(crate) fn publish(&self, out: &Path) -> Result<(), LiveError> {
313        use std::io::Write;
314        let staging = out.join(".repeat-staging");
315        // A staging directory left by an ended process holds no evidence:
316        // the checkpoint and the per-case files are the record, and every
317        // staged file is rebuilt here. Remove it rather than refuse.
318        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}