Skip to main content

thndrs_agent/
replay.rs

1//! Deterministic replay fixtures and evaluation for context projections.
2//!
3//! Replay data is deliberately provider-neutral. A fixture describes ordered
4//! evidence and required facts; it does not prescribe the prose a projection
5//! must use. Policies select items, and this module measures the resulting
6//! projection and checks that required facts and recovery handles remain
7//! available. The default evaluator does not sample wall-clock time, which
8//! keeps JSON and Markdown reports suitable for golden-file comparisons.
9
10use 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
21/// Version of the on-disk context replay fixture schema.
22pub const REPLAY_FIXTURE_SCHEMA_VERSION: &str = "context-replay-v1";
23
24/// Version of the deterministic replay report schema.
25pub const REPLAY_REPORT_SCHEMA_VERSION: &str = "context-replay-report-v1";
26
27/// Policy used to select items for a replay projection.
28pub trait ReplayPolicy {
29    /// Stable policy name written to reports.
30    fn name(&self) -> &str;
31
32    /// Whether this item is present in the policy's model projection.
33    fn include(&self, item: &ReplayItem) -> bool;
34}
35
36/// The complete unoptimized projection policy.
37#[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/// A named candidate policy which can omit explicit item ids.
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct CandidatePolicy {
53    name: String,
54    omitted_ids: BTreeSet<String>,
55}
56
57impl CandidatePolicy {
58    /// Create a candidate that initially includes every fixture item.
59    pub fn new(name: impl Into<String>) -> Self {
60        Self { name: name.into(), omitted_ids: BTreeSet::new() }
61    }
62
63    /// Return a candidate with one item omitted.
64    pub fn omit(mut self, id: impl Into<String>) -> Self {
65        self.omitted_ids.insert(id.into());
66        self
67    }
68
69    /// Return a candidate with all supplied item ids omitted.
70    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    /// Return the ids explicitly omitted by this candidate.
80    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/// Error returned when fixture data or a replay policy violates an invariant.
96#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
97pub enum ReplayError {
98    /// The fixture cannot be loaded or does not satisfy its schema.
99    #[error("invalid replay fixture: {0}")]
100    InvalidFixture(String),
101    /// A baseline or candidate failed a required preservation invariant.
102    #[error("replay invariant failed for {policy}: {message}")]
103    InvariantViolation {
104        /// Policy which failed validation.
105        policy: String,
106        /// Stable explanation of the failed invariant.
107        message: String,
108    },
109    /// A report could not be serialized.
110    #[error("replay serialization failed: {0}")]
111    Serialization(String),
112}
113
114/// Kind of evidence represented by one replay item.
115#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ReplayItemKind {
118    /// User-provided instruction or constraint.
119    UserTurn,
120    /// Assistant response or reasoning summary.
121    Assistant,
122    /// Tool output which may be repeated or overlapping.
123    ToolOutput,
124    /// Passing test or check output.
125    PassingTest,
126    /// Failing test or check output.
127    FailingTest,
128    /// High-volume progress output.
129    Progress,
130    /// An error or diagnostic.
131    Error,
132    /// A command invocation.
133    Command,
134    /// A write operation and its result.
135    Write,
136    /// Evidence explicitly protected from lossy removal.
137    ProtectedEvidence,
138}
139
140impl ReplayItemKind {
141    /// Stable fixture/report label.
142    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/// Scenario represented by a frozen fixture.
159#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum ReplayScenario {
162    /// Repeated reads of the same source.
163    RepeatedReads,
164    /// Reads whose ranges overlap without being identical.
165    OverlappingReads,
166    /// Passing test output.
167    PassingTests,
168    /// Failing test output.
169    FailingTests,
170    /// Noisy progress output.
171    NoisyProgress,
172    /// An error which occurs in the middle of a sequence.
173    MiddlePositionError,
174    /// Repeated commands with a changed state fingerprint.
175    StateChangingCommands,
176    /// A failed write carrying a large input.
177    FailedLargeWrite,
178    /// Evidence protected from automatic omission.
179    ProtectedEvidence,
180    /// Recorded provider cache components.
181    CacheComponents,
182    /// Bounded artifact recovery.
183    Recovery,
184}
185
186impl ReplayScenario {
187    /// Stable fixture/report label.
188    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/// Controls whether evaluator timing is measured or deterministic.
206#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
207pub enum ReplayTiming {
208    /// Use zero timing so serialized reports compare byte-for-byte.
209    #[default]
210    Deterministic,
211    /// Record wall-clock elapsed microseconds for exploratory runs.
212    Measured,
213}
214
215/// One ordered piece of fixture evidence.
216#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
217pub struct ReplayItem {
218    /// Stable item id within this fixture.
219    pub id: String,
220    /// Kind of evidence.
221    pub kind: ReplayItemKind,
222    /// Human-readable source label; it is not used as a required fact.
223    pub label: String,
224    /// Provider-neutral bounded projection text for this item.
225    pub content: String,
226    /// Original evidence size when the bounded fixture content is a sample.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub original_bytes: Option<u64>,
229    /// Required fact ids supported by this item.
230    #[serde(default)]
231    pub fact_ids: Vec<String>,
232    /// Opaque bounded recovery handle, when this item is recoverable.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub recovery_handle: Option<String>,
235    /// Whether this item is protected evidence.
236    #[serde(default)]
237    pub protected: bool,
238    /// State fingerprint for state-aware identity checks.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub state_fingerprint: Option<String>,
241}
242
243impl ReplayItem {
244    /// Exact UTF-8 size of the item content before projection framing.
245    pub fn content_bytes(&self) -> u64 {
246        self.content.len() as u64
247    }
248
249    /// Size represented by a receipt before any candidate projection decision.
250    pub fn source_bytes(&self) -> u64 {
251        self.original_bytes.unwrap_or_else(|| self.content_bytes())
252    }
253}
254
255/// A fact which must remain represented, independent of expected prose.
256#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
257pub struct RequiredFact {
258    /// Stable fact id.
259    pub id: String,
260    /// Maintainer-facing explanation of why the fact matters.
261    pub description: String,
262    /// Whether the fact is protected from lossy omission.
263    #[serde(default)]
264    pub protected: bool,
265}
266
267/// Expected recovery behavior for one artifact handle.
268#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
269pub struct RecoveryCase {
270    /// Stable recovery case id.
271    pub id: String,
272    /// Opaque artifact handle expected to be available when selected.
273    pub artifact_handle: String,
274    /// Whether this fixture expects the policy to retain the handle.
275    pub expected_available: bool,
276}
277
278/// Provider usage explicitly recorded alongside a fixture.
279#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
280pub struct RecordedProviderUsage {
281    /// Provider adapter label.
282    pub provider: String,
283    /// Provider-specific normalization rule.
284    pub rule: ProviderUsageRule,
285    /// Raw components recorded by the fixture.
286    pub components: ProviderUsageComponents,
287}
288
289impl RecordedProviderUsage {
290    /// Normalize the recorded components for inclusion in a report.
291    pub fn normalize(&self) -> ProviderUsage {
292        self.components.normalize(&self.provider, self.rule)
293    }
294}
295
296/// A versioned, deterministic context replay fixture.
297#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
298pub struct ReplayFixture {
299    /// Fixture schema version.
300    pub schema_version: String,
301    /// Stable fixture id.
302    pub id: String,
303    /// Maintainer-facing description.
304    pub description: String,
305    /// Scenarios deliberately covered by this fixture.
306    pub scenarios: Vec<ReplayScenario>,
307    /// Ordered context evidence.
308    pub items: Vec<ReplayItem>,
309    /// Facts that the evaluator must preserve.
310    pub required_facts: Vec<RequiredFact>,
311    /// Recovery outcomes to check.
312    #[serde(default)]
313    pub recovery: Vec<RecoveryCase>,
314    /// Optional provider usage; absent means the report must omit it.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub provider_usage: Option<RecordedProviderUsage>,
317}
318
319impl ReplayFixture {
320    /// Validate schema, ids, fact references, and recovery handles.
321    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    /// Serialize this fixture as stable pretty JSON.
378    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/// A reduction or selection receipt for one fixture item.
384#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
385pub struct ReplayReceipt {
386    /// Stable item id.
387    pub item_id: String,
388    /// Policy or reducer method label.
389    pub method: String,
390    /// Method version.
391    pub version: String,
392    /// Content bytes before this policy decision.
393    pub before_bytes: u64,
394    /// Framed projection bytes after this policy decision.
395    pub after_bytes: u64,
396    /// Whether the decision can remove information.
397    pub lossy: bool,
398}
399
400/// Required-fact preservation result in a report.
401#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
402pub struct RequiredFactResult {
403    /// Stable fact id.
404    pub id: String,
405    /// Whether at least one selected item carries the fact.
406    pub preserved: bool,
407    /// Selected evidence item ids carrying the fact.
408    pub item_ids: Vec<String>,
409}
410
411/// Recovery result in a report.
412#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
413pub struct RecoveryOutcome {
414    /// Stable recovery case id.
415    pub id: String,
416    /// Opaque handle from the fixture.
417    pub artifact_handle: String,
418    /// Expected availability.
419    pub expected_available: bool,
420    /// Availability produced by the policy projection.
421    pub available: bool,
422}
423
424/// The ephemeral projection and its deterministic measurements.
425#[derive(Clone, Debug, Eq, PartialEq)]
426pub struct ReplayProjection {
427    /// Policy name.
428    pub policy: String,
429    /// Selected item ids in fixture order.
430    pub item_ids: Vec<String>,
431    /// Framed provider-neutral projection text, retained only during replay.
432    pub rendered: String,
433    /// Exact bytes of the projection text.
434    pub exact_bytes: ByteMeasurement,
435    /// Conservative estimate based on those exact bytes.
436    pub estimated_tokens: TokenMeasurement,
437    /// Per-item selection receipts.
438    pub receipts: Vec<ReplayReceipt>,
439    /// Recovery handles present in the projection.
440    pub recovery_handles: Vec<String>,
441}
442
443/// Reportable measurements and invariants for one policy.
444#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
445pub struct ProjectionReport {
446    /// Policy name.
447    pub policy: String,
448    /// Exact projection bytes.
449    pub exact_bytes: ByteMeasurement,
450    /// Estimated projection tokens.
451    pub estimated_tokens: TokenMeasurement,
452    /// Stable digest of the projection without persisting its body.
453    pub projection_digest: String,
454    /// Number of selected items.
455    pub item_count: usize,
456    /// Selection/reduction receipts.
457    pub receipts: Vec<ReplayReceipt>,
458    /// Required facts and their supporting selected items.
459    pub required_facts: Vec<RequiredFactResult>,
460    /// Recovery outcomes.
461    pub recovery: Vec<RecoveryOutcome>,
462    /// Elapsed evaluator time in microseconds; zero in deterministic mode.
463    pub elapsed_micros: u64,
464}
465
466/// Size comparison between baseline and candidate projections.
467#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
468pub struct ReplayComparison {
469    /// Candidate bytes minus baseline bytes.
470    pub exact_bytes_delta: i64,
471    /// Candidate estimated tokens minus baseline estimated tokens.
472    pub estimated_tokens_delta: i64,
473}
474
475/// Complete deterministic report for one baseline/candidate evaluation.
476#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
477pub struct ReplayReport {
478    /// Report schema version.
479    pub schema_version: String,
480    /// Fixture id.
481    pub fixture_id: String,
482    /// Baseline measurements.
483    pub baseline: ProjectionReport,
484    /// Candidate measurements.
485    pub candidate: ProjectionReport,
486    /// Size comparison.
487    pub comparison: ReplayComparison,
488    /// Provider usage, only when the fixture explicitly records it.
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub provider_usage: Option<ProviderUsage>,
491}
492
493/// Typed evaluator for baseline/candidate fixture comparisons.
494#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
495pub struct ReplayEvaluator {
496    timing: ReplayTiming,
497}
498
499impl ReplayEvaluator {
500    /// Return an evaluator whose report is deterministic across runs.
501    pub const fn new() -> Self {
502        Self { timing: ReplayTiming::Deterministic }
503    }
504
505    /// Return an evaluator which records wall-clock timing for each policy.
506    pub const fn measured() -> Self {
507        Self { timing: ReplayTiming::Measured }
508    }
509
510    /// Evaluate a baseline and candidate and fail on preservation/recovery violations.
511    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    /// Serialize the report as stable pretty JSON.
570    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    /// Render the report as deterministic Markdown without projection bodies.
575    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
603/// Parse and validate one JSON fixture.
604pub 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
611/// Select fixture items using a typed policy.
612pub 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
616/// Build a pure measured projection from fixture items selected by a policy.
617pub 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
672/// Evaluate one fixture with the complete baseline policy and a candidate.
673pub 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('&', "&amp;")
719        .replace('<', "&lt;")
720        .replace('>', "&gt;")
721        .replace('"', "&quot;")
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}