1use std::collections::{BTreeMap, BTreeSet};
19use std::fmt;
20
21use rustev_contract::calibration::CalibrationArtifact;
22use rustev_contract::descriptor::BackendDescriptor;
23use rustev_contract::ids::{ArtifactId, RequestId};
24use rustev_contract::judgment::{Judgment, Unresolved};
25use rustev_contract::output::{BackendOutputDoc, RawOutput};
26use rustev_contract::plan::{Plan, PlanExecution, PlanStepDetail};
27use rustev_contract::reason::RuntimeReasonDoc;
28use rustev_contract::replay::{
29 BundleError, CaptureStatus, ItemLocation, MAX_BUNDLE_ITEMS, ReplayBundle,
30};
31use rustev_contract::retention::Availability;
32use rustev_contract::run::{CoreEvidence, RequestResult, RunRecord, Termination};
33use rustev_contract::scope::Scope;
34use rustev_contract::snapshot::Snapshot;
35use rustev_contract::time::Timestamp;
36use rustev_contract::{Document, Identified, schema};
37use rustev_core::evaluate::{Evaluation, Supplied};
38use rustev_core::{Compiled, LoadError};
39
40use crate::acquisition_replay::{MissingAcquisition, PageValue, fill_missing, supply_page};
41
42use crate::resolve::{Budget, ItemFailure, Resolver, check_bytes, item_bytes};
43
44#[derive(Debug, Clone)]
46pub struct ReplayConfig {
47 pub scope: Scope,
50 pub now_ms: Timestamp,
52 pub host_cap_ms: Option<u64>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum Inconsistency {
59 DecisionId,
60 PlanId,
61 SnapshotId,
62 EvaluationTime,
63 TooManySupplies {
65 max: u64,
66 },
67 NotPending,
69 NotInRun,
71 Origin,
73 UnknownTarget,
75 RequestIdentity,
77 Value,
80 OutputBinding,
82 OutputRefused,
84 MissingSupply {
86 step: String,
87 instance: Vec<String>,
88 },
89 RequestOrder,
91 StartRefused,
93 FinishRefused,
95 NoRecordForm,
97}
98
99#[derive(Debug, Clone, PartialEq)]
101pub enum Incomparable {
102 AcquisitionNotCaptured {
103 missing: Vec<MissingAcquisition>,
104 judgment: Box<Judgment>,
105 },
106 ScopeMismatch,
107 InvalidBundle(BundleError),
108 CaptureIncomplete(CaptureStatus),
109 Unavailable {
110 location: ItemLocation,
111 availability: Availability,
112 },
113 Oversized {
114 location: ItemLocation,
115 },
116 NoExpectedJudgment,
118 CompilerChanged {
119 recorded_compiler: String,
120 current_compiler: String,
121 recorded_registry: String,
122 current_registry: String,
123 },
124 MissingDependency {
126 detail: String,
127 },
128 PlanMismatch,
130 Inconsistent {
131 location: ItemLocation,
132 what: Inconsistency,
133 },
134}
135
136impl Incomparable {
137 pub fn code(&self) -> String {
139 match self {
140 Incomparable::AcquisitionNotCaptured { .. } => "acquisition-not-captured".into(),
141 Incomparable::ScopeMismatch => "scope-mismatch".into(),
142 Incomparable::InvalidBundle(_) => "invalid-bundle".into(),
143 Incomparable::CaptureIncomplete(_) => "capture-incomplete".into(),
144 Incomparable::Unavailable {
145 location: ItemLocation::AcquisitionPage(_),
146 availability: Availability::Missing,
147 } => "content-not-retained".into(),
148 Incomparable::Unavailable { availability, .. } => availability.name().into(),
149 Incomparable::Oversized { .. } => "oversized".into(),
150 Incomparable::NoExpectedJudgment => "no-expected-judgment".into(),
151 Incomparable::CompilerChanged { .. } => "compiler-changed".into(),
152 Incomparable::MissingDependency { .. } => "missing-dependency".into(),
153 Incomparable::PlanMismatch => "plan-mismatch".into(),
154 Incomparable::Inconsistent { .. } => "inconsistent".into(),
155 }
156 }
157}
158
159impl fmt::Display for Incomparable {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 match self {
162 Incomparable::Unavailable {
163 location,
164 availability,
165 } => write!(f, "{location} is {availability}"),
166 Incomparable::Oversized { location } => write!(f, "{location} is oversized"),
167 Incomparable::Inconsistent { location, what } => {
168 write!(f, "inconsistent at {location}: {what:?}")
169 }
170 Incomparable::InvalidBundle(e) => write!(f, "invalid bundle: {e}"),
171 other => f.write_str(&other.code()),
172 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq)]
178pub enum RetainedValue {
179 Output {
180 artifact: ArtifactId,
181 output: RawOutput,
182 },
183 Reason(Unresolved),
185}
186
187#[derive(Debug, Clone, PartialEq)]
189pub struct RetainedSupply {
190 pub step: String,
191 pub instance: Vec<String>,
192 pub request: RequestId,
193 pub target: u32,
194 pub value: RetainedValue,
195}
196
197#[derive(Debug, Clone)]
203pub struct Reproduced {
204 pub(crate) decision_id: String,
205 pub(crate) scope: Scope,
206 pub(crate) plan: Plan,
207 pub(crate) calibrations: Vec<CalibrationArtifact>,
208 pub(crate) snapshot: Snapshot,
209 pub(crate) evaluation_time: Timestamp,
210 pub(crate) judgment: Judgment,
211 pub(crate) run: RunRecord,
212 pub(crate) supplies: Vec<RetainedSupply>,
213 pub(crate) pages: Vec<(rustev_contract::ids::AcquisitionRequestId, PageValue)>,
214}
215
216impl Reproduced {
217 pub fn decision_id(&self) -> &str {
218 &self.decision_id
219 }
220 pub fn scope(&self) -> &Scope {
221 &self.scope
222 }
223 pub fn plan(&self) -> &Plan {
225 &self.plan
226 }
227 pub fn calibrations(&self) -> &[CalibrationArtifact] {
228 &self.calibrations
229 }
230 pub fn snapshot(&self) -> &Snapshot {
231 &self.snapshot
232 }
233 pub fn evaluation_time(&self) -> Timestamp {
234 self.evaluation_time
235 }
236 pub fn judgment(&self) -> &Judgment {
238 &self.judgment
239 }
240 pub fn run(&self) -> &RunRecord {
242 &self.run
243 }
244 pub fn supplies(&self) -> &[RetainedSupply] {
246 &self.supplies
247 }
248}
249
250#[derive(Debug, Clone)]
251pub enum ReplayOutcome {
252 Reproduced(Box<Reproduced>),
253 Diverged {
254 expected: Box<Judgment>,
255 actual: Box<Judgment>,
256 },
257 Incomparable(Incomparable),
258}
259
260fn inconsistent(location: ItemLocation, what: Inconsistency) -> Incomparable {
261 Incomparable::Inconsistent { location, what }
262}
263
264fn item_failure(location: ItemLocation, f: ItemFailure) -> Incomparable {
265 match f {
266 ItemFailure::Unavailable(availability) => Incomparable::Unavailable {
267 location,
268 availability,
269 },
270 ItemFailure::Oversized => Incomparable::Oversized { location },
271 }
272}
273
274pub fn reproduce(
276 bundle: &ReplayBundle,
277 resolver: &dyn Resolver,
278 config: &ReplayConfig,
279) -> ReplayOutcome {
280 match reproduce_inner(bundle, resolver, config) {
281 Ok(o) => o,
282 Err(i) => ReplayOutcome::Incomparable(i),
283 }
284}
285
286struct Resolved {
287 plan: Plan,
288 descriptors: Vec<BackendDescriptor>,
289 calibrations: Vec<CalibrationArtifact>,
290 snapshot: Snapshot,
291 run: RunRecord,
292 values: Vec<Value>,
293 sources: Vec<rustev_contract::source::SourceDescriptor>,
294 pages: Vec<PageValue>,
295}
296
297enum Value {
298 Output(BackendOutputDoc),
299 Reason(RuntimeReasonDoc),
300}
301
302fn resolve_all(
303 bundle: &ReplayBundle,
304 resolver: &dyn Resolver,
305 now: Timestamp,
306) -> Result<Resolved, Incomparable> {
307 let mut budget = Budget::default();
308 let mut get = |location: ItemLocation, item| {
309 item_bytes(item, now, resolver, &mut budget)
310 .map_err(|f| item_failure(location, f))
311 .map(|b| (location, item, b))
312 };
313 fn typed<T: Document + PartialEq>(
314 (location, item, bytes): (
315 ItemLocation,
316 &rustev_contract::retention::RetentionItem,
317 Vec<u8>,
318 ),
319 ) -> Result<T, Incomparable> {
320 check_bytes::<T>(item, &bytes).map_err(|f| item_failure(location, f))
321 }
322 let plan: Plan = typed(get(ItemLocation::Plan, &bundle.plan)?)?;
323 let mut descriptors = vec![];
324 for (i, d) in bundle.descriptors.iter().enumerate() {
325 descriptors.push(typed(get(ItemLocation::Descriptor(i), d)?)?);
326 }
327 let mut calibrations = vec![];
328 for (i, c) in bundle.calibrations.iter().enumerate() {
329 calibrations.push(typed(get(ItemLocation::Calibration(i), c)?)?);
330 }
331 let snapshot: Snapshot = typed(get(ItemLocation::Snapshot, &bundle.snapshot)?)?;
332 let run: RunRecord = typed(get(ItemLocation::Run, &bundle.run)?)?;
333 let mut values = vec![];
334 for (i, s) in bundle.supplies.iter().enumerate() {
335 let location = ItemLocation::Supply(i);
336 let got = get(location, &s.value)?;
337 values.push(if s.value.document_schema == schema::RUNTIME_REASON {
338 let r: RuntimeReasonDoc = typed(got)?;
339 if r.check().is_err() {
340 return Err(Incomparable::Unavailable {
341 location,
342 availability: Availability::Mismatched,
343 });
344 }
345 Value::Reason(r)
346 } else {
347 Value::Output(typed(got)?)
348 });
349 }
350 let mut sources = vec![];
351 for (i, source) in bundle.sources.iter().flatten().enumerate() {
352 sources.push(typed(get(ItemLocation::Source(i), source)?)?);
353 }
354 let mut pages = vec![];
355 for (i, page) in bundle.acquisition_pages.iter().flatten().enumerate() {
356 let got = get(ItemLocation::AcquisitionPage(i), &page.value)?;
357 pages.push(if page.value.document_schema == schema::RUNTIME_REASON {
358 PageValue::Reason(typed::<RuntimeReasonDoc>(got)?.reason)
359 } else {
360 PageValue::Page(typed(got)?)
361 });
362 }
363 Ok(Resolved {
364 sources,
365 pages,
366 plan,
367 descriptors,
368 calibrations,
369 snapshot,
370 run,
371 values,
372 })
373}
374
375fn target_artifact(plan: &Plan, step: &str, target: u32) -> Option<ArtifactId> {
377 let Some(PlanStepDetail::Semantic(b)) =
378 plan.steps.iter().find(|s| s.id == step).map(|s| &s.detail)
379 else {
380 return None;
381 };
382 if target == 0 {
383 return Some(b.artifact.clone());
384 }
385 match &plan.execution {
386 PlanExecution::Declared(d) => d
387 .fallbacks
388 .iter()
389 .find(|f| f.step == step && f.target == target)
390 .map(|f| f.artifact.clone()),
391 PlanExecution::None => None,
392 }
393}
394
395fn list_new(
397 ev: &Evaluation<'_>,
398 listed: &mut Vec<(String, Vec<String>)>,
399 seen: &mut BTreeSet<(String, Vec<String>)>,
400) {
401 for r in ev.pending() {
402 let key = (r.step.clone(), r.instance.clone());
403 if seen.insert(key.clone()) {
404 listed.push(key);
405 }
406 }
407}
408
409fn reproduce_inner(
410 bundle: &ReplayBundle,
411 resolver: &dyn Resolver,
412 config: &ReplayConfig,
413) -> Result<ReplayOutcome, Incomparable> {
414 if config.scope != bundle.scope {
416 return Err(Incomparable::ScopeMismatch);
417 }
418 bundle
419 .check(config.host_cap_ms)
420 .map_err(Incomparable::InvalidBundle)?;
421 if bundle.capture != CaptureStatus::Complete {
422 return Err(Incomparable::CaptureIncomplete(bundle.capture));
423 }
424 let r = resolve_all(bundle, resolver, config.now_ms)?;
426 let run = &r.run;
427 let run_at = ItemLocation::Run;
428 if run.decision_id != bundle.decision_id {
429 return Err(inconsistent(run_at, Inconsistency::DecisionId));
430 }
431 if run.plan_id != bundle.plan_id {
432 return Err(inconsistent(run_at, Inconsistency::PlanId));
433 }
434 let evidence = match (&run.termination, &run.core) {
435 (Termination::Judged, CoreEvidence::Recorded(e)) => e,
436 _ => return Err(Incomparable::NoExpectedJudgment),
437 };
438 if evidence.decision_id != bundle.decision_id {
439 return Err(inconsistent(run_at, Inconsistency::DecisionId));
440 }
441 if evidence.plan_id != bundle.plan_id || evidence.judgment.plan_id != bundle.plan_id {
442 return Err(inconsistent(run_at, Inconsistency::PlanId));
443 }
444 if evidence.snapshot_id != bundle.snapshot_id {
445 return Err(inconsistent(run_at, Inconsistency::SnapshotId));
446 }
447 if evidence.evaluation_time_ms != bundle.evaluation_time_ms {
448 return Err(inconsistent(run_at, Inconsistency::EvaluationTime));
449 }
450 if r.plan.id().ok().as_ref() != Some(&bundle.plan_id) {
451 return Err(inconsistent(ItemLocation::Plan, Inconsistency::PlanId));
452 }
453 if r.snapshot.id().ok().as_ref() != Some(&bundle.snapshot_id) {
454 return Err(inconsistent(
455 ItemLocation::Snapshot,
456 Inconsistency::SnapshotId,
457 ));
458 }
459 let max = r
460 .plan
461 .definition
462 .limits
463 .max_semantic_requests
464 .min(MAX_BUNDLE_ITEMS as u64);
465 if bundle.supplies.len() as u64 > max {
466 return Err(inconsistent(
467 ItemLocation::Bundle,
468 Inconsistency::TooManySupplies { max },
469 ));
470 }
471 let max_pages = r
472 .plan
473 .definition
474 .limits
475 .acquisition
476 .as_ref()
477 .map_or(0, |l| l.max_calls);
478 if r.pages.len() as u64 > max_pages {
479 return Err(inconsistent(
480 ItemLocation::Bundle,
481 Inconsistency::TooManySupplies { max: max_pages },
482 ));
483 }
484 let compiled =
486 Compiled::load_checked_with_sources(&r.plan, &r.descriptors, &r.calibrations, &r.sources)
487 .map_err(|e| match e {
488 LoadError::CompilerChanged {
489 recorded_compiler,
490 current_compiler,
491 recorded_registry,
492 current_registry,
493 } => Incomparable::CompilerChanged {
494 recorded_compiler,
495 current_compiler: current_compiler.into(),
496 recorded_registry,
497 current_registry: current_registry.into(),
498 },
499 e @ (LoadError::MissingDescriptor { .. } | LoadError::MissingCalibration { .. }) => {
500 Incomparable::MissingDependency {
501 detail: e.to_string(),
502 }
503 }
504 LoadError::PlanMismatch { .. } => Incomparable::PlanMismatch,
505 })?;
506 let mut ev = Evaluation::start(&compiled, &r.snapshot, bundle.evaluation_time_ms)
508 .map_err(|_| inconsistent(ItemLocation::Snapshot, Inconsistency::StartRefused))?;
509 let by_key: BTreeMap<(&str, &[String]), usize> = run
510 .requests
511 .iter()
512 .enumerate()
513 .map(|(i, q)| ((q.step.as_str(), q.instance.as_slice()), i))
514 .collect();
515 let mut listed = vec![];
516 let mut seen = BTreeSet::new();
517 list_new(&ev, &mut listed, &mut seen);
518 let mut supplies = vec![];
519 let pages = bundle.acquisition_pages.as_deref().unwrap_or(&[]);
520 let mut missing = vec![];
521 let (mut semantic_index, mut page_index) = (0, 0);
522 loop {
523 fill_missing(&mut ev, &pages[page_index..], &mut missing)?;
524 list_new(&ev, &mut listed, &mut seen);
525 let semantic = bundle.supplies.get(semantic_index);
526 let page = pages.get(page_index);
527 if let Some(page) = page
528 && semantic.is_none_or(|s| page.sequence < s.sequence)
529 {
530 if missing.is_empty()
532 || ev.pending_acquisitions().iter().any(|p| {
533 p.step == page.step && p.tier_index == page.tier && p.page_index == page.page
534 })
535 {
536 supply_page(&mut ev, page, &r.pages[page_index], run, page_index)?;
537 }
538 page_index += 1;
539 continue;
540 }
541 let Some(entry) = semantic else {
542 break;
543 };
544 let i = semantic_index;
545 let value = &r.values[i];
546 semantic_index += 1;
547 if !missing.is_empty()
548 && !ev
549 .pending()
550 .iter()
551 .any(|p| p.step == entry.step && p.instance == entry.instance)
552 {
553 continue;
554 }
555 let at = ItemLocation::Supply(i);
556 let bad = |what| Err(inconsistent(at, what));
557 let Some(q) = by_key
558 .get(&(entry.step.as_str(), entry.instance.as_slice()))
559 .map(|&k| &run.requests[k])
560 else {
561 return bad(Inconsistency::NotInRun);
562 };
563 let Some(from) = q.supplied_from() else {
564 return bad(Inconsistency::NotInRun);
565 };
566 let origin = from
569 .attempt
570 .map(|a| a.attempt_id.as_str())
571 .or(from.reused_from);
572 let output_target = from
573 .attempt
574 .map(|a| a.target)
575 .or(from.reused_from.map(|_| from.target));
576 if from.target != entry.target
577 || origin != entry.attempt_id.as_deref()
578 || matches!(q.result, RequestResult::Output { .. })
579 && output_target != Some(entry.target)
580 {
581 return bad(Inconsistency::Origin);
582 }
583 let doc =
584 match ev.request_document(&entry.step, &entry.instance, entry.target, &bundle.scope) {
585 Ok(d) => d,
586 Err(rustev_core::RequestIdentityError::UnknownTarget { .. }) => {
587 return bad(Inconsistency::UnknownTarget);
588 }
589 Err(_) => return bad(Inconsistency::NotPending),
590 };
591 if doc.id().ok().as_ref() != Some(&entry.request) {
592 return bad(Inconsistency::RequestIdentity);
593 }
594 let (supplied, retained) = match (value, &q.result) {
595 (Value::Output(o), RequestResult::Output { target }) if *target == entry.target => {
596 let bound = target_artifact(&r.plan, &entry.step, entry.target);
597 if o.step != entry.step
598 || o.instance != entry.instance
599 || bound.as_ref() != Some(&o.artifact)
600 || from.attempt.is_some_and(|a| a.artifact != o.artifact)
601 {
602 return bad(Inconsistency::OutputBinding);
603 }
604 match ev.check_output(&entry.step, &entry.instance, &o.output) {
606 Ok(Ok(())) => {}
607 _ => return bad(Inconsistency::OutputRefused),
608 }
609 (
610 Supplied::Output(o.output.clone()),
611 RetainedValue::Output {
612 artifact: o.artifact.clone(),
613 output: o.output.clone(),
614 },
615 )
616 }
617 (Value::Reason(d), RequestResult::Failed(u)) if &d.reason == u => (
618 Supplied::Failed(d.reason.clone()),
619 RetainedValue::Reason(d.reason.clone()),
620 ),
621 _ => return bad(Inconsistency::Value),
622 };
623 if let Some(context) = entry.failure_context {
624 ev.note_failure(
625 &entry.step,
626 &entry.instance,
627 rustev_core::FailureContext {
628 origin: context.origin,
629 determinism: context.determinism,
630 },
631 );
632 }
633 if ev.supply(&entry.step, &entry.instance, supplied).is_err() {
634 return bad(Inconsistency::NotPending);
635 }
636 list_new(&ev, &mut listed, &mut seen);
637 supplies.push(RetainedSupply {
638 step: entry.step.clone(),
639 instance: entry.instance.clone(),
640 request: entry.request.clone(),
641 target: entry.target,
642 value: retained,
643 });
644 }
645 if let Some(p) = ev.pending().first() {
646 return Err(inconsistent(
647 ItemLocation::Bundle,
648 Inconsistency::MissingSupply {
649 step: p.step.clone(),
650 instance: p.instance.clone(),
651 },
652 ));
653 }
654 let recorded: Vec<(String, Vec<String>)> = run
655 .requests
656 .iter()
657 .map(|q| (q.step.clone(), q.instance.clone()))
658 .collect();
659 if missing.is_empty() && recorded != listed {
660 return Err(inconsistent(run_at, Inconsistency::RequestOrder));
661 }
662 let (judgment, _) = ev
664 .finish(&bundle.decision_id)
665 .map_err(|_| inconsistent(ItemLocation::Bundle, Inconsistency::FinishRefused))?;
666 if !missing.is_empty() {
667 return Err(Incomparable::AcquisitionNotCaptured {
668 missing,
669 judgment: Box::new(judgment),
670 });
671 }
672 let expected = &evidence.judgment;
673 let same = match (judgment.record_canonical(), expected.record_canonical()) {
674 (Ok(a), Ok(b)) => a == b,
675 _ => {
676 return Err(inconsistent(ItemLocation::Run, Inconsistency::NoRecordForm));
677 }
678 };
679 if !same {
680 return Ok(ReplayOutcome::Diverged {
681 expected: Box::new(expected.clone()),
682 actual: Box::new(judgment),
683 });
684 }
685 Ok(ReplayOutcome::Reproduced(Box::new(Reproduced {
686 decision_id: bundle.decision_id.clone(),
687 scope: bundle.scope.clone(),
688 plan: r.plan.clone(),
689 calibrations: r.calibrations.clone(),
690 snapshot: r.snapshot,
691 evaluation_time: bundle.evaluation_time_ms,
692 judgment,
693 run: r.run.clone(),
694 supplies,
695 pages: pages
696 .iter()
697 .zip(&r.pages)
698 .map(|(p, v)| (p.request.clone(), v.clone()))
699 .collect(),
700 })))
701}