Skip to main content

rustev_live/
run.rs

1//! Dataset-scale execution over resolved inputs (spec 043).
2
3use std::cell::Cell;
4use std::collections::{BTreeMap, BTreeSet};
5use std::future::{Future, poll_fn};
6use std::path::PathBuf;
7use std::pin::Pin;
8use std::task::Poll;
9
10use rustev_contract::ids::{ContentDigest, DatasetId, SnapshotId};
11use rustev_contract::live::{AuthorizationOutcome, HistoricalLink};
12use rustev_contract::replay::ReplayBundle;
13use rustev_contract::scope::{Handle, Scope};
14use rustev_contract::snapshot::Snapshot;
15use rustev_contract::{Document, Identified, Timestamp};
16use rustev_core::Compiled;
17use rustev_core::seams::CancelSignal;
18use rustev_runtime::{
19    Clock, Completion, LiveAuthorizer, LiveRefusal, LiveRequest, LiveResult, LiveSources,
20    LiveSpend, Runtime, SourceOutcome,
21};
22use serde::{Deserialize, Serialize};
23
24use crate::JournalGate;
25use crate::documents::*;
26use crate::journal::{RunSpend, SpendJournal, SpendSummary};
27use crate::probe::{self, ProbeInputs, ProbeReport, ProbeSnapshot, ProbeStatus};
28
29pub use crate::reports::ReportInputs;
30
31/// Host inputs for spec 026 comparison, used only after live execution.
32pub struct SourceComparison {
33    pub adapter: rustev_eval::dataset::AdapterRef,
34    pub agreement: rustev_eval::config::Agreement,
35    /// Called after each live result for trusted scope and current host time.
36    pub replay: Box<dyn Fn() -> rustev_eval::replay::ReplayConfig>,
37    pub resolver: Box<dyn rustev_eval::resolve::Resolver>,
38}
39
40pub struct RunSpec {
41    /// Host-resolved retained page documents, also used by the synthetic probe.
42    pub acquisition_pages: Vec<rustev_contract::acquisition::AcquisitionPage>,
43    pub source_comparison: Option<SourceComparison>,
44    pub reports: Option<ReportInputs>,
45    pub config: LiveConfig,
46    pub mode: Mode,
47    pub run_id: String,
48    pub dataset_id: DatasetId,
49    pub split: String,
50    pub candidates: BTreeMap<String, Compiled>,
51    pub cases: Vec<LiveCase>,
52    pub probe_snapshot: ProbeSnapshot,
53    pub probe_time: Timestamp,
54    pub principal_handle: Vec<u8>,
55    pub subject: Handle,
56    pub scope: Scope,
57    pub input_digests: BTreeMap<String, ContentDigest>,
58}
59
60pub struct LiveCase {
61    pub case_id: String,
62    pub input: CaseInput,
63}
64
65/// Dataset identity and split come from `RunSpec`; bundles retain their source link.
66pub enum CaseOrigin {
67    DatasetCase,
68    ReplayBundle {
69        bundle_digest: ContentDigest,
70        decision_id: String,
71    },
72}
73
74pub enum CaseInput {
75    Available {
76        source: CaseOrigin,
77        /// Retained source for optional offline comparison, never live supplies.
78        bundle: Option<Box<ReplayBundle>>,
79        snapshot_bytes: Vec<u8>,
80        snapshot_id: SnapshotId,
81        source_time: Option<Timestamp>,
82        scope: Option<Scope>,
83    },
84    Unavailable {
85        reason: String,
86    },
87}
88
89#[derive(Debug, Clone)]
90pub struct RunOptions {
91    pub out: PathBuf,
92    pub resume: bool,
93    pub rerun_interrupted: bool,
94    pub probe_only: bool,
95    pub run_deadline_ms: Option<u64>,
96}
97
98pub struct PreparedRun {
99    spec: RunSpec,
100    opts: RunOptions,
101    snapshots: BTreeMap<String, Snapshot>,
102    checkpoint: LiveCheckpoint,
103    completed: BTreeMap<usize, Result<rustev_eval::report::Observation, LiveError>>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum RunStatus {
109    EvaluatedLive,
110    Probed,
111    ProbeFailed,
112    SpendCapped,
113    Interrupted,
114}
115
116/// Exit numbers are provisional under spec 043, 3.14; the status names never change.
117pub fn exit_code(status: RunStatus) -> u8 {
118    match status {
119        RunStatus::EvaluatedLive | RunStatus::Probed => 0,
120        RunStatus::SpendCapped => 14,
121        RunStatus::ProbeFailed => 15,
122        RunStatus::Interrupted => 16,
123    }
124}
125
126#[derive(Debug, Serialize)]
127pub struct RunOutcome {
128    pub live_run: LiveRun,
129    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
130    pub repeat_reports: BTreeMap<String, rustev_eval::repeat::RepeatReport>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub comparison: Option<rustev_eval::comparison::Comparison>,
133    pub status: RunStatus,
134    pub probe: ProbeReport,
135    /// Base decision id and last checkpoint state, in manifest order.
136    /// Interrupted items have the reporting reason `live-interrupted`.
137    pub items: Vec<(String, ItemState)>,
138    pub run_spend: RunSpend,
139    pub journal_before: SpendSummary,
140    pub journal_after: SpendSummary,
141}
142
143/// Validate all resolved inputs before the caller opens a journal.
144pub fn prepare(mut spec: RunSpec, opts: RunOptions) -> Result<PreparedRun, LiveError> {
145    // Spec 043 3.3.5: live sends of final_test cases are refused, because the
146    // engine counts no exposure a host could record under spec 023 3.2.
147    if spec.split == "final_test" {
148        return Err(LiveError::invalid(
149            "live-config",
150            "final_test cases are not sent live",
151        ));
152    }
153    probe::check_acquisitions(&spec.config, &spec.candidates)?;
154    for (i, page) in spec.acquisition_pages.iter().enumerate() {
155        let key = format!("live-acquisition-capture/{i}");
156        if spec
157            .input_digests
158            .insert(key, page.record_digest().map_err(LiveError::io)?)
159            .is_some()
160        {
161            return Err(LiveError::invalid(
162                "live-config",
163                "reserved capture digest key",
164            ));
165        }
166    }
167    if let Some(reports) = &spec.reports {
168        reports.check(&spec)?;
169    }
170    if spec.config.compare_with_source {
171        let context = spec.source_comparison.as_ref().ok_or_else(|| {
172            LiveError::invalid("live-config", "source comparison context is required")
173        })?;
174        use rustev_contract::live::{MAX_NOTES, MAX_TEXT_BYTES};
175        if context.adapter.name.is_empty()
176            || context.adapter.version.is_empty()
177            || context.agreement.params.len() > MAX_NOTES
178            || [&context.adapter.name, &context.adapter.version]
179                .into_iter()
180                .chain(&context.agreement.params)
181                .any(|s| s.len() > MAX_TEXT_BYTES)
182        {
183            return Err(LiveError::invalid(
184                "live-config",
185                "invalid source comparison definition",
186            ));
187        }
188        for case in &spec.cases {
189            match &case.input {
190                CaseInput::Available {
191                    source:
192                        CaseOrigin::ReplayBundle {
193                            bundle_digest,
194                            decision_id,
195                        },
196                    bundle: Some(bundle),
197                    snapshot_id,
198                    ..
199                } => {
200                    let link = HistoricalLink::of(bundle)
201                        .map_err(|e| LiveError::invalid("live-config", e))?;
202                    if link.bundle_digest != *bundle_digest
203                        || link.decision_id != *decision_id
204                        || link.snapshot_id != *snapshot_id
205                    {
206                        return Err(LiveError::invalid(
207                            "live-config",
208                            "source bundle identity differs",
209                        ));
210                    }
211                }
212                // An unavailable bundle is that case's own outcome (spec 015),
213                // never a reason to refuse the run.
214                CaseInput::Unavailable { .. } => {}
215                _ => {
216                    return Err(LiveError::invalid(
217                        "live-config",
218                        "source comparison requires bundle inputs",
219                    ));
220                }
221            }
222        }
223    }
224    let reached: Candidates = spec
225        .candidates
226        .iter()
227        .map(|(n, c)| (n.clone(), probe::reached(c).into_iter().collect()))
228        .collect();
229    spec.config.check(spec.mode, &reached)?;
230    let mut items = Vec::new();
231    for repeat in 1..=spec.config.repeats {
232        for candidate in spec.candidates.keys() {
233            for case in &spec.cases {
234                decision_id(&spec.run_id, candidate, repeat, &case.case_id, None)?;
235                items.push(CheckpointItem {
236                    candidate: candidate.clone(),
237                    repeat,
238                    case_id: case.case_id.clone(),
239                    states: vec![ItemState::Pending],
240                });
241            }
242        }
243    }
244    let mut snapshots = BTreeMap::new();
245    let mut names = BTreeSet::new();
246    for case in &spec.cases {
247        if !names.insert(&case.case_id) {
248            return Err(LiveError::invalid("live-config", "duplicate case id"));
249        }
250        if let CaseInput::Available {
251            snapshot_bytes,
252            snapshot_id,
253            source_time,
254            ..
255        } = &case.input
256        {
257            let snapshot = Snapshot::parse(snapshot_bytes)
258                .map_err(|e| LiveError::invalid("identity-mismatch", e))?;
259            if snapshot
260                .id()
261                .map_err(|e| LiveError::invalid("identity-mismatch", e))?
262                != *snapshot_id
263            {
264                return Err(LiveError::invalid(
265                    "identity-mismatch",
266                    "case snapshot digest differs",
267                ));
268            }
269            if spec.config.evaluation_time == EvaluationTime::Source && source_time.is_none() {
270                return Err(LiveError::invalid(
271                    "evaluation-time-unavailable",
272                    &case.case_id,
273                ));
274            }
275            snapshots.insert(case.case_id.clone(), snapshot);
276        }
277    }
278    if spec.probe_snapshot.id() != &spec.config.probe.digest {
279        return Err(LiveError::invalid(
280            "probe-snapshot",
281            "the probe snapshot is not the one probe.digest names",
282        ));
283    }
284    if spec.cases.iter().any(|c| {
285        matches!(&c.input, CaseInput::Available { snapshot_id, .. } if snapshot_id == spec.probe_snapshot.id())
286    }) {
287        return Err(LiveError::invalid(
288            "probe-snapshot",
289            "the probe snapshot is a case snapshot",
290        ));
291    }
292    let bindings = CheckpointBindings {
293        run_id: spec.run_id.clone(),
294        live_config_id: spec.config.id().map_err(LiveError::io)?,
295        dataset_id: spec.dataset_id.clone(),
296        split: spec.split.clone(),
297        candidates: spec
298            .candidates
299            .iter()
300            .map(|(name, c)| CandidateBinding {
301                name: name.clone(),
302                plan_id: c.id.clone(),
303                bindings: reached[name].clone(),
304            })
305            .collect(),
306        journal_header_digest: SpendJournal::testing_header_digest(),
307        input_digests: spec.input_digests.clone(),
308    };
309    let mut checkpoint = LiveCheckpoint {
310        schema: LiveCheckpoint::SCHEMA.into(),
311        bindings,
312        items,
313    };
314    checkpoint.check()?;
315    let path = opts.out.join("checkpoint.json");
316    if opts.resume {
317        if !path.try_exists().map_err(LiveError::io)? {
318            return Err(LiveError {
319                status: "io_error",
320                subject: "checkpoint",
321                detail: "no checkpoint exists; nothing was sent because the journal is opened only after the checkpoint exists; a fresh run without resume is safe after removing --out or choosing a new --out".into(),
322            });
323        }
324        let saved = match LiveCheckpoint::read(&path, &checkpoint.bindings) {
325            Ok(saved) => saved,
326            // A checkpoint bound to the testing header under the superseded
327            // cap names the same journal before its header migration (A-75):
328            // accept it and bind the current header from now on.
329            Err(e) if e.subject == "checkpoint-mismatch" => {
330                let mut legacy = checkpoint.bindings.clone();
331                legacy.journal_header_digest = SpendJournal::superseded_testing_header_digest();
332                let mut saved = LiveCheckpoint::read(&path, &legacy).map_err(|_| e)?;
333                saved.bindings = checkpoint.bindings.clone();
334                saved
335            }
336            Err(e) => return Err(e),
337        };
338        if saved.items.len() != checkpoint.items.len()
339            || saved.items.iter().zip(&checkpoint.items).any(|(a, b)| {
340                (&a.candidate, a.repeat, &a.case_id) != (&b.candidate, b.repeat, &b.case_id)
341            })
342        {
343            return Err(LiveError::invalid(
344                "checkpoint-mismatch",
345                "work items differ",
346            ));
347        }
348        checkpoint = saved;
349        // Staged reports are rebuilt from the checkpoint and retained case files.
350        clean_temps(&opts.out)?;
351        let cases = opts.out.join("cases");
352        if cases.try_exists().map_err(LiveError::io)? {
353            clean_temps(&cases)?;
354        }
355        let probes = opts.out.join("probe");
356        if probes.try_exists().map_err(LiveError::io)? {
357            clean_temps(&probes)?;
358        }
359        let staging = opts.out.join(".repeat-staging");
360        if staging.try_exists().map_err(LiveError::io)? {
361            std::fs::remove_dir_all(staging).map_err(LiveError::io)?;
362        }
363    } else {
364        std::fs::create_dir(&opts.out).map_err(|e| LiveError {
365            status: "io_error",
366            subject: "out",
367            detail: e.to_string(),
368        })?;
369        std::fs::create_dir(opts.out.join("cases")).map_err(LiveError::io)?;
370        write_atomic(
371            &opts.out.join("live-config.json"),
372            &spec.config.canonical().map_err(LiveError::io)?,
373        )?;
374        checkpoint.write_atomic(&path)?;
375    }
376    Ok(PreparedRun {
377        spec,
378        opts,
379        snapshots,
380        checkpoint,
381        completed: BTreeMap::new(),
382    })
383}
384
385/// The first writer wins: the deadline is the cause only when it raises a
386/// signal nobody had raised yet.
387fn raise_for_deadline(signal: &CancelSignal, deadline_hit: &Cell<bool>) {
388    if !signal.is_raised() {
389        deadline_hit.set(true);
390        signal.raise();
391    }
392}
393
394// Only the crate's atomic-write leftovers are disposable. Retained outputs
395// and unrelated files are never used as staging or removed by recovery.
396fn clean_temps(dir: &std::path::Path) -> Result<(), LiveError> {
397    for entry in std::fs::read_dir(dir).map_err(LiveError::io)? {
398        let entry = entry.map_err(LiveError::io)?;
399        let name = entry.file_name();
400        let name = name.to_string_lossy();
401        if name.starts_with(".rustev-checkpoint-") && name.ends_with(".tmp") {
402            std::fs::remove_file(entry.path()).map_err(LiveError::io)?;
403        }
404    }
405    Ok(())
406}
407
408fn rerun(item: &CheckpointItem) -> Option<u64> {
409    item.states
410        .iter()
411        .filter_map(|s| match s {
412            ItemState::Rerun { k } => Some(*k),
413            _ => None,
414        })
415        .max()
416}
417fn current_id(run: &str, item: &CheckpointItem) -> String {
418    decision_id(
419        run,
420        &item.candidate,
421        item.repeat,
422        &item.case_id,
423        rerun(item),
424    )
425    .expect("validated checkpoint item")
426}
427fn pending(item: &CheckpointItem) -> bool {
428    matches!(
429        item.states.last(),
430        Some(ItemState::Pending | ItemState::Rerun { .. })
431    )
432}
433
434type Work<'a> =
435    Pin<Box<dyn Future<Output = (usize, Result<(LiveResult, SourceOutcome), LiveRefusal>)> + 'a>>;
436
437impl PreparedRun {
438    fn save(&self) -> Result<(), LiveError> {
439        self.checkpoint
440            .write_atomic(self.opts.out.join("checkpoint.json"))
441    }
442
443    /// Each admitted item holds every binding reached by its plan until it
444    /// settles. This conservatively bounds concurrent items per binding;
445    /// scheduling of attempts within an item remains the runtime's job.
446    pub async fn execute(
447        mut self,
448        runtime: &Runtime,
449        journal: SpendJournal,
450        authorizer: &dyn LiveAuthorizer,
451        clock: &dyn Clock,
452        cancel: &CancelSignal,
453    ) -> Result<RunOutcome, LiveError> {
454        let deadline = self
455            .opts
456            .run_deadline_ms
457            .map(|d| clock.now().saturating_add(d));
458        let signal = cancel.child();
459        let gate = JournalGate::new(
460            journal.clone(),
461            &self.spec.run_id,
462            self.spec.config.run_cap_units,
463        )
464        .map_err(|e| LiveError::invalid("journal-budget", e.code))?;
465        let before = journal.summary();
466        for item in &mut self.checkpoint.items {
467            if pending(item) {
468                let ids = journal.reservations_for(&current_id(&self.spec.run_id, item));
469                if !ids.is_empty() {
470                    item.states
471                        .push(ItemState::Interrupted { attempt_ids: ids });
472                }
473            }
474            if matches!(item.states.last(), Some(ItemState::Interrupted { .. }))
475                && self.opts.rerun_interrupted
476            {
477                let k = rerun(item).unwrap_or(0) + 1;
478                item.states.push(if k > 99 {
479                    ItemState::Refused {
480                        code: "rerun-limit".into(),
481                    }
482                } else {
483                    ItemState::Rerun { k }
484                });
485            }
486        }
487        self.save()?;
488        let inputs = ProbeInputs {
489            acquisition_pages: &self.spec.acquisition_pages,
490            runtime,
491            config: &self.spec.config,
492            candidates: &self.spec.candidates,
493            snapshot: &self.spec.probe_snapshot,
494            evaluation_time: self.spec.probe_time,
495            principal_handle: self.spec.principal_handle.clone(),
496            subject: self.spec.subject.clone(),
497            scope: self.spec.scope.clone(),
498            clock,
499        };
500        // Set only when the deadline raises the signal first; a caller's
501        // earlier cancel keeps its own cause even if the deadline then passes.
502        let deadline_hit = Cell::new(false);
503        let report = {
504            let probe_dir = self.opts.out.join("probe");
505            std::fs::create_dir_all(&probe_dir).map_err(LiveError::io)?;
506            let mut probing = Box::pin(probe::probe_recorded(
507                inputs,
508                authorizer,
509                &gate,
510                &signal,
511                self.opts.resume,
512                |index, call, candidate, result, sources| {
513                    let doc = LiveCaseDocument::from_result(
514                        &self.spec.config,
515                        CaseRun {
516                            run_id: self.spec.run_id.clone(),
517                            live_config_id: self.checkpoint.bindings.live_config_id.clone(),
518                            candidate: candidate.into(),
519                            repeat: None,
520                            rerun: None,
521                        },
522                        CaseSource::None,
523                        &journal,
524                        result,
525                        sources,
526                    )?;
527                    doc.write_result(&probe_dir.join(format!("{index}.{call}")), result)
528                        .map(Some)
529                },
530            ));
531            let mut timer = deadline.map(|d| clock.sleep_until(d));
532            poll_fn(|cx| {
533                // A completed timer is dropped, never polled again.
534                if timer
535                    .as_mut()
536                    .is_some_and(|t| t.as_mut().poll(cx).is_ready())
537                {
538                    timer = None;
539                    raise_for_deadline(&signal, &deadline_hit);
540                }
541                probing.as_mut().poll(cx)
542            })
543            .await?
544        };
545        let mut status = match report.status {
546            ProbeStatus::Probed if self.opts.probe_only => RunStatus::Probed,
547            ProbeStatus::Probed => RunStatus::EvaluatedLive,
548            ProbeStatus::ProbeFailed => RunStatus::ProbeFailed,
549            ProbeStatus::SpendCapped => RunStatus::SpendCapped,
550            ProbeStatus::Interrupted => RunStatus::Interrupted,
551        };
552        if signal.is_raised() && status != RunStatus::SpendCapped {
553            status = RunStatus::Interrupted;
554        }
555        let mut spend_refusal = report.spend_refusal.clone();
556        if status == RunStatus::EvaluatedLive {
557            (status, spend_refusal) = self
558                .dispatch(
559                    runtime,
560                    authorizer,
561                    clock,
562                    &signal,
563                    (deadline, &deadline_hit),
564                    &gate,
565                )
566                .await?;
567        }
568        let stop_reason = match status {
569            RunStatus::EvaluatedLive | RunStatus::Probed => None,
570            RunStatus::SpendCapped => spend_refusal,
571            RunStatus::ProbeFailed => report
572                .bindings
573                .iter()
574                .find_map(|b| b.failure.as_ref().map(|f| f.code.clone())),
575            RunStatus::Interrupted => Some(
576                if deadline_hit.get() {
577                    "run-deadline"
578                } else {
579                    "cancelled"
580                }
581                .into(),
582            ),
583        };
584        let mut counts = BTreeMap::from([
585            ("pending".into(), 0),
586            ("completed".into(), 0),
587            ("unavailable".into(), 0),
588            ("refused".into(), 0),
589            ("interrupted".into(), 0),
590        ]);
591        let mut cases = Vec::new();
592        for item in &self.checkpoint.items {
593            let name = match item.states.last().expect("nonempty history") {
594                ItemState::Pending | ItemState::Rerun { .. } => "pending",
595                ItemState::Completed {
596                    live_document_digest,
597                    run_record_digest,
598                } => {
599                    let case_document_digest = LiveCaseDocument::read(
600                        &self
601                            .opts
602                            .out
603                            .join("cases")
604                            .join(format!("{}.case.json", case_name(item))),
605                    )?
606                    .record_digest()
607                    .map_err(LiveError::io)?;
608                    cases.push(LiveRunCase {
609                        case_document_digest,
610                        candidate: item.candidate.clone(),
611                        repeat: item.repeat,
612                        case_id: item.case_id.clone(),
613                        rerun: rerun(item),
614                        live_document_digest: live_document_digest.clone(),
615                        run_record_digest: run_record_digest.clone(),
616                    });
617                    "completed"
618                }
619                ItemState::Unavailable { .. } => "unavailable",
620                ItemState::Refused { .. } => "refused",
621                ItemState::Interrupted { .. } => "interrupted",
622            };
623            *counts.get_mut(name).expect("known state") += 1;
624        }
625        let reports = if status == RunStatus::EvaluatedLive {
626            self.spec
627                .reports
628                .as_ref()
629                .map(|inputs| {
630                    crate::reports::build(
631                        inputs,
632                        &self.spec,
633                        &self.opts,
634                        &self.checkpoint,
635                        &self.completed,
636                    )
637                })
638                .transpose()?
639        } else {
640            None
641        };
642        let live_run = LiveRun {
643            declared_no_spend: self
644                .spec
645                .config
646                .bindings
647                .iter()
648                .filter(|b| b.cost_unit == CostUnit::None)
649                .map(|b| b.identity.clone())
650                .collect(),
651            schema: LiveRun::SCHEMA.into(),
652            identities: self.checkpoint.bindings.clone(),
653            status,
654            stop_reason,
655            probe: report.clone(),
656            counts,
657            run_spend: journal.run_split(&self.spec.run_id),
658            journal_before: before.clone(),
659            journal_after: journal.summary(),
660            cases,
661            repeat_reports: reports
662                .as_ref()
663                .map(|r| r.digests())
664                .transpose()?
665                .unwrap_or_default(),
666        };
667        live_run.check()?;
668        let live_run_bytes = live_run.record_canonical().map_err(LiveError::io)?;
669        if let Some(reports) = &reports {
670            reports.publish(&self.opts.out)?;
671        }
672        let (repeat_reports, comparison) = reports
673            .map(|r| (r.reports, Some(r.comparison)))
674            .unwrap_or_default();
675        let outcome = RunOutcome {
676            repeat_reports,
677            comparison,
678            run_spend: live_run.run_spend.clone(),
679            journal_after: live_run.journal_after.clone(),
680            live_run,
681            status,
682            probe: report,
683            items: self
684                .checkpoint
685                .items
686                .iter()
687                .map(|i| {
688                    (
689                        decision_id(&self.spec.run_id, &i.candidate, i.repeat, &i.case_id, None)
690                            .expect("validated id"),
691                        i.states.last().expect("nonempty history").clone(),
692                    )
693                })
694                .collect(),
695            journal_before: before,
696        };
697        write_atomic(&self.opts.out.join("live-run.json"), &live_run_bytes)?;
698        Ok(outcome)
699    }
700
701    async fn dispatch(
702        &mut self,
703        runtime: &Runtime,
704        authorizer: &dyn LiveAuthorizer,
705        clock: &dyn Clock,
706        signal: &CancelSignal,
707        (deadline, deadline_hit): (Option<u64>, &Cell<bool>),
708        gate: &JournalGate,
709    ) -> Result<(RunStatus, Option<String>), LiveError> {
710        let spend = probe::spend_bindings(&self.spec.config);
711        let acquisitions = probe::acquisition_postures(&self.spec.config)?;
712        let reach: BTreeMap<_, _> = self
713            .spec
714            .candidates
715            .iter()
716            .map(|(n, c)| (n.clone(), probe::reached(c)))
717            .collect();
718        let mut held: BTreeMap<BindingIdentity, u64> = BTreeMap::new();
719        let mut last: BTreeMap<BindingIdentity, u64> = BTreeMap::new();
720        let mut work: Vec<Work<'_>> = Vec::new();
721        let mut next = 0;
722        let mut status = RunStatus::EvaluatedLive;
723        let mut error = None;
724        let mut spend_refusal = None;
725        loop {
726            if deadline.is_some_and(|d| clock.now() >= d) {
727                raise_for_deadline(signal, deadline_hit);
728            }
729            if signal.is_raised() && status != RunStatus::SpendCapped {
730                status = RunStatus::Interrupted;
731            }
732            let mut wake_at = None;
733            if status == RunStatus::EvaluatedLive && error.is_none() {
734                while next < self.checkpoint.items.len() {
735                    let item = &self.checkpoint.items[next];
736                    if !pending(item) {
737                        next += 1;
738                        continue;
739                    }
740                    let case = self
741                        .spec
742                        .cases
743                        .iter()
744                        .find(|c| c.case_id == item.case_id)
745                        .expect("validated case");
746                    if let CaseInput::Unavailable { reason } = &case.input {
747                        self.checkpoint.items[next]
748                            .states
749                            .push(ItemState::Unavailable {
750                                reason: reason.clone(),
751                            });
752                        if let Err(e) = self.save() {
753                            error = Some(e);
754                            break;
755                        }
756                        next += 1;
757                        continue;
758                    }
759                    let bindings = &reach[&item.candidate];
760                    let rules: Vec<_> = self
761                        .spec
762                        .config
763                        .bindings
764                        .iter()
765                        .filter(|b| bindings.contains(&b.identity))
766                        .collect();
767                    if rules
768                        .iter()
769                        .any(|b| held.get(&b.identity).copied().unwrap_or(0) >= b.max_in_flight)
770                    {
771                        break;
772                    }
773                    let start = rules
774                        .iter()
775                        .filter_map(|b| {
776                            last.get(&b.identity)
777                                .map(|t| t.saturating_add(b.min_interval_ms))
778                        })
779                        .max()
780                        .unwrap_or(0);
781                    if start > clock.now() {
782                        wake_at = Some(start);
783                        break;
784                    }
785                    let CaseInput::Available {
786                        snapshot_id,
787                        bundle,
788                        source_time,
789                        scope,
790                        ..
791                    } = &case.input
792                    else {
793                        unreachable!()
794                    };
795                    let request = LiveRequest {
796                        decision_id: current_id(&self.spec.run_id, item),
797                        snapshot: self.snapshots[&case.case_id].clone(),
798                        expected_snapshot_id: Some(snapshot_id.clone()),
799                        evaluation_time: match self.spec.config.evaluation_time {
800                            EvaluationTime::Fixed { ms } => ms,
801                            EvaluationTime::Source => source_time.expect("checked source time"),
802                        },
803                        deadline_ms: rules.iter().map(|b| b.case_deadline_ms).min(),
804                        principal_handle: self.spec.principal_handle.clone(),
805                        subject: self.spec.subject.clone(),
806                        scope: scope.clone().unwrap_or_else(|| self.spec.scope.clone()),
807                        capture: None,
808                        historical: if self.spec.config.compare_with_source {
809                            Some(
810                                HistoricalLink::of(bundle.as_ref().expect("checked bundle"))
811                                    .expect("checked historical link"),
812                            )
813                        } else {
814                            None
815                        },
816                    };
817                    let plan = match runtime.prepare(self.spec.candidates[&item.candidate].clone())
818                    {
819                        Ok(plan) => plan,
820                        Err(_) => {
821                            self.checkpoint.items[next].states.push(ItemState::Refused {
822                                code: "check-plan".into(),
823                            });
824                            if let Err(e) = self.save() {
825                                error = Some(e);
826                                break;
827                            }
828                            next += 1;
829                            continue;
830                        }
831                    };
832                    for b in bindings {
833                        *held.entry(b.clone()).or_default() += 1;
834                        last.insert(b.clone(), clock.now());
835                    }
836                    let mut pages = self.spec.acquisition_pages.clone();
837                    if let Some(bundle) = bundle {
838                        for entry in bundle.acquisition_pages.iter().flatten() {
839                            if let rustev_contract::retention::Retention::Retained {
840                                bytes,
841                                digest,
842                                ..
843                            } = &entry.value.retention
844                                && let Ok(page) =
845                                    rustev_contract::acquisition::AcquisitionPage::parse(
846                                        bytes.as_bytes(),
847                                    )
848                                && page.record_digest().ok().as_ref() == Some(digest)
849                            {
850                                pages.push(page);
851                            }
852                        }
853                    }
854                    let acquisitions = &acquisitions;
855                    let index = next;
856                    let spend = &spend;
857                    work.push(Box::pin(async move {
858                        let result = runtime
859                            .reexecute_live_with_sources(
860                                &plan,
861                                request,
862                                authorizer,
863                                signal,
864                                LiveSpend {
865                                    gate,
866                                    bindings: spend,
867                                },
868                                LiveSources {
869                                    acquisitions,
870                                    pages: &pages,
871                                },
872                            )
873                            .await;
874                        (index, result)
875                    }));
876                    next += 1;
877                    // Poll admitted work before admitting another item, so an immediate
878                    // spend refusal stops admission at its first observable result.
879                    break;
880                }
881            }
882            if work.is_empty()
883                && (next == self.checkpoint.items.len()
884                    || status != RunStatus::EvaluatedLive
885                    || error.is_some())
886            {
887                break;
888            }
889            let more = status == RunStatus::EvaluatedLive
890                && error.is_none()
891                && next < self.checkpoint.items.len()
892                && wake_at.is_none();
893            let mut timer = wake_at
894                .into_iter()
895                .chain(deadline.filter(|_| !signal.is_raised()))
896                .min()
897                .map(|d| clock.sleep_until(d));
898            let event = poll_fn(|cx| {
899                if status == RunStatus::EvaluatedLive && signal.poll_raised(cx).is_ready() {
900                    return Poll::Ready(None);
901                }
902                if deadline.is_some_and(|d| clock.now() >= d) && !signal.is_raised() {
903                    raise_for_deadline(signal, deadline_hit);
904                    return Poll::Ready(None);
905                }
906                for (slot, f) in work.iter_mut().enumerate() {
907                    if let Poll::Ready(result) = f.as_mut().poll(cx) {
908                        return Poll::Ready(Some((slot, result)));
909                    }
910                }
911                if timer
912                    .as_mut()
913                    .is_some_and(|t| t.as_mut().poll(cx).is_ready())
914                {
915                    return Poll::Ready(None);
916                }
917                if more {
918                    // Continue only if the next item can acquire all its bindings.
919                    let item = &self.checkpoint.items[next];
920                    if !pending(item)
921                        || self
922                            .spec
923                            .config
924                            .bindings
925                            .iter()
926                            .filter(|b| reach[&item.candidate].contains(&b.identity))
927                            .all(|b| held.get(&b.identity).copied().unwrap_or(0) < b.max_in_flight)
928                    {
929                        return Poll::Ready(None);
930                    }
931                }
932                Poll::Pending
933            })
934            .await;
935            let Some((slot, (index, result))) = event else {
936                continue;
937            };
938            drop(work.remove(slot));
939            let item = &mut self.checkpoint.items[index];
940            for b in &reach[&item.candidate] {
941                *held.get_mut(b).expect("held binding") -= 1;
942            }
943            let state = match result {
944                Err(e) => ItemState::Refused {
945                    code: probe::refusal_code(&e).into(),
946                },
947                Ok((mut r, sources)) => {
948                    if let Some(s) = &r.spend_refusal {
949                        // Source evidence (a refused source reservation, or a
950                        // missing or ambiguous capture) is kept beside the refusal.
951                        if !sources.refusals.is_empty() || sources.incomparable.is_some() {
952                            save_case(&self.opts, &self.spec, gate.journal(), item, &r, &sources)?;
953                        }
954                        spend_refusal.get_or_insert_with(|| s.code.clone());
955                        status = RunStatus::SpendCapped;
956                        ItemState::Refused {
957                            code: s.code.clone(),
958                        }
959                    } else if let Some(reason) = &sources.incomparable {
960                        save_case(&self.opts, &self.spec, gate.journal(), item, &r, &sources)?;
961                        ItemState::Refused {
962                            code: reason.clone(),
963                        }
964                    } else if !sources.refusals.is_empty() {
965                        save_case(&self.opts, &self.spec, gate.journal(), item, &r, &sources)?;
966                        ItemState::Refused {
967                            code: sources.refusals[0].1.clone(),
968                        }
969                    } else if signal.is_raised() && matches!(r.completion, Completion::Cancelled) {
970                        // A judgment reached before the signal is kept.
971                        ItemState::Interrupted {
972                            attempt_ids: r
973                                .record
974                                .requests
975                                .iter()
976                                .flat_map(|q| &q.attempts)
977                                .map(|a| a.attempt_id.clone())
978                                .collect(),
979                        }
980                    } else if r.record.requests.iter().all(|q| q.attempts.is_empty())
981                        && r.document.effects.iter().any(|e| {
982                            matches!(e.authorization, AuthorizationOutcome::Refused { .. })
983                        })
984                    {
985                        ItemState::Refused {
986                            code: "authorization_refused".into(),
987                        }
988                    } else {
989                        if self.spec.config.compare_with_source {
990                            let context = self
991                                .spec
992                                .source_comparison
993                                .as_ref()
994                                .expect("checked comparison");
995                            let case = self
996                                .spec
997                                .cases
998                                .iter()
999                                .find(|c| c.case_id == item.case_id)
1000                                .expect("validated case");
1001                            let CaseInput::Available {
1002                                bundle: Some(bundle),
1003                                ..
1004                            } = &case.input
1005                            else {
1006                                unreachable!("checked bundle")
1007                            };
1008                            let (comparison, retention) =
1009                                rustev_eval::live::compare_live(&rustev_eval::live::LiveCase {
1010                                    bundle,
1011                                    resolver: context.resolver.as_ref(),
1012                                    config: &(context.replay)(),
1013                                    live: &r.document,
1014                                    live_run: &r.record,
1015                                    live_plan: &self.spec.candidates[&item.candidate].plan,
1016                                    adapter: &context.adapter,
1017                                    agreement: &context.agreement,
1018                                });
1019                            r.document = rustev_eval::live::with_comparison(
1020                                &r.document,
1021                                comparison,
1022                                retention,
1023                            );
1024                        }
1025                        match save_case(&self.opts, &self.spec, gate.journal(), item, &r, &sources)
1026                        {
1027                            Ok(s) => {
1028                                if self.spec.reports.is_some() {
1029                                    let observation = crate::reports::completed(
1030                                        &r.document,
1031                                        &r.record,
1032                                        &self.spec.candidates[&item.candidate].plan,
1033                                        match &r.completion {
1034                                            Completion::Judged(j) => Some(j.as_ref()),
1035                                            Completion::Cancelled => None,
1036                                        },
1037                                    );
1038                                    self.completed.insert(index, observation);
1039                                }
1040                                s
1041                            }
1042                            Err(e) => {
1043                                error.get_or_insert(e);
1044                                continue;
1045                            }
1046                        }
1047                    }
1048                }
1049            };
1050            item.states.push(state);
1051            if let Err(e) = self.save() {
1052                error.get_or_insert(e);
1053            }
1054        }
1055        if let Some(e) = error {
1056            return Err(e);
1057        }
1058        Ok((status, spend_refusal))
1059    }
1060}
1061
1062fn case_name(item: &CheckpointItem) -> String {
1063    let suffix = rerun(item)
1064        .map(|k| format!(".rerun-{k}"))
1065        .unwrap_or_default();
1066    format!(
1067        "{}.{}.{}{suffix}",
1068        item.candidate, item.repeat, item.case_id
1069    )
1070}
1071
1072fn save_case(
1073    opts: &RunOptions,
1074    spec: &RunSpec,
1075    journal: &SpendJournal,
1076    item: &CheckpointItem,
1077    r: &LiveResult,
1078    sources: &SourceOutcome,
1079) -> Result<ItemState, LiveError> {
1080    let case = spec
1081        .cases
1082        .iter()
1083        .find(|c| c.case_id == item.case_id)
1084        .expect("validated case");
1085    let CaseInput::Available { source, .. } = &case.input else {
1086        unreachable!()
1087    };
1088    let source = match source {
1089        CaseOrigin::DatasetCase => CaseSource::DatasetCase {
1090            dataset_id: spec.dataset_id.clone(),
1091            case_id: case.case_id.clone(),
1092            split: spec.split.clone(),
1093        },
1094        CaseOrigin::ReplayBundle {
1095            bundle_digest,
1096            decision_id,
1097        } => CaseSource::ReplayBundle {
1098            bundle_digest: bundle_digest.clone(),
1099            decision_id: decision_id.clone(),
1100        },
1101    };
1102    let doc = LiveCaseDocument::from_result(
1103        &spec.config,
1104        CaseRun {
1105            run_id: spec.run_id.clone(),
1106            live_config_id: spec.config.id().map_err(LiveError::io)?,
1107            candidate: item.candidate.clone(),
1108            repeat: Some(item.repeat),
1109            rerun: rerun(item),
1110        },
1111        source,
1112        journal,
1113        r,
1114        sources,
1115    )?;
1116    doc.write_result(&opts.out.join("cases").join(case_name(item)), r)?;
1117    Ok(ItemState::Completed {
1118        live_document_digest: doc.live_document_digest,
1119        run_record_digest: doc.run_record_digest,
1120    })
1121}