1use std::collections::BTreeSet;
11use std::fmt::Write;
12use std::time::Instant;
13
14use serde::{Deserialize, Serialize};
15
16use crate::accounting::{
17 ByteMeasurement, MeasurementProvenance, ProviderUsage, ProviderUsageComponents, ProviderUsageRule, TokenMeasurement,
18};
19use crate::accounting::{TOKEN_ESTIMATOR_VERSION, estimate_serialized_tokens};
20
21pub const REPLAY_FIXTURE_SCHEMA_VERSION: &str = "context-replay-v1";
23
24pub const REPLAY_REPORT_SCHEMA_VERSION: &str = "context-replay-report-v1";
26
27pub trait ReplayPolicy {
29 fn name(&self) -> &str;
31
32 fn include(&self, item: &ReplayItem) -> bool;
34}
35
36#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
38pub struct BaselinePolicy;
39
40impl ReplayPolicy for BaselinePolicy {
41 fn name(&self) -> &str {
42 "baseline"
43 }
44
45 fn include(&self, _item: &ReplayItem) -> bool {
46 true
47 }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct CandidatePolicy {
53 name: String,
54 omitted_ids: BTreeSet<String>,
55}
56
57impl CandidatePolicy {
58 pub fn new(name: impl Into<String>) -> Self {
60 Self { name: name.into(), omitted_ids: BTreeSet::new() }
61 }
62
63 pub fn omit(mut self, id: impl Into<String>) -> Self {
65 self.omitted_ids.insert(id.into());
66 self
67 }
68
69 pub fn omit_all<I, S>(mut self, ids: I) -> Self
71 where
72 I: IntoIterator<Item = S>,
73 S: Into<String>,
74 {
75 self.omitted_ids.extend(ids.into_iter().map(Into::into));
76 self
77 }
78
79 pub fn omitted_ids(&self) -> &BTreeSet<String> {
81 &self.omitted_ids
82 }
83}
84
85impl ReplayPolicy for CandidatePolicy {
86 fn name(&self) -> &str {
87 &self.name
88 }
89
90 fn include(&self, item: &ReplayItem) -> bool {
91 !self.omitted_ids.contains(&item.id)
92 }
93}
94
95#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
97pub enum ReplayError {
98 #[error("invalid replay fixture: {0}")]
100 InvalidFixture(String),
101 #[error("replay invariant failed for {policy}: {message}")]
103 InvariantViolation {
104 policy: String,
106 message: String,
108 },
109 #[error("replay serialization failed: {0}")]
111 Serialization(String),
112}
113
114#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ReplayItemKind {
118 UserTurn,
120 Assistant,
122 ToolOutput,
124 PassingTest,
126 FailingTest,
128 Progress,
130 Error,
132 Command,
134 Write,
136 ProtectedEvidence,
138}
139
140impl ReplayItemKind {
141 pub const fn label(self) -> &'static str {
143 match self {
144 Self::UserTurn => "user_turn",
145 Self::Assistant => "assistant",
146 Self::ToolOutput => "tool_output",
147 Self::PassingTest => "passing_test",
148 Self::FailingTest => "failing_test",
149 Self::Progress => "progress",
150 Self::Error => "error",
151 Self::Command => "command",
152 Self::Write => "write",
153 Self::ProtectedEvidence => "protected_evidence",
154 }
155 }
156}
157
158#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum ReplayScenario {
162 RepeatedReads,
164 OverlappingReads,
166 PassingTests,
168 FailingTests,
170 NoisyProgress,
172 MiddlePositionError,
174 StateChangingCommands,
176 FailedLargeWrite,
178 ProtectedEvidence,
180 CacheComponents,
182 Recovery,
184}
185
186impl ReplayScenario {
187 pub const fn label(self) -> &'static str {
189 match self {
190 Self::RepeatedReads => "repeated_reads",
191 Self::OverlappingReads => "overlapping_reads",
192 Self::PassingTests => "passing_tests",
193 Self::FailingTests => "failing_tests",
194 Self::NoisyProgress => "noisy_progress",
195 Self::MiddlePositionError => "middle_position_error",
196 Self::StateChangingCommands => "state_changing_commands",
197 Self::FailedLargeWrite => "failed_large_write",
198 Self::ProtectedEvidence => "protected_evidence",
199 Self::CacheComponents => "cache_components",
200 Self::Recovery => "recovery",
201 }
202 }
203}
204
205#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
207pub enum ReplayTiming {
208 #[default]
210 Deterministic,
211 Measured,
213}
214
215#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
217pub struct ReplayItem {
218 pub id: String,
220 pub kind: ReplayItemKind,
222 pub label: String,
224 pub content: String,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub original_bytes: Option<u64>,
229 #[serde(default)]
231 pub fact_ids: Vec<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub recovery_handle: Option<String>,
235 #[serde(default)]
237 pub protected: bool,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub state_fingerprint: Option<String>,
241}
242
243impl ReplayItem {
244 pub fn content_bytes(&self) -> u64 {
246 self.content.len() as u64
247 }
248
249 pub fn source_bytes(&self) -> u64 {
251 self.original_bytes.unwrap_or_else(|| self.content_bytes())
252 }
253}
254
255#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
257pub struct RequiredFact {
258 pub id: String,
260 pub description: String,
262 #[serde(default)]
264 pub protected: bool,
265}
266
267#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
269pub struct RecoveryCase {
270 pub id: String,
272 pub artifact_handle: String,
274 pub expected_available: bool,
276}
277
278#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
280pub struct RecordedProviderUsage {
281 pub provider: String,
283 pub rule: ProviderUsageRule,
285 pub components: ProviderUsageComponents,
287}
288
289impl RecordedProviderUsage {
290 pub fn normalize(&self) -> ProviderUsage {
292 self.components.normalize(&self.provider, self.rule)
293 }
294}
295
296#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
298pub struct ReplayFixture {
299 pub schema_version: String,
301 pub id: String,
303 pub description: String,
305 pub scenarios: Vec<ReplayScenario>,
307 pub items: Vec<ReplayItem>,
309 pub required_facts: Vec<RequiredFact>,
311 #[serde(default)]
313 pub recovery: Vec<RecoveryCase>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub provider_usage: Option<RecordedProviderUsage>,
317}
318
319impl ReplayFixture {
320 pub fn validate(&self) -> Result<(), ReplayError> {
322 if self.schema_version != REPLAY_FIXTURE_SCHEMA_VERSION {
323 return Err(ReplayError::InvalidFixture(format!(
324 "schema_version must be {REPLAY_FIXTURE_SCHEMA_VERSION}"
325 )));
326 }
327 if self.id.trim().is_empty() {
328 return Err(ReplayError::InvalidFixture("id must not be empty".to_string()));
329 }
330
331 let mut item_ids = BTreeSet::new();
332 for item in &self.items {
333 if item.id.trim().is_empty() || !item_ids.insert(&item.id) {
334 return Err(ReplayError::InvalidFixture(format!(
335 "item id is empty or duplicated: {}",
336 item.id
337 )));
338 }
339 }
340
341 let fact_ids: BTreeSet<&str> = self.required_facts.iter().map(|fact| fact.id.as_str()).collect();
342 if fact_ids.len() != self.required_facts.len() {
343 return Err(ReplayError::InvalidFixture(
344 "required fact ids must be unique".to_string(),
345 ));
346 }
347 for item in &self.items {
348 for fact_id in &item.fact_ids {
349 if !fact_ids.contains(fact_id.as_str()) {
350 return Err(ReplayError::InvalidFixture(format!(
351 "item {} references unknown fact {fact_id}",
352 item.id
353 )));
354 }
355 }
356 }
357
358 let mut recovery_ids = BTreeSet::new();
359 let mut handles = BTreeSet::new();
360 for case in &self.recovery {
361 if case.id.trim().is_empty() || !recovery_ids.insert(&case.id) {
362 return Err(ReplayError::InvalidFixture(format!(
363 "recovery id is empty or duplicated: {}",
364 case.id
365 )));
366 }
367 if case.artifact_handle.trim().is_empty() || !handles.insert(&case.artifact_handle) {
368 return Err(ReplayError::InvalidFixture(format!(
369 "recovery handle is empty or duplicated: {}",
370 case.artifact_handle
371 )));
372 }
373 }
374 Ok(())
375 }
376
377 pub fn to_json(&self) -> Result<String, ReplayError> {
379 serde_json::to_string_pretty(self).map_err(|error| ReplayError::Serialization(error.to_string()))
380 }
381}
382
383#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
385pub struct ReplayReceipt {
386 pub item_id: String,
388 pub method: String,
390 pub version: String,
392 pub before_bytes: u64,
394 pub after_bytes: u64,
396 pub lossy: bool,
398}
399
400#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
402pub struct RequiredFactResult {
403 pub id: String,
405 pub preserved: bool,
407 pub item_ids: Vec<String>,
409}
410
411#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
413pub struct RecoveryOutcome {
414 pub id: String,
416 pub artifact_handle: String,
418 pub expected_available: bool,
420 pub available: bool,
422}
423
424#[derive(Clone, Debug, Eq, PartialEq)]
426pub struct ReplayProjection {
427 pub policy: String,
429 pub item_ids: Vec<String>,
431 pub rendered: String,
433 pub exact_bytes: ByteMeasurement,
435 pub estimated_tokens: TokenMeasurement,
437 pub receipts: Vec<ReplayReceipt>,
439 pub recovery_handles: Vec<String>,
441}
442
443#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
445pub struct ProjectionReport {
446 pub policy: String,
448 pub exact_bytes: ByteMeasurement,
450 pub estimated_tokens: TokenMeasurement,
452 pub projection_digest: String,
454 pub item_count: usize,
456 pub receipts: Vec<ReplayReceipt>,
458 pub required_facts: Vec<RequiredFactResult>,
460 pub recovery: Vec<RecoveryOutcome>,
462 pub elapsed_micros: u64,
464}
465
466#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
468pub struct ReplayComparison {
469 pub exact_bytes_delta: i64,
471 pub estimated_tokens_delta: i64,
473}
474
475#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
477pub struct ReplayReport {
478 pub schema_version: String,
480 pub fixture_id: String,
482 pub baseline: ProjectionReport,
484 pub candidate: ProjectionReport,
486 pub comparison: ReplayComparison,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub provider_usage: Option<ProviderUsage>,
491}
492
493#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
495pub struct ReplayEvaluator {
496 timing: ReplayTiming,
497}
498
499impl ReplayEvaluator {
500 pub const fn new() -> Self {
502 Self { timing: ReplayTiming::Deterministic }
503 }
504
505 pub const fn measured() -> Self {
507 Self { timing: ReplayTiming::Measured }
508 }
509
510 pub fn evaluate<B: ReplayPolicy, C: ReplayPolicy>(
512 &self, fixture: &ReplayFixture, baseline: &B, candidate: &C,
513 ) -> Result<ReplayReport, ReplayError> {
514 fixture.validate()?;
515 let baseline = self.evaluate_policy(fixture, baseline)?;
516 let candidate = self.evaluate_policy(fixture, candidate)?;
517 let baseline_tokens = baseline.estimated_tokens.value.unwrap_or_default();
518 let candidate_tokens = candidate.estimated_tokens.value.unwrap_or_default();
519 Ok(ReplayReport {
520 schema_version: REPLAY_REPORT_SCHEMA_VERSION.to_string(),
521 fixture_id: fixture.id.clone(),
522 comparison: ReplayComparison {
523 exact_bytes_delta: signed_delta(candidate.exact_bytes.value, baseline.exact_bytes.value),
524 estimated_tokens_delta: signed_delta(candidate_tokens, baseline_tokens),
525 },
526 provider_usage: fixture.provider_usage.as_ref().map(RecordedProviderUsage::normalize),
527 baseline,
528 candidate,
529 })
530 }
531
532 fn evaluate_policy<P: ReplayPolicy>(
533 &self, fixture: &ReplayFixture, policy: &P,
534 ) -> Result<ProjectionReport, ReplayError> {
535 let started = (self.timing == ReplayTiming::Measured).then(Instant::now);
536 let projection = project_fixture(fixture, policy);
537 let required_facts = required_fact_results(fixture, &projection.item_ids);
538 let recovery = recovery_outcomes(fixture, &projection.recovery_handles);
539 if let Some(fact) = required_facts.iter().find(|fact| !fact.preserved) {
540 return Err(ReplayError::InvariantViolation {
541 policy: policy.name().to_string(),
542 message: format!("required fact {} was not preserved", fact.id),
543 });
544 }
545 if let Some(outcome) = recovery
546 .iter()
547 .find(|outcome| outcome.available != outcome.expected_available)
548 {
549 return Err(ReplayError::InvariantViolation {
550 policy: policy.name().to_string(),
551 message: format!("recovery case {} availability was unexpected", outcome.id),
552 });
553 }
554 Ok(ProjectionReport {
555 policy: projection.policy,
556 exact_bytes: projection.exact_bytes,
557 estimated_tokens: projection.estimated_tokens,
558 projection_digest: digest(&projection.rendered),
559 item_count: projection.item_ids.len(),
560 receipts: projection.receipts,
561 required_facts,
562 recovery,
563 elapsed_micros: started.map_or(0, |started| started.elapsed().as_micros() as u64),
564 })
565 }
566}
567
568impl ReplayReport {
569 pub fn to_json(&self) -> Result<String, ReplayError> {
571 serde_json::to_string_pretty(self).map_err(|error| ReplayError::Serialization(error.to_string()))
572 }
573
574 pub fn to_markdown(&self) -> String {
576 let mut out = String::new();
577 let _ = writeln!(&mut out, "# Context replay: {}", self.fixture_id);
578 let _ = writeln!(&mut out, "\nSchema: `{}`", self.schema_version);
579 render_projection_markdown(&mut out, &self.baseline);
580 render_projection_markdown(&mut out, &self.candidate);
581 let _ = writeln!(
582 &mut out,
583 "\n## Comparison\n\n| Measure | Delta |\n| --- | ---: |\n| Exact bytes | {} |\n| Estimated tokens | {} |",
584 self.comparison.exact_bytes_delta, self.comparison.estimated_tokens_delta
585 );
586 render_fact_markdown(&mut out, &self.baseline, &self.candidate);
587 render_recovery_markdown(&mut out, &self.baseline, &self.candidate);
588 render_receipt_markdown(&mut out, &self.baseline);
589 render_receipt_markdown(&mut out, &self.candidate);
590 if let Some(provider_usage) = &self.provider_usage {
591 let _ = writeln!(
592 &mut out,
593 "\n## Provider usage\n\n- Provider: `{}`\n- Rule: `{}`\n- Inclusive input tokens: `{}`",
594 provider_usage.provider,
595 provider_usage.rule.label(),
596 display_token(provider_usage.inclusive_input_tokens.value)
597 );
598 }
599 out
600 }
601}
602
603pub fn load_fixture(json: &str) -> Result<ReplayFixture, ReplayError> {
605 let fixture: ReplayFixture =
606 serde_json::from_str(json).map_err(|error| ReplayError::InvalidFixture(error.to_string()))?;
607 fixture.validate()?;
608 Ok(fixture)
609}
610
611pub fn select_items<'a, P: ReplayPolicy>(fixture: &'a ReplayFixture, policy: &P) -> Vec<&'a ReplayItem> {
613 fixture.items.iter().filter(|item| policy.include(item)).collect()
614}
615
616pub fn project_fixture<P: ReplayPolicy>(fixture: &ReplayFixture, policy: &P) -> ReplayProjection {
618 let selected = select_items(fixture, policy);
619 let selected_ids = selected.iter().map(|item| item.id.clone()).collect::<Vec<_>>();
620 let mut rendered = String::new();
621 let mut recovery_handles = Vec::new();
622 let mut selected_contributions = BTreeSet::new();
623 for item in &selected {
624 let contribution = render_item(item);
625 rendered.push_str(&contribution);
626 selected_contributions.insert(item.id.as_str());
627 if let Some(handle) = &item.recovery_handle {
628 recovery_handles.push(handle.clone());
629 }
630 }
631 let receipts = fixture
632 .items
633 .iter()
634 .map(|item| ReplayReceipt {
635 item_id: item.id.clone(),
636 method: if selected_contributions.contains(item.id.as_str()) {
637 policy.name().to_string()
638 } else {
639 "policy_omitted".to_string()
640 },
641 version: REPLAY_REPORT_SCHEMA_VERSION.to_string(),
642 before_bytes: item.source_bytes(),
643 after_bytes: if selected_contributions.contains(item.id.as_str()) {
644 render_item(item).len() as u64
645 } else {
646 0
647 },
648 lossy: !selected_contributions.contains(item.id.as_str()),
649 })
650 .collect();
651 let exact_bytes = rendered.len() as u64;
652 ReplayProjection {
653 policy: policy.name().to_string(),
654 item_ids: selected_ids,
655 rendered,
656 exact_bytes: ByteMeasurement {
657 value: exact_bytes,
658 provenance: MeasurementProvenance::ExactSerialized { boundary: "replay_projection".to_string() },
659 },
660 estimated_tokens: TokenMeasurement {
661 value: Some(estimate_serialized_tokens(exact_bytes)),
662 provenance: MeasurementProvenance::Estimated {
663 estimator: "utf8_bytes_divisor_3_plus_item_overhead".to_string(),
664 version: TOKEN_ESTIMATOR_VERSION.to_string(),
665 },
666 },
667 receipts,
668 recovery_handles,
669 }
670}
671
672pub fn evaluate_fixture<C: ReplayPolicy>(fixture: &ReplayFixture, candidate: &C) -> Result<ReplayReport, ReplayError> {
674 ReplayEvaluator::new().evaluate(fixture, &BaselinePolicy, candidate)
675}
676
677fn required_fact_results(fixture: &ReplayFixture, selected_ids: &[String]) -> Vec<RequiredFactResult> {
678 fixture
679 .required_facts
680 .iter()
681 .map(|fact| {
682 let item_ids = fixture
683 .items
684 .iter()
685 .filter(|item| selected_ids.contains(&item.id) && item.fact_ids.iter().any(|id| id == &fact.id))
686 .map(|item| item.id.clone())
687 .collect::<Vec<_>>();
688 RequiredFactResult { id: fact.id.clone(), preserved: !item_ids.is_empty(), item_ids }
689 })
690 .collect()
691}
692
693fn recovery_outcomes(fixture: &ReplayFixture, selected_handles: &[String]) -> Vec<RecoveryOutcome> {
694 fixture
695 .recovery
696 .iter()
697 .map(|case| RecoveryOutcome {
698 id: case.id.clone(),
699 artifact_handle: case.artifact_handle.clone(),
700 expected_available: case.expected_available,
701 available: selected_handles.iter().any(|handle| handle == &case.artifact_handle),
702 })
703 .collect()
704}
705
706fn render_item(item: &ReplayItem) -> String {
707 format!(
708 "<replay_item id=\"{}\" kind=\"{}\" label=\"{}\">\n{}\n</replay_item>\n",
709 escape_xml(&item.id),
710 item.kind.label(),
711 escape_xml(&item.label),
712 item.content
713 )
714}
715
716fn escape_xml(value: &str) -> String {
717 value
718 .replace('&', "&")
719 .replace('<', "<")
720 .replace('>', ">")
721 .replace('"', """)
722}
723
724fn signed_delta(current: u64, baseline: u64) -> i64 {
725 (current as i128)
726 .saturating_sub(baseline as i128)
727 .clamp(i64::MIN as i128, i64::MAX as i128) as i64
728}
729
730fn digest(value: &str) -> String {
731 let mut hash = 0xcbf29ce484222325_u64;
732 for byte in value.as_bytes() {
733 hash ^= u64::from(*byte);
734 hash = hash.wrapping_mul(0x100000001b3);
735 }
736 format!("fnv1a64:{hash:016x}")
737}
738
739fn display_token(value: Option<u64>) -> String {
740 value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
741}
742
743fn render_projection_markdown(out: &mut String, projection: &ProjectionReport) {
744 let _ = writeln!(
745 out,
746 "\n## {}\n\n| Measure | Value |\n| --- | ---: |\n| Exact bytes | {} |\n| Estimated tokens | {} |\n| Items | {} |\n| Projection digest | `{}` |\n| Elapsed micros | {} |",
747 projection.policy,
748 projection.exact_bytes.value,
749 display_token(projection.estimated_tokens.value),
750 projection.item_count,
751 projection.projection_digest,
752 projection.elapsed_micros
753 );
754}
755
756fn render_fact_markdown(out: &mut String, baseline: &ProjectionReport, candidate: &ProjectionReport) {
757 let _ = writeln!(
758 out,
759 "\n## Required facts\n\n| Fact | Baseline | Candidate |\n| --- | --- | --- |\n{}
760",
761 baseline
762 .required_facts
763 .iter()
764 .zip(&candidate.required_facts)
765 .map(|(baseline, candidate)| format!(
766 "| `{}` | {} | {} |",
767 baseline.id, baseline.preserved, candidate.preserved
768 ))
769 .collect::<Vec<_>>()
770 .join("\n")
771 );
772}
773
774fn render_recovery_markdown(out: &mut String, baseline: &ProjectionReport, candidate: &ProjectionReport) {
775 let _ = writeln!(
776 out,
777 "\n## Recovery\n\n| Case | Expected | Baseline | Candidate |\n| --- | --- | --- | --- |\n{}
778",
779 baseline
780 .recovery
781 .iter()
782 .zip(&candidate.recovery)
783 .map(|(baseline, candidate)| format!(
784 "| `{}` | {} | {} | {} |",
785 baseline.id, baseline.expected_available, baseline.available, candidate.available
786 ))
787 .collect::<Vec<_>>()
788 .join("\n")
789 );
790}
791
792fn render_receipt_markdown(out: &mut String, projection: &ProjectionReport) {
793 let _ = writeln!(
794 out,
795 "\n## {} receipts\n\n| Item | Method | Before bytes | After bytes | Lossy |\n| --- | --- | ---: | ---: | --- |\n{}\n",
796 projection.policy,
797 projection
798 .receipts
799 .iter()
800 .map(|receipt| format!(
801 "| `{}` | `{}` | {} | {} | {} |",
802 receipt.item_id, receipt.method, receipt.before_bytes, receipt.after_bytes, receipt.lossy
803 ))
804 .collect::<Vec<_>>()
805 .join("\n")
806 );
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 const FIXTURE: &str = include_str!("../fixtures/context-replay/context.json");
814
815 #[test]
816 fn fixture_round_trip_and_report_are_deterministic() {
817 let fixture = load_fixture(FIXTURE).expect("fixture loads");
818 let candidate = CandidatePolicy::new("candidate");
819 let first = evaluate_fixture(&fixture, &candidate).expect("evaluation succeeds");
820 let second = evaluate_fixture(&fixture, &candidate).expect("evaluation succeeds");
821 assert_eq!(first, second);
822 assert_eq!(first.to_json().expect("json"), second.to_json().expect("json"));
823 assert_eq!(first.to_markdown(), second.to_markdown());
824 assert!(first.baseline.exact_bytes.value > 0);
825 assert!(!first.baseline.receipts.is_empty());
826 assert!(first.provider_usage.is_some());
827 }
828
829 #[test]
830 fn required_fact_omission_fails_independently_of_timing() {
831 let fixture = load_fixture(FIXTURE).expect("fixture loads");
832 let error = evaluate_fixture(&fixture, &CandidatePolicy::new("candidate").omit("error-middle"))
833 .expect_err("required fact omission must fail");
834 assert!(matches!(error, ReplayError::InvariantViolation { .. }));
835 }
836
837 #[test]
838 fn recovery_is_checked_by_opaque_handle() {
839 let mut fixture = load_fixture(FIXTURE).expect("fixture loads");
840 fixture.required_facts.retain(|fact| fact.id != "protected-write");
841 fixture
842 .items
843 .iter_mut()
844 .find(|item| item.id == "protected-output")
845 .expect("protected item")
846 .fact_ids
847 .clear();
848 let error = evaluate_fixture(&fixture, &CandidatePolicy::new("candidate").omit("protected-output"))
849 .expect_err("recovery omission must fail");
850 assert!(error.to_string().contains("recovery"));
851 }
852
853 #[test]
854 fn unknown_provider_usage_is_omitted_from_json() {
855 let mut fixture = load_fixture(FIXTURE).expect("fixture loads");
856 fixture.provider_usage = None;
857 let report = evaluate_fixture(&fixture, &CandidatePolicy::new("candidate")).expect("evaluation succeeds");
858 assert!(report.provider_usage.is_none());
859 assert!(!report.to_json().expect("json").contains("provider_usage"));
860 }
861
862 #[test]
863 fn malformed_fixture_is_rejected() {
864 let mut fixture = load_fixture(FIXTURE).expect("fixture loads");
865 fixture.items[0].fact_ids.push("missing".to_string());
866 assert!(matches!(fixture.validate(), Err(ReplayError::InvalidFixture(_))));
867 }
868}