1use 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
14pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum SchedulerPermutationClass {
52 Exact,
54 SessionNormalizedPermutation,
56 EnvelopeBounded,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub enum EffectOrderingClass {
63 Exact,
65 ReplayDeterministic,
67 EnvelopeBounded,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum FailureVisibleDiffClass {
74 Exact,
76 EnvelopeBounded,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct WaveWidthBound {
83 pub baseline_max_wave_width: usize,
85 pub candidate_max_wave_width: usize,
87 pub declared_upper_bound: usize,
89}
90
91impl WaveWidthBound {
92 #[must_use]
94 pub fn within_declared_bound(&self) -> bool {
95 self.candidate_max_wave_width <= self.declared_upper_bound
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct EnvelopeDiff {
102 #[serde(
104 default = "default_schema_version",
105 deserialize_with = "deserialize_envelope_schema_version"
106 )]
107 pub schema_version: String,
108 pub baseline_engine: String,
110 pub candidate_engine: String,
112 pub scheduler_permutation_class: SchedulerPermutationClass,
114 pub wave_width_bound: WaveWidthBound,
116 pub effect_ordering_class: EffectOrderingClass,
118 pub failure_visible_diff_class: FailureVisibleDiffClass,
120 pub effect_determinism_tier: EffectDeterminismTier,
122}
123
124impl EnvelopeDiff {
125 #[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 pub fn canonical_json(&self) -> Result<Vec<u8>, serde_json::Error> {
165 serde_json::to_vec(self)
166 }
167
168 #[must_use]
170 pub fn stable_hash_hex(&self) -> String {
171 stable_hash_hex_from_serializable(self)
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct EnvelopeDiffArtifactV1 {
178 #[serde(
180 default = "default_schema_version",
181 deserialize_with = "deserialize_envelope_schema_version"
182 )]
183 pub schema_version: String,
184 pub envelope_diff: EnvelopeDiff,
186 pub baseline_fragment_hash: String,
188 pub candidate_fragment_hash: String,
190 pub envelope_diff_hash: String,
192}
193
194impl EnvelopeDiffArtifactV1 {
195 #[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 out.push(HEX[(byte >> 4) as usize] as char);
301 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 semantic_audit_log: Vec::new(),
337 }
338 }
339
340 #[test]
341 fn scheduler_class_detects_session_permutation() {
342 let baseline = fragment(vec![sent(1, 1), sent(2, 2)]);
343 let candidate = fragment(vec![sent(2, 3), sent(1, 4)]);
344 let diff = EnvelopeDiff::from_replay_fragments(
345 "canonical",
346 "threaded",
347 &baseline,
348 &candidate,
349 1,
350 2,
351 2,
352 EffectDeterminismTier::EnvelopeBoundedNondeterministic,
353 );
354 assert_eq!(
355 diff.scheduler_permutation_class,
356 SchedulerPermutationClass::SessionNormalizedPermutation
357 );
358 }
359
360 #[test]
361 fn envelope_hash_is_stable_for_equal_payloads() {
362 let baseline = fragment(vec![sent(1, 1)]);
363 let candidate = fragment(vec![sent(1, 1)]);
364 let left = EnvelopeDiff::from_replay_fragments(
365 "a",
366 "b",
367 &baseline,
368 &candidate,
369 1,
370 1,
371 1,
372 EffectDeterminismTier::StrictDeterministic,
373 );
374 let right = EnvelopeDiff::from_replay_fragments(
375 "a",
376 "b",
377 &baseline,
378 &candidate,
379 1,
380 1,
381 1,
382 EffectDeterminismTier::StrictDeterministic,
383 );
384 assert_eq!(left.stable_hash_hex(), right.stable_hash_hex());
385 }
386
387 #[test]
388 fn artifact_hash_tracks_envelope_payload() {
389 let baseline = fragment(vec![sent(1, 1)]);
390 let candidate = fragment(vec![sent(1, 1)]);
391 let artifact = EnvelopeDiffArtifactV1::from_replay_fragments(
392 "canonical",
393 "threaded",
394 &baseline,
395 &candidate,
396 1,
397 1,
398 1,
399 EffectDeterminismTier::StrictDeterministic,
400 );
401 assert!(!artifact.envelope_diff_hash.is_empty());
402 assert_eq!(
403 artifact.envelope_diff_hash,
404 artifact.envelope_diff.stable_hash_hex()
405 );
406 }
407
408 #[test]
409 fn legacy_numeric_schema_version_deserializes_to_string_identifier() {
410 let payload = serde_json::json!({
411 "schema_version": 1,
412 "baseline_engine": "lean",
413 "candidate_engine": "threaded",
414 "scheduler_permutation_class": "Exact",
415 "wave_width_bound": {
416 "baseline_max_wave_width": 1,
417 "candidate_max_wave_width": 1,
418 "declared_upper_bound": 1
419 },
420 "effect_ordering_class": "Exact",
421 "failure_visible_diff_class": "Exact",
422 "effect_determinism_tier": "strict_deterministic"
423 });
424 let decoded: EnvelopeDiff =
425 serde_json::from_value(payload).expect("legacy schema version should deserialize");
426 assert_eq!(decoded.schema_version, ENVELOPE_DIFF_SCHEMA_VERSION);
427 }
428}