Skip to main content

telltale_vm/
envelope_diff.rs

1//! Envelope differential artifacts for cross-engine conformance.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::determinism::EffectDeterminismTier;
8use crate::serialization::CanonicalReplayFragmentV1;
9use crate::trace::normalize_trace;
10use crate::trace::obs_session;
11use crate::verification::{DefaultVerificationModel, HashTag, VerificationModel};
12use crate::vm::ObsEvent;
13
14/// Canonical schema version identifier for envelope differential artifacts.
15pub const ENVELOPE_DIFF_SCHEMA_VERSION: &str = "vm.envelope_diff.v1";
16
17fn default_schema_version() -> String {
18    ENVELOPE_DIFF_SCHEMA_VERSION.to_string()
19}
20
21fn normalize_envelope_schema_version(raw: &str) -> String {
22    if raw == "1" {
23        ENVELOPE_DIFF_SCHEMA_VERSION.to_string()
24    } else {
25        raw.to_string()
26    }
27}
28
29fn deserialize_envelope_schema_version<'de, D>(deserializer: D) -> Result<String, D::Error>
30where
31    D: serde::Deserializer<'de>,
32{
33    #[derive(Deserialize)]
34    #[serde(untagged)]
35    enum SchemaVersionValue {
36        String(String),
37        Integer(u64),
38    }
39
40    let parsed = SchemaVersionValue::deserialize(deserializer)?;
41    Ok(match parsed {
42        SchemaVersionValue::String(version) => normalize_envelope_schema_version(&version),
43        SchemaVersionValue::Integer(version) => {
44            normalize_envelope_schema_version(&version.to_string())
45        }
46    })
47}
48
49/// Scheduler-level differential class between two runs.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum SchedulerPermutationClass {
52    /// Global event order is identical.
53    Exact,
54    /// Global order differs but per-session order is preserved.
55    SessionNormalizedPermutation,
56    /// Differences exceed session-normalized permutation.
57    EnvelopeBounded,
58}
59
60/// Effect-ordering differential class between two runs.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub enum EffectOrderingClass {
63    /// Effect traces match exactly.
64    Exact,
65    /// Replay-fragment behavior matches despite effect ordering differences.
66    ReplayDeterministic,
67    /// Differences are accepted only under an explicit envelope bound.
68    EnvelopeBounded,
69}
70
71/// Failure-visible differential class between two runs.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum FailureVisibleDiffClass {
74    /// Failure-visible snapshots match exactly.
75    Exact,
76    /// Failure-visible differences are accepted only under an explicit envelope.
77    EnvelopeBounded,
78}
79
80/// Wave-width bounds recorded for one envelope differential.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct WaveWidthBound {
83    /// Observed max wave width in the baseline run.
84    pub baseline_max_wave_width: usize,
85    /// Observed max wave width in the candidate run.
86    pub candidate_max_wave_width: usize,
87    /// Declared admissible upper bound for candidate wave width.
88    pub declared_upper_bound: usize,
89}
90
91impl WaveWidthBound {
92    /// Return true when the observed candidate width stays within the declared bound.
93    #[must_use]
94    pub fn within_declared_bound(&self) -> bool {
95        self.candidate_max_wave_width <= self.declared_upper_bound
96    }
97}
98
99/// Runtime differential envelope emitted by multi-engine runs.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct EnvelopeDiff {
102    /// Schema version for this artifact payload.
103    #[serde(
104        default = "default_schema_version",
105        deserialize_with = "deserialize_envelope_schema_version"
106    )]
107    pub schema_version: String,
108    /// Baseline engine identifier.
109    pub baseline_engine: String,
110    /// Candidate engine identifier.
111    pub candidate_engine: String,
112    /// Scheduler-permutation differential class.
113    pub scheduler_permutation_class: SchedulerPermutationClass,
114    /// Wave-width differential dimension.
115    pub wave_width_bound: WaveWidthBound,
116    /// Effect-ordering differential class.
117    pub effect_ordering_class: EffectOrderingClass,
118    /// Failure-visible differential class.
119    pub failure_visible_diff_class: FailureVisibleDiffClass,
120    /// Declared effect determinism tier for the compared runs.
121    pub effect_determinism_tier: EffectDeterminismTier,
122}
123
124impl EnvelopeDiff {
125    /// Construct an `EnvelopeDiff` from canonical replay fragments.
126    #[must_use]
127    pub fn from_replay_fragments(
128        baseline_engine: impl Into<String>,
129        candidate_engine: impl Into<String>,
130        baseline: &CanonicalReplayFragmentV1,
131        candidate: &CanonicalReplayFragmentV1,
132        baseline_max_wave_width: usize,
133        candidate_max_wave_width: usize,
134        declared_upper_bound: usize,
135        effect_determinism_tier: EffectDeterminismTier,
136    ) -> Self {
137        let scheduler_permutation_class =
138            classify_scheduler_permutation(&baseline.obs_trace, &candidate.obs_trace);
139        let effect_ordering_class =
140            classify_effect_ordering(baseline, candidate, scheduler_permutation_class);
141        let failure_visible_diff_class = classify_failure_visible(baseline, candidate);
142
143        Self {
144            schema_version: default_schema_version(),
145            baseline_engine: baseline_engine.into(),
146            candidate_engine: candidate_engine.into(),
147            scheduler_permutation_class,
148            wave_width_bound: WaveWidthBound {
149                baseline_max_wave_width,
150                candidate_max_wave_width,
151                declared_upper_bound,
152            },
153            effect_ordering_class,
154            failure_visible_diff_class,
155            effect_determinism_tier,
156        }
157    }
158
159    /// Stable canonical JSON serialization.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if JSON serialization fails.
164    pub fn canonical_json(&self) -> Result<Vec<u8>, serde_json::Error> {
165        serde_json::to_vec(self)
166    }
167
168    /// Stable hash digest for this envelope differential artifact.
169    #[must_use]
170    pub fn stable_hash_hex(&self) -> String {
171        stable_hash_hex_from_serializable(self)
172    }
173}
174
175/// Emitted envelope artifact carrying the diff and stable hashes.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct EnvelopeDiffArtifactV1 {
178    /// Schema version for this artifact payload.
179    #[serde(
180        default = "default_schema_version",
181        deserialize_with = "deserialize_envelope_schema_version"
182    )]
183    pub schema_version: String,
184    /// Envelope differential payload.
185    pub envelope_diff: EnvelopeDiff,
186    /// Stable hash of the baseline canonical replay fragment.
187    pub baseline_fragment_hash: String,
188    /// Stable hash of the candidate canonical replay fragment.
189    pub candidate_fragment_hash: String,
190    /// Stable hash of the envelope differential payload.
191    pub envelope_diff_hash: String,
192}
193
194impl EnvelopeDiffArtifactV1 {
195    /// Build an artifact from replay fragments and computed envelope dimensions.
196    #[must_use]
197    pub fn from_replay_fragments(
198        baseline_engine: impl Into<String>,
199        candidate_engine: impl Into<String>,
200        baseline: &CanonicalReplayFragmentV1,
201        candidate: &CanonicalReplayFragmentV1,
202        baseline_max_wave_width: usize,
203        candidate_max_wave_width: usize,
204        declared_upper_bound: usize,
205        effect_determinism_tier: EffectDeterminismTier,
206    ) -> Self {
207        let envelope_diff = EnvelopeDiff::from_replay_fragments(
208            baseline_engine,
209            candidate_engine,
210            baseline,
211            candidate,
212            baseline_max_wave_width,
213            candidate_max_wave_width,
214            declared_upper_bound,
215            effect_determinism_tier,
216        );
217        let baseline_fragment_hash = stable_hash_hex_from_serializable(baseline);
218        let candidate_fragment_hash = stable_hash_hex_from_serializable(candidate);
219        let envelope_diff_hash = envelope_diff.stable_hash_hex();
220        Self {
221            schema_version: default_schema_version(),
222            envelope_diff,
223            baseline_fragment_hash,
224            candidate_fragment_hash,
225            envelope_diff_hash,
226        }
227    }
228}
229
230fn classify_scheduler_permutation(
231    baseline_trace: &[ObsEvent],
232    candidate_trace: &[ObsEvent],
233) -> SchedulerPermutationClass {
234    if baseline_trace == candidate_trace {
235        return SchedulerPermutationClass::Exact;
236    }
237    let baseline_normalized = normalize_trace(baseline_trace);
238    let candidate_normalized = normalize_trace(candidate_trace);
239    if per_session_projection(&baseline_normalized) == per_session_projection(&candidate_normalized)
240    {
241        return SchedulerPermutationClass::SessionNormalizedPermutation;
242    }
243    SchedulerPermutationClass::EnvelopeBounded
244}
245
246fn per_session_projection(trace: &[ObsEvent]) -> BTreeMap<usize, Vec<ObsEvent>> {
247    let mut out: BTreeMap<usize, Vec<ObsEvent>> = BTreeMap::new();
248    for event in trace {
249        if let Some(sid) = obs_session(event) {
250            out.entry(sid).or_default().push(event.clone());
251        }
252    }
253    out
254}
255
256fn classify_effect_ordering(
257    baseline: &CanonicalReplayFragmentV1,
258    candidate: &CanonicalReplayFragmentV1,
259    scheduler_permutation_class: SchedulerPermutationClass,
260) -> EffectOrderingClass {
261    if baseline.effect_trace == candidate.effect_trace {
262        return EffectOrderingClass::Exact;
263    }
264    match scheduler_permutation_class {
265        SchedulerPermutationClass::Exact
266        | SchedulerPermutationClass::SessionNormalizedPermutation => {
267            EffectOrderingClass::ReplayDeterministic
268        }
269        SchedulerPermutationClass::EnvelopeBounded => EffectOrderingClass::EnvelopeBounded,
270    }
271}
272
273fn classify_failure_visible(
274    baseline: &CanonicalReplayFragmentV1,
275    candidate: &CanonicalReplayFragmentV1,
276) -> FailureVisibleDiffClass {
277    if baseline.crashed_sites == candidate.crashed_sites
278        && baseline.partitioned_edges == candidate.partitioned_edges
279        && baseline.corrupted_edges == candidate.corrupted_edges
280        && baseline.timed_out_sites == candidate.timed_out_sites
281    {
282        FailureVisibleDiffClass::Exact
283    } else {
284        FailureVisibleDiffClass::EnvelopeBounded
285    }
286}
287
288fn stable_hash_hex_from_serializable<T: Serialize>(value: &T) -> String {
289    let bytes = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
290    let digest = DefaultVerificationModel::hash(HashTag::Value, &bytes);
291    bytes_to_hex(&digest.0)
292}
293
294#[allow(clippy::as_conversions)]
295fn bytes_to_hex(bytes: &[u8]) -> String {
296    const HEX: &[u8; 16] = b"0123456789abcdef";
297    let mut out = String::with_capacity(bytes.len() * 2);
298    for byte in bytes {
299        // Nibble values are always in 0..16, so usize indexing is safe.
300        out.push(HEX[(byte >> 4) as usize] as char);
301        // Same invariant for the low nibble.
302        out.push(HEX[(byte & 0x0f) as usize] as char);
303    }
304    out
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::session::Edge;
311
312    fn sent(session: usize, tick: u64) -> ObsEvent {
313        ObsEvent::Sent {
314            tick,
315            edge: Edge::new(session, "A", "B"),
316            session,
317            from: "A".to_string(),
318            to: "B".to_string(),
319            label: "m".to_string(),
320        }
321    }
322
323    fn fragment(trace: Vec<ObsEvent>) -> CanonicalReplayFragmentV1 {
324        CanonicalReplayFragmentV1 {
325            schema_version: crate::serialization::SERIALIZATION_SCHEMA_VERSION.to_string(),
326            obs_trace: trace,
327            effect_trace: Vec::new(),
328            crashed_sites: Vec::new(),
329            partitioned_edges: Vec::new(),
330            corrupted_edges: Vec::new(),
331            timed_out_sites: Vec::new(),
332            effect_determinism_tier: EffectDeterminismTier::StrictDeterministic,
333            communication_replay_mode: crate::communication_replay::CommunicationReplayMode::Off,
334            communication_replay_root: None,
335            communication_consumption_artifacts: Vec::new(),
336        }
337    }
338
339    #[test]
340    fn scheduler_class_detects_session_permutation() {
341        let baseline = fragment(vec![sent(1, 1), sent(2, 2)]);
342        let candidate = fragment(vec![sent(2, 3), sent(1, 4)]);
343        let diff = EnvelopeDiff::from_replay_fragments(
344            "canonical",
345            "threaded",
346            &baseline,
347            &candidate,
348            1,
349            2,
350            2,
351            EffectDeterminismTier::EnvelopeBoundedNondeterministic,
352        );
353        assert_eq!(
354            diff.scheduler_permutation_class,
355            SchedulerPermutationClass::SessionNormalizedPermutation
356        );
357    }
358
359    #[test]
360    fn envelope_hash_is_stable_for_equal_payloads() {
361        let baseline = fragment(vec![sent(1, 1)]);
362        let candidate = fragment(vec![sent(1, 1)]);
363        let left = EnvelopeDiff::from_replay_fragments(
364            "a",
365            "b",
366            &baseline,
367            &candidate,
368            1,
369            1,
370            1,
371            EffectDeterminismTier::StrictDeterministic,
372        );
373        let right = EnvelopeDiff::from_replay_fragments(
374            "a",
375            "b",
376            &baseline,
377            &candidate,
378            1,
379            1,
380            1,
381            EffectDeterminismTier::StrictDeterministic,
382        );
383        assert_eq!(left.stable_hash_hex(), right.stable_hash_hex());
384    }
385
386    #[test]
387    fn artifact_hash_tracks_envelope_payload() {
388        let baseline = fragment(vec![sent(1, 1)]);
389        let candidate = fragment(vec![sent(1, 1)]);
390        let artifact = EnvelopeDiffArtifactV1::from_replay_fragments(
391            "canonical",
392            "threaded",
393            &baseline,
394            &candidate,
395            1,
396            1,
397            1,
398            EffectDeterminismTier::StrictDeterministic,
399        );
400        assert!(!artifact.envelope_diff_hash.is_empty());
401        assert_eq!(
402            artifact.envelope_diff_hash,
403            artifact.envelope_diff.stable_hash_hex()
404        );
405    }
406
407    #[test]
408    fn legacy_numeric_schema_version_deserializes_to_string_identifier() {
409        let payload = serde_json::json!({
410            "schema_version": 1,
411            "baseline_engine": "lean",
412            "candidate_engine": "threaded",
413            "scheduler_permutation_class": "Exact",
414            "wave_width_bound": {
415                "baseline_max_wave_width": 1,
416                "candidate_max_wave_width": 1,
417                "declared_upper_bound": 1
418            },
419            "effect_ordering_class": "Exact",
420            "failure_visible_diff_class": "Exact",
421            "effect_determinism_tier": "strict_deterministic"
422        });
423        let decoded: EnvelopeDiff =
424            serde_json::from_value(payload).expect("legacy schema version should deserialize");
425        assert_eq!(decoded.schema_version, ENVELOPE_DIFF_SCHEMA_VERSION);
426    }
427}