1use 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
31pub struct SourceComparison {
33 pub adapter: rustev_eval::dataset::AdapterRef,
34 pub agreement: rustev_eval::config::Agreement,
35 pub replay: Box<dyn Fn() -> rustev_eval::replay::ReplayConfig>,
37 pub resolver: Box<dyn rustev_eval::resolve::Resolver>,
38}
39
40pub struct RunSpec {
41 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
65pub 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 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
116pub 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 pub items: Vec<(String, ItemState)>,
138 pub run_spend: RunSpend,
139 pub journal_before: SpendSummary,
140 pub journal_after: SpendSummary,
141}
142
143pub fn prepare(mut spec: RunSpec, opts: RunOptions) -> Result<PreparedRun, LiveError> {
145 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 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 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 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
385fn 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
394fn 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 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(¤t_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 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 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 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 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 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 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}