Skip to main content

asupersync/lab/
dual_run.rs

1#![allow(missing_docs)]
2//! Dual-run scenario types for lab-vs-live differential testing.
3//!
4//! This module implements the shared seed plumbing and replay metadata
5//! types defined by the `DualRunScenarioSpec` contract
6//! (`docs/lab_live_scenario_adapter_contract.md`).
7//!
8//! # Seed Flow
9//!
10//! ```text
11//! DualRunScenarioSpec.seed_plan
12//!     ├─→ Lab adapter: SeedPlan → LabConfig (inherit or override)
13//!     └─→ Live adapter: SeedPlan → live runner seed (inherit or override)
14//!
15//! SeedPlan.canonical_seed + scenario_id → deterministic execution
16//! SeedPlan.seed_lineage_id → artifact traceability
17//! ```
18//!
19//! # Scenario Identity
20//!
21//! The system distinguishes two layers of identity:
22//!
23//! - **Scenario family**: the stable adversarial case (e.g., "cancel during
24//!   two-phase send") — survives shrinking, promotion, and reruns.
25//! - **Execution instance**: one concrete run of a family (seed + config
26//!   snapshot) — unique per execution.
27//!
28//! This separation lets reruns, shrink steps, and regression promotion
29//! carry the family identity cleanly while tracking which specific
30//! execution produced evidence.
31//!
32//! # Replay Metadata
33//!
34//! [`ReplayMetadata`] captures both identity layers plus enough provenance
35//! to rerun or explain a mismatch. It is emitted into normalized
36//! observables and mismatch bundles.
37
38use crate::lab::config::LabConfig;
39use serde::{Deserialize, Serialize};
40use std::collections::BTreeMap;
41use std::fmt;
42
43// Keep deterministic seed derivation available in normal library builds;
44// `test_logging` is gated behind `test-internals` and is unavailable in wasm.
45fn derive_component_seed(root: u64, component: &str) -> u64 {
46    fnv1a_mix(root, component.as_bytes())
47}
48
49fn derive_scenario_seed(root: u64, scenario: &str) -> u64 {
50    let tag = format!("scenario:{scenario}");
51    fnv1a_mix(root, tag.as_bytes())
52}
53
54fn fnv1a_mix(root: u64, tag: &[u8]) -> u64 {
55    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
56    const FNV_PRIME: u64 = 0x0100_0000_01b3;
57
58    let mut hash = FNV_OFFSET;
59    for byte in root.to_le_bytes() {
60        hash ^= u64::from(byte);
61        hash = hash.wrapping_mul(FNV_PRIME);
62    }
63    for &byte in tag {
64        hash ^= u64::from(byte);
65        hash = hash.wrapping_mul(FNV_PRIME);
66    }
67    hash
68}
69
70// ============================================================================
71// Seed Mode and Replay Policy
72// ============================================================================
73
74/// How an adapter derives its effective seed from the canonical seed.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SeedMode {
78    /// Use `canonical_seed` directly (or derived via `derive_scenario_seed`).
79    Inherit,
80    /// The adapter provides its own seed, overriding the canonical one.
81    /// The override value is stored in `SeedPlan::lab_seed_override` or
82    /// `SeedPlan::live_seed_override`.
83    Override,
84}
85
86/// Replay strategy for seed-based reproducibility.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ReplayPolicy {
90    /// Run with exactly one seed. Simplest and most common.
91    SingleSeed,
92    /// Sweep a range of seeds derived from the canonical seed.
93    /// Used for schedule exploration.
94    SeedSweep,
95    /// Replay from a previously captured trace bundle.
96    /// Seed is informational; the trace dictates scheduling.
97    ReplayBundle,
98}
99
100// ============================================================================
101// Seed Plan
102// ============================================================================
103
104/// Deterministic seed plan for dual-run scenario execution.
105///
106/// This is the single source of truth for how both lab and live adapters
107/// obtain their seeds. It enforces the contract rule: "The live adapter
108/// may not silently pick a different seed than the lab adapter."
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct SeedPlan {
111    /// Stable seed chosen by the scenario author.
112    pub canonical_seed: u64,
113
114    /// Stable token emitted into mismatch artifacts and repro commands.
115    /// Typically the scenario_id or a human-readable lineage tag.
116    pub seed_lineage_id: String,
117
118    /// How the lab adapter derives its effective seed.
119    pub lab_seed_mode: SeedMode,
120
121    /// How the live adapter derives its effective seed.
122    pub live_seed_mode: SeedMode,
123
124    /// Replay strategy.
125    pub replay_policy: ReplayPolicy,
126
127    /// Explicit lab seed override (only used when `lab_seed_mode == Override`).
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub lab_seed_override: Option<u64>,
130
131    /// Explicit live seed override (only used when `live_seed_mode == Override`).
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub live_seed_override: Option<u64>,
134
135    /// Optional entropy seed override. When `None`, entropy derives from
136    /// the effective seed via `derive_component_seed(seed, "entropy")`.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub entropy_seed_override: Option<u64>,
139}
140
141impl SeedPlan {
142    /// Create a simple seed plan that inherits the canonical seed on both sides.
143    #[must_use]
144    pub fn inherit(canonical_seed: u64, lineage_id: impl Into<String>) -> Self {
145        Self {
146            canonical_seed,
147            seed_lineage_id: lineage_id.into(),
148            lab_seed_mode: SeedMode::Inherit,
149            live_seed_mode: SeedMode::Inherit,
150            replay_policy: ReplayPolicy::SingleSeed,
151            lab_seed_override: None,
152            live_seed_override: None,
153            entropy_seed_override: None,
154        }
155    }
156
157    /// Compute the effective seed for the lab adapter.
158    #[must_use]
159    pub fn effective_lab_seed(&self) -> u64 {
160        match self.lab_seed_mode {
161            SeedMode::Inherit => self.canonical_seed,
162            SeedMode::Override => self.lab_seed_override.unwrap_or(self.canonical_seed),
163        }
164    }
165
166    /// Compute the effective seed for the live adapter.
167    #[must_use]
168    pub fn effective_live_seed(&self) -> u64 {
169        match self.live_seed_mode {
170            SeedMode::Inherit => self.canonical_seed,
171            SeedMode::Override => self.live_seed_override.unwrap_or(self.canonical_seed),
172        }
173    }
174
175    /// Compute the effective entropy seed for an adapter.
176    /// Uses the explicit override if set, otherwise derives from the
177    /// given effective seed.
178    #[must_use]
179    pub fn effective_entropy_seed(&self, effective_seed: u64) -> u64 {
180        self.entropy_seed_override
181            .unwrap_or_else(|| derive_component_seed(effective_seed, "entropy"))
182    }
183
184    /// Build a [`LabConfig`] from this seed plan.
185    ///
186    /// Sets `seed` and `entropy_seed` according to the plan's lab mode.
187    #[must_use]
188    pub fn to_lab_config(&self) -> LabConfig {
189        let seed = self.effective_lab_seed();
190        let entropy = self.effective_entropy_seed(seed);
191        LabConfig::new(seed).entropy_seed(entropy)
192    }
193
194    /// Generate seeds for a sweep of `count` derived seeds.
195    ///
196    /// Each seed is deterministically derived from the canonical seed
197    /// using `derive_scenario_seed` with a sweep index tag.
198    /// Only meaningful when `replay_policy == SeedSweep`.
199    #[must_use]
200    pub fn sweep_seeds(&self, count: usize) -> Vec<u64> {
201        (0..count)
202            .map(|i| {
203                let tag = format!("sweep:{i}");
204                derive_scenario_seed(self.canonical_seed, &tag)
205            })
206            .collect()
207    }
208
209    /// Set lab seed mode to override with the given seed.
210    #[must_use]
211    pub fn with_lab_override(mut self, seed: u64) -> Self {
212        self.lab_seed_mode = SeedMode::Override;
213        self.lab_seed_override = Some(seed);
214        self
215    }
216
217    /// Set live seed mode to override with the given seed.
218    #[must_use]
219    pub fn with_live_override(mut self, seed: u64) -> Self {
220        self.live_seed_mode = SeedMode::Override;
221        self.live_seed_override = Some(seed);
222        self
223    }
224
225    /// Set the replay policy.
226    #[must_use]
227    pub fn with_replay_policy(mut self, policy: ReplayPolicy) -> Self {
228        self.replay_policy = policy;
229        self
230    }
231
232    /// Set an explicit entropy seed override for both adapters.
233    #[must_use]
234    pub fn with_entropy_seed(mut self, seed: u64) -> Self {
235        self.entropy_seed_override = Some(seed);
236        self
237    }
238}
239
240impl fmt::Display for SeedPlan {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        write!(
243            f,
244            "SeedPlan(canonical=0x{:X}, lineage={}, lab={:?}, live={:?}, policy={:?})",
245            self.canonical_seed,
246            self.seed_lineage_id,
247            self.lab_seed_mode,
248            self.live_seed_mode,
249            self.replay_policy,
250        )
251    }
252}
253
254// ============================================================================
255// Scenario Identity
256// ============================================================================
257
258/// Stable identifier for a scenario family.
259///
260/// A family represents the abstract adversarial case independent of any
261/// particular execution. The same family survives shrinking, promotion
262/// into regression suites, and reruns with different seeds.
263#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
264pub struct ScenarioFamilyId {
265    /// Primary stable identifier (e.g., `"phase1.cancel.race.one_loser"`).
266    pub id: String,
267    /// Semantic surface being exercised (e.g., `"cancellation.race"`).
268    pub surface_id: String,
269    /// Versioned comparator contract for this surface.
270    pub surface_contract_version: String,
271}
272
273impl ScenarioFamilyId {
274    /// Create a new scenario family identifier.
275    #[must_use]
276    pub fn new(
277        id: impl Into<String>,
278        surface_id: impl Into<String>,
279        contract_version: impl Into<String>,
280    ) -> Self {
281        Self {
282            id: id.into(),
283            surface_id: surface_id.into(),
284            surface_contract_version: contract_version.into(),
285        }
286    }
287}
288
289impl fmt::Display for ScenarioFamilyId {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        write!(
292            f,
293            "{}@{}({})",
294            self.id, self.surface_id, self.surface_contract_version
295        )
296    }
297}
298
299/// Unique identifier for a specific execution of a scenario family.
300///
301/// Combines the family identity with the concrete seed and a monotonic
302/// run counter. Two executions of the same family with different seeds
303/// produce different instance IDs.
304#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
305pub struct ExecutionInstanceId {
306    /// Which scenario family this execution belongs to.
307    pub family_id: String,
308    /// Effective seed used for this execution.
309    pub effective_seed: u64,
310    /// Runtime kind that produced this instance.
311    pub runtime_kind: RuntimeKind,
312    /// Monotonic run index within a sweep (0 for single-seed runs).
313    pub run_index: u32,
314}
315
316impl ExecutionInstanceId {
317    /// Create a new execution instance ID for a single-seed lab run.
318    #[must_use]
319    pub fn lab(family_id: impl Into<String>, seed: u64) -> Self {
320        Self {
321            family_id: family_id.into(),
322            effective_seed: seed,
323            runtime_kind: RuntimeKind::Lab,
324            run_index: 0,
325        }
326    }
327
328    /// Create a new execution instance ID for a single-seed live run.
329    #[must_use]
330    pub fn live(family_id: impl Into<String>, seed: u64) -> Self {
331        Self {
332            family_id: family_id.into(),
333            effective_seed: seed,
334            runtime_kind: RuntimeKind::Live,
335            run_index: 0,
336        }
337    }
338
339    /// Set the run index (for sweep runs).
340    #[must_use]
341    pub fn with_run_index(mut self, index: u32) -> Self {
342        self.run_index = index;
343        self
344    }
345
346    /// Produce a stable string key for this instance.
347    #[must_use]
348    pub fn key(&self) -> String {
349        format!(
350            "{}:{}:0x{:X}:{}",
351            self.family_id, self.runtime_kind, self.effective_seed, self.run_index
352        )
353    }
354}
355
356impl fmt::Display for ExecutionInstanceId {
357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358        write!(
359            f,
360            "{}[{}@0x{:X}#{}]",
361            self.family_id, self.runtime_kind, self.effective_seed, self.run_index
362        )
363    }
364}
365
366/// Which runtime produced an execution.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
368#[serde(rename_all = "snake_case")]
369pub enum RuntimeKind {
370    /// Deterministic lab runtime (`LabRuntime`).
371    Lab,
372    /// Live runtime (`RuntimeBuilder::current_thread()` for Phase 1).
373    Live,
374}
375
376impl fmt::Display for RuntimeKind {
377    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378        match self {
379            Self::Lab => write!(f, "lab"),
380            Self::Live => write!(f, "live"),
381        }
382    }
383}
384
385// ============================================================================
386// Replay Metadata
387// ============================================================================
388
389/// Replay and provenance metadata for a single execution.
390///
391/// Captures everything needed to rerun or explain a mismatch:
392/// family identity (what scenario?), instance identity (which run?),
393/// effective seeds, trace evidence, and repro commands.
394///
395/// This maps to the `provenance` section of the normalized observable
396/// schema (`lab-live-normalized-observable-v1`).
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct ReplayMetadata {
399    /// Scenario family identity.
400    pub family: ScenarioFamilyId,
401
402    /// Execution instance identity.
403    pub instance: ExecutionInstanceId,
404
405    /// Seed plan that produced this execution.
406    pub seed_plan: SeedPlan,
407
408    /// Effective seed actually used by the adapter.
409    pub effective_seed: u64,
410
411    /// Effective entropy seed actually used.
412    pub effective_entropy_seed: u64,
413
414    /// Trace fingerprint from lab execution (Foata/Mazurkiewicz class).
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub trace_fingerprint: Option<u64>,
417
418    /// Schedule hash from lab execution.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub schedule_hash: Option<u64>,
421
422    /// Event hash from lab execution.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub event_hash: Option<u64>,
425
426    /// Total events observed.
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub event_count: Option<u64>,
429
430    /// Total scheduler steps.
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub steps_total: Option<u64>,
433
434    /// Path to artifact bundle.
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub artifact_path: Option<String>,
437
438    /// Direct deterministic rerun command.
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub repro_command: Option<String>,
441
442    /// Hash of the config used for this execution.
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub config_hash: Option<String>,
445
446    /// Live-side nondeterminism notes retained for later classification.
447    #[serde(default, skip_serializing_if = "Vec::is_empty")]
448    pub nondeterminism_notes: Vec<String>,
449}
450
451impl ReplayMetadata {
452    /// Create replay metadata for a lab execution from a seed plan.
453    #[must_use]
454    pub fn for_lab(family: ScenarioFamilyId, seed_plan: &SeedPlan) -> Self {
455        let effective_seed = seed_plan.effective_lab_seed();
456        let effective_entropy_seed = seed_plan.effective_entropy_seed(effective_seed);
457        let instance = ExecutionInstanceId::lab(&family.id, effective_seed);
458
459        Self {
460            family,
461            instance,
462            seed_plan: seed_plan.clone(),
463            effective_seed,
464            effective_entropy_seed,
465            trace_fingerprint: None,
466            schedule_hash: None,
467            event_hash: None,
468            event_count: None,
469            steps_total: None,
470            artifact_path: None,
471            repro_command: None,
472            config_hash: None,
473            nondeterminism_notes: Vec::new(),
474        }
475    }
476
477    /// Create replay metadata for a live execution from a seed plan.
478    #[must_use]
479    pub fn for_live(family: ScenarioFamilyId, seed_plan: &SeedPlan) -> Self {
480        let effective_seed = seed_plan.effective_live_seed();
481        let effective_entropy_seed = seed_plan.effective_entropy_seed(effective_seed);
482        let instance = ExecutionInstanceId::live(&family.id, effective_seed);
483
484        Self {
485            family,
486            instance,
487            seed_plan: seed_plan.clone(),
488            effective_seed,
489            effective_entropy_seed,
490            trace_fingerprint: None,
491            schedule_hash: None,
492            event_hash: None,
493            event_count: None,
494            steps_total: None,
495            artifact_path: None,
496            repro_command: None,
497            config_hash: None,
498            nondeterminism_notes: Vec::new(),
499        }
500    }
501
502    /// Update from a `LabRunReport`'s trace certificate.
503    #[must_use]
504    pub fn with_lab_report(
505        mut self,
506        trace_fingerprint: u64,
507        event_hash: u64,
508        event_count: u64,
509        schedule_hash: u64,
510        steps_total: u64,
511    ) -> Self {
512        self.trace_fingerprint = Some(trace_fingerprint);
513        self.event_hash = Some(event_hash);
514        self.event_count = Some(event_count);
515        self.schedule_hash = Some(schedule_hash);
516        self.steps_total = Some(steps_total);
517        self
518    }
519
520    /// Set the repro command.
521    #[must_use]
522    pub fn with_repro_command(mut self, cmd: impl Into<String>) -> Self {
523        self.repro_command = Some(cmd.into());
524        self
525    }
526
527    /// Set the artifact path.
528    #[must_use]
529    pub fn with_artifact_path(mut self, path: impl Into<String>) -> Self {
530        self.artifact_path = Some(path.into());
531        self
532    }
533
534    /// Attach nondeterminism notes gathered during the execution.
535    #[must_use]
536    pub fn with_nondeterminism_notes(mut self, notes: Vec<String>) -> Self {
537        self.nondeterminism_notes = notes;
538        self
539    }
540
541    /// Generate a default repro command for this execution.
542    #[must_use]
543    pub fn default_repro_command(&self) -> String {
544        format!(
545            "rch exec -- env ASUPERSYNC_SEED=0x{:X} cargo test {} -- --nocapture",
546            self.effective_seed, self.family.id
547        )
548    }
549}
550
551// ============================================================================
552// Seed Lineage Record
553// ============================================================================
554
555/// Complete record of seeds used across a dual-run pair.
556///
557/// Emitted into mismatch bundles and summary records so that every
558/// seed decision is auditable. Satisfies the contract requirement:
559/// "Seed rewrites must be explicit in `seed_plan`, never hidden."
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
561pub struct SeedLineageRecord {
562    /// Seed lineage identifier from the plan.
563    pub seed_lineage_id: String,
564
565    /// Canonical seed from the plan.
566    pub canonical_seed: u64,
567
568    /// Effective lab seed actually used.
569    pub lab_effective_seed: u64,
570
571    /// Effective live seed actually used.
572    pub live_effective_seed: u64,
573
574    /// Lab seed mode.
575    pub lab_seed_mode: SeedMode,
576
577    /// Live seed mode.
578    pub live_seed_mode: SeedMode,
579
580    /// Effective lab entropy seed.
581    pub lab_entropy_seed: u64,
582
583    /// Effective live entropy seed.
584    pub live_entropy_seed: u64,
585
586    /// Replay policy used.
587    pub replay_policy: ReplayPolicy,
588
589    /// Whether lab and live used the same effective seed.
590    pub seeds_match: bool,
591
592    /// Additional audit annotations.
593    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
594    pub annotations: BTreeMap<String, String>,
595}
596
597impl SeedLineageRecord {
598    /// Build a lineage record from a seed plan.
599    #[must_use]
600    pub fn from_plan(plan: &SeedPlan) -> Self {
601        let lab_seed = plan.effective_lab_seed();
602        let live_seed = plan.effective_live_seed();
603        let lab_entropy = plan.effective_entropy_seed(lab_seed);
604        let live_entropy = plan.effective_entropy_seed(live_seed);
605
606        Self {
607            seed_lineage_id: plan.seed_lineage_id.clone(),
608            canonical_seed: plan.canonical_seed,
609            lab_effective_seed: lab_seed,
610            live_effective_seed: live_seed,
611            lab_seed_mode: plan.lab_seed_mode,
612            live_seed_mode: plan.live_seed_mode,
613            lab_entropy_seed: lab_entropy,
614            live_entropy_seed: live_entropy,
615            replay_policy: plan.replay_policy,
616            seeds_match: lab_seed == live_seed,
617            annotations: BTreeMap::new(),
618        }
619    }
620
621    /// Add an audit annotation.
622    #[must_use]
623    pub fn with_annotation(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
624        self.annotations.insert(key.into(), value.into());
625        self
626    }
627}
628
629// ============================================================================
630// Dual-Run Scenario Spec (partial — shared seed/replay fields only)
631// ============================================================================
632
633/// Schema version for the dual-run scenario spec.
634pub const DUAL_RUN_SCHEMA_VERSION: &str = "lab-live-scenario-spec-v1";
635
636/// Rollout phase for a dual-run scenario.
637#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
638pub enum Phase {
639    /// Phase 1: cancellation, combinators, channels, obligations, region
640    /// close, sync primitives. Current-thread live runner only.
641    #[serde(rename = "Phase 1")]
642    Phase1,
643    /// Phase 2: timers, virtualized transport.
644    #[serde(rename = "Phase 2")]
645    Phase2,
646    /// Phase 3: actor/supervision, HTTP/gRPC on captured boundaries.
647    #[serde(rename = "Phase 3")]
648    Phase3,
649}
650
651impl fmt::Display for Phase {
652    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
653        match self {
654            Self::Phase1 => write!(f, "Phase 1"),
655            Self::Phase2 => write!(f, "Phase 2"),
656            Self::Phase3 => write!(f, "Phase 3"),
657        }
658    }
659}
660
661/// Core identity and seed fields of a `DualRunScenarioSpec`.
662///
663/// This struct captures the seed-plan-aware subset of the full
664/// `DualRunScenarioSpec` contract. The full contract includes
665/// participants, operations, perturbations, expectations, and bindings
666/// which are built by downstream beads (`asupersync-2a6k9.2.4`+).
667///
668/// This bead (`asupersync-2a6k9.2.3`) makes seeds, parameters, and
669/// replay metadata first-class across both execution paths.
670#[derive(Debug, Clone, Serialize, Deserialize)]
671pub struct DualRunScenarioIdentity {
672    /// Stable contract discriminator.
673    pub schema_version: String,
674
675    /// Stable case identifier reused across lab and live.
676    pub scenario_id: String,
677
678    /// Semantic surface being exercised.
679    pub surface_id: String,
680
681    /// Versioned comparator contract.
682    pub surface_contract_version: String,
683
684    /// Human-readable scenario meaning.
685    pub description: String,
686
687    /// Rollout phase from the scope matrix.
688    pub phase: Phase,
689
690    /// Deterministic seed and rerun lineage.
691    pub seed_plan: SeedPlan,
692
693    /// Ownership, tags, bead lineage.
694    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
695    pub metadata: BTreeMap<String, String>,
696}
697
698impl DualRunScenarioIdentity {
699    /// Create a Phase 1 scenario identity with inherited seeds.
700    #[must_use]
701    pub fn phase1(
702        scenario_id: impl Into<String>,
703        surface_id: impl Into<String>,
704        contract_version: impl Into<String>,
705        description: impl Into<String>,
706        canonical_seed: u64,
707    ) -> Self {
708        let sid = scenario_id.into();
709        Self {
710            schema_version: DUAL_RUN_SCHEMA_VERSION.to_string(),
711            scenario_id: sid.clone(),
712            surface_id: surface_id.into(),
713            surface_contract_version: contract_version.into(),
714            description: description.into(),
715            phase: Phase::Phase1,
716            seed_plan: SeedPlan::inherit(canonical_seed, sid),
717            metadata: BTreeMap::new(),
718        }
719    }
720
721    /// Extract the scenario family identity.
722    #[must_use]
723    pub fn family_id(&self) -> ScenarioFamilyId {
724        ScenarioFamilyId::new(
725            &self.scenario_id,
726            &self.surface_id,
727            &self.surface_contract_version,
728        )
729    }
730
731    /// Build lab replay metadata from this identity.
732    #[must_use]
733    pub fn lab_replay_metadata(&self) -> ReplayMetadata {
734        ReplayMetadata::for_lab(self.family_id(), &self.seed_plan)
735    }
736
737    /// Build live replay metadata from this identity.
738    #[must_use]
739    pub fn live_replay_metadata(&self) -> ReplayMetadata {
740        ReplayMetadata::for_live(self.family_id(), &self.seed_plan)
741    }
742
743    /// Build a seed lineage record for audit.
744    #[must_use]
745    pub fn seed_lineage(&self) -> SeedLineageRecord {
746        SeedLineageRecord::from_plan(&self.seed_plan)
747    }
748
749    /// Build a `LabConfig` from this identity's seed plan.
750    #[must_use]
751    pub fn to_lab_config(&self) -> LabConfig {
752        self.seed_plan.to_lab_config()
753    }
754
755    /// Set a metadata annotation.
756    #[must_use]
757    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
758        self.metadata.insert(key.into(), value.into());
759        self
760    }
761
762    /// Override the seed plan.
763    #[must_use]
764    pub fn with_seed_plan(mut self, plan: SeedPlan) -> Self {
765        self.seed_plan = plan;
766        self
767    }
768}
769
770// ============================================================================
771// Normalized Observable Schema (lab-live-normalized-observable-v1)
772// ============================================================================
773
774/// Schema version for normalized observables.
775pub const NORMALIZED_OBSERVABLE_SCHEMA_VERSION: &str = "lab-live-normalized-observable-v1";
776
777/// Outcome class for the terminal result.
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
779#[serde(rename_all = "snake_case")]
780pub enum OutcomeClass {
781    /// Successful completion.
782    Ok,
783    /// Failed with an error.
784    Err,
785    /// Cancelled via the cancellation protocol.
786    Cancelled,
787    /// Panicked during execution.
788    Panicked,
789}
790
791impl fmt::Display for OutcomeClass {
792    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
793        match self {
794            Self::Ok => write!(f, "ok"),
795            Self::Err => write!(f, "err"),
796            Self::Cancelled => write!(f, "cancelled"),
797            Self::Panicked => write!(f, "panicked"),
798        }
799    }
800}
801
802/// Terminal phase of the cancellation protocol.
803#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
804#[serde(rename_all = "snake_case")]
805#[allow(missing_docs)]
806pub enum CancelTerminalPhase {
807    NotCancelled,
808    CancelRequested,
809    Cancelling,
810    Finalizing,
811    Completed,
812}
813
814/// Loser drain status.
815#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
816#[serde(rename_all = "snake_case")]
817pub enum DrainStatus {
818    /// No drain was needed for this participant.
819    NotApplicable,
820    /// All losers were fully drained.
821    Complete,
822    /// Some losers were not fully drained.
823    Incomplete,
824}
825
826/// Region close state.
827#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
828#[serde(rename_all = "snake_case")]
829pub enum RegionState {
830    /// Region is accepting new work.
831    Open,
832    /// Region close has been initiated.
833    Closing,
834    /// Region is draining children.
835    Draining,
836    /// Region finalizers are running.
837    Finalizing,
838    /// Region has reached quiescence.
839    Closed,
840}
841
842/// Comparison tolerance for resource counters.
843#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
844#[serde(rename_all = "snake_case")]
845pub enum CounterTolerance {
846    /// Counts must match exactly.
847    Exact,
848    /// Observed count must be at least the expected value.
849    AtLeast,
850    /// Observed count must be at most the expected value.
851    AtMost,
852    /// Counter comparison is not supported for this surface.
853    Unsupported,
854}
855
856/// Terminal outcome subrecord.
857#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
858#[allow(missing_docs)]
859pub struct TerminalOutcome {
860    pub class: OutcomeClass,
861    pub severity: OutcomeClass,
862    #[serde(skip_serializing_if = "Option::is_none")]
863    pub surface_result: Option<String>,
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub error_class: Option<String>,
866    #[serde(skip_serializing_if = "Option::is_none")]
867    pub cancel_reason_class: Option<String>,
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub panic_class: Option<String>,
870}
871
872impl TerminalOutcome {
873    /// Create an Ok terminal outcome.
874    #[must_use]
875    pub fn ok() -> Self {
876        Self {
877            class: OutcomeClass::Ok,
878            severity: OutcomeClass::Ok,
879            surface_result: None,
880            error_class: None,
881            cancel_reason_class: None,
882            panic_class: None,
883        }
884    }
885
886    /// Create a Cancelled terminal outcome.
887    #[must_use]
888    pub fn cancelled(reason_class: impl Into<String>) -> Self {
889        Self {
890            class: OutcomeClass::Cancelled,
891            severity: OutcomeClass::Cancelled,
892            surface_result: None,
893            error_class: None,
894            cancel_reason_class: Some(reason_class.into()),
895            panic_class: None,
896        }
897    }
898
899    /// Create an Err terminal outcome.
900    #[must_use]
901    pub fn err(error_class: impl Into<String>) -> Self {
902        Self {
903            class: OutcomeClass::Err,
904            severity: OutcomeClass::Err,
905            surface_result: None,
906            error_class: Some(error_class.into()),
907            cancel_reason_class: None,
908            panic_class: None,
909        }
910    }
911}
912
913/// Cancellation subrecord.
914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915#[allow(clippy::struct_excessive_bools)]
916#[allow(missing_docs)]
917pub struct CancellationRecord {
918    pub requested: bool,
919    pub acknowledged: bool,
920    pub cleanup_completed: bool,
921    pub finalization_completed: bool,
922    pub terminal_phase: CancelTerminalPhase,
923    #[serde(skip_serializing_if = "Option::is_none")]
924    pub checkpoint_observed: Option<bool>,
925}
926
927impl CancellationRecord {
928    /// No cancellation occurred.
929    #[must_use]
930    pub fn none() -> Self {
931        Self {
932            requested: false,
933            acknowledged: false,
934            cleanup_completed: false,
935            finalization_completed: false,
936            terminal_phase: CancelTerminalPhase::NotCancelled,
937            checkpoint_observed: None,
938        }
939    }
940
941    /// Full cancellation protocol completed.
942    #[must_use]
943    pub fn completed() -> Self {
944        Self {
945            requested: true,
946            acknowledged: true,
947            cleanup_completed: true,
948            finalization_completed: true,
949            terminal_phase: CancelTerminalPhase::Completed,
950            checkpoint_observed: Some(true),
951        }
952    }
953}
954
955/// Loser drain subrecord.
956#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
957#[allow(missing_docs)]
958pub struct LoserDrainRecord {
959    pub applicable: bool,
960    pub expected_losers: u32,
961    pub drained_losers: u32,
962    pub status: DrainStatus,
963    #[serde(skip_serializing_if = "Option::is_none")]
964    pub evidence: Option<String>,
965}
966
967impl LoserDrainRecord {
968    /// No loser drain applicable.
969    #[must_use]
970    pub fn not_applicable() -> Self {
971        Self {
972            applicable: false,
973            expected_losers: 0,
974            drained_losers: 0,
975            status: DrainStatus::NotApplicable,
976            evidence: None,
977        }
978    }
979
980    /// All losers drained.
981    #[must_use]
982    pub fn complete(expected: u32) -> Self {
983        Self {
984            applicable: true,
985            expected_losers: expected,
986            drained_losers: expected,
987            status: DrainStatus::Complete,
988            evidence: None,
989        }
990    }
991}
992
993/// Region close subrecord.
994#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
995#[allow(missing_docs)]
996pub struct RegionCloseRecord {
997    pub root_state: RegionState,
998    pub quiescent: bool,
999    pub live_children: u32,
1000    pub finalizers_pending: u32,
1001    pub close_completed: bool,
1002}
1003
1004impl RegionCloseRecord {
1005    /// Region closed to quiescence.
1006    #[must_use]
1007    pub fn quiescent() -> Self {
1008        Self {
1009            root_state: RegionState::Closed,
1010            quiescent: true,
1011            live_children: 0,
1012            finalizers_pending: 0,
1013            close_completed: true,
1014        }
1015    }
1016}
1017
1018/// Obligation balance subrecord.
1019#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1020#[allow(missing_docs)]
1021pub struct ObligationBalanceRecord {
1022    pub reserved: u32,
1023    pub committed: u32,
1024    pub aborted: u32,
1025    pub leaked: u32,
1026    pub unresolved: u32,
1027    pub balanced: bool,
1028}
1029
1030impl ObligationBalanceRecord {
1031    /// Fully balanced (no leaks, no unresolved).
1032    #[must_use]
1033    pub fn balanced(reserved: u32, committed: u32, aborted: u32) -> Self {
1034        Self {
1035            reserved,
1036            committed,
1037            aborted,
1038            leaked: 0,
1039            unresolved: 0,
1040            balanced: true,
1041        }
1042    }
1043
1044    /// Zero obligations.
1045    #[must_use]
1046    pub fn zero() -> Self {
1047        Self::balanced(0, 0, 0)
1048    }
1049
1050    /// Recompute `balanced` and `unresolved` from the other fields.
1051    #[must_use]
1052    pub fn recompute(mut self) -> Self {
1053        let terminal = self
1054            .committed
1055            .saturating_add(self.aborted)
1056            .saturating_add(self.leaked);
1057        self.unresolved = self.reserved.saturating_sub(terminal);
1058        self.balanced = self.leaked == 0 && self.unresolved == 0;
1059        self
1060    }
1061}
1062
1063/// Resource surface subrecord.
1064#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1065#[allow(missing_docs)]
1066pub struct ResourceSurfaceRecord {
1067    pub contract_scope: String,
1068    #[serde(default)]
1069    pub counters: BTreeMap<String, i64>,
1070    #[serde(default)]
1071    pub tolerances: BTreeMap<String, CounterTolerance>,
1072}
1073
1074impl ResourceSurfaceRecord {
1075    /// Create a resource surface with no counters.
1076    #[must_use]
1077    pub fn empty(scope: impl Into<String>) -> Self {
1078        Self {
1079            contract_scope: scope.into(),
1080            counters: BTreeMap::new(),
1081            tolerances: BTreeMap::new(),
1082        }
1083    }
1084
1085    /// Add an exact counter.
1086    #[must_use]
1087    pub fn with_counter(mut self, name: impl Into<String>, value: i64) -> Self {
1088        let n = name.into();
1089        self.counters.insert(n.clone(), value);
1090        self.tolerances.insert(n, CounterTolerance::Exact);
1091        self
1092    }
1093
1094    /// Add a counter with a specific tolerance.
1095    #[must_use]
1096    pub fn with_counter_tolerance(
1097        mut self,
1098        name: impl Into<String>,
1099        value: i64,
1100        tolerance: CounterTolerance,
1101    ) -> Self {
1102        let n = name.into();
1103        self.counters.insert(n.clone(), value);
1104        self.tolerances.insert(n, tolerance);
1105        self
1106    }
1107}
1108
1109/// Semantic section of a normalized observable.
1110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1111#[allow(missing_docs)]
1112pub struct NormalizedSemantics {
1113    pub terminal_outcome: TerminalOutcome,
1114    pub cancellation: CancellationRecord,
1115    pub loser_drain: LoserDrainRecord,
1116    pub region_close: RegionCloseRecord,
1117    pub obligation_balance: ObligationBalanceRecord,
1118    pub resource_surface: ResourceSurfaceRecord,
1119}
1120
1121/// Complete normalized observable record.
1122#[derive(Debug, Clone, Serialize, Deserialize)]
1123#[allow(missing_docs)]
1124pub struct NormalizedObservable {
1125    pub schema_version: String,
1126    pub scenario_id: String,
1127    pub surface_id: String,
1128    pub surface_contract_version: String,
1129    pub runtime_kind: RuntimeKind,
1130    pub semantics: NormalizedSemantics,
1131    pub provenance: ReplayMetadata,
1132}
1133
1134impl NormalizedObservable {
1135    /// Create a normalized observable from identity and semantics.
1136    #[must_use]
1137    pub fn new(
1138        identity: &DualRunScenarioIdentity,
1139        runtime_kind: RuntimeKind,
1140        semantics: NormalizedSemantics,
1141        provenance: ReplayMetadata,
1142    ) -> Self {
1143        Self {
1144            schema_version: NORMALIZED_OBSERVABLE_SCHEMA_VERSION.to_string(),
1145            scenario_id: identity.scenario_id.clone(),
1146            surface_id: identity.surface_id.clone(),
1147            surface_contract_version: identity.surface_contract_version.clone(),
1148            runtime_kind,
1149            semantics,
1150            provenance,
1151        }
1152    }
1153}
1154
1155// ============================================================================
1156// Witness / Assertion Helpers
1157// ============================================================================
1158
1159/// A single mismatch between lab and live observables.
1160#[derive(Debug, Clone, Serialize, Deserialize)]
1161pub struct SemanticMismatch {
1162    /// Dot-separated path to the mismatched field.
1163    pub field: String,
1164    /// Description of the mismatch.
1165    pub description: String,
1166    /// Lab-side value (display representation).
1167    pub lab_value: String,
1168    /// Live-side value (display representation).
1169    pub live_value: String,
1170}
1171
1172impl fmt::Display for SemanticMismatch {
1173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1174        write!(
1175            f,
1176            "{}: {} (lab={}, live={})",
1177            self.field, self.description, self.lab_value, self.live_value
1178        )
1179    }
1180}
1181
1182/// Result of comparing two normalized observables.
1183#[derive(Debug, Clone, Serialize, Deserialize)]
1184pub struct ComparisonVerdict {
1185    /// Scenario identity.
1186    pub scenario_id: String,
1187    /// Surface identity.
1188    pub surface_id: String,
1189    /// Whether the comparison passed (no semantic mismatches).
1190    pub passed: bool,
1191    /// Semantic mismatches found.
1192    pub mismatches: Vec<SemanticMismatch>,
1193    /// Seed lineage record for audit.
1194    pub seed_lineage: SeedLineageRecord,
1195}
1196
1197impl ComparisonVerdict {
1198    /// Whether the verdict indicates semantic equivalence.
1199    #[must_use]
1200    pub fn is_equivalent(&self) -> bool {
1201        self.passed
1202    }
1203
1204    /// Format a human-readable summary.
1205    #[must_use]
1206    pub fn summary(&self) -> String {
1207        if self.passed {
1208            format!(
1209                "PASS: {} on {} (seed lineage: {})",
1210                self.scenario_id, self.surface_id, self.seed_lineage.seed_lineage_id
1211            )
1212        } else {
1213            let mismatch_list: Vec<String> =
1214                self.mismatches.iter().map(ToString::to_string).collect();
1215            format!(
1216                "FAIL: {} on {} — {} mismatch(es):\n  {}",
1217                self.scenario_id,
1218                self.surface_id,
1219                self.mismatches.len(),
1220                mismatch_list.join("\n  ")
1221            )
1222        }
1223    }
1224
1225    /// Format a human-readable summary augmented with capture provenance.
1226    #[must_use]
1227    pub fn summary_with_manifests(
1228        &self,
1229        lab_manifest: Option<&CaptureManifest>,
1230        live_manifest: Option<&CaptureManifest>,
1231    ) -> String {
1232        if self.passed {
1233            return self.summary();
1234        }
1235
1236        let mismatch_list: Vec<String> = self
1237            .mismatches
1238            .iter()
1239            .map(|mismatch| {
1240                let mut line = mismatch.to_string();
1241                let mut capture_notes = Vec::new();
1242                if let Some(lab_capture) = lab_manifest
1243                    .and_then(|manifest| manifest.describe_field_capture(&mismatch.field))
1244                {
1245                    capture_notes.push(format!("lab_capture={lab_capture}"));
1246                }
1247                if let Some(live_capture) = live_manifest
1248                    .and_then(|manifest| manifest.describe_field_capture(&mismatch.field))
1249                {
1250                    capture_notes.push(format!("live_capture={live_capture}"));
1251                }
1252                if !capture_notes.is_empty() {
1253                    line.push_str(" [");
1254                    line.push_str(&capture_notes.join("; "));
1255                    line.push(']');
1256                }
1257                line
1258            })
1259            .collect();
1260
1261        format!(
1262            "FAIL: {} on {} — {} mismatch(es):\n  {}",
1263            self.scenario_id,
1264            self.surface_id,
1265            self.mismatches.len(),
1266            mismatch_list.join("\n  ")
1267        )
1268    }
1269}
1270
1271impl fmt::Display for ComparisonVerdict {
1272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1273        write!(f, "{}", self.summary())
1274    }
1275}
1276
1277/// Provisional mismatch class prior to any automatic reruns.
1278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1279#[serde(rename_all = "snake_case")]
1280pub enum ProvisionalDivergenceClass {
1281    /// No semantic mismatch or invariant failure was observed.
1282    Pass,
1283    /// Surface is outside the current supported comparison envelope.
1284    UnsupportedSurface,
1285    /// The compared artifacts are not on the same schema contract.
1286    ArtifactSchemaViolation,
1287    /// The surface is conceptually valid, but current evidence is insufficient.
1288    InsufficientObservability,
1289    /// Only scheduler/provenance noise remains after semantic comparison.
1290    SchedulerNoiseSuspected,
1291    /// A semantic mismatch on an admitted surface still needs reruns.
1292    SemanticMismatchAdmittedSurface,
1293    /// The live side already shows a hard contract break.
1294    HardContractBreak,
1295}
1296
1297impl fmt::Display for ProvisionalDivergenceClass {
1298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1299        match self {
1300            Self::Pass => write!(f, "pass"),
1301            Self::UnsupportedSurface => write!(f, "unsupported_surface"),
1302            Self::ArtifactSchemaViolation => write!(f, "artifact_schema_violation"),
1303            Self::InsufficientObservability => write!(f, "insufficient_observability"),
1304            Self::SchedulerNoiseSuspected => write!(f, "scheduler_noise_suspected"),
1305            Self::SemanticMismatchAdmittedSurface => {
1306                write!(f, "semantic_mismatch_admitted_surface")
1307            }
1308            Self::HardContractBreak => write!(f, "hard_contract_break"),
1309        }
1310    }
1311}
1312
1313/// Final divergence class from the published taxonomy.
1314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1315#[serde(rename_all = "snake_case")]
1316pub enum FinalDivergenceClass {
1317    RuntimeSemanticBug,
1318    LabModelOrMappingBug,
1319    IrreproducibleDivergence,
1320    UnsupportedSurface,
1321    ArtifactSchemaViolation,
1322    InsufficientObservability,
1323    SchedulerNoiseSuspected,
1324}
1325
1326impl fmt::Display for FinalDivergenceClass {
1327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1328        match self {
1329            Self::RuntimeSemanticBug => write!(f, "runtime_semantic_bug"),
1330            Self::LabModelOrMappingBug => write!(f, "lab_model_or_mapping_bug"),
1331            Self::IrreproducibleDivergence => write!(f, "irreproducible_divergence"),
1332            Self::UnsupportedSurface => write!(f, "unsupported_surface"),
1333            Self::ArtifactSchemaViolation => write!(f, "artifact_schema_violation"),
1334            Self::InsufficientObservability => write!(f, "insufficient_observability"),
1335            Self::SchedulerNoiseSuspected => write!(f, "scheduler_noise_suspected"),
1336        }
1337    }
1338}
1339
1340/// Time/noise class emitted by the mismatch policy layer.
1341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1342#[serde(rename_all = "snake_case")]
1343pub enum TimePolicyClass {
1344    NotApplicable,
1345    ProvenanceOnlyTime,
1346    SchedulerNoiseSignal,
1347    QualifiedTime,
1348    UnsupportedTimeSurface,
1349    SemanticTime,
1350    PolicyViolation,
1351}
1352
1353impl fmt::Display for TimePolicyClass {
1354    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1355        match self {
1356            Self::NotApplicable => write!(f, "not_applicable"),
1357            Self::ProvenanceOnlyTime => write!(f, "provenance_only_time"),
1358            Self::SchedulerNoiseSignal => write!(f, "scheduler_noise_signal"),
1359            Self::QualifiedTime => write!(f, "qualified_time"),
1360            Self::UnsupportedTimeSurface => write!(f, "unsupported_time_surface"),
1361            Self::SemanticTime => write!(f, "semantic_time"),
1362            Self::PolicyViolation => write!(f, "policy_violation"),
1363        }
1364    }
1365}
1366
1367/// Which scheduler/provenance drift triggered a noise classification.
1368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1369#[serde(rename_all = "snake_case")]
1370pub enum SchedulerNoiseClass {
1371    None,
1372    NondeterminismNotesOnly,
1373    ScheduleHashDrift,
1374    EventHashDrift,
1375    EventCountDrift,
1376    ProvenanceDrift,
1377}
1378
1379impl fmt::Display for SchedulerNoiseClass {
1380    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1381        match self {
1382            Self::None => write!(f, "none"),
1383            Self::NondeterminismNotesOnly => write!(f, "nondeterminism_notes_only"),
1384            Self::ScheduleHashDrift => write!(f, "schedule_hash_drift"),
1385            Self::EventHashDrift => write!(f, "event_hash_drift"),
1386            Self::EventCountDrift => write!(f, "event_count_drift"),
1387            Self::ProvenanceDrift => write!(f, "provenance_drift"),
1388        }
1389    }
1390}
1391
1392/// Automatic rerun plan for a provisional differential classification.
1393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1394#[serde(tag = "kind", rename_all = "snake_case")]
1395pub enum RerunDecision {
1396    None,
1397    LiveConfirmations { additional_runs: u8 },
1398    DeterministicLabReplayAndLiveConfirmations { additional_live_runs: u8 },
1399    ConfirmationIfRicherInstrumentationEnabled { additional_runs: u8 },
1400}
1401
1402impl fmt::Display for RerunDecision {
1403    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1404        match self {
1405            Self::None => write!(f, "none"),
1406            Self::LiveConfirmations { additional_runs } => {
1407                write!(f, "live_confirmations(+{additional_runs})")
1408            }
1409            Self::DeterministicLabReplayAndLiveConfirmations {
1410                additional_live_runs,
1411            } => write!(
1412                f,
1413                "deterministic_lab_replay_and_live_confirmations(+{additional_live_runs} live)"
1414            ),
1415            Self::ConfirmationIfRicherInstrumentationEnabled { additional_runs } => write!(
1416                f,
1417                "confirmation_if_richer_instrumentation_enabled(+{additional_runs})"
1418            ),
1419        }
1420    }
1421}
1422
1423/// Policy output layered on top of the raw semantic comparison result.
1424#[derive(Debug, Clone, Serialize, Deserialize)]
1425pub struct DifferentialPolicyOutcome {
1426    /// Provisional class before any automatic reruns are executed.
1427    pub provisional_class: ProvisionalDivergenceClass,
1428    /// Whether the harness should schedule reruns for classification.
1429    pub rerun_decision: RerunDecision,
1430    /// Final class suggestion when policy can decide immediately.
1431    #[serde(skip_serializing_if = "Option::is_none")]
1432    pub suggested_final_class: Option<FinalDivergenceClass>,
1433    /// Time/noise interpretation from the normalization policy.
1434    pub time_policy_class: TimePolicyClass,
1435    /// Which scheduler/provenance signal was recognized.
1436    pub scheduler_noise_class: SchedulerNoiseClass,
1437    /// Optional reason for suppression or immediate rejection.
1438    #[serde(skip_serializing_if = "Option::is_none")]
1439    pub suppression_reason: Option<String>,
1440    /// Human-readable explanation for logs and summaries.
1441    pub explanation: String,
1442}
1443
1444impl DifferentialPolicyOutcome {
1445    #[must_use]
1446    pub fn summary(&self) -> String {
1447        let mut parts = vec![
1448            format!("provisional_class={}", self.provisional_class),
1449            format!("rerun_decision={}", self.rerun_decision),
1450            format!("time_policy_class={}", self.time_policy_class),
1451            format!("scheduler_noise_class={}", self.scheduler_noise_class),
1452        ];
1453        if let Some(final_class) = self.suggested_final_class {
1454            parts.push(format!("suggested_final_class={final_class}"));
1455        }
1456        if let Some(reason) = &self.suppression_reason {
1457            parts.push(format!("suppression_reason={reason}"));
1458        }
1459        parts.push(self.explanation.clone());
1460        parts.join("; ")
1461    }
1462}
1463
1464impl fmt::Display for DifferentialPolicyOutcome {
1465    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1466        write!(f, "{}", self.summary())
1467    }
1468}
1469
1470/// Compare two normalized observables and produce a verdict.
1471///
1472/// Compares all semantic fields. Provenance is recorded but not compared
1473/// (audit-only by default).
1474#[must_use]
1475pub fn compare_observables(
1476    lab: &NormalizedObservable,
1477    live: &NormalizedObservable,
1478    seed_lineage: SeedLineageRecord,
1479) -> ComparisonVerdict {
1480    let mut mismatches = Vec::new();
1481
1482    // Schema version
1483    if lab.schema_version != live.schema_version {
1484        mismatches.push(SemanticMismatch {
1485            field: "schema_version".to_string(),
1486            description: "Schema version mismatch".to_string(),
1487            lab_value: lab.schema_version.clone(),
1488            live_value: live.schema_version.clone(),
1489        });
1490    }
1491
1492    // Scenario identity
1493    if lab.scenario_id != live.scenario_id {
1494        mismatches.push(SemanticMismatch {
1495            field: "scenario_id".to_string(),
1496            description: "Scenario ID mismatch".to_string(),
1497            lab_value: lab.scenario_id.clone(),
1498            live_value: live.scenario_id.clone(),
1499        });
1500    }
1501    if lab.surface_id != live.surface_id {
1502        mismatches.push(SemanticMismatch {
1503            field: "surface_id".to_string(),
1504            description: "Surface ID mismatch".to_string(),
1505            lab_value: lab.surface_id.clone(),
1506            live_value: live.surface_id.clone(),
1507        });
1508    }
1509    if lab.surface_contract_version != live.surface_contract_version {
1510        mismatches.push(SemanticMismatch {
1511            field: "surface_contract_version".to_string(),
1512            description: "Surface contract version mismatch".to_string(),
1513            lab_value: lab.surface_contract_version.clone(),
1514            live_value: live.surface_contract_version.clone(),
1515        });
1516    }
1517
1518    // Terminal outcome
1519    compare_terminal_outcome(
1520        &lab.semantics.terminal_outcome,
1521        &live.semantics.terminal_outcome,
1522        &mut mismatches,
1523    );
1524
1525    // Cancellation
1526    compare_cancellation(
1527        &lab.semantics.cancellation,
1528        &live.semantics.cancellation,
1529        &mut mismatches,
1530    );
1531
1532    // Loser drain
1533    compare_loser_drain(
1534        &lab.semantics.loser_drain,
1535        &live.semantics.loser_drain,
1536        &mut mismatches,
1537    );
1538
1539    // Region close
1540    compare_region_close(
1541        &lab.semantics.region_close,
1542        &live.semantics.region_close,
1543        &mut mismatches,
1544    );
1545
1546    // Obligation balance
1547    compare_obligation_balance(
1548        &lab.semantics.obligation_balance,
1549        &live.semantics.obligation_balance,
1550        &mut mismatches,
1551    );
1552
1553    // Resource surface
1554    compare_resource_surface(
1555        &lab.semantics.resource_surface,
1556        &live.semantics.resource_surface,
1557        &mut mismatches,
1558    );
1559
1560    ComparisonVerdict {
1561        scenario_id: lab.scenario_id.clone(),
1562        surface_id: lab.surface_id.clone(),
1563        passed: mismatches.is_empty(),
1564        mismatches,
1565        seed_lineage,
1566    }
1567}
1568
1569fn compare_terminal_outcome(
1570    lab: &TerminalOutcome,
1571    live: &TerminalOutcome,
1572    mismatches: &mut Vec<SemanticMismatch>,
1573) {
1574    if lab.class != live.class {
1575        mismatches.push(SemanticMismatch {
1576            field: "semantics.terminal_outcome.class".to_string(),
1577            description: "Terminal outcome class mismatch".to_string(),
1578            lab_value: format!("{}", lab.class),
1579            live_value: format!("{}", live.class),
1580        });
1581    }
1582    if lab.severity != live.severity {
1583        mismatches.push(SemanticMismatch {
1584            field: "semantics.terminal_outcome.severity".to_string(),
1585            description: "Terminal outcome severity mismatch".to_string(),
1586            lab_value: format!("{}", lab.severity),
1587            live_value: format!("{}", live.severity),
1588        });
1589    }
1590    if lab.surface_result != live.surface_result {
1591        mismatches.push(SemanticMismatch {
1592            field: "semantics.terminal_outcome.surface_result".to_string(),
1593            description: "Surface result mismatch".to_string(),
1594            lab_value: format!("{:?}", lab.surface_result),
1595            live_value: format!("{:?}", live.surface_result),
1596        });
1597    }
1598    if lab.error_class != live.error_class {
1599        mismatches.push(SemanticMismatch {
1600            field: "semantics.terminal_outcome.error_class".to_string(),
1601            description: "Error class mismatch".to_string(),
1602            lab_value: format!("{:?}", lab.error_class),
1603            live_value: format!("{:?}", live.error_class),
1604        });
1605    }
1606    if lab.cancel_reason_class != live.cancel_reason_class {
1607        mismatches.push(SemanticMismatch {
1608            field: "semantics.terminal_outcome.cancel_reason_class".to_string(),
1609            description: "Cancel reason class mismatch".to_string(),
1610            lab_value: format!("{:?}", lab.cancel_reason_class),
1611            live_value: format!("{:?}", live.cancel_reason_class),
1612        });
1613    }
1614    if lab.panic_class != live.panic_class {
1615        mismatches.push(SemanticMismatch {
1616            field: "semantics.terminal_outcome.panic_class".to_string(),
1617            description: "Panic class mismatch".to_string(),
1618            lab_value: format!("{:?}", lab.panic_class),
1619            live_value: format!("{:?}", live.panic_class),
1620        });
1621    }
1622}
1623
1624fn compare_cancellation(
1625    lab: &CancellationRecord,
1626    live: &CancellationRecord,
1627    mismatches: &mut Vec<SemanticMismatch>,
1628) {
1629    let fields = [
1630        ("requested", lab.requested, live.requested),
1631        ("acknowledged", lab.acknowledged, live.acknowledged),
1632        (
1633            "cleanup_completed",
1634            lab.cleanup_completed,
1635            live.cleanup_completed,
1636        ),
1637        (
1638            "finalization_completed",
1639            lab.finalization_completed,
1640            live.finalization_completed,
1641        ),
1642    ];
1643    for (name, lab_val, live_val) in fields {
1644        if lab_val != live_val {
1645            mismatches.push(SemanticMismatch {
1646                field: format!("semantics.cancellation.{name}"),
1647                description: format!("Cancellation {name} mismatch"),
1648                lab_value: format!("{lab_val}"),
1649                live_value: format!("{live_val}"),
1650            });
1651        }
1652    }
1653    if lab.terminal_phase != live.terminal_phase {
1654        mismatches.push(SemanticMismatch {
1655            field: "semantics.cancellation.terminal_phase".to_string(),
1656            description: "Cancellation terminal phase mismatch".to_string(),
1657            lab_value: format!("{:?}", lab.terminal_phase),
1658            live_value: format!("{:?}", live.terminal_phase),
1659        });
1660    }
1661    // checkpoint_observed: only compare if both sides report it
1662    if let (Some(lab_cp), Some(live_cp)) = (lab.checkpoint_observed, live.checkpoint_observed) {
1663        if lab_cp != live_cp {
1664            mismatches.push(SemanticMismatch {
1665                field: "semantics.cancellation.checkpoint_observed".to_string(),
1666                description: "Checkpoint observed mismatch".to_string(),
1667                lab_value: format!("{lab_cp}"),
1668                live_value: format!("{live_cp}"),
1669            });
1670        }
1671    }
1672}
1673
1674fn compare_loser_drain(
1675    lab: &LoserDrainRecord,
1676    live: &LoserDrainRecord,
1677    mismatches: &mut Vec<SemanticMismatch>,
1678) {
1679    if lab.status != live.status {
1680        mismatches.push(SemanticMismatch {
1681            field: "semantics.loser_drain.status".to_string(),
1682            description: "Loser drain status mismatch".to_string(),
1683            lab_value: format!("{:?}", lab.status),
1684            live_value: format!("{:?}", live.status),
1685        });
1686    }
1687    if lab.applicable != live.applicable {
1688        mismatches.push(SemanticMismatch {
1689            field: "semantics.loser_drain.applicable".to_string(),
1690            description: "Loser drain applicability mismatch".to_string(),
1691            lab_value: format!("{}", lab.applicable),
1692            live_value: format!("{}", live.applicable),
1693        });
1694    }
1695    let counts_unknown = loser_drain_counts_unknown(lab) || loser_drain_counts_unknown(live);
1696    if !counts_unknown && lab.expected_losers != live.expected_losers {
1697        mismatches.push(SemanticMismatch {
1698            field: "semantics.loser_drain.expected_losers".to_string(),
1699            description: "Expected losers count mismatch".to_string(),
1700            lab_value: format!("{}", lab.expected_losers),
1701            live_value: format!("{}", live.expected_losers),
1702        });
1703    }
1704    if !counts_unknown && lab.drained_losers != live.drained_losers {
1705        mismatches.push(SemanticMismatch {
1706            field: "semantics.loser_drain.drained_losers".to_string(),
1707            description: "Drained losers count mismatch".to_string(),
1708            lab_value: format!("{}", lab.drained_losers),
1709            live_value: format!("{}", live.drained_losers),
1710        });
1711    }
1712}
1713
1714fn compare_region_close(
1715    lab: &RegionCloseRecord,
1716    live: &RegionCloseRecord,
1717    mismatches: &mut Vec<SemanticMismatch>,
1718) {
1719    // The published dual-run contract requires quiescence, child/finalizer
1720    // counts, and close completion for region-close comparison. Non-quiescent
1721    // root_state is only a best-effort phase hint, and adapters do not always
1722    // have equally precise visibility there.
1723    if lab.quiescent && live.quiescent && lab.root_state != live.root_state {
1724        mismatches.push(SemanticMismatch {
1725            field: "semantics.region_close.root_state".to_string(),
1726            description: "Region root state mismatch".to_string(),
1727            lab_value: format!("{:?}", lab.root_state),
1728            live_value: format!("{:?}", live.root_state),
1729        });
1730    }
1731    if lab.quiescent != live.quiescent {
1732        mismatches.push(SemanticMismatch {
1733            field: "semantics.region_close.quiescent".to_string(),
1734            description: "Region quiescence mismatch".to_string(),
1735            lab_value: format!("{}", lab.quiescent),
1736            live_value: format!("{}", live.quiescent),
1737        });
1738    }
1739    if lab.close_completed != live.close_completed {
1740        mismatches.push(SemanticMismatch {
1741            field: "semantics.region_close.close_completed".to_string(),
1742            description: "Region close completed mismatch".to_string(),
1743            lab_value: format!("{}", lab.close_completed),
1744            live_value: format!("{}", live.close_completed),
1745        });
1746    }
1747    let counts_unknown = region_close_counts_unknown(lab) || region_close_counts_unknown(live);
1748    if !counts_unknown && lab.live_children != live.live_children {
1749        mismatches.push(SemanticMismatch {
1750            field: "semantics.region_close.live_children".to_string(),
1751            description: "Region live child count mismatch".to_string(),
1752            lab_value: format!("{}", lab.live_children),
1753            live_value: format!("{}", live.live_children),
1754        });
1755    }
1756    if !counts_unknown && lab.finalizers_pending != live.finalizers_pending {
1757        mismatches.push(SemanticMismatch {
1758            field: "semantics.region_close.finalizers_pending".to_string(),
1759            description: "Region finalizers pending mismatch".to_string(),
1760            lab_value: format!("{}", lab.finalizers_pending),
1761            live_value: format!("{}", live.finalizers_pending),
1762        });
1763    }
1764}
1765
1766fn loser_drain_counts_unknown(record: &LoserDrainRecord) -> bool {
1767    record.applicable
1768        && record.expected_losers == 0
1769        && record.drained_losers == 0
1770        && record
1771            .evidence
1772            .as_deref()
1773            .is_some_and(|source| source.starts_with("oracle.loser_drain."))
1774}
1775
1776fn region_close_counts_unknown(record: &RegionCloseRecord) -> bool {
1777    !record.quiescent
1778        && !record.close_completed
1779        && record.root_state == RegionState::Closing
1780        && record.live_children == 0
1781        && record.finalizers_pending == 0
1782}
1783
1784fn compare_obligation_balance(
1785    lab: &ObligationBalanceRecord,
1786    live: &ObligationBalanceRecord,
1787    mismatches: &mut Vec<SemanticMismatch>,
1788) {
1789    if lab.balanced != live.balanced {
1790        mismatches.push(SemanticMismatch {
1791            field: "semantics.obligation_balance.balanced".to_string(),
1792            description: "Obligation balance mismatch".to_string(),
1793            lab_value: format!("{}", lab.balanced),
1794            live_value: format!("{}", live.balanced),
1795        });
1796    }
1797    if lab.leaked != live.leaked {
1798        mismatches.push(SemanticMismatch {
1799            field: "semantics.obligation_balance.leaked".to_string(),
1800            description: "Leaked obligation count mismatch".to_string(),
1801            lab_value: format!("{}", lab.leaked),
1802            live_value: format!("{}", live.leaked),
1803        });
1804    }
1805    if lab.unresolved != live.unresolved {
1806        mismatches.push(SemanticMismatch {
1807            field: "semantics.obligation_balance.unresolved".to_string(),
1808            description: "Unresolved obligation count mismatch".to_string(),
1809            lab_value: format!("{}", lab.unresolved),
1810            live_value: format!("{}", live.unresolved),
1811        });
1812    }
1813    if lab.reserved != live.reserved {
1814        mismatches.push(SemanticMismatch {
1815            field: "semantics.obligation_balance.reserved".to_string(),
1816            description: "Reserved obligation count mismatch".to_string(),
1817            lab_value: format!("{}", lab.reserved),
1818            live_value: format!("{}", live.reserved),
1819        });
1820    }
1821    if lab.committed != live.committed {
1822        mismatches.push(SemanticMismatch {
1823            field: "semantics.obligation_balance.committed".to_string(),
1824            description: "Committed obligation count mismatch".to_string(),
1825            lab_value: format!("{}", lab.committed),
1826            live_value: format!("{}", live.committed),
1827        });
1828    }
1829    if lab.aborted != live.aborted {
1830        mismatches.push(SemanticMismatch {
1831            field: "semantics.obligation_balance.aborted".to_string(),
1832            description: "Aborted obligation count mismatch".to_string(),
1833            lab_value: format!("{}", lab.aborted),
1834            live_value: format!("{}", live.aborted),
1835        });
1836    }
1837}
1838
1839fn compare_resource_surface(
1840    lab: &ResourceSurfaceRecord,
1841    live: &ResourceSurfaceRecord,
1842    mismatches: &mut Vec<SemanticMismatch>,
1843) {
1844    if lab.contract_scope != live.contract_scope {
1845        mismatches.push(SemanticMismatch {
1846            field: "semantics.resource_surface.contract_scope".to_string(),
1847            description: "Resource surface contract scope mismatch".to_string(),
1848            lab_value: lab.contract_scope.clone(),
1849            live_value: live.contract_scope.clone(),
1850        });
1851        return; // No point comparing counters if scopes differ.
1852    }
1853
1854    // Compare counters using declared tolerances.
1855    for (name, &lab_val) in &lab.counters {
1856        let Some(&live_val) = live.counters.get(name) else {
1857            mismatches.push(SemanticMismatch {
1858                field: format!("semantics.resource_surface.counters.{name}"),
1859                description: format!("Counter '{name}' missing in live observable"),
1860                lab_value: format!("{lab_val}"),
1861                live_value: "absent".to_string(),
1862            });
1863            continue;
1864        };
1865
1866        let lab_tolerance = lab
1867            .tolerances
1868            .get(name)
1869            .copied()
1870            .unwrap_or(CounterTolerance::Exact);
1871        let live_tolerance = live
1872            .tolerances
1873            .get(name)
1874            .copied()
1875            .unwrap_or(CounterTolerance::Exact);
1876
1877        if lab_tolerance != live_tolerance {
1878            mismatches.push(SemanticMismatch {
1879                field: format!("semantics.resource_surface.tolerances.{name}"),
1880                description: format!("Counter '{name}' tolerance mismatch"),
1881                lab_value: format!("{lab_tolerance:?}"),
1882                live_value: format!("{live_tolerance:?}"),
1883            });
1884        }
1885
1886        let mismatch = match lab_tolerance {
1887            CounterTolerance::Exact => lab_val != live_val,
1888            CounterTolerance::AtLeast => live_val < lab_val,
1889            CounterTolerance::AtMost => live_val > lab_val,
1890            CounterTolerance::Unsupported => false,
1891        };
1892
1893        if mismatch {
1894            mismatches.push(SemanticMismatch {
1895                field: format!("semantics.resource_surface.counters.{name}"),
1896                description: format!("Counter '{name}' mismatch (tolerance: {lab_tolerance:?})"),
1897                lab_value: format!("{lab_val}"),
1898                live_value: format!("{live_val}"),
1899            });
1900        }
1901    }
1902
1903    // Check for counters in live but not in lab.
1904    for name in live.counters.keys() {
1905        if !lab.counters.contains_key(name) {
1906            let live_val = live.counters[name];
1907            mismatches.push(SemanticMismatch {
1908                field: format!("semantics.resource_surface.counters.{name}"),
1909                description: format!("Counter '{name}' present in live but not in lab"),
1910                lab_value: "absent".to_string(),
1911                live_value: format!("{live_val}"),
1912            });
1913        }
1914    }
1915}
1916
1917fn classify_scheduler_noise(
1918    lab: &NormalizedObservable,
1919    live: &NormalizedObservable,
1920) -> SchedulerNoiseClass {
1921    if let (Some(lab_hash), Some(live_hash)) =
1922        (lab.provenance.schedule_hash, live.provenance.schedule_hash)
1923    {
1924        if lab_hash != live_hash {
1925            return SchedulerNoiseClass::ScheduleHashDrift;
1926        }
1927    }
1928    if let (Some(lab_hash), Some(live_hash)) =
1929        (lab.provenance.event_hash, live.provenance.event_hash)
1930    {
1931        if lab_hash != live_hash {
1932            return SchedulerNoiseClass::EventHashDrift;
1933        }
1934    }
1935    if let (Some(lab_count), Some(live_count)) =
1936        (lab.provenance.event_count, live.provenance.event_count)
1937    {
1938        if lab_count != live_count {
1939            return SchedulerNoiseClass::EventCountDrift;
1940        }
1941    }
1942    if lab.provenance.artifact_path != live.provenance.artifact_path
1943        || lab.provenance.config_hash != live.provenance.config_hash
1944    {
1945        return SchedulerNoiseClass::ProvenanceDrift;
1946    }
1947    if !live.provenance.nondeterminism_notes.is_empty() {
1948        return SchedulerNoiseClass::NondeterminismNotesOnly;
1949    }
1950    SchedulerNoiseClass::None
1951}
1952
1953fn classify_time_policy(
1954    identity: &DualRunScenarioIdentity,
1955    verdict: &ComparisonVerdict,
1956    noise_class: SchedulerNoiseClass,
1957) -> TimePolicyClass {
1958    let has_timer_contract = [
1959        "scenario_clock_id",
1960        "logical_deadline_id",
1961        "normalization_window",
1962    ]
1963    .iter()
1964    .all(|key| identity.metadata.contains_key(*key));
1965    let has_time_mismatch = verdict.mismatches.iter().any(|mismatch| {
1966        mismatch.field.contains("timeout")
1967            || mismatch.field.contains("deadline")
1968            || mismatch.field.contains("clock")
1969    });
1970
1971    if has_time_mismatch && has_timer_contract {
1972        return TimePolicyClass::SemanticTime;
1973    }
1974    if has_time_mismatch {
1975        return TimePolicyClass::UnsupportedTimeSurface;
1976    }
1977    if noise_class != SchedulerNoiseClass::None && verdict.mismatches.is_empty() {
1978        return TimePolicyClass::SchedulerNoiseSignal;
1979    }
1980    TimePolicyClass::NotApplicable
1981}
1982
1983fn eligibility_verdict(identity: &DualRunScenarioIdentity) -> Option<&str> {
1984    identity
1985        .metadata
1986        .get("eligibility_verdict")
1987        .map(String::as_str)
1988}
1989
1990fn is_bridge_only_downgrade(identity: &DualRunScenarioIdentity) -> bool {
1991    let has_bridge_only_support_class = matches!(
1992        identity.metadata.get("support_class").map(String::as_str),
1993        Some("bridge_only")
1994    );
1995
1996    let has_supported_downgrade_reason = matches!(
1997        identity.metadata.get("reason_code").map(String::as_str),
1998        Some(
1999            "downgrade_to_server_bridge"
2000                | "downgrade_to_edge_bridge"
2001                | "downgrade_to_websocket_or_fetch"
2002                | "downgrade_to_export_bytes_for_download"
2003                | "downgrade_to_bridge_only"
2004        )
2005    );
2006
2007    has_bridge_only_support_class && has_supported_downgrade_reason
2008}
2009
2010fn unsupported_surface_reason(identity: &DualRunScenarioIdentity) -> Option<String> {
2011    if let Some(
2012        verdict @ ("blocked_missing_virtualization"
2013        | "blocked_missing_verification"
2014        | "blocked_scope_red_line"
2015        | "unsupported"
2016        | "rejected"
2017        | "unsupported_surface"),
2018    ) = eligibility_verdict(identity)
2019    {
2020        return Some(format!("eligibility_verdict={verdict}"));
2021    }
2022
2023    if let Some(class) = identity.metadata.get("support_class") {
2024        if matches!(class.as_str(), "unsupported" | "unsupported_surface") {
2025            return Some(format!("support_class={class}"));
2026        }
2027    }
2028
2029    if is_bridge_only_downgrade(identity) {
2030        return None;
2031    }
2032
2033    if let Some(reason) = identity.metadata.get("unsupported_reason") {
2034        return Some(reason.clone());
2035    }
2036
2037    None
2038}
2039
2040fn insufficient_observability_reason(
2041    identity: &DualRunScenarioIdentity,
2042    verdict: &ComparisonVerdict,
2043    live: &NormalizedObservable,
2044) -> Option<String> {
2045    if matches!(
2046        eligibility_verdict(identity),
2047        Some("blocked_missing_observability")
2048    ) {
2049        return Some("eligibility_verdict=blocked_missing_observability".to_string());
2050    }
2051
2052    if let Some(status) = identity.metadata.get("observability_status") {
2053        let lowered = status.to_ascii_lowercase();
2054        if ["blocked", "missing", "limited", "insufficient"]
2055            .iter()
2056            .any(|needle| lowered.contains(needle))
2057        {
2058            return Some(status.clone());
2059        }
2060    }
2061
2062    if verdict
2063        .mismatches
2064        .iter()
2065        .any(|mismatch| mismatch.description.contains("missing in live observable"))
2066        && live
2067            .semantics
2068            .resource_surface
2069            .tolerances
2070            .values()
2071            .any(|tolerance| *tolerance == CounterTolerance::Unsupported)
2072    {
2073        return Some(
2074            "live observable omitted a required counter while declaring unsupported tolerance"
2075                .to_string(),
2076        );
2077    }
2078
2079    None
2080}
2081
2082fn hard_contract_break_reason(
2083    live: &NormalizedObservable,
2084    live_invariant_violations: &[String],
2085) -> Option<String> {
2086    if !live_invariant_violations.is_empty() {
2087        return Some(format!(
2088            "live invariant violations: {}",
2089            live_invariant_violations.join("; ")
2090        ));
2091    }
2092    if live.semantics.obligation_balance.leaked > 0 {
2093        return Some("live run leaked obligations".to_string());
2094    }
2095    if live.semantics.obligation_balance.unresolved > 0 {
2096        return Some("live run left obligations unresolved".to_string());
2097    }
2098    if live.semantics.loser_drain.applicable
2099        && live.semantics.loser_drain.status != DrainStatus::Complete
2100    {
2101        return Some("live run did not complete loser drain".to_string());
2102    }
2103    if !live.semantics.region_close.quiescent {
2104        return Some("live root region did not close to quiescence".to_string());
2105    }
2106    if live.semantics.terminal_outcome.class == OutcomeClass::Panicked {
2107        return Some("live run panicked on an admitted surface".to_string());
2108    }
2109    if live.semantics.cancellation.acknowledged
2110        && (!live.semantics.cancellation.cleanup_completed
2111            || !live.semantics.cancellation.finalization_completed)
2112    {
2113        return Some("live cancellation acknowledged without cleanup/finalization".to_string());
2114    }
2115    None
2116}
2117
2118fn terminal_policy_outcome(
2119    provisional_class: ProvisionalDivergenceClass,
2120    rerun_decision: RerunDecision,
2121    suggested_final_class: Option<FinalDivergenceClass>,
2122    time_policy_class: TimePolicyClass,
2123    scheduler_noise_class: SchedulerNoiseClass,
2124    suppression_reason: Option<String>,
2125    explanation: impl Into<String>,
2126) -> DifferentialPolicyOutcome {
2127    DifferentialPolicyOutcome {
2128        provisional_class,
2129        rerun_decision,
2130        suggested_final_class,
2131        time_policy_class,
2132        scheduler_noise_class,
2133        suppression_reason,
2134        explanation: explanation.into(),
2135    }
2136}
2137
2138fn classify_differential_policy(
2139    identity: &DualRunScenarioIdentity,
2140    lab: &NormalizedObservable,
2141    live: &NormalizedObservable,
2142    verdict: &ComparisonVerdict,
2143    lab_invariant_violations: &[String],
2144    live_invariant_violations: &[String],
2145) -> DifferentialPolicyOutcome {
2146    let noise_class = classify_scheduler_noise(lab, live);
2147    let time_policy_class = classify_time_policy(identity, verdict, noise_class);
2148
2149    if lab.schema_version != live.schema_version {
2150        return terminal_policy_outcome(
2151            ProvisionalDivergenceClass::ArtifactSchemaViolation,
2152            RerunDecision::None,
2153            Some(FinalDivergenceClass::ArtifactSchemaViolation),
2154            time_policy_class,
2155            noise_class,
2156            Some("schema version mismatch".to_string()),
2157            "comparison artifacts do not share a schema contract, so reruns would not be honest",
2158        );
2159    }
2160
2161    if let Some(reason) = unsupported_surface_reason(identity) {
2162        return terminal_policy_outcome(
2163            ProvisionalDivergenceClass::UnsupportedSurface,
2164            RerunDecision::None,
2165            Some(FinalDivergenceClass::UnsupportedSurface),
2166            time_policy_class,
2167            noise_class,
2168            Some(reason),
2169            "scenario metadata marks this surface unsupported, so the mismatch is rejected immediately",
2170        );
2171    }
2172
2173    if let Some(reason) = insufficient_observability_reason(identity, verdict, live) {
2174        return terminal_policy_outcome(
2175            ProvisionalDivergenceClass::InsufficientObservability,
2176            RerunDecision::ConfirmationIfRicherInstrumentationEnabled { additional_runs: 1 },
2177            Some(FinalDivergenceClass::InsufficientObservability),
2178            time_policy_class,
2179            noise_class,
2180            Some(reason),
2181            "required evidence is missing or explicitly blocked, so this surface cannot be promoted honestly",
2182        );
2183    }
2184
2185    if let Some(reason) = hard_contract_break_reason(live, live_invariant_violations) {
2186        return terminal_policy_outcome(
2187            ProvisionalDivergenceClass::HardContractBreak,
2188            RerunDecision::None,
2189            Some(FinalDivergenceClass::RuntimeSemanticBug),
2190            time_policy_class,
2191            noise_class,
2192            Some(reason),
2193            "the live side already violates a hard semantic contract, so the framework should escalate immediately",
2194        );
2195    }
2196
2197    if verdict.passed
2198        && lab_invariant_violations.is_empty()
2199        && live_invariant_violations.is_empty()
2200        && noise_class != SchedulerNoiseClass::None
2201    {
2202        return terminal_policy_outcome(
2203            ProvisionalDivergenceClass::SchedulerNoiseSuspected,
2204            RerunDecision::LiveConfirmations { additional_runs: 2 },
2205            Some(FinalDivergenceClass::SchedulerNoiseSuspected),
2206            time_policy_class,
2207            noise_class,
2208            Some(
2209                "semantic observables stayed equal while only scheduler/provenance signals drifted"
2210                    .to_string(),
2211            ),
2212            "the semantic verdict remains a pass, but the report should retain scheduler-noise triage metadata",
2213        );
2214    }
2215
2216    if verdict.passed && lab_invariant_violations.is_empty() && live_invariant_violations.is_empty()
2217    {
2218        return terminal_policy_outcome(
2219            ProvisionalDivergenceClass::Pass,
2220            RerunDecision::None,
2221            None,
2222            time_policy_class,
2223            noise_class,
2224            None,
2225            "semantic observables match and no invariant failures were observed",
2226        );
2227    }
2228
2229    terminal_policy_outcome(
2230        ProvisionalDivergenceClass::SemanticMismatchAdmittedSurface,
2231        RerunDecision::DeterministicLabReplayAndLiveConfirmations {
2232            additional_live_runs: 2,
2233        },
2234        None,
2235        time_policy_class,
2236        noise_class,
2237        None,
2238        "semantic mismatches survived the initial comparison on an admitted surface; schedule the canonical lab replay plus two live confirmation reruns",
2239    )
2240}
2241
2242// ============================================================================
2243// Assertion Helpers
2244// ============================================================================
2245
2246/// Assert that a normalized observable satisfies the core Asupersync
2247/// invariants: no obligation leaks, region closed to quiescence, and
2248/// losers drained (if applicable).
2249///
2250/// Returns a list of invariant violations (empty if all pass).
2251#[must_use]
2252pub fn check_core_invariants(obs: &NormalizedObservable) -> Vec<String> {
2253    let mut violations = Vec::new();
2254
2255    // Obligation balance
2256    if !obs.semantics.obligation_balance.balanced {
2257        violations.push(format!(
2258            "Obligation balance: leaked={}, unresolved={}",
2259            obs.semantics.obligation_balance.leaked, obs.semantics.obligation_balance.unresolved
2260        ));
2261    }
2262
2263    // Region quiescence
2264    if !obs.semantics.region_close.quiescent {
2265        violations.push(format!(
2266            "Region not quiescent: state={:?}, live_children={}, finalizers_pending={}",
2267            obs.semantics.region_close.root_state,
2268            obs.semantics.region_close.live_children,
2269            obs.semantics.region_close.finalizers_pending
2270        ));
2271    }
2272
2273    // Loser drain
2274    if obs.semantics.loser_drain.applicable
2275        && obs.semantics.loser_drain.status == DrainStatus::Incomplete
2276    {
2277        violations.push(format!(
2278            "Incomplete loser drain: expected={}, drained={}",
2279            obs.semantics.loser_drain.expected_losers, obs.semantics.loser_drain.drained_losers
2280        ));
2281    }
2282
2283    // Cancellation protocol completion
2284    if obs.semantics.cancellation.requested && !obs.semantics.cancellation.cleanup_completed {
2285        violations.push(format!(
2286            "Cancellation cleanup incomplete: phase={:?}",
2287            obs.semantics.cancellation.terminal_phase
2288        ));
2289    }
2290    if obs.semantics.cancellation.requested
2291        && obs.semantics.cancellation.cleanup_completed
2292        && !obs.semantics.cancellation.finalization_completed
2293    {
2294        violations.push(format!(
2295            "Cancellation finalization incomplete: phase={:?}",
2296            obs.semantics.cancellation.terminal_phase
2297        ));
2298    }
2299
2300    violations
2301}
2302
2303/// Assert a normalized observable against expected semantics.
2304///
2305/// Returns mismatches between actual and expected values.
2306#[must_use]
2307pub fn assert_semantics(
2308    actual: &NormalizedSemantics,
2309    expected: &NormalizedSemantics,
2310) -> Vec<SemanticMismatch> {
2311    // Build temporary observables just for comparison.
2312    let lab = NormalizedObservable {
2313        schema_version: NORMALIZED_OBSERVABLE_SCHEMA_VERSION.to_string(),
2314        scenario_id: String::new(),
2315        surface_id: String::new(),
2316        surface_contract_version: String::new(),
2317        runtime_kind: RuntimeKind::Lab,
2318        semantics: expected.clone(),
2319        provenance: ReplayMetadata::for_lab(
2320            ScenarioFamilyId::new("", "", ""),
2321            &SeedPlan::inherit(0, ""),
2322        ),
2323    };
2324    let live = NormalizedObservable {
2325        schema_version: NORMALIZED_OBSERVABLE_SCHEMA_VERSION.to_string(),
2326        scenario_id: String::new(),
2327        surface_id: String::new(),
2328        surface_contract_version: String::new(),
2329        runtime_kind: RuntimeKind::Live,
2330        semantics: actual.clone(),
2331        provenance: ReplayMetadata::for_live(
2332            ScenarioFamilyId::new("", "", ""),
2333            &SeedPlan::inherit(0, ""),
2334        ),
2335    };
2336
2337    let verdict = compare_observables(
2338        &lab,
2339        &live,
2340        SeedLineageRecord::from_plan(&SeedPlan::inherit(0, "")),
2341    );
2342    verdict.mismatches
2343}
2344
2345// ============================================================================
2346// Live Runner Adapter
2347// ============================================================================
2348
2349/// Execution profile for the live runner.
2350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2351#[serde(rename_all = "snake_case")]
2352pub enum LiveExecutionProfile {
2353    /// Phase 1: `RuntimeBuilder::current_thread()` — single-threaded,
2354    /// no ambient globals, explicit `Cx`.
2355    CurrentThread,
2356}
2357
2358impl fmt::Display for LiveExecutionProfile {
2359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2360        match self {
2361            Self::CurrentThread => write!(f, "phase1.current_thread"),
2362        }
2363    }
2364}
2365
2366/// Configuration for a live runner execution.
2367#[derive(Debug, Clone, Serialize, Deserialize)]
2368pub struct LiveRunnerConfig {
2369    /// Effective seed for this live execution.
2370    pub seed: u64,
2371    /// Effective entropy seed.
2372    pub entropy_seed: u64,
2373    /// Execution profile.
2374    pub profile: LiveExecutionProfile,
2375    /// Scenario identity.
2376    pub scenario_id: String,
2377    /// Surface identity.
2378    pub surface_id: String,
2379    /// Seed lineage ID for audit.
2380    pub seed_lineage_id: String,
2381}
2382
2383impl LiveRunnerConfig {
2384    /// Create a live runner config from a `DualRunScenarioIdentity`.
2385    #[must_use]
2386    pub fn from_identity(identity: &DualRunScenarioIdentity) -> Self {
2387        let live_seed = identity.seed_plan.effective_live_seed();
2388        let entropy = identity.seed_plan.effective_entropy_seed(live_seed);
2389        Self {
2390            seed: live_seed,
2391            entropy_seed: entropy,
2392            profile: LiveExecutionProfile::CurrentThread,
2393            scenario_id: identity.scenario_id.clone(),
2394            surface_id: identity.surface_id.clone(),
2395            seed_lineage_id: identity.seed_plan.seed_lineage_id.clone(),
2396        }
2397    }
2398
2399    /// Create a live runner config from a `SeedPlan` with a scenario ID.
2400    #[must_use]
2401    pub fn from_plan(
2402        plan: &SeedPlan,
2403        scenario_id: impl Into<String>,
2404        surface_id: impl Into<String>,
2405    ) -> Self {
2406        let live_seed = plan.effective_live_seed();
2407        let entropy = plan.effective_entropy_seed(live_seed);
2408        Self {
2409            seed: live_seed,
2410            entropy_seed: entropy,
2411            profile: LiveExecutionProfile::CurrentThread,
2412            scenario_id: scenario_id.into(),
2413            surface_id: surface_id.into(),
2414            seed_lineage_id: plan.seed_lineage_id.clone(),
2415        }
2416    }
2417}
2418
2419impl fmt::Display for LiveRunnerConfig {
2420    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2421        write!(
2422            f,
2423            "LiveRunner(scenario={}, surface={}, seed=0x{:X}, profile={})",
2424            self.scenario_id, self.surface_id, self.seed, self.profile
2425        )
2426    }
2427}
2428
2429/// Witness collector for live-side semantic evidence.
2430///
2431/// The live adapter cannot rely on lab-only introspection (oracle reports,
2432/// trace certificates). Instead, it collects evidence from explicit
2433/// witnesses: joined handles, counters, lifecycle hooks, and stream
2434/// termination signals.
2435///
2436/// A `LiveWitnessCollector` is passed into the live execution closure.
2437/// The closure records evidence, and the collector normalizes it into
2438/// `NormalizedSemantics` at the end.
2439#[derive(Debug, Clone)]
2440pub struct LiveWitnessCollector {
2441    terminal_outcome: TerminalOutcome,
2442    cancellation: CancellationRecord,
2443    loser_drain: LoserDrainRecord,
2444    region_close: RegionCloseRecord,
2445    obligation_balance: ObligationBalanceRecord,
2446    resource_surface: ResourceSurfaceRecord,
2447    manifest: CaptureManifest,
2448    /// Nondeterminism qualifiers observed during execution.
2449    nondeterminism_notes: Vec<String>,
2450}
2451
2452impl LiveWitnessCollector {
2453    /// Create a new collector with default (happy-path) assumptions.
2454    ///
2455    /// All fields start at "clean" values. The live execution closure
2456    /// overrides them as evidence is observed.
2457    #[must_use]
2458    pub fn new(surface_scope: impl Into<String>) -> Self {
2459        let surface_scope = surface_scope.into();
2460        let mut manifest = CaptureManifest::new();
2461        manifest.inferred("terminal_outcome", "run_live_adapter.default_ok");
2462        manifest.inferred("cancellation", "run_live_adapter.default_no_cancellation");
2463        manifest.unsupported("cancellation.checkpoint_observed");
2464        manifest.inferred("loser_drain", "run_live_adapter.default_not_applicable");
2465        manifest.inferred("region_close", "run_live_adapter.default_quiescent");
2466        manifest.inferred(
2467            "obligation_balance",
2468            "run_live_adapter.default_balanced_obligations",
2469        );
2470        manifest.observed(
2471            "resource_surface.contract_scope",
2472            "scenario_identity.surface_id",
2473        );
2474
2475        Self {
2476            terminal_outcome: TerminalOutcome::ok(),
2477            cancellation: CancellationRecord::none(),
2478            loser_drain: LoserDrainRecord::not_applicable(),
2479            region_close: RegionCloseRecord::quiescent(),
2480            obligation_balance: ObligationBalanceRecord::zero(),
2481            resource_surface: ResourceSurfaceRecord::empty(surface_scope),
2482            manifest,
2483            nondeterminism_notes: Vec::new(),
2484        }
2485    }
2486
2487    /// Record the terminal outcome.
2488    pub fn set_outcome(&mut self, outcome: TerminalOutcome) {
2489        self.terminal_outcome = outcome;
2490        self.manifest
2491            .observed("terminal_outcome", "witness.set_outcome");
2492    }
2493
2494    /// Record cancellation evidence.
2495    pub fn set_cancellation(&mut self, record: CancellationRecord) {
2496        if record.checkpoint_observed.is_some() {
2497            self.manifest.observed(
2498                "cancellation.checkpoint_observed",
2499                "witness.set_cancellation",
2500            );
2501        } else {
2502            self.manifest
2503                .unsupported("cancellation.checkpoint_observed");
2504        }
2505        self.cancellation = record;
2506        self.manifest
2507            .observed("cancellation", "witness.set_cancellation");
2508    }
2509
2510    /// Record loser drain evidence.
2511    pub fn set_loser_drain(&mut self, record: LoserDrainRecord) {
2512        self.loser_drain = record;
2513        self.manifest
2514            .observed("loser_drain", "witness.set_loser_drain");
2515    }
2516
2517    /// Record region close evidence.
2518    pub fn set_region_close(&mut self, record: RegionCloseRecord) {
2519        self.region_close = record;
2520        self.manifest
2521            .observed("region_close", "witness.set_region_close");
2522    }
2523
2524    /// Record obligation balance evidence.
2525    pub fn set_obligation_balance(&mut self, record: ObligationBalanceRecord) {
2526        self.obligation_balance = record;
2527        self.manifest
2528            .observed("obligation_balance", "witness.set_obligation_balance");
2529    }
2530
2531    /// Set a resource counter.
2532    pub fn record_counter(&mut self, name: impl Into<String>, value: i64) {
2533        let n = name.into();
2534        let counter_manifest_key = format!("resource_surface.counters.{n}");
2535        let tolerance_manifest_key = format!("resource_surface.tolerances.{n}");
2536        self.resource_surface.counters.insert(n.clone(), value);
2537        self.resource_surface
2538            .tolerances
2539            .insert(n, CounterTolerance::Exact);
2540        self.manifest
2541            .observed(counter_manifest_key, "witness.record_counter");
2542        self.manifest
2543            .observed(tolerance_manifest_key, "witness.record_counter");
2544    }
2545
2546    /// Set a resource counter with tolerance.
2547    pub fn record_counter_with_tolerance(
2548        &mut self,
2549        name: impl Into<String>,
2550        value: i64,
2551        tolerance: CounterTolerance,
2552    ) {
2553        let n = name.into();
2554        self.resource_surface.counters.insert(n.clone(), value);
2555        self.resource_surface
2556            .tolerances
2557            .insert(n.clone(), tolerance);
2558        self.manifest.observed(
2559            format!("resource_surface.counters.{n}"),
2560            "witness.record_counter_with_tolerance",
2561        );
2562        self.manifest.observed(
2563            format!("resource_surface.tolerances.{n}"),
2564            "witness.record_counter_with_tolerance",
2565        );
2566    }
2567
2568    /// Note a nondeterminism qualifier (e.g., "scheduler ordering may vary").
2569    pub fn note_nondeterminism(&mut self, note: impl Into<String>) {
2570        self.nondeterminism_notes.push(note.into());
2571    }
2572
2573    /// Finalize into normalized semantics.
2574    #[must_use]
2575    pub fn finalize(self) -> NormalizedSemantics {
2576        NormalizedSemantics {
2577            terminal_outcome: self.terminal_outcome,
2578            cancellation: self.cancellation,
2579            loser_drain: self.loser_drain,
2580            region_close: self.region_close,
2581            obligation_balance: self.obligation_balance,
2582            resource_surface: self.resource_surface,
2583        }
2584    }
2585
2586    /// Access the capture manifest built for the live run.
2587    #[must_use]
2588    pub fn capture_manifest(&self) -> &CaptureManifest {
2589        &self.manifest
2590    }
2591
2592    /// Access nondeterminism notes.
2593    #[must_use]
2594    pub fn nondeterminism_notes(&self) -> &[String] {
2595        &self.nondeterminism_notes
2596    }
2597}
2598
2599/// Structured metadata emitted by a live run.
2600#[derive(Debug, Clone, Serialize, Deserialize)]
2601pub struct LiveRunMetadata {
2602    /// Configuration used.
2603    pub config: LiveRunnerConfig,
2604    /// Nondeterminism qualifiers observed.
2605    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2606    pub nondeterminism_notes: Vec<String>,
2607    /// Capture provenance for the normalized live semantics.
2608    pub capture_manifest: CaptureManifest,
2609    /// Replay metadata for this execution.
2610    pub replay: ReplayMetadata,
2611}
2612
2613/// Result of a live runner execution.
2614#[derive(Debug, Clone)]
2615pub struct LiveRunResult {
2616    /// Normalized semantics from the live run.
2617    pub semantics: NormalizedSemantics,
2618    /// Structured run metadata.
2619    pub metadata: LiveRunMetadata,
2620}
2621
2622/// Execute a differential scenario through the live runner adapter.
2623///
2624/// This is the live-side counterpart to lab execution. It:
2625/// 1. Builds a `LiveRunnerConfig` from the identity
2626/// 2. Logs structured start metadata
2627/// 3. Invokes the user's execution closure with a `LiveWitnessCollector`
2628/// 4. Logs structured completion metadata
2629/// 5. Returns `LiveRunResult` with normalized semantics
2630///
2631/// # Example
2632///
2633/// ```ignore
2634/// let identity = DualRunScenarioIdentity::phase1(
2635///     "cancel.race", "cancellation.race", "v1", "desc", 42,
2636/// );
2637/// let result = run_live_adapter(&identity, |config, witness| {
2638///     // Run on current-thread runtime
2639///     let rt = RuntimeBuilder::current_thread().build().unwrap();
2640///     let cx = Cx::for_testing();
2641///     rt.block_on(async {
2642///         // ... execute scenario, record witnesses ...
2643///         witness.set_outcome(TerminalOutcome::ok());
2644///     });
2645/// });
2646/// ```
2647#[must_use]
2648pub fn run_live_adapter(
2649    identity: &DualRunScenarioIdentity,
2650    f: impl FnOnce(&LiveRunnerConfig, &mut LiveWitnessCollector),
2651) -> LiveRunResult {
2652    let config = LiveRunnerConfig::from_identity(identity);
2653    let mut witness = LiveWitnessCollector::new(&identity.surface_id);
2654
2655    #[cfg(feature = "tracing-integration")]
2656    tracing::info!(
2657        scenario_id = %identity.scenario_id,
2658        surface_id = %identity.surface_id,
2659        seed = %format_args!("0x{:X}", config.seed),
2660        entropy_seed = %format_args!("0x{:X}", config.entropy_seed),
2661        profile = %config.profile,
2662        seed_lineage = %config.seed_lineage_id,
2663        "LIVE_RUN_START"
2664    );
2665
2666    f(&config, &mut witness);
2667
2668    let nondeterminism_notes = witness.nondeterminism_notes().to_vec();
2669    let capture_manifest = witness.capture_manifest().clone();
2670    let semantics = witness.finalize();
2671    let replay = ReplayMetadata::for_live(identity.family_id(), &identity.seed_plan)
2672        .with_nondeterminism_notes(nondeterminism_notes.clone());
2673
2674    #[cfg(feature = "tracing-integration")]
2675    tracing::info!(
2676        scenario_id = %identity.scenario_id,
2677        outcome = %semantics.terminal_outcome.class,
2678        quiescent = semantics.region_close.quiescent,
2679        obligation_balanced = semantics.obligation_balance.balanced,
2680        nondeterminism_count = nondeterminism_notes.len(),
2681        "LIVE_RUN_COMPLETE"
2682    );
2683
2684    LiveRunResult {
2685        semantics,
2686        metadata: LiveRunMetadata {
2687            config,
2688            nondeterminism_notes,
2689            capture_manifest,
2690            replay,
2691        },
2692    }
2693}
2694
2695// ============================================================================
2696// Semantic Capture Hooks
2697// ============================================================================
2698
2699/// Observability status for a captured field.
2700///
2701/// When a live adapter cannot observe a semantic field, it must declare
2702/// the limitation explicitly rather than fabricating a value.
2703#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2704#[serde(rename_all = "snake_case")]
2705pub enum FieldObservability {
2706    /// Field was observed from a stable semantic hook.
2707    Observed,
2708    /// Field was inferred from indirect evidence.
2709    Inferred,
2710    /// Field is not observable on this adapter and was set to a default.
2711    Unsupported,
2712}
2713
2714impl fmt::Display for FieldObservability {
2715    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2716        match self {
2717            Self::Observed => write!(f, "observed"),
2718            Self::Inferred => write!(f, "inferred"),
2719            Self::Unsupported => write!(f, "unsupported"),
2720        }
2721    }
2722}
2723
2724/// Evidence annotation for a single captured field.
2725#[derive(Debug, Clone, Serialize, Deserialize)]
2726pub struct CaptureAnnotation {
2727    /// Dot-path of the field (e.g., `"cancellation.checkpoint_observed"`).
2728    pub field: String,
2729    /// How the field was captured.
2730    pub observability: FieldObservability,
2731    /// Source of the evidence (e.g., `"task_handle.join"`, `"oracle.loser_drain"`).
2732    pub source: String,
2733}
2734
2735/// Semantic capture manifest for a live run.
2736///
2737/// Records how each normalized field was captured, enabling downstream
2738/// tools to distinguish strongly-observed from weakly-inferred evidence.
2739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2740pub struct CaptureManifest {
2741    /// Per-field capture annotations.
2742    pub annotations: Vec<CaptureAnnotation>,
2743    /// Fields that are unsupported on this adapter.
2744    pub unsupported_fields: Vec<String>,
2745}
2746
2747impl CaptureManifest {
2748    fn upsert(&mut self, field: String, observability: FieldObservability, source: String) {
2749        self.unsupported_fields
2750            .retain(|existing| existing != &field);
2751        if observability == FieldObservability::Unsupported {
2752            self.unsupported_fields.push(field.clone());
2753            self.unsupported_fields.sort_unstable();
2754            self.unsupported_fields.dedup();
2755        }
2756
2757        if let Some(annotation) = self.annotations.iter_mut().find(|a| a.field == field) {
2758            annotation.observability = observability;
2759            annotation.source = source;
2760        } else {
2761            self.annotations.push(CaptureAnnotation {
2762                field,
2763                observability,
2764                source,
2765            });
2766        }
2767        self.annotations.sort_by(|left, right| {
2768            left.field
2769                .cmp(&right.field)
2770                .then(left.source.cmp(&right.source))
2771        });
2772    }
2773
2774    fn annotation_for_candidate(&self, field: &str) -> Option<&CaptureAnnotation> {
2775        self.annotations
2776            .iter()
2777            .find(|annotation| annotation.field == field)
2778    }
2779
2780    /// Create an empty manifest.
2781    #[must_use]
2782    pub fn new() -> Self {
2783        Self::default()
2784    }
2785
2786    /// Record that a field was directly observed.
2787    pub fn observed(&mut self, field: impl Into<String>, source: impl Into<String>) {
2788        self.upsert(field.into(), FieldObservability::Observed, source.into());
2789    }
2790
2791    /// Record that a field was inferred from indirect evidence.
2792    pub fn inferred(&mut self, field: impl Into<String>, source: impl Into<String>) {
2793        self.upsert(field.into(), FieldObservability::Inferred, source.into());
2794    }
2795
2796    /// Record that a field is unsupported and was defaulted.
2797    pub fn unsupported(&mut self, field: impl Into<String>) {
2798        self.upsert(
2799            field.into(),
2800            FieldObservability::Unsupported,
2801            "default".to_string(),
2802        );
2803    }
2804
2805    /// How many fields were captured total.
2806    #[must_use]
2807    pub fn total_fields(&self) -> usize {
2808        self.annotations.len()
2809    }
2810
2811    /// How many fields are unsupported.
2812    #[must_use]
2813    pub fn unsupported_count(&self) -> usize {
2814        self.unsupported_fields.len()
2815    }
2816
2817    /// Whether all fields were directly observed (no inferred or unsupported).
2818    #[must_use]
2819    pub fn fully_observed(&self) -> bool {
2820        !self.annotations.is_empty()
2821            && self
2822                .annotations
2823                .iter()
2824                .all(|a| a.observability == FieldObservability::Observed)
2825    }
2826
2827    /// Resolve the capture annotation for a semantic field or one of its
2828    /// parents.
2829    #[must_use]
2830    pub fn annotation_for_field(&self, field: &str) -> Option<&CaptureAnnotation> {
2831        if let Some(annotation) = self.annotation_for_candidate(field) {
2832            return Some(annotation);
2833        }
2834
2835        let normalized = field.strip_prefix("semantics.").unwrap_or(field);
2836        if let Some(annotation) = self.annotation_for_candidate(normalized) {
2837            return Some(annotation);
2838        }
2839
2840        let mut candidate = normalized;
2841        while let Some((parent, _)) = candidate.rsplit_once('.') {
2842            if let Some(annotation) = self.annotation_for_candidate(parent) {
2843                return Some(annotation);
2844            }
2845            candidate = parent;
2846        }
2847
2848        None
2849    }
2850
2851    /// Render capture provenance for a semantic field.
2852    #[must_use]
2853    pub fn describe_field_capture(&self, field: &str) -> Option<String> {
2854        self.annotation_for_field(field)
2855            .map(|annotation| format!("{} via {}", annotation.observability, annotation.source))
2856    }
2857}
2858
2859/// Capture a `TerminalOutcome` from an `Outcome<T, E>`.
2860///
2861/// Maps the four-valued `Outcome` enum to the normalized
2862/// `TerminalOutcome` record. Error and cancel reason classes are
2863/// derived from `Display` on the error/reason values.
2864#[must_use]
2865pub fn capture_terminal_outcome<T, E: fmt::Display>(
2866    outcome: &crate::types::outcome::Outcome<T, E>,
2867) -> TerminalOutcome {
2868    match outcome {
2869        crate::types::outcome::Outcome::Ok(_) => TerminalOutcome::ok(),
2870        crate::types::outcome::Outcome::Err(e) => TerminalOutcome::err(format!("{e}")),
2871        crate::types::outcome::Outcome::Cancelled(reason) => {
2872            TerminalOutcome::cancelled(format!("{reason}"))
2873        }
2874        crate::types::outcome::Outcome::Panicked(_) => TerminalOutcome {
2875            class: OutcomeClass::Panicked,
2876            severity: OutcomeClass::Panicked,
2877            surface_result: None,
2878            error_class: None,
2879            cancel_reason_class: None,
2880            panic_class: Some("caught_panic".to_string()),
2881        },
2882    }
2883}
2884
2885/// Capture a `TerminalOutcome` from a `Result<T, E>`.
2886///
2887/// Maps `Ok` to `OutcomeClass::Ok` and `Err` to `OutcomeClass::Err`.
2888#[must_use]
2889pub fn capture_terminal_from_result<T, E: fmt::Display>(result: &Result<T, E>) -> TerminalOutcome {
2890    match result {
2891        Ok(_) => TerminalOutcome::ok(),
2892        Err(e) => TerminalOutcome::err(format!("{e}")),
2893    }
2894}
2895
2896/// Capture obligation balance from explicit counters.
2897///
2898/// This is a convenience for live adapters that track obligations
2899/// via explicit counters rather than a full ledger.
2900#[must_use]
2901pub fn capture_obligation_balance(
2902    reserved: u32,
2903    committed: u32,
2904    aborted: u32,
2905) -> ObligationBalanceRecord {
2906    let leaked = reserved.saturating_sub(committed.saturating_add(aborted));
2907    ObligationBalanceRecord {
2908        reserved,
2909        committed,
2910        aborted,
2911        leaked,
2912        unresolved: 0,
2913        balanced: leaked == 0,
2914    }
2915    .recompute()
2916}
2917
2918/// Capture region close evidence from explicit flags.
2919///
2920/// For live adapters that check quiescence by joining all child tasks.
2921#[must_use]
2922pub fn capture_region_close(
2923    all_children_joined: bool,
2924    all_finalizers_done: bool,
2925) -> RegionCloseRecord {
2926    let quiescent = all_children_joined && all_finalizers_done;
2927    RegionCloseRecord {
2928        // This helper is used once a close path is already under evaluation,
2929        // so non-quiescent states should reflect drain/finalize progress rather
2930        // than pretending the region is still open for new work.
2931        root_state: if quiescent {
2932            RegionState::Closed
2933        } else if all_children_joined {
2934            RegionState::Finalizing
2935        } else {
2936            RegionState::Draining
2937        },
2938        quiescent,
2939        live_children: u32::from(!all_children_joined),
2940        finalizers_pending: u32::from(!all_finalizers_done),
2941        close_completed: quiescent,
2942    }
2943}
2944
2945/// Capture loser drain evidence from join results.
2946///
2947/// `loser_joined` is a list of booleans indicating whether each loser
2948/// task was successfully joined (true = drained).
2949#[must_use]
2950pub fn capture_loser_drain(loser_joined: &[bool]) -> LoserDrainRecord {
2951    if loser_joined.is_empty() {
2952        return LoserDrainRecord::not_applicable();
2953    }
2954    let expected = loser_joined.len() as u32;
2955    let drained = loser_joined.iter().filter(|&&x| x).count() as u32;
2956    LoserDrainRecord {
2957        applicable: true,
2958        expected_losers: expected,
2959        drained_losers: drained,
2960        status: if drained == expected {
2961            DrainStatus::Complete
2962        } else {
2963            DrainStatus::Incomplete
2964        },
2965        evidence: Some("task_handle.join".to_string()),
2966    }
2967}
2968
2969/// Capture cancellation evidence from explicit lifecycle flags.
2970#[must_use]
2971#[allow(clippy::fn_params_excessive_bools)]
2972pub fn capture_cancellation(
2973    requested: bool,
2974    acknowledged: bool,
2975    cleanup_completed: bool,
2976    finalization_completed: bool,
2977    checkpoint_observed: Option<bool>,
2978) -> CancellationRecord {
2979    let terminal_phase = if !requested {
2980        CancelTerminalPhase::NotCancelled
2981    } else if finalization_completed {
2982        CancelTerminalPhase::Completed
2983    } else if cleanup_completed {
2984        CancelTerminalPhase::Finalizing
2985    } else if acknowledged {
2986        CancelTerminalPhase::Cancelling
2987    } else {
2988        CancelTerminalPhase::CancelRequested
2989    };
2990
2991    CancellationRecord {
2992        requested,
2993        acknowledged,
2994        cleanup_completed,
2995        finalization_completed,
2996        terminal_phase,
2997        checkpoint_observed,
2998    }
2999}
3000
3001// ============================================================================
3002// Lab Evidence Normalizer
3003// ============================================================================
3004
3005/// Normalize a `LabRunReport` into `NormalizedSemantics`.
3006///
3007/// Extracts semantic facts from the lab report and oracle results:
3008/// - Terminal outcome from oracle pass/fail status
3009/// - Region quiescence from `report.quiescent`
3010/// - Obligation leaks from invariant violations
3011/// - Cancellation and loser drain from oracle entries
3012///
3013/// Returns `(NormalizedSemantics, CaptureManifest)` so callers know
3014/// exactly how each field was derived.
3015#[must_use]
3016#[allow(clippy::too_many_lines)]
3017pub fn normalize_lab_report(
3018    report: &crate::lab::runtime::LabRunReport,
3019    surface_scope: &str,
3020) -> (NormalizedSemantics, CaptureManifest) {
3021    let mut manifest = CaptureManifest::new();
3022
3023    // Terminal outcome: if oracle failed or invariant violations, it's an error.
3024    let terminal_outcome = if !report.invariant_violations.is_empty() {
3025        manifest.observed("terminal_outcome", "invariant_violations");
3026        TerminalOutcome::err("invariant_violation")
3027    } else if !report.oracle_report.all_passed() {
3028        manifest.observed("terminal_outcome", "oracle_report.failures");
3029        TerminalOutcome::err("oracle_failure")
3030    } else {
3031        manifest.observed("terminal_outcome", "oracle_report.all_passed");
3032        TerminalOutcome::ok()
3033    };
3034
3035    // Region close: directly from quiescence flag.
3036    manifest.observed("region_close.quiescent", "LabRunReport.quiescent");
3037    let region_close = RegionCloseRecord {
3038        root_state: if report.quiescent {
3039            RegionState::Closed
3040        } else {
3041            RegionState::Closing
3042        },
3043        quiescent: report.quiescent,
3044        live_children: 0,
3045        finalizers_pending: 0,
3046        close_completed: report.quiescent,
3047    };
3048
3049    // Obligation balance: check for leak oracle or invariant violations.
3050    let has_leak = report
3051        .invariant_violations
3052        .iter()
3053        .any(|v| v.contains("obligation") || v.contains("leak"));
3054    let obligation_oracle_failed = report
3055        .oracle_report
3056        .entry("obligation_leak")
3057        .is_some_and(|e| !e.passed);
3058    manifest.observed("obligation_balance", "oracle.obligation_leak + invariants");
3059    let obligation_balance = if has_leak || obligation_oracle_failed {
3060        ObligationBalanceRecord {
3061            reserved: 0,
3062            committed: 0,
3063            aborted: 0,
3064            leaked: 1,
3065            unresolved: 0,
3066            balanced: false,
3067        }
3068    } else {
3069        ObligationBalanceRecord::zero()
3070    };
3071
3072    // Loser drain: check for loser_drain oracle.
3073    let loser_drain_entry = report.oracle_report.entry("loser_drain");
3074    let loser_drain = if let Some(entry) = loser_drain_entry {
3075        manifest.observed("loser_drain", "oracle.loser_drain");
3076        if entry.passed {
3077            // Oracle passed but we don't know exact counts.
3078            LoserDrainRecord {
3079                applicable: true,
3080                expected_losers: 0,
3081                drained_losers: 0,
3082                status: DrainStatus::Complete,
3083                evidence: Some("oracle.loser_drain.passed".to_string()),
3084            }
3085        } else {
3086            LoserDrainRecord {
3087                applicable: true,
3088                expected_losers: 0,
3089                drained_losers: 0,
3090                status: DrainStatus::Incomplete,
3091                evidence: Some("oracle.loser_drain.failed".to_string()),
3092            }
3093        }
3094    } else {
3095        manifest.inferred("loser_drain", "no_oracle_entry");
3096        LoserDrainRecord::not_applicable()
3097    };
3098
3099    // Cancellation: check for cancellation_protocol oracle.
3100    let cancel_entry = report.oracle_report.entry("cancellation_protocol");
3101    let cancellation = if let Some(entry) = cancel_entry {
3102        manifest.observed("cancellation", "oracle.cancellation_protocol");
3103        if entry.passed {
3104            CancellationRecord::completed()
3105        } else {
3106            CancellationRecord {
3107                requested: true,
3108                acknowledged: false,
3109                cleanup_completed: false,
3110                finalization_completed: false,
3111                terminal_phase: CancelTerminalPhase::CancelRequested,
3112                checkpoint_observed: None,
3113            }
3114        }
3115    } else {
3116        manifest.inferred("cancellation", "no_oracle_entry");
3117        CancellationRecord::none()
3118    };
3119
3120    let semantics = NormalizedSemantics {
3121        terminal_outcome,
3122        cancellation,
3123        loser_drain,
3124        region_close,
3125        obligation_balance,
3126        resource_surface: ResourceSurfaceRecord::empty(surface_scope),
3127    };
3128
3129    (semantics, manifest)
3130}
3131
3132/// Build a complete `NormalizedObservable` from a lab run.
3133///
3134/// Combines `normalize_lab_report` with identity and provenance.
3135#[must_use]
3136pub fn normalize_lab_observable(
3137    identity: &DualRunScenarioIdentity,
3138    report: &crate::lab::runtime::LabRunReport,
3139) -> NormalizedObservable {
3140    let (semantics, _manifest) = normalize_lab_report(report, &identity.surface_id);
3141    let mut prov = ReplayMetadata::for_lab(identity.family_id(), &identity.seed_plan);
3142    prov = prov.with_lab_report(
3143        report.trace_fingerprint,
3144        report.trace_certificate.event_hash,
3145        report.trace_certificate.event_count,
3146        report.trace_certificate.schedule_hash,
3147        report.steps_total,
3148    );
3149    NormalizedObservable::new(identity, RuntimeKind::Lab, semantics, prov)
3150}
3151
3152/// Build a complete `NormalizedObservable` from a live run result.
3153#[must_use]
3154pub fn normalize_live_observable(
3155    identity: &DualRunScenarioIdentity,
3156    live_result: &LiveRunResult,
3157) -> NormalizedObservable {
3158    let provenance = live_result
3159        .metadata
3160        .replay
3161        .clone()
3162        .with_nondeterminism_notes(live_result.metadata.nondeterminism_notes.clone());
3163    NormalizedObservable::new(
3164        identity,
3165        RuntimeKind::Live,
3166        live_result.semantics.clone(),
3167        provenance,
3168    )
3169}
3170
3171// ============================================================================
3172// Fuzz-to-Scenario Promotion
3173// ============================================================================
3174
3175/// A promoted fuzz finding as a replayable dual-run scenario descriptor.
3176#[derive(Debug, Clone, Serialize, Deserialize)]
3177pub struct PromotedFuzzScenario {
3178    /// Dual-run scenario identity with seed plan derived from the finding.
3179    pub identity: DualRunScenarioIdentity,
3180    /// Original fuzz seed that discovered the issue.
3181    pub original_seed: u64,
3182    /// Minimized seed (if available), used as the canonical replay seed.
3183    pub replay_seed: u64,
3184    /// Violation categories observed.
3185    pub violation_categories: Vec<String>,
3186    /// Trace fingerprint from the failing lab run.
3187    pub trace_fingerprint: u64,
3188    /// Certificate hash from the failing lab run.
3189    pub certificate_hash: u64,
3190    /// Human-readable description of what was found.
3191    pub description: String,
3192    /// Provenance: which fuzz campaign produced this.
3193    pub campaign_base_seed: Option<u64>,
3194    /// Provenance: iteration index in the campaign.
3195    pub campaign_iteration: Option<usize>,
3196    /// Optional artifact path for the source fuzz or regression bundle.
3197    #[serde(skip_serializing_if = "Option::is_none")]
3198    pub source_artifact_path: Option<String>,
3199}
3200
3201impl PromotedFuzzScenario {
3202    /// Default repro command for this scenario.
3203    #[must_use]
3204    pub fn repro_command(&self) -> String {
3205        format!(
3206            "rch exec -- env ASUPERSYNC_SEED=0x{:X} cargo test {} -- --nocapture",
3207            self.replay_seed, self.identity.scenario_id
3208        )
3209    }
3210
3211    /// Annotate the promoted scenario with the source artifact bundle path.
3212    #[must_use]
3213    pub fn with_source_artifact_path(mut self, path: impl Into<String>) -> Self {
3214        self.source_artifact_path = Some(path.into());
3215        self
3216    }
3217
3218    /// Build lab replay metadata for this promoted fuzz scenario.
3219    #[must_use]
3220    pub fn lab_replay_metadata(&self) -> ReplayMetadata {
3221        let mut metadata = self
3222            .identity
3223            .lab_replay_metadata()
3224            .with_repro_command(self.repro_command());
3225        metadata.trace_fingerprint = Some(self.trace_fingerprint);
3226        if let Some(path) = &self.source_artifact_path {
3227            metadata = metadata.with_artifact_path(path.clone());
3228        }
3229        metadata
3230    }
3231}
3232
3233impl fmt::Display for PromotedFuzzScenario {
3234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3235        write!(
3236            f,
3237            "PromotedFuzz({}, seed=0x{:X}, violations=[{}])",
3238            self.identity.scenario_id,
3239            self.replay_seed,
3240            self.violation_categories.join(", ")
3241        )
3242    }
3243}
3244
3245fn promoted_violation_categories(
3246    violations: &[crate::lab::runtime::InvariantViolation],
3247) -> Vec<String> {
3248    use crate::lab::runtime::InvariantViolation;
3249
3250    let mut categories: Vec<String> = violations
3251        .iter()
3252        .map(|violation| match violation {
3253            InvariantViolation::ObligationLeak { .. } => "obligation_leak".to_string(),
3254            InvariantViolation::TaskLeak { .. } => "task_leak".to_string(),
3255            InvariantViolation::ActorLeak { .. } => "actor_leak".to_string(),
3256            InvariantViolation::QuiescenceViolation => "quiescence_violation".to_string(),
3257            InvariantViolation::Futurelock { .. } => "futurelock".to_string(),
3258            InvariantViolation::CancellationProtocol { .. } => "cancellation_protocol".to_string(),
3259        })
3260        .collect();
3261    categories.sort_unstable();
3262    categories.dedup();
3263    categories
3264}
3265
3266/// Replayable differential scenario promoted from schedule exploration output.
3267#[derive(Debug, Clone, Serialize, Deserialize)]
3268pub struct PromotedExplorationScenario {
3269    /// Replayable dual-run identity selected for the representative schedule.
3270    pub identity: DualRunScenarioIdentity,
3271    /// Representative seed chosen for replay/minimized regression coverage.
3272    pub replay_seed: u64,
3273    /// Canonical trace fingerprint for the promoted schedule class.
3274    pub trace_fingerprint: u64,
3275    /// Schedule hash for the representative run.
3276    pub representative_schedule_hash: u64,
3277    /// All seeds observed in the original exploration class.
3278    pub original_seeds: Vec<u64>,
3279    /// Seeds in the class that produced invariant violations.
3280    pub violation_seeds: Vec<u64>,
3281    /// Stable stringified violation summaries for this class.
3282    pub violation_summaries: Vec<String>,
3283    /// All schedule hashes observed in the class.
3284    pub supporting_schedule_hashes: Vec<u64>,
3285    /// Number of runs collapsed into this promoted class.
3286    pub class_run_count: usize,
3287    /// Total runs in the source exploration report.
3288    pub source_total_runs: usize,
3289    /// Total unique classes in the source exploration report.
3290    pub source_unique_classes: usize,
3291    /// Optional artifact path for the source exploration report bundle.
3292    #[serde(skip_serializing_if = "Option::is_none")]
3293    pub source_artifact_path: Option<String>,
3294    /// Human-readable scenario meaning.
3295    pub description: String,
3296}
3297
3298impl PromotedExplorationScenario {
3299    /// Default repro command for this promoted schedule scenario.
3300    #[must_use]
3301    pub fn repro_command(&self) -> String {
3302        format!(
3303            "rch exec -- env ASUPERSYNC_SEED=0x{:X} cargo test {} -- --nocapture",
3304            self.replay_seed, self.identity.scenario_id
3305        )
3306    }
3307
3308    /// Annotate the promoted scenario with the source artifact bundle path.
3309    #[must_use]
3310    pub fn with_source_artifact_path(mut self, path: impl Into<String>) -> Self {
3311        self.source_artifact_path = Some(path.into());
3312        self
3313    }
3314
3315    /// Build lab replay metadata for the representative schedule.
3316    #[must_use]
3317    pub fn lab_replay_metadata(&self) -> ReplayMetadata {
3318        let mut metadata = self
3319            .identity
3320            .lab_replay_metadata()
3321            .with_repro_command(self.repro_command());
3322        metadata.trace_fingerprint = Some(self.trace_fingerprint);
3323        metadata.schedule_hash = Some(self.representative_schedule_hash);
3324        if let Some(path) = &self.source_artifact_path {
3325            metadata = metadata.with_artifact_path(path.clone());
3326        }
3327        metadata
3328    }
3329}
3330
3331impl fmt::Display for PromotedExplorationScenario {
3332    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3333        write!(
3334            f,
3335            "PromotedExploration({}, fingerprint=0x{:X}, seed=0x{:X}, runs={})",
3336            self.identity.scenario_id,
3337            self.trace_fingerprint,
3338            self.replay_seed,
3339            self.class_run_count
3340        )
3341    }
3342}
3343
3344/// Promote a `FuzzFinding` into a replayable `DualRunScenarioIdentity`.
3345#[must_use]
3346pub fn promote_fuzz_finding(
3347    finding: &crate::lab::fuzz::FuzzFinding,
3348    surface_id: &str,
3349    contract_version: &str,
3350) -> PromotedFuzzScenario {
3351    let replay_seed = finding.minimized_seed.unwrap_or(finding.seed);
3352    let violation_cats = promoted_violation_categories(&finding.violations);
3353    let primary_violation = violation_cats.first().map_or("unknown", String::as_str);
3354
3355    let scenario_id = format!(
3356        "fuzz.{surface_id}.{primary_violation}.seed_{:x}",
3357        replay_seed & 0xFFFF_FFFF
3358    );
3359    let description = format!(
3360        "Fuzz-discovered {primary_violation} adversarial case: {} violation(s) at seed 0x{:X}",
3361        finding.violations.len(),
3362        finding.seed
3363    );
3364
3365    let identity = DualRunScenarioIdentity::phase1(
3366        &scenario_id,
3367        surface_id,
3368        contract_version,
3369        &description,
3370        replay_seed,
3371    )
3372    .with_seed_plan(
3373        SeedPlan::inherit(replay_seed, scenario_id.clone()).with_entropy_seed(finding.entropy_seed),
3374    )
3375    .with_metadata("promoted_from", "fuzz_finding")
3376    .with_metadata("original_seed", format!("0x{:X}", finding.seed))
3377    .with_metadata("entropy_seed", format!("0x{:X}", finding.entropy_seed))
3378    .with_metadata(
3379        "trace_fingerprint",
3380        format!("0x{:X}", finding.trace_fingerprint),
3381    )
3382    .with_metadata(
3383        "certificate_hash",
3384        format!("0x{:X}", finding.certificate_hash),
3385    )
3386    .with_metadata("violation_categories", violation_cats.join(","));
3387
3388    PromotedFuzzScenario {
3389        identity,
3390        original_seed: finding.seed,
3391        replay_seed,
3392        violation_categories: violation_cats,
3393        trace_fingerprint: finding.trace_fingerprint,
3394        certificate_hash: finding.certificate_hash,
3395        description,
3396        campaign_base_seed: None,
3397        campaign_iteration: None,
3398        source_artifact_path: None,
3399    }
3400}
3401
3402/// Promote a `FuzzRegressionCase` into a replayable scenario descriptor.
3403#[must_use]
3404pub fn promote_regression_case(
3405    case: &crate::lab::fuzz::FuzzRegressionCase,
3406    surface_id: &str,
3407    contract_version: &str,
3408) -> PromotedFuzzScenario {
3409    let primary_violation = case
3410        .violation_categories
3411        .first()
3412        .map_or("unknown", String::as_str);
3413    let scenario_id = format!(
3414        "regression.{surface_id}.{primary_violation}.seed_{:x}",
3415        case.replay_seed & 0xFFFF_FFFF
3416    );
3417    let description = format!(
3418        "Regression case ({primary_violation}): {} violation(s), replay seed 0x{:X}",
3419        case.violation_categories.len(),
3420        case.replay_seed
3421    );
3422
3423    let identity = DualRunScenarioIdentity::phase1(
3424        &scenario_id,
3425        surface_id,
3426        contract_version,
3427        &description,
3428        case.replay_seed,
3429    )
3430    .with_seed_plan(
3431        SeedPlan::inherit(case.replay_seed, scenario_id.clone())
3432            .with_entropy_seed(case.entropy_seed),
3433    )
3434    .with_metadata("promoted_from", "regression_case")
3435    .with_metadata("original_seed", format!("0x{:X}", case.seed))
3436    .with_metadata("entropy_seed", format!("0x{:X}", case.entropy_seed))
3437    .with_metadata(
3438        "trace_fingerprint",
3439        format!("0x{:X}", case.trace_fingerprint),
3440    )
3441    .with_metadata("certificate_hash", format!("0x{:X}", case.certificate_hash))
3442    .with_metadata("violation_categories", case.violation_categories.join(","));
3443
3444    PromotedFuzzScenario {
3445        identity,
3446        original_seed: case.seed,
3447        replay_seed: case.replay_seed,
3448        violation_categories: case.violation_categories.clone(),
3449        trace_fingerprint: case.trace_fingerprint,
3450        certificate_hash: case.certificate_hash,
3451        description,
3452        campaign_base_seed: None,
3453        campaign_iteration: None,
3454        source_artifact_path: None,
3455    }
3456}
3457
3458/// Promote an entire `FuzzRegressionCorpus` into replayable scenarios.
3459#[must_use]
3460pub fn promote_regression_corpus(
3461    corpus: &crate::lab::fuzz::FuzzRegressionCorpus,
3462    surface_id: &str,
3463    contract_version: &str,
3464) -> Vec<PromotedFuzzScenario> {
3465    corpus
3466        .cases
3467        .iter()
3468        .enumerate()
3469        .map(|(i, case)| {
3470            let mut promoted = promote_regression_case(case, surface_id, contract_version);
3471            promoted.campaign_base_seed = Some(corpus.base_seed);
3472            promoted.campaign_iteration = Some(i);
3473            promoted.identity.metadata.insert(
3474                "campaign_base_seed".to_string(),
3475                format!("0x{:X}", corpus.base_seed),
3476            );
3477            promoted.identity.metadata.insert(
3478                "campaign_entropy_seed".to_string(),
3479                format!("0x{:X}", corpus.entropy_seed),
3480            );
3481            promoted
3482                .identity
3483                .metadata
3484                .insert("campaign_iteration".to_string(), i.to_string());
3485            promoted
3486        })
3487        .collect()
3488}
3489
3490/// Promote schedule-exploration classes into replayable differential scenarios.
3491///
3492/// The promotion rule keeps one representative run per canonical fingerprint
3493/// class. When a class contains violations, the smallest violating seed is
3494/// chosen so regression promotion remains focused on the failing lineage.
3495#[must_use]
3496pub fn promote_exploration_report(
3497    report: &crate::lab::explorer::ExplorationReport,
3498    surface_id: &str,
3499    contract_version: &str,
3500) -> Vec<PromotedExplorationScenario> {
3501    #[derive(Default)]
3502    struct ClassAggregate {
3503        seeds: Vec<u64>,
3504        schedule_hashes: Vec<u64>,
3505        run_count: usize,
3506        representative_schedule_hash: Option<u64>,
3507        violation_seeds: Vec<u64>,
3508        violation_summaries: Vec<String>,
3509    }
3510
3511    let mut by_fingerprint: BTreeMap<u64, ClassAggregate> = BTreeMap::new();
3512
3513    for run in &report.runs {
3514        let entry = by_fingerprint.entry(run.fingerprint).or_default();
3515        entry.seeds.push(run.seed);
3516        entry.schedule_hashes.push(run.certificate_hash);
3517        entry.run_count += 1;
3518        if entry.representative_schedule_hash.is_none() {
3519            entry.representative_schedule_hash = Some(run.certificate_hash);
3520        }
3521    }
3522
3523    for violation in &report.violations {
3524        let entry = by_fingerprint.entry(violation.fingerprint).or_default();
3525        entry.violation_seeds.push(violation.seed);
3526        entry
3527            .violation_summaries
3528            .extend(violation.violations.iter().map(ToString::to_string));
3529    }
3530
3531    by_fingerprint
3532        .into_iter()
3533        .map(|(trace_fingerprint, mut aggregate)| {
3534            aggregate.seeds.sort_unstable();
3535            aggregate.seeds.dedup();
3536            aggregate.schedule_hashes.sort_unstable();
3537            aggregate.schedule_hashes.dedup();
3538            aggregate.violation_seeds.sort_unstable();
3539            aggregate.violation_seeds.dedup();
3540            aggregate.violation_summaries.sort();
3541            aggregate.violation_summaries.dedup();
3542
3543            let (replay_seed, representative_reason) =
3544                if let Some(seed) = aggregate.violation_seeds.first().copied() {
3545                    (seed, "lowest_violation_seed")
3546                } else {
3547                    (
3548                        *aggregate
3549                            .seeds
3550                            .first()
3551                            .expect("exploration class must contain at least one run"),
3552                        "lowest_seed",
3553                    )
3554                };
3555
3556            let representative_schedule_hash = report
3557                .runs
3558                .iter()
3559                .find(|run| run.fingerprint == trace_fingerprint && run.seed == replay_seed)
3560                .map(|run| run.certificate_hash)
3561                .or(aggregate.representative_schedule_hash)
3562                .expect("exploration class must have a representative schedule hash");
3563
3564            let scenario_id = format!(
3565                "schedule.{surface_id}.fp_{trace_fingerprint:016x}.seed_{:08x}",
3566                replay_seed & 0xFFFF_FFFF
3567            );
3568            let description = format!(
3569                "Promoted schedule exploration class 0x{trace_fingerprint:X}: {} run(s), representative seed 0x{replay_seed:X}",
3570                aggregate.run_count
3571            );
3572
3573            let identity = DualRunScenarioIdentity::phase1(
3574                &scenario_id,
3575                surface_id,
3576                contract_version,
3577                &description,
3578                replay_seed,
3579            )
3580            .with_metadata("promoted_from", "exploration_report")
3581            .with_metadata("trace_fingerprint", format!("0x{trace_fingerprint:X}"))
3582            .with_metadata("class_run_count", aggregate.run_count.to_string())
3583            .with_metadata("source_total_runs", report.total_runs.to_string())
3584            .with_metadata("source_unique_classes", report.unique_classes.to_string())
3585            .with_metadata("representative_reason", representative_reason);
3586
3587            PromotedExplorationScenario {
3588                identity,
3589                replay_seed,
3590                trace_fingerprint,
3591                representative_schedule_hash,
3592                original_seeds: aggregate.seeds,
3593                violation_seeds: aggregate.violation_seeds,
3594                violation_summaries: aggregate.violation_summaries,
3595                supporting_schedule_hashes: aggregate.schedule_hashes,
3596                class_run_count: aggregate.run_count,
3597                source_total_runs: report.total_runs,
3598                source_unique_classes: report.unique_classes,
3599                source_artifact_path: None,
3600                description,
3601            }
3602        })
3603        .collect()
3604}
3605
3606// ============================================================================
3607// Dual-Run Harness Entrypoint
3608// ============================================================================
3609
3610/// Result of a dual-run harness execution.
3611#[derive(Debug, Clone)]
3612pub struct DualRunResult {
3613    /// Lab-side normalized observable.
3614    pub lab: NormalizedObservable,
3615    /// Live-side normalized observable.
3616    pub live: NormalizedObservable,
3617    /// Comparison verdict.
3618    pub verdict: ComparisonVerdict,
3619    /// Core invariant violations for the lab run.
3620    pub lab_invariant_violations: Vec<String>,
3621    /// Core invariant violations for the live run.
3622    pub live_invariant_violations: Vec<String>,
3623    /// Seed lineage record.
3624    pub seed_lineage: SeedLineageRecord,
3625    /// Policy-layer classification and rerun plan.
3626    pub policy: DifferentialPolicyOutcome,
3627}
3628
3629impl DualRunResult {
3630    /// Whether the dual-run passed: no semantic mismatches and no invariant
3631    /// violations on either side.
3632    #[must_use]
3633    pub fn passed(&self) -> bool {
3634        self.verdict.passed
3635            && self.lab_invariant_violations.is_empty()
3636            && self.live_invariant_violations.is_empty()
3637    }
3638
3639    /// Formatted summary of the result.
3640    #[must_use]
3641    pub fn summary(&self) -> String {
3642        let mut parts = vec![self.verdict.summary()];
3643        if !self.lab_invariant_violations.is_empty() {
3644            parts.push(format!(
3645                "Lab invariant violations: {}",
3646                self.lab_invariant_violations.join("; ")
3647            ));
3648        }
3649        if !self.live_invariant_violations.is_empty() {
3650            parts.push(format!(
3651                "Live invariant violations: {}",
3652                self.live_invariant_violations.join("; ")
3653            ));
3654        }
3655        parts.push(format!("Policy: {}", self.policy.summary()));
3656        parts.join("\n")
3657    }
3658}
3659
3660impl fmt::Display for DualRunResult {
3661    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3662        write!(f, "{}", self.summary())
3663    }
3664}
3665
3666/// Builder for dual-run differential test harnesses.
3667///
3668/// # Usage
3669///
3670/// ```ignore
3671/// let result = DualRunHarness::phase1(
3672///     "cancel.race.one_loser",
3673///     "cancellation.race",
3674///     "v1",
3675///     "Race two tasks, cancel loser, verify drain",
3676///     42,
3677/// )
3678/// .lab(|config| {
3679///     let mut lab = LabRuntime::new(config);
3680///     // ... run scenario ...
3681///     make_happy_semantics()
3682/// })
3683/// .live(|seed, entropy_seed| {
3684///     // ... run scenario on current-thread runtime ...
3685///     make_happy_semantics()
3686/// })
3687/// .run();
3688///
3689/// assert!(result.passed());
3690/// ```
3691pub struct DualRunHarness {
3692    identity: DualRunScenarioIdentity,
3693    lab_fn: Option<Box<dyn FnOnce(LabConfig) -> NormalizedSemantics>>,
3694    live_fn: Option<Box<dyn FnOnce(u64, u64) -> LiveExecutionCapture>>,
3695}
3696
3697#[derive(Debug, Clone)]
3698struct LiveExecutionCapture {
3699    semantics: NormalizedSemantics,
3700    replay: Option<ReplayMetadata>,
3701}
3702
3703impl From<NormalizedSemantics> for LiveExecutionCapture {
3704    fn from(semantics: NormalizedSemantics) -> Self {
3705        Self {
3706            semantics,
3707            replay: None,
3708        }
3709    }
3710}
3711
3712impl From<LiveRunResult> for LiveExecutionCapture {
3713    fn from(result: LiveRunResult) -> Self {
3714        Self {
3715            semantics: result.semantics,
3716            replay: Some(
3717                result
3718                    .metadata
3719                    .replay
3720                    .with_nondeterminism_notes(result.metadata.nondeterminism_notes),
3721            ),
3722        }
3723    }
3724}
3725
3726impl DualRunHarness {
3727    /// Create a Phase 1 harness builder.
3728    #[must_use]
3729    pub fn phase1(
3730        scenario_id: impl Into<String>,
3731        surface_id: impl Into<String>,
3732        contract_version: impl Into<String>,
3733        description: impl Into<String>,
3734        canonical_seed: u64,
3735    ) -> Self {
3736        Self {
3737            identity: DualRunScenarioIdentity::phase1(
3738                scenario_id,
3739                surface_id,
3740                contract_version,
3741                description,
3742                canonical_seed,
3743            ),
3744            lab_fn: None,
3745            live_fn: None,
3746        }
3747    }
3748
3749    /// Create a harness from an existing identity.
3750    #[must_use]
3751    pub fn from_identity(identity: DualRunScenarioIdentity) -> Self {
3752        Self {
3753            identity,
3754            lab_fn: None,
3755            live_fn: None,
3756        }
3757    }
3758
3759    /// Set the lab execution function.
3760    ///
3761    /// Receives a `LabConfig` derived from the seed plan. Must return
3762    /// normalized semantics from the lab execution.
3763    #[must_use]
3764    pub fn lab(mut self, f: impl FnOnce(LabConfig) -> NormalizedSemantics + 'static) -> Self {
3765        self.lab_fn = Some(Box::new(f));
3766        self
3767    }
3768
3769    /// Set the live execution function.
3770    ///
3771    /// Receives `(effective_seed, entropy_seed)` derived from the seed plan.
3772    /// Must return normalized semantics from the live execution.
3773    #[must_use]
3774    pub fn live(mut self, f: impl FnOnce(u64, u64) -> NormalizedSemantics + 'static) -> Self {
3775        self.live_fn = Some(Box::new(move |seed, entropy| f(seed, entropy).into()));
3776        self
3777    }
3778
3779    /// Set the live execution function using the richer `LiveRunResult`.
3780    ///
3781    /// This preserves nondeterminism notes and replay metadata so the
3782    /// mismatch classifier can distinguish scheduler noise from semantic drift.
3783    #[must_use]
3784    pub fn live_result(mut self, f: impl FnOnce(u64, u64) -> LiveRunResult + 'static) -> Self {
3785        self.live_fn = Some(Box::new(move |seed, entropy| f(seed, entropy).into()));
3786        self
3787    }
3788
3789    /// Override the seed plan.
3790    #[must_use]
3791    pub fn with_seed_plan(mut self, plan: SeedPlan) -> Self {
3792        self.identity.seed_plan = plan;
3793        self
3794    }
3795
3796    /// Execute both sides and produce a comparison result.
3797    ///
3798    /// # Panics
3799    ///
3800    /// Panics if either `lab` or `live` was not set.
3801    #[must_use]
3802    pub fn run(self) -> DualRunResult {
3803        let lab_fn = self.lab_fn.expect("DualRunHarness: lab function not set");
3804        let live_fn = self.live_fn.expect("DualRunHarness: live function not set");
3805
3806        let plan = &self.identity.seed_plan;
3807        let family = self.identity.family_id();
3808
3809        // Run lab side.
3810        let lab_config = plan.to_lab_config();
3811        let lab_semantics = lab_fn(lab_config);
3812        let lab_prov = ReplayMetadata::for_lab(family.clone(), plan);
3813        let lab_obs =
3814            NormalizedObservable::new(&self.identity, RuntimeKind::Lab, lab_semantics, lab_prov);
3815
3816        // Run live side.
3817        let live_seed = plan.effective_live_seed();
3818        let live_entropy = plan.effective_entropy_seed(live_seed);
3819        let live_capture = live_fn(live_seed, live_entropy);
3820        let live_semantics = live_capture.semantics;
3821        let live_prov = live_capture
3822            .replay
3823            .unwrap_or_else(|| ReplayMetadata::for_live(family, plan));
3824        let live_obs =
3825            NormalizedObservable::new(&self.identity, RuntimeKind::Live, live_semantics, live_prov);
3826
3827        // Check invariants.
3828        let lab_violations = check_core_invariants(&lab_obs);
3829        let live_violations = check_core_invariants(&live_obs);
3830
3831        // Compare.
3832        let lineage = SeedLineageRecord::from_plan(plan);
3833        let verdict = compare_observables(&lab_obs, &live_obs, lineage.clone());
3834        let policy = classify_differential_policy(
3835            &self.identity,
3836            &lab_obs,
3837            &live_obs,
3838            &verdict,
3839            &lab_violations,
3840            &live_violations,
3841        );
3842
3843        // Log result.
3844        #[cfg(feature = "tracing-integration")]
3845        tracing::info!(
3846            scenario_id = %self.identity.scenario_id,
3847            surface_id = %self.identity.surface_id,
3848            seed = %format_args!("0x{:X}", plan.canonical_seed),
3849            passed = verdict.passed,
3850            lab_violations = lab_violations.len(),
3851            live_violations = live_violations.len(),
3852            mismatches = verdict.mismatches.len(),
3853            provisional_class = %policy.provisional_class,
3854            rerun_decision = %policy.rerun_decision,
3855            time_policy_class = %policy.time_policy_class,
3856            scheduler_noise_class = %policy.scheduler_noise_class,
3857            suppression_reason = ?policy.suppression_reason,
3858            "DUAL_RUN_RESULT"
3859        );
3860
3861        DualRunResult {
3862            lab: lab_obs,
3863            live: live_obs,
3864            verdict,
3865            lab_invariant_violations: lab_violations,
3866            live_invariant_violations: live_violations,
3867            seed_lineage: lineage,
3868            policy,
3869        }
3870    }
3871}
3872
3873/// Convenience: run a dual-run test and assert it passes.
3874///
3875/// Panics with a detailed message if the test fails.
3876pub fn assert_dual_run_passes(result: &DualRunResult) {
3877    assert!(
3878        result.passed(),
3879        "Dual-run test failed for scenario '{}' on surface '{}':\n{}",
3880        result.verdict.scenario_id,
3881        result.verdict.surface_id,
3882        result.summary()
3883    );
3884}
3885
3886// ============================================================================
3887// Tests
3888// ============================================================================
3889
3890#[cfg(test)]
3891mod tests {
3892    use super::*;
3893
3894    fn init_test(name: &str) {
3895        crate::test_utils::init_test_logging();
3896        crate::test_phase!(name);
3897    }
3898
3899    // --- SeedMode ---
3900
3901    #[test]
3902    fn seed_mode_serde_roundtrip() {
3903        init_test("seed_mode_serde_roundtrip");
3904        let json = serde_json::to_string(&SeedMode::Inherit).unwrap();
3905        assert_eq!(json, "\"inherit\"");
3906        let parsed: SeedMode = serde_json::from_str(&json).unwrap();
3907        assert_eq!(parsed, SeedMode::Inherit);
3908
3909        let json = serde_json::to_string(&SeedMode::Override).unwrap();
3910        assert_eq!(json, "\"override\"");
3911        let parsed: SeedMode = serde_json::from_str(&json).unwrap();
3912        assert_eq!(parsed, SeedMode::Override);
3913        crate::test_complete!("seed_mode_serde_roundtrip");
3914    }
3915
3916    // --- ReplayPolicy ---
3917
3918    #[test]
3919    fn replay_policy_serde_roundtrip() {
3920        init_test("replay_policy_serde_roundtrip");
3921        for policy in [
3922            ReplayPolicy::SingleSeed,
3923            ReplayPolicy::SeedSweep,
3924            ReplayPolicy::ReplayBundle,
3925        ] {
3926            let json = serde_json::to_string(&policy).unwrap();
3927            let parsed: ReplayPolicy = serde_json::from_str(&json).unwrap();
3928            assert_eq!(parsed, policy);
3929        }
3930        crate::test_complete!("replay_policy_serde_roundtrip");
3931    }
3932
3933    // --- SeedPlan ---
3934
3935    #[test]
3936    fn seed_plan_inherit_uses_canonical() {
3937        init_test("seed_plan_inherit_uses_canonical");
3938        let plan = SeedPlan::inherit(0xBEEF, "test-scenario");
3939        assert_eq!(plan.effective_lab_seed(), 0xBEEF);
3940        assert_eq!(plan.effective_live_seed(), 0xBEEF);
3941        assert_eq!(plan.lab_seed_mode, SeedMode::Inherit);
3942        assert_eq!(plan.live_seed_mode, SeedMode::Inherit);
3943        crate::test_complete!("seed_plan_inherit_uses_canonical");
3944    }
3945
3946    #[test]
3947    fn seed_plan_override_uses_explicit_seed() {
3948        init_test("seed_plan_override_uses_explicit_seed");
3949        let plan = SeedPlan::inherit(0xBEEF, "test")
3950            .with_lab_override(0xCAFE)
3951            .with_live_override(0xFACE);
3952        assert_eq!(plan.effective_lab_seed(), 0xCAFE);
3953        assert_eq!(plan.effective_live_seed(), 0xFACE);
3954        assert_eq!(plan.lab_seed_mode, SeedMode::Override);
3955        assert_eq!(plan.live_seed_mode, SeedMode::Override);
3956        crate::test_complete!("seed_plan_override_uses_explicit_seed");
3957    }
3958
3959    #[test]
3960    fn seed_plan_override_without_value_falls_back_to_canonical() {
3961        init_test("seed_plan_override_without_value_falls_back");
3962        let mut plan = SeedPlan::inherit(0xBEEF, "test");
3963        plan.lab_seed_mode = SeedMode::Override;
3964        // No lab_seed_override set — should fall back to canonical.
3965        assert_eq!(plan.effective_lab_seed(), 0xBEEF);
3966        crate::test_complete!("seed_plan_override_without_value_falls_back");
3967    }
3968
3969    #[test]
3970    fn seed_plan_entropy_derives_from_effective() {
3971        init_test("seed_plan_entropy_derives_from_effective");
3972        let plan = SeedPlan::inherit(42, "test");
3973        let entropy = plan.effective_entropy_seed(42);
3974        // Must be deterministic.
3975        assert_eq!(entropy, plan.effective_entropy_seed(42));
3976        // Must differ from the seed itself (extremely unlikely to collide).
3977        assert_ne!(entropy, 42);
3978        crate::test_complete!("seed_plan_entropy_derives_from_effective");
3979    }
3980
3981    #[test]
3982    fn seed_plan_entropy_override() {
3983        init_test("seed_plan_entropy_override");
3984        let plan = SeedPlan::inherit(42, "test").with_entropy_seed(999);
3985        assert_eq!(plan.effective_entropy_seed(42), 999);
3986        assert_eq!(plan.effective_entropy_seed(100), 999);
3987        crate::test_complete!("seed_plan_entropy_override");
3988    }
3989
3990    #[test]
3991    fn seed_plan_to_lab_config() {
3992        init_test("seed_plan_to_lab_config");
3993        let plan = SeedPlan::inherit(0xDEAD, "test");
3994        let config = plan.to_lab_config();
3995        assert_eq!(config.seed, 0xDEAD);
3996        let expected_entropy = plan.effective_entropy_seed(0xDEAD);
3997        assert_eq!(config.entropy_seed, expected_entropy);
3998        crate::test_complete!("seed_plan_to_lab_config");
3999    }
4000
4001    #[test]
4002    fn seed_plan_to_lab_config_with_override() {
4003        init_test("seed_plan_to_lab_config_with_override");
4004        let plan = SeedPlan::inherit(0xDEAD, "test").with_lab_override(0xCAFE);
4005        let config = plan.to_lab_config();
4006        assert_eq!(config.seed, 0xCAFE);
4007        crate::test_complete!("seed_plan_to_lab_config_with_override");
4008    }
4009
4010    #[test]
4011    fn seed_plan_sweep_deterministic() {
4012        init_test("seed_plan_sweep_deterministic");
4013        let plan = SeedPlan::inherit(42, "test").with_replay_policy(ReplayPolicy::SeedSweep);
4014        let seeds1 = plan.sweep_seeds(5);
4015        let seeds2 = plan.sweep_seeds(5);
4016        assert_eq!(seeds1, seeds2);
4017        assert_eq!(seeds1.len(), 5);
4018        // All seeds should be distinct.
4019        let mut unique = seeds1;
4020        unique.sort_unstable();
4021        unique.dedup();
4022        assert_eq!(unique.len(), 5);
4023        crate::test_complete!("seed_plan_sweep_deterministic");
4024    }
4025
4026    #[test]
4027    fn seed_plan_serde_roundtrip() {
4028        init_test("seed_plan_serde_roundtrip");
4029        let plan = SeedPlan::inherit(0xABCD, "lineage-1")
4030            .with_lab_override(0x1234)
4031            .with_entropy_seed(0x5678)
4032            .with_replay_policy(ReplayPolicy::SeedSweep);
4033        let json = serde_json::to_string_pretty(&plan).unwrap();
4034        let parsed: SeedPlan = serde_json::from_str(&json).unwrap();
4035        assert_eq!(parsed, plan);
4036        crate::test_complete!("seed_plan_serde_roundtrip");
4037    }
4038
4039    #[test]
4040    fn seed_plan_display() {
4041        init_test("seed_plan_display");
4042        let plan = SeedPlan::inherit(42, "test-scenario");
4043        let display = format!("{plan}");
4044        assert!(display.contains("0x2A"));
4045        assert!(display.contains("test-scenario"));
4046        crate::test_complete!("seed_plan_display");
4047    }
4048
4049    // --- ScenarioFamilyId ---
4050
4051    #[test]
4052    fn scenario_family_id_display() {
4053        init_test("scenario_family_id_display");
4054        let fam = ScenarioFamilyId::new("cancel.race", "cancellation.race", "v1");
4055        let s = format!("{fam}");
4056        assert!(s.contains("cancel.race"));
4057        assert!(s.contains("cancellation.race"));
4058        assert!(s.contains("v1"));
4059        crate::test_complete!("scenario_family_id_display");
4060    }
4061
4062    #[test]
4063    fn scenario_family_id_serde_roundtrip() {
4064        init_test("scenario_family_id_serde_roundtrip");
4065        let fam = ScenarioFamilyId::new("cancel.race", "cancellation.race", "v1");
4066        let json = serde_json::to_string(&fam).unwrap();
4067        let parsed: ScenarioFamilyId = serde_json::from_str(&json).unwrap();
4068        assert_eq!(parsed, fam);
4069        crate::test_complete!("scenario_family_id_serde_roundtrip");
4070    }
4071
4072    // --- ExecutionInstanceId ---
4073
4074    #[test]
4075    fn execution_instance_lab_vs_live() {
4076        init_test("execution_instance_lab_vs_live");
4077        let lab = ExecutionInstanceId::lab("test-family", 42);
4078        let live = ExecutionInstanceId::live("test-family", 42);
4079        assert_eq!(lab.runtime_kind, RuntimeKind::Lab);
4080        assert_eq!(live.runtime_kind, RuntimeKind::Live);
4081        assert_ne!(lab.key(), live.key());
4082        crate::test_complete!("execution_instance_lab_vs_live");
4083    }
4084
4085    #[test]
4086    fn execution_instance_key_stable() {
4087        init_test("execution_instance_key_stable");
4088        let inst = ExecutionInstanceId::lab("fam", 0xBEEF).with_run_index(3);
4089        let key1 = inst.key();
4090        let key2 = inst.key();
4091        assert_eq!(key1, key2);
4092        assert!(key1.contains("fam"));
4093        assert!(key1.contains("0xBEEF"));
4094        assert!(key1.contains('3'));
4095        crate::test_complete!("execution_instance_key_stable");
4096    }
4097
4098    // --- RuntimeKind ---
4099
4100    #[test]
4101    fn runtime_kind_display() {
4102        init_test("runtime_kind_display");
4103        assert_eq!(format!("{}", RuntimeKind::Lab), "lab");
4104        assert_eq!(format!("{}", RuntimeKind::Live), "live");
4105        crate::test_complete!("runtime_kind_display");
4106    }
4107
4108    // --- ReplayMetadata ---
4109
4110    #[test]
4111    fn replay_metadata_lab_seeds_match_plan() {
4112        init_test("replay_metadata_lab_seeds_match_plan");
4113        let family = ScenarioFamilyId::new("test", "surface", "v1");
4114        let plan = SeedPlan::inherit(0xDEAD, "lineage");
4115        let meta = ReplayMetadata::for_lab(family, &plan);
4116        assert_eq!(meta.effective_seed, 0xDEAD);
4117        assert_eq!(meta.instance.runtime_kind, RuntimeKind::Lab);
4118        assert_eq!(
4119            meta.effective_entropy_seed,
4120            plan.effective_entropy_seed(0xDEAD)
4121        );
4122        crate::test_complete!("replay_metadata_lab_seeds_match_plan");
4123    }
4124
4125    #[test]
4126    fn replay_metadata_live_seeds_match_plan() {
4127        init_test("replay_metadata_live_seeds_match_plan");
4128        let family = ScenarioFamilyId::new("test", "surface", "v1");
4129        let plan = SeedPlan::inherit(0xCAFE, "lineage");
4130        let meta = ReplayMetadata::for_live(family, &plan);
4131        assert_eq!(meta.effective_seed, 0xCAFE);
4132        assert_eq!(meta.instance.runtime_kind, RuntimeKind::Live);
4133        crate::test_complete!("replay_metadata_live_seeds_match_plan");
4134    }
4135
4136    #[test]
4137    fn replay_metadata_with_overrides() {
4138        init_test("replay_metadata_with_overrides");
4139        let family = ScenarioFamilyId::new("test", "surface", "v1");
4140        let plan = SeedPlan::inherit(42, "lineage").with_lab_override(999);
4141        let meta = ReplayMetadata::for_lab(family, &plan);
4142        assert_eq!(meta.effective_seed, 999);
4143        crate::test_complete!("replay_metadata_with_overrides");
4144    }
4145
4146    #[test]
4147    fn replay_metadata_with_lab_report() {
4148        init_test("replay_metadata_with_lab_report");
4149        let family = ScenarioFamilyId::new("test", "surface", "v1");
4150        let plan = SeedPlan::inherit(42, "lineage");
4151        let meta = ReplayMetadata::for_lab(family, &plan)
4152            .with_lab_report(0xF1, 0xE1, 100, 0x51, 500)
4153            .with_repro_command("cargo test test -- --nocapture")
4154            .with_artifact_path("/tmp/artifacts/test");
4155        assert_eq!(meta.trace_fingerprint, Some(0xF1));
4156        assert_eq!(meta.event_count, Some(100));
4157        assert_eq!(meta.steps_total, Some(500));
4158        assert!(meta.repro_command.is_some());
4159        assert!(meta.artifact_path.is_some());
4160        crate::test_complete!("replay_metadata_with_lab_report");
4161    }
4162
4163    #[test]
4164    fn replay_metadata_default_repro_command() {
4165        init_test("replay_metadata_default_repro_command");
4166        let family = ScenarioFamilyId::new("cancel.race", "surface", "v1");
4167        let plan = SeedPlan::inherit(0xDEAD, "lineage");
4168        let meta = ReplayMetadata::for_lab(family, &plan);
4169        let cmd = meta.default_repro_command();
4170        assert!(cmd.contains("rch exec -- env ASUPERSYNC_SEED=0xDEAD"));
4171        assert!(cmd.contains("0xDEAD"));
4172        assert!(cmd.contains("cancel.race"));
4173        crate::test_complete!("replay_metadata_default_repro_command");
4174    }
4175
4176    #[test]
4177    fn replay_metadata_serde_roundtrip() {
4178        init_test("replay_metadata_serde_roundtrip");
4179        let family = ScenarioFamilyId::new("test", "surface", "v1");
4180        let plan = SeedPlan::inherit(42, "lineage");
4181        let meta = ReplayMetadata::for_lab(family, &plan).with_repro_command("cargo test");
4182        let json = serde_json::to_string_pretty(&meta).unwrap();
4183        let parsed: ReplayMetadata = serde_json::from_str(&json).unwrap();
4184        assert_eq!(parsed.effective_seed, meta.effective_seed);
4185        assert_eq!(parsed.family.id, "test");
4186        crate::test_complete!("replay_metadata_serde_roundtrip");
4187    }
4188
4189    // --- SeedLineageRecord ---
4190
4191    #[test]
4192    fn seed_lineage_record_inherit_seeds_match() {
4193        init_test("seed_lineage_record_inherit_seeds_match");
4194        let plan = SeedPlan::inherit(0xBEEF, "lineage-1");
4195        let record = SeedLineageRecord::from_plan(&plan);
4196        assert!(record.seeds_match);
4197        assert_eq!(record.lab_effective_seed, 0xBEEF);
4198        assert_eq!(record.live_effective_seed, 0xBEEF);
4199        assert_eq!(record.lab_entropy_seed, record.live_entropy_seed);
4200        crate::test_complete!("seed_lineage_record_inherit_seeds_match");
4201    }
4202
4203    #[test]
4204    fn seed_lineage_record_override_seeds_differ() {
4205        init_test("seed_lineage_record_override_seeds_differ");
4206        let plan = SeedPlan::inherit(42, "lineage-1")
4207            .with_lab_override(100)
4208            .with_live_override(200);
4209        let record = SeedLineageRecord::from_plan(&plan);
4210        assert!(!record.seeds_match);
4211        assert_eq!(record.lab_effective_seed, 100);
4212        assert_eq!(record.live_effective_seed, 200);
4213        crate::test_complete!("seed_lineage_record_override_seeds_differ");
4214    }
4215
4216    #[test]
4217    fn seed_lineage_record_serde_roundtrip() {
4218        init_test("seed_lineage_record_serde_roundtrip");
4219        let plan = SeedPlan::inherit(42, "lin");
4220        let record = SeedLineageRecord::from_plan(&plan).with_annotation("source", "test");
4221        let json = serde_json::to_string(&record).unwrap();
4222        let parsed: SeedLineageRecord = serde_json::from_str(&json).unwrap();
4223        assert_eq!(parsed.canonical_seed, 42);
4224        assert_eq!(parsed.annotations.get("source").unwrap(), "test");
4225        crate::test_complete!("seed_lineage_record_serde_roundtrip");
4226    }
4227
4228    // --- DualRunScenarioIdentity ---
4229
4230    #[test]
4231    fn dual_run_scenario_identity_phase1() {
4232        init_test("dual_run_scenario_identity_phase1");
4233        let ident = DualRunScenarioIdentity::phase1(
4234            "phase1.cancel.race.one_loser",
4235            "cancellation.race",
4236            "v1",
4237            "Race two tasks, cancel loser, verify drain",
4238            42,
4239        );
4240        assert_eq!(ident.schema_version, DUAL_RUN_SCHEMA_VERSION);
4241        assert_eq!(ident.phase, Phase::Phase1);
4242        assert_eq!(ident.seed_plan.canonical_seed, 42);
4243        assert_eq!(
4244            ident.seed_plan.seed_lineage_id,
4245            "phase1.cancel.race.one_loser"
4246        );
4247        crate::test_complete!("dual_run_scenario_identity_phase1");
4248    }
4249
4250    #[test]
4251    fn dual_run_identity_lab_config() {
4252        init_test("dual_run_identity_lab_config");
4253        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 0xBEEF);
4254        let config = ident.to_lab_config();
4255        assert_eq!(config.seed, 0xBEEF);
4256        crate::test_complete!("dual_run_identity_lab_config");
4257    }
4258
4259    #[test]
4260    fn dual_run_identity_replay_metadata_lab_live_differ() {
4261        init_test("dual_run_identity_replay_metadata_lab_live_differ");
4262        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
4263        let lab_meta = ident.lab_replay_metadata();
4264        let live_meta = ident.live_replay_metadata();
4265        assert_eq!(lab_meta.instance.runtime_kind, RuntimeKind::Lab);
4266        assert_eq!(live_meta.instance.runtime_kind, RuntimeKind::Live);
4267        // With inherit mode, effective seeds match.
4268        assert_eq!(lab_meta.effective_seed, live_meta.effective_seed);
4269        crate::test_complete!("dual_run_identity_replay_metadata_lab_live_differ");
4270    }
4271
4272    #[test]
4273    fn dual_run_identity_family_id() {
4274        init_test("dual_run_identity_family_id");
4275        let ident = DualRunScenarioIdentity::phase1("test", "surface", "v1", "desc", 42);
4276        let fam = ident.family_id();
4277        assert_eq!(fam.id, "test");
4278        assert_eq!(fam.surface_id, "surface");
4279        assert_eq!(fam.surface_contract_version, "v1");
4280        crate::test_complete!("dual_run_identity_family_id");
4281    }
4282
4283    #[test]
4284    fn dual_run_identity_seed_lineage() {
4285        init_test("dual_run_identity_seed_lineage");
4286        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
4287        let lineage = ident.seed_lineage();
4288        assert!(lineage.seeds_match);
4289        assert_eq!(lineage.canonical_seed, 42);
4290        crate::test_complete!("dual_run_identity_seed_lineage");
4291    }
4292
4293    #[test]
4294    fn dual_run_identity_with_seed_plan_override() {
4295        init_test("dual_run_identity_with_seed_plan_override");
4296        let plan = SeedPlan::inherit(99, "custom-lineage").with_lab_override(0xFF);
4297        let ident =
4298            DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42).with_seed_plan(plan);
4299        assert_eq!(ident.seed_plan.canonical_seed, 99);
4300        assert_eq!(ident.to_lab_config().seed, 0xFF);
4301        crate::test_complete!("dual_run_identity_with_seed_plan_override");
4302    }
4303
4304    #[test]
4305    fn dual_run_identity_metadata() {
4306        init_test("dual_run_identity_metadata");
4307        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42)
4308            .with_metadata("bead", "2a6k9.2.3")
4309            .with_metadata("author", "SapphireHill");
4310        assert_eq!(ident.metadata.get("bead").unwrap(), "2a6k9.2.3");
4311        assert_eq!(ident.metadata.get("author").unwrap(), "SapphireHill");
4312        crate::test_complete!("dual_run_identity_metadata");
4313    }
4314
4315    #[test]
4316    fn dual_run_identity_serde_roundtrip() {
4317        init_test("dual_run_identity_serde_roundtrip");
4318        let ident = DualRunScenarioIdentity::phase1(
4319            "phase1.cancel.race.one_loser",
4320            "cancellation.race",
4321            "v1",
4322            "Race two tasks, cancel loser, verify drain",
4323            42,
4324        )
4325        .with_metadata("bead", "2a6k9.2.3");
4326        let json = serde_json::to_string_pretty(&ident).unwrap();
4327        let parsed: DualRunScenarioIdentity = serde_json::from_str(&json).unwrap();
4328        assert_eq!(parsed.scenario_id, ident.scenario_id);
4329        assert_eq!(parsed.seed_plan, ident.seed_plan);
4330        assert_eq!(parsed.phase, Phase::Phase1);
4331        crate::test_complete!("dual_run_identity_serde_roundtrip");
4332    }
4333
4334    // --- Cross-cutting: seed determinism across lab and live ---
4335
4336    #[test]
4337    fn same_plan_produces_same_lab_config() {
4338        init_test("same_plan_produces_same_lab_config");
4339        let plan = SeedPlan::inherit(0xCAFE_BABE, "determinism-check");
4340        let c1 = plan.to_lab_config();
4341        let c2 = plan.to_lab_config();
4342        assert_eq!(c1.seed, c2.seed);
4343        assert_eq!(c1.entropy_seed, c2.entropy_seed);
4344        crate::test_complete!("same_plan_produces_same_lab_config");
4345    }
4346
4347    #[test]
4348    fn inherit_mode_lab_live_seeds_identical() {
4349        init_test("inherit_mode_lab_live_seeds_identical");
4350        let plan = SeedPlan::inherit(0xDEAD_BEEF, "identical-check");
4351        assert_eq!(plan.effective_lab_seed(), plan.effective_live_seed());
4352        let lab_ent = plan.effective_entropy_seed(plan.effective_lab_seed());
4353        let live_ent = plan.effective_entropy_seed(plan.effective_live_seed());
4354        assert_eq!(lab_ent, live_ent);
4355        crate::test_complete!("inherit_mode_lab_live_seeds_identical");
4356    }
4357
4358    #[test]
4359    fn different_canonical_seeds_produce_different_entropies() {
4360        init_test("different_canonical_seeds_different_entropies");
4361        let p1 = SeedPlan::inherit(1, "a");
4362        let p2 = SeedPlan::inherit(2, "b");
4363        assert_ne!(
4364            p1.effective_entropy_seed(p1.effective_lab_seed()),
4365            p2.effective_entropy_seed(p2.effective_lab_seed())
4366        );
4367        crate::test_complete!("different_canonical_seeds_different_entropies");
4368    }
4369
4370    // --- Normalized Observable types ---
4371
4372    fn make_happy_semantics() -> NormalizedSemantics {
4373        NormalizedSemantics {
4374            terminal_outcome: TerminalOutcome::ok(),
4375            cancellation: CancellationRecord::none(),
4376            loser_drain: LoserDrainRecord::not_applicable(),
4377            region_close: RegionCloseRecord::quiescent(),
4378            obligation_balance: ObligationBalanceRecord::zero(),
4379            resource_surface: ResourceSurfaceRecord::empty("test"),
4380        }
4381    }
4382
4383    fn make_observable(kind: RuntimeKind, semantics: NormalizedSemantics) -> NormalizedObservable {
4384        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
4385        let prov = match kind {
4386            RuntimeKind::Lab => ident.lab_replay_metadata(),
4387            RuntimeKind::Live => ident.live_replay_metadata(),
4388        };
4389        NormalizedObservable::new(&ident, kind, semantics, prov)
4390    }
4391
4392    #[test]
4393    fn terminal_outcome_ok_serde() {
4394        init_test("terminal_outcome_ok_serde");
4395        let t = TerminalOutcome::ok();
4396        let json = serde_json::to_string(&t).unwrap();
4397        let parsed: TerminalOutcome = serde_json::from_str(&json).unwrap();
4398        assert_eq!(parsed.class, OutcomeClass::Ok);
4399        crate::test_complete!("terminal_outcome_ok_serde");
4400    }
4401
4402    #[test]
4403    fn terminal_outcome_cancelled() {
4404        init_test("terminal_outcome_cancelled");
4405        let t = TerminalOutcome::cancelled("user_request");
4406        assert_eq!(t.class, OutcomeClass::Cancelled);
4407        assert_eq!(t.cancel_reason_class.as_deref(), Some("user_request"));
4408        crate::test_complete!("terminal_outcome_cancelled");
4409    }
4410
4411    #[test]
4412    fn cancellation_record_none_vs_completed() {
4413        init_test("cancellation_record_none_vs_completed");
4414        let none = CancellationRecord::none();
4415        let completed = CancellationRecord::completed();
4416        assert!(!none.requested);
4417        assert!(completed.requested);
4418        assert!(completed.acknowledged);
4419        assert!(completed.cleanup_completed);
4420        assert!(completed.finalization_completed);
4421        assert_eq!(completed.terminal_phase, CancelTerminalPhase::Completed);
4422        crate::test_complete!("cancellation_record_none_vs_completed");
4423    }
4424
4425    #[test]
4426    fn loser_drain_complete() {
4427        init_test("loser_drain_complete");
4428        let drain = LoserDrainRecord::complete(3);
4429        assert!(drain.applicable);
4430        assert_eq!(drain.expected_losers, 3);
4431        assert_eq!(drain.drained_losers, 3);
4432        assert_eq!(drain.status, DrainStatus::Complete);
4433        crate::test_complete!("loser_drain_complete");
4434    }
4435
4436    #[test]
4437    fn obligation_balance_recompute() {
4438        init_test("obligation_balance_recompute");
4439        let b = ObligationBalanceRecord {
4440            reserved: 10,
4441            committed: 7,
4442            aborted: 2,
4443            leaked: 1,
4444            unresolved: 99, // wrong, should recompute
4445            balanced: true, // wrong
4446        }
4447        .recompute();
4448        assert_eq!(b.unresolved, 0); // 10 - (7+2+1) = 0
4449        assert!(!b.balanced); // leaked > 0
4450        crate::test_complete!("obligation_balance_recompute");
4451    }
4452
4453    #[test]
4454    fn resource_surface_counter_tolerance() {
4455        init_test("resource_surface_counter_tolerance");
4456        let rs = ResourceSurfaceRecord::empty("test-surface")
4457            .with_counter("msgs", 5)
4458            .with_counter_tolerance("bytes", 100, CounterTolerance::AtLeast);
4459        assert_eq!(rs.counters["msgs"], 5);
4460        assert_eq!(rs.tolerances["msgs"], CounterTolerance::Exact);
4461        assert_eq!(rs.tolerances["bytes"], CounterTolerance::AtLeast);
4462        crate::test_complete!("resource_surface_counter_tolerance");
4463    }
4464
4465    #[test]
4466    fn normalized_observable_serde_roundtrip() {
4467        init_test("normalized_observable_serde_roundtrip");
4468        let obs = make_observable(RuntimeKind::Lab, make_happy_semantics());
4469        let json = serde_json::to_string_pretty(&obs).unwrap();
4470        let parsed: NormalizedObservable = serde_json::from_str(&json).unwrap();
4471        assert_eq!(parsed.schema_version, NORMALIZED_OBSERVABLE_SCHEMA_VERSION);
4472        assert_eq!(parsed.runtime_kind, RuntimeKind::Lab);
4473        assert_eq!(parsed.semantics.terminal_outcome.class, OutcomeClass::Ok);
4474        crate::test_complete!("normalized_observable_serde_roundtrip");
4475    }
4476
4477    // --- Compare / Verdict ---
4478
4479    #[test]
4480    fn compare_identical_observables_passes() {
4481        init_test("compare_identical_observables_passes");
4482        let lab = make_observable(RuntimeKind::Lab, make_happy_semantics());
4483        let live = make_observable(RuntimeKind::Live, make_happy_semantics());
4484        let plan = SeedPlan::inherit(42, "test");
4485        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4486        assert!(verdict.passed);
4487        assert!(verdict.mismatches.is_empty());
4488        crate::test_complete!("compare_identical_observables_passes");
4489    }
4490
4491    #[test]
4492    fn compare_outcome_mismatch_fails() {
4493        init_test("compare_outcome_mismatch_fails");
4494        let lab_sem = make_happy_semantics();
4495        let mut live_sem = make_happy_semantics();
4496        live_sem.terminal_outcome = TerminalOutcome::cancelled("timeout");
4497        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4498        let live = make_observable(RuntimeKind::Live, live_sem);
4499        let plan = SeedPlan::inherit(42, "test");
4500        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4501        assert!(!verdict.passed);
4502        assert!(
4503            verdict
4504                .mismatches
4505                .iter()
4506                .any(|m| m.field.contains("terminal_outcome.class"))
4507        );
4508        crate::test_complete!("compare_outcome_mismatch_fails");
4509    }
4510
4511    #[test]
4512    fn compare_surface_identity_mismatch_fails() {
4513        init_test("compare_surface_identity_mismatch_fails");
4514        let lab = make_observable(RuntimeKind::Lab, make_happy_semantics());
4515        let mut live = make_observable(RuntimeKind::Live, make_happy_semantics());
4516        live.surface_id = "different.surface".to_string();
4517        live.surface_contract_version = "v2".to_string();
4518        let plan = SeedPlan::inherit(42, "test");
4519        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4520        assert!(!verdict.passed);
4521        assert!(verdict.mismatches.iter().any(|m| m.field == "surface_id"));
4522        assert!(
4523            verdict
4524                .mismatches
4525                .iter()
4526                .any(|m| m.field == "surface_contract_version")
4527        );
4528        crate::test_complete!("compare_surface_identity_mismatch_fails");
4529    }
4530
4531    #[test]
4532    fn compare_terminal_reason_and_panic_class_mismatch_fails() {
4533        init_test("compare_terminal_reason_and_panic_class_mismatch_fails");
4534        let mut lab_sem = make_happy_semantics();
4535        lab_sem.terminal_outcome = TerminalOutcome::cancelled("timeout");
4536
4537        let mut live_sem = make_happy_semantics();
4538        live_sem.terminal_outcome = TerminalOutcome::cancelled("shutdown");
4539
4540        let lab = make_observable(RuntimeKind::Lab, lab_sem.clone());
4541        let live = make_observable(RuntimeKind::Live, live_sem);
4542        let plan = SeedPlan::inherit(42, "test");
4543        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4544        assert!(!verdict.passed);
4545        assert!(
4546            verdict
4547                .mismatches
4548                .iter()
4549                .any(|m| { m.field == "semantics.terminal_outcome.cancel_reason_class" })
4550        );
4551
4552        let mut panic_sem = lab_sem;
4553        panic_sem.terminal_outcome = TerminalOutcome {
4554            class: OutcomeClass::Panicked,
4555            severity: OutcomeClass::Panicked,
4556            surface_result: None,
4557            error_class: None,
4558            cancel_reason_class: None,
4559            panic_class: Some("panic_a".to_string()),
4560        };
4561        let mut other_panic_sem = make_happy_semantics();
4562        other_panic_sem.terminal_outcome = TerminalOutcome {
4563            class: OutcomeClass::Panicked,
4564            severity: OutcomeClass::Panicked,
4565            surface_result: None,
4566            error_class: None,
4567            cancel_reason_class: None,
4568            panic_class: Some("panic_b".to_string()),
4569        };
4570        let panic_lab = make_observable(RuntimeKind::Lab, panic_sem);
4571        let panic_live = make_observable(RuntimeKind::Live, other_panic_sem);
4572        let panic_verdict =
4573            compare_observables(&panic_lab, &panic_live, SeedLineageRecord::from_plan(&plan));
4574        assert!(!panic_verdict.passed);
4575        assert!(
4576            panic_verdict
4577                .mismatches
4578                .iter()
4579                .any(|m| m.field == "semantics.terminal_outcome.panic_class")
4580        );
4581        crate::test_complete!("compare_terminal_reason_and_panic_class_mismatch_fails");
4582    }
4583
4584    #[test]
4585    fn compare_obligation_leak_mismatch() {
4586        init_test("compare_obligation_leak_mismatch");
4587        let lab_sem = make_happy_semantics();
4588        let mut live_sem = make_happy_semantics();
4589        live_sem.obligation_balance = ObligationBalanceRecord {
4590            reserved: 5,
4591            committed: 3,
4592            aborted: 0,
4593            leaked: 2,
4594            unresolved: 0,
4595            balanced: false,
4596        };
4597        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4598        let live = make_observable(RuntimeKind::Live, live_sem);
4599        let plan = SeedPlan::inherit(42, "test");
4600        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4601        assert!(!verdict.passed);
4602        assert!(
4603            verdict
4604                .mismatches
4605                .iter()
4606                .any(|m| m.field.contains("leaked"))
4607        );
4608        crate::test_complete!("compare_obligation_leak_mismatch");
4609    }
4610
4611    #[test]
4612    fn compare_obligation_component_mismatch_fails_even_when_balanced() {
4613        init_test("compare_obligation_component_mismatch_fails_even_when_balanced");
4614        let mut lab_sem = make_happy_semantics();
4615        lab_sem.obligation_balance = ObligationBalanceRecord::balanced(3, 3, 0);
4616        let mut live_sem = make_happy_semantics();
4617        live_sem.obligation_balance = ObligationBalanceRecord::balanced(3, 2, 1);
4618        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4619        let live = make_observable(RuntimeKind::Live, live_sem);
4620        let plan = SeedPlan::inherit(42, "test");
4621        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4622        assert!(!verdict.passed);
4623        assert!(
4624            verdict
4625                .mismatches
4626                .iter()
4627                .any(|m| m.field == "semantics.obligation_balance.committed")
4628        );
4629        assert!(
4630            verdict
4631                .mismatches
4632                .iter()
4633                .any(|m| m.field == "semantics.obligation_balance.aborted")
4634        );
4635        crate::test_complete!("compare_obligation_component_mismatch_fails_even_when_balanced");
4636    }
4637
4638    #[test]
4639    fn compare_resource_counter_exact_mismatch() {
4640        init_test("compare_resource_counter_exact_mismatch");
4641        let mut lab_sem = make_happy_semantics();
4642        lab_sem.resource_surface = ResourceSurfaceRecord::empty("test").with_counter("msgs", 5);
4643        let mut live_sem = make_happy_semantics();
4644        live_sem.resource_surface = ResourceSurfaceRecord::empty("test").with_counter("msgs", 3);
4645        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4646        let live = make_observable(RuntimeKind::Live, live_sem);
4647        let plan = SeedPlan::inherit(42, "test");
4648        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4649        assert!(!verdict.passed);
4650        assert!(
4651            verdict
4652                .mismatches
4653                .iter()
4654                .any(|m| m.field.contains("counters.msgs"))
4655        );
4656        crate::test_complete!("compare_resource_counter_exact_mismatch");
4657    }
4658
4659    #[test]
4660    fn compare_resource_counter_missing_in_live_fails() {
4661        init_test("compare_resource_counter_missing_in_live_fails");
4662        let mut lab_sem = make_happy_semantics();
4663        lab_sem.resource_surface = ResourceSurfaceRecord::empty("test").with_counter("msgs", 0);
4664        let mut live_sem = make_happy_semantics();
4665        live_sem.resource_surface = ResourceSurfaceRecord::empty("test");
4666        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4667        let live = make_observable(RuntimeKind::Live, live_sem);
4668        let plan = SeedPlan::inherit(42, "test");
4669        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4670        assert!(!verdict.passed);
4671        assert!(
4672            verdict
4673                .mismatches
4674                .iter()
4675                .any(|m| m.description.contains("missing in live observable"))
4676        );
4677        crate::test_complete!("compare_resource_counter_missing_in_live_fails");
4678    }
4679
4680    #[test]
4681    fn compare_resource_counter_missing_in_lab_fails() {
4682        init_test("compare_resource_counter_missing_in_lab_fails");
4683        let mut lab_sem = make_happy_semantics();
4684        lab_sem.resource_surface = ResourceSurfaceRecord::empty("test");
4685        let mut live_sem = make_happy_semantics();
4686        live_sem.resource_surface = ResourceSurfaceRecord::empty("test").with_counter("msgs", 0);
4687        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4688        let live = make_observable(RuntimeKind::Live, live_sem);
4689        let plan = SeedPlan::inherit(42, "test");
4690        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4691        assert!(!verdict.passed);
4692        assert!(
4693            verdict
4694                .mismatches
4695                .iter()
4696                .any(|m| m.description.contains("present in live but not in lab"))
4697        );
4698        crate::test_complete!("compare_resource_counter_missing_in_lab_fails");
4699    }
4700
4701    #[test]
4702    fn compare_resource_tolerance_mismatch_fails() {
4703        init_test("compare_resource_tolerance_mismatch_fails");
4704        let mut lab_sem = make_happy_semantics();
4705        lab_sem.resource_surface = ResourceSurfaceRecord::empty("test").with_counter_tolerance(
4706            "msgs",
4707            5,
4708            CounterTolerance::Exact,
4709        );
4710        let mut live_sem = make_happy_semantics();
4711        live_sem.resource_surface = ResourceSurfaceRecord::empty("test").with_counter_tolerance(
4712            "msgs",
4713            5,
4714            CounterTolerance::Unsupported,
4715        );
4716        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4717        let live = make_observable(RuntimeKind::Live, live_sem);
4718        let plan = SeedPlan::inherit(42, "test");
4719        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4720        assert!(!verdict.passed);
4721        assert!(
4722            verdict
4723                .mismatches
4724                .iter()
4725                .any(|m| m.field == "semantics.resource_surface.tolerances.msgs")
4726        );
4727        crate::test_complete!("compare_resource_tolerance_mismatch_fails");
4728    }
4729
4730    #[test]
4731    fn compare_resource_counter_at_least_passes() {
4732        init_test("compare_resource_counter_at_least_passes");
4733        let mut lab_sem = make_happy_semantics();
4734        lab_sem.resource_surface = ResourceSurfaceRecord::empty("test").with_counter_tolerance(
4735            "msgs",
4736            5,
4737            CounterTolerance::AtLeast,
4738        );
4739        let mut live_sem = make_happy_semantics();
4740        live_sem.resource_surface = ResourceSurfaceRecord::empty("test").with_counter_tolerance(
4741            "msgs",
4742            7,
4743            CounterTolerance::AtLeast,
4744        );
4745        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4746        let live = make_observable(RuntimeKind::Live, live_sem);
4747        let plan = SeedPlan::inherit(42, "test");
4748        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4749        assert!(verdict.passed);
4750        crate::test_complete!("compare_resource_counter_at_least_passes");
4751    }
4752
4753    #[test]
4754    fn compare_region_close_counts_mismatch_fails() {
4755        init_test("compare_region_close_counts_mismatch_fails");
4756        let lab_sem = make_happy_semantics();
4757        let mut live_sem = make_happy_semantics();
4758        live_sem.region_close.live_children = 1;
4759        live_sem.region_close.finalizers_pending = 2;
4760        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4761        let live = make_observable(RuntimeKind::Live, live_sem);
4762        let plan = SeedPlan::inherit(42, "test");
4763        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4764        assert!(!verdict.passed);
4765        assert!(
4766            verdict
4767                .mismatches
4768                .iter()
4769                .any(|m| m.field == "semantics.region_close.live_children")
4770        );
4771        assert!(
4772            verdict
4773                .mismatches
4774                .iter()
4775                .any(|m| m.field == "semantics.region_close.finalizers_pending")
4776        );
4777        crate::test_complete!("compare_region_close_counts_mismatch_fails");
4778    }
4779
4780    #[test]
4781    fn compare_region_close_ignores_non_quiescent_root_state_hint_mismatch() {
4782        init_test("compare_region_close_ignores_non_quiescent_root_state_hint_mismatch");
4783        let mut lab_sem = make_happy_semantics();
4784        lab_sem.region_close = RegionCloseRecord {
4785            root_state: RegionState::Open,
4786            quiescent: false,
4787            live_children: 0,
4788            finalizers_pending: 0,
4789            close_completed: false,
4790        };
4791
4792        let mut live_sem = make_happy_semantics();
4793        live_sem.region_close = RegionCloseRecord {
4794            root_state: RegionState::Finalizing,
4795            quiescent: false,
4796            live_children: 0,
4797            finalizers_pending: 0,
4798            close_completed: false,
4799        };
4800
4801        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4802        let live = make_observable(RuntimeKind::Live, live_sem);
4803        let plan = SeedPlan::inherit(42, "test");
4804        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4805
4806        assert!(verdict.passed);
4807        assert!(
4808            !verdict
4809                .mismatches
4810                .iter()
4811                .any(|m| m.field == "semantics.region_close.root_state")
4812        );
4813        crate::test_complete!(
4814            "compare_region_close_ignores_non_quiescent_root_state_hint_mismatch"
4815        );
4816    }
4817
4818    #[test]
4819    fn compare_region_close_ignores_unknown_non_quiescent_lab_counts() {
4820        init_test("compare_region_close_ignores_unknown_non_quiescent_lab_counts");
4821        let mut lab_sem = make_happy_semantics();
4822        lab_sem.region_close = RegionCloseRecord {
4823            root_state: RegionState::Closing,
4824            quiescent: false,
4825            live_children: 0,
4826            finalizers_pending: 0,
4827            close_completed: false,
4828        };
4829
4830        let mut live_sem = make_happy_semantics();
4831        live_sem.region_close = RegionCloseRecord {
4832            root_state: RegionState::Draining,
4833            quiescent: false,
4834            live_children: 1,
4835            finalizers_pending: 0,
4836            close_completed: false,
4837        };
4838
4839        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4840        let live = make_observable(RuntimeKind::Live, live_sem);
4841        let plan = SeedPlan::inherit(42, "test");
4842        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4843
4844        assert!(verdict.passed);
4845        crate::test_complete!("compare_region_close_ignores_unknown_non_quiescent_lab_counts");
4846    }
4847
4848    #[test]
4849    fn compare_loser_drain_ignores_unknown_lab_counts_from_oracle_pass() {
4850        init_test("compare_loser_drain_ignores_unknown_lab_counts_from_oracle_pass");
4851        let mut lab_sem = make_happy_semantics();
4852        lab_sem.loser_drain = LoserDrainRecord {
4853            applicable: true,
4854            expected_losers: 0,
4855            drained_losers: 0,
4856            status: DrainStatus::Complete,
4857            evidence: Some("oracle.loser_drain.passed".to_string()),
4858        };
4859
4860        let mut live_sem = make_happy_semantics();
4861        live_sem.loser_drain = LoserDrainRecord::complete(2);
4862
4863        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4864        let live = make_observable(RuntimeKind::Live, live_sem);
4865        let plan = SeedPlan::inherit(42, "test");
4866        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4867
4868        assert!(verdict.passed);
4869        crate::test_complete!("compare_loser_drain_ignores_unknown_lab_counts_from_oracle_pass");
4870    }
4871
4872    #[test]
4873    fn compare_loser_drain_unknown_lab_counts_still_fail_on_status_mismatch() {
4874        init_test("compare_loser_drain_unknown_lab_counts_still_fail_on_status_mismatch");
4875        let mut lab_sem = make_happy_semantics();
4876        lab_sem.loser_drain = LoserDrainRecord {
4877            applicable: true,
4878            expected_losers: 0,
4879            drained_losers: 0,
4880            status: DrainStatus::Complete,
4881            evidence: Some("oracle.loser_drain.passed".to_string()),
4882        };
4883
4884        let mut live_sem = make_happy_semantics();
4885        live_sem.loser_drain = LoserDrainRecord {
4886            applicable: true,
4887            expected_losers: 2,
4888            drained_losers: 1,
4889            status: DrainStatus::Incomplete,
4890            evidence: Some("task_handle.join".to_string()),
4891        };
4892
4893        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4894        let live = make_observable(RuntimeKind::Live, live_sem);
4895        let plan = SeedPlan::inherit(42, "test");
4896        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4897
4898        assert!(!verdict.passed);
4899        assert!(
4900            verdict
4901                .mismatches
4902                .iter()
4903                .any(|m| m.field == "semantics.loser_drain.status")
4904        );
4905        crate::test_complete!(
4906            "compare_loser_drain_unknown_lab_counts_still_fail_on_status_mismatch"
4907        );
4908    }
4909
4910    #[test]
4911    fn compare_cancellation_mismatch() {
4912        init_test("compare_cancellation_mismatch");
4913        let mut lab_sem = make_happy_semantics();
4914        lab_sem.cancellation = CancellationRecord::completed();
4915        let live_sem = make_happy_semantics(); // no cancellation
4916        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4917        let live = make_observable(RuntimeKind::Live, live_sem);
4918        let plan = SeedPlan::inherit(42, "test");
4919        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4920        assert!(!verdict.passed);
4921        assert!(
4922            verdict
4923                .mismatches
4924                .iter()
4925                .any(|m| m.field.contains("cancellation"))
4926        );
4927        crate::test_complete!("compare_cancellation_mismatch");
4928    }
4929
4930    #[test]
4931    fn verdict_display_pass() {
4932        init_test("verdict_display_pass");
4933        let lab = make_observable(RuntimeKind::Lab, make_happy_semantics());
4934        let live = make_observable(RuntimeKind::Live, make_happy_semantics());
4935        let plan = SeedPlan::inherit(42, "test");
4936        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4937        let summary = verdict.summary();
4938        assert!(summary.contains("PASS"));
4939        crate::test_complete!("verdict_display_pass");
4940    }
4941
4942    #[test]
4943    fn verdict_display_fail() {
4944        init_test("verdict_display_fail");
4945        let lab_sem = make_happy_semantics();
4946        let mut live_sem = make_happy_semantics();
4947        live_sem.region_close.quiescent = false;
4948        let lab = make_observable(RuntimeKind::Lab, lab_sem);
4949        let live = make_observable(RuntimeKind::Live, live_sem);
4950        let plan = SeedPlan::inherit(42, "test");
4951        let verdict = compare_observables(&lab, &live, SeedLineageRecord::from_plan(&plan));
4952        let summary = verdict.summary();
4953        assert!(summary.contains("FAIL"));
4954        assert!(summary.contains("mismatch"));
4955        crate::test_complete!("verdict_display_fail");
4956    }
4957
4958    // --- Core Invariant Checks ---
4959
4960    #[test]
4961    fn check_core_invariants_all_pass() {
4962        init_test("check_core_invariants_all_pass");
4963        let obs = make_observable(RuntimeKind::Lab, make_happy_semantics());
4964        let violations = check_core_invariants(&obs);
4965        assert!(violations.is_empty());
4966        crate::test_complete!("check_core_invariants_all_pass");
4967    }
4968
4969    #[test]
4970    fn check_core_invariants_obligation_leak() {
4971        init_test("check_core_invariants_obligation_leak");
4972        let mut sem = make_happy_semantics();
4973        sem.obligation_balance.leaked = 1;
4974        sem.obligation_balance.balanced = false;
4975        let obs = make_observable(RuntimeKind::Lab, sem);
4976        let violations = check_core_invariants(&obs);
4977        assert!(!violations.is_empty());
4978        assert!(violations[0].contains("leaked"));
4979        crate::test_complete!("check_core_invariants_obligation_leak");
4980    }
4981
4982    #[test]
4983    fn check_core_invariants_not_quiescent() {
4984        init_test("check_core_invariants_not_quiescent");
4985        let mut sem = make_happy_semantics();
4986        sem.region_close.quiescent = false;
4987        sem.region_close.live_children = 2;
4988        let obs = make_observable(RuntimeKind::Lab, sem);
4989        let violations = check_core_invariants(&obs);
4990        assert!(violations.iter().any(|v| v.contains("quiescent")));
4991        crate::test_complete!("check_core_invariants_not_quiescent");
4992    }
4993
4994    #[test]
4995    fn check_core_invariants_incomplete_drain() {
4996        init_test("check_core_invariants_incomplete_drain");
4997        let mut sem = make_happy_semantics();
4998        sem.loser_drain = LoserDrainRecord {
4999            applicable: true,
5000            expected_losers: 3,
5001            drained_losers: 1,
5002            status: DrainStatus::Incomplete,
5003            evidence: None,
5004        };
5005        let obs = make_observable(RuntimeKind::Lab, sem);
5006        let violations = check_core_invariants(&obs);
5007        assert!(violations.iter().any(|v| v.contains("drain")));
5008        crate::test_complete!("check_core_invariants_incomplete_drain");
5009    }
5010
5011    #[test]
5012    fn check_core_invariants_cancel_incomplete() {
5013        init_test("check_core_invariants_cancel_incomplete");
5014        let mut sem = make_happy_semantics();
5015        sem.cancellation.requested = true;
5016        sem.cancellation.cleanup_completed = false;
5017        sem.cancellation.terminal_phase = CancelTerminalPhase::Cancelling;
5018        let obs = make_observable(RuntimeKind::Lab, sem);
5019        let violations = check_core_invariants(&obs);
5020        assert!(
5021            violations
5022                .iter()
5023                .any(|v| v.contains("Cancellation cleanup incomplete"))
5024        );
5025        assert!(
5026            !violations
5027                .iter()
5028                .any(|v| v.contains("Cancellation finalization incomplete")),
5029            "finalization should not be required before cleanup completes"
5030        );
5031        crate::test_complete!("check_core_invariants_cancel_incomplete");
5032    }
5033
5034    #[test]
5035    fn check_core_invariants_cancel_finalization_incomplete() {
5036        init_test("check_core_invariants_cancel_finalization_incomplete");
5037        let mut sem = make_happy_semantics();
5038        sem.cancellation.requested = true;
5039        sem.cancellation.cleanup_completed = true;
5040        sem.cancellation.finalization_completed = false;
5041        sem.cancellation.terminal_phase = CancelTerminalPhase::Finalizing;
5042        let obs = make_observable(RuntimeKind::Lab, sem);
5043        let violations = check_core_invariants(&obs);
5044        assert!(
5045            violations
5046                .iter()
5047                .any(|v| v.contains("Cancellation finalization incomplete"))
5048        );
5049        crate::test_complete!("check_core_invariants_cancel_finalization_incomplete");
5050    }
5051
5052    // --- assert_semantics ---
5053
5054    #[test]
5055    fn assert_semantics_identical_passes() {
5056        init_test("assert_semantics_identical_passes");
5057        let sem = make_happy_semantics();
5058        let mismatches = assert_semantics(&sem, &sem);
5059        assert!(mismatches.is_empty());
5060        crate::test_complete!("assert_semantics_identical_passes");
5061    }
5062
5063    #[test]
5064    fn assert_semantics_detects_diff() {
5065        init_test("assert_semantics_detects_diff");
5066        let expected = make_happy_semantics();
5067        let mut actual = make_happy_semantics();
5068        actual.terminal_outcome = TerminalOutcome::err("network_error");
5069        let mismatches = assert_semantics(&actual, &expected);
5070        assert!(!mismatches.is_empty());
5071        crate::test_complete!("assert_semantics_detects_diff");
5072    }
5073
5074    // --- DualRunHarness ---
5075
5076    #[test]
5077    fn harness_identical_runs_pass() {
5078        init_test("harness_identical_runs_pass");
5079        let result = DualRunHarness::phase1(
5080            "test.happy_path",
5081            "test.surface",
5082            "v1",
5083            "Both sides produce identical semantics",
5084            42,
5085        )
5086        .lab(|_config| make_happy_semantics())
5087        .live(|_seed, _entropy| make_happy_semantics())
5088        .run();
5089
5090        assert!(result.passed());
5091        assert!(result.verdict.is_equivalent());
5092        assert!(result.lab_invariant_violations.is_empty());
5093        assert!(result.live_invariant_violations.is_empty());
5094        crate::test_complete!("harness_identical_runs_pass");
5095    }
5096
5097    #[test]
5098    fn harness_outcome_mismatch_fails() {
5099        init_test("harness_outcome_mismatch_fails");
5100        let result = DualRunHarness::phase1(
5101            "test.mismatch",
5102            "test.surface",
5103            "v1",
5104            "Lab succeeds, live cancels",
5105            42,
5106        )
5107        .lab(|_config| make_happy_semantics())
5108        .live(|_seed, _entropy| {
5109            let mut sem = make_happy_semantics();
5110            sem.terminal_outcome = TerminalOutcome::cancelled("timeout");
5111            sem
5112        })
5113        .run();
5114
5115        assert!(!result.passed());
5116        assert!(!result.verdict.is_equivalent());
5117        crate::test_complete!("harness_outcome_mismatch_fails");
5118    }
5119
5120    #[test]
5121    fn harness_lab_invariant_violation_fails() {
5122        init_test("harness_lab_invariant_violation_fails");
5123        let result = DualRunHarness::phase1(
5124            "test.leak",
5125            "test.surface",
5126            "v1",
5127            "Lab leaks obligations",
5128            42,
5129        )
5130        .lab(|_config| {
5131            let mut sem = make_happy_semantics();
5132            sem.obligation_balance.leaked = 1;
5133            sem.obligation_balance.balanced = false;
5134            sem
5135        })
5136        .live(|_seed, _entropy| {
5137            let mut sem = make_happy_semantics();
5138            sem.obligation_balance.leaked = 1;
5139            sem.obligation_balance.balanced = false;
5140            sem
5141        })
5142        .run();
5143
5144        // Semantics match (both leak), but invariant check catches it.
5145        assert!(result.verdict.is_equivalent());
5146        assert!(!result.lab_invariant_violations.is_empty());
5147        assert!(!result.passed()); // Failed due to invariant violations.
5148        crate::test_complete!("harness_lab_invariant_violation_fails");
5149    }
5150
5151    #[test]
5152    fn harness_receives_correct_seeds() {
5153        use std::sync::Arc;
5154        use std::sync::atomic::{AtomicU64, Ordering};
5155        init_test("harness_receives_correct_seeds");
5156
5157        let captured_lab_seed = Arc::new(AtomicU64::new(0));
5158        let captured_live_seed = Arc::new(AtomicU64::new(0));
5159        let lab_clone = Arc::clone(&captured_lab_seed);
5160        let live_clone = Arc::clone(&captured_live_seed);
5161
5162        let result = DualRunHarness::phase1("test.seeds", "s", "v1", "d", 0xBEEF)
5163            .lab(move |config| {
5164                lab_clone.store(config.seed, Ordering::Relaxed);
5165                make_happy_semantics()
5166            })
5167            .live(move |seed, _entropy| {
5168                live_clone.store(seed, Ordering::Relaxed);
5169                make_happy_semantics()
5170            })
5171            .run();
5172
5173        assert!(result.passed());
5174        assert_eq!(captured_lab_seed.load(Ordering::Relaxed), 0xBEEF);
5175        assert_eq!(captured_live_seed.load(Ordering::Relaxed), 0xBEEF);
5176        crate::test_complete!("harness_receives_correct_seeds");
5177    }
5178
5179    #[test]
5180    fn harness_with_custom_seed_plan() {
5181        use std::sync::Arc;
5182        use std::sync::atomic::{AtomicU64, Ordering};
5183        init_test("harness_with_custom_seed_plan");
5184
5185        let captured_lab = Arc::new(AtomicU64::new(0));
5186        let captured_live = Arc::new(AtomicU64::new(0));
5187        let lab_c = Arc::clone(&captured_lab);
5188        let live_c = Arc::clone(&captured_live);
5189
5190        let plan = SeedPlan::inherit(42, "custom")
5191            .with_lab_override(0xCAFE)
5192            .with_live_override(0xFACE);
5193
5194        let result = DualRunHarness::phase1("test", "s", "v1", "d", 42)
5195            .with_seed_plan(plan)
5196            .lab(move |config| {
5197                lab_c.store(config.seed, Ordering::Relaxed);
5198                make_happy_semantics()
5199            })
5200            .live(move |seed, _entropy| {
5201                live_c.store(seed, Ordering::Relaxed);
5202                make_happy_semantics()
5203            })
5204            .run();
5205
5206        assert_eq!(captured_lab.load(Ordering::Relaxed), 0xCAFE);
5207        assert_eq!(captured_live.load(Ordering::Relaxed), 0xFACE);
5208        // Semantics match despite different seeds.
5209        assert!(result.verdict.is_equivalent());
5210        // But seeds don't match.
5211        assert!(!result.seed_lineage.seeds_match);
5212        crate::test_complete!("harness_with_custom_seed_plan");
5213    }
5214
5215    #[test]
5216    fn harness_from_identity() {
5217        init_test("harness_from_identity");
5218        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 99);
5219        let result = DualRunHarness::from_identity(ident)
5220            .lab(|_| make_happy_semantics())
5221            .live(|_, _| make_happy_semantics())
5222            .run();
5223        assert!(result.passed());
5224        assert_eq!(result.verdict.scenario_id, "test");
5225        crate::test_complete!("harness_from_identity");
5226    }
5227
5228    #[test]
5229    fn dual_run_result_display() {
5230        init_test("dual_run_result_display");
5231        let result = DualRunHarness::phase1("test", "s", "v1", "d", 42)
5232            .lab(|_| make_happy_semantics())
5233            .live(|_, _| make_happy_semantics())
5234            .run();
5235        let summary = format!("{result}");
5236        assert!(summary.contains("PASS"));
5237        crate::test_complete!("dual_run_result_display");
5238    }
5239
5240    #[test]
5241    fn harness_noise_notes_classify_scheduler_noise() {
5242        init_test("harness_noise_notes_classify_scheduler_noise");
5243        let ident = DualRunScenarioIdentity::phase1("test.noise", "test.surface", "v1", "d", 42);
5244        let live_ident = ident.clone();
5245
5246        let result = DualRunHarness::from_identity(ident)
5247            .lab(|_| make_happy_semantics())
5248            .live_result(move |_, _| {
5249                let mut result = run_live_adapter(&live_ident, |_config, witness| {
5250                    witness.set_outcome(TerminalOutcome::ok());
5251                    witness.note_nondeterminism("thread scheduling");
5252                });
5253                result.semantics.resource_surface = ResourceSurfaceRecord::empty("test");
5254                result
5255            })
5256            .run();
5257
5258        assert!(result.passed());
5259        assert_eq!(
5260            result.policy.provisional_class,
5261            ProvisionalDivergenceClass::SchedulerNoiseSuspected
5262        );
5263        assert_eq!(
5264            result.policy.rerun_decision,
5265            RerunDecision::LiveConfirmations { additional_runs: 2 }
5266        );
5267        assert_eq!(
5268            result.policy.scheduler_noise_class,
5269            SchedulerNoiseClass::NondeterminismNotesOnly
5270        );
5271        crate::test_complete!("harness_noise_notes_classify_scheduler_noise");
5272    }
5273
5274    #[test]
5275    fn classify_scheduler_noise_prefers_hash_drift_over_notes() {
5276        init_test("classify_scheduler_noise_prefers_hash_drift_over_notes");
5277        let lab = make_observable(RuntimeKind::Lab, make_happy_semantics());
5278        let mut live = make_observable(RuntimeKind::Live, make_happy_semantics());
5279        let mut lab_prov = lab.provenance.clone();
5280        lab_prov.schedule_hash = Some(0xAAAA);
5281        let mut live_prov = live.provenance.clone();
5282        live_prov.schedule_hash = Some(0xBBBB);
5283        live_prov.nondeterminism_notes = vec!["thread scheduling".to_string()];
5284
5285        let lab = NormalizedObservable {
5286            provenance: lab_prov,
5287            ..lab
5288        };
5289        live.provenance = live_prov;
5290
5291        assert_eq!(
5292            classify_scheduler_noise(&lab, &live),
5293            SchedulerNoiseClass::ScheduleHashDrift
5294        );
5295        crate::test_complete!("classify_scheduler_noise_prefers_hash_drift_over_notes");
5296    }
5297
5298    #[test]
5299    fn harness_semantic_mismatch_policy_requests_reruns() {
5300        init_test("harness_semantic_mismatch_policy_requests_reruns");
5301        let result = DualRunHarness::phase1("test.mismatch.policy", "test.surface", "v1", "d", 42)
5302            .lab(|_| make_happy_semantics())
5303            .live(|_, _| {
5304                let mut sem = make_happy_semantics();
5305                sem.terminal_outcome = TerminalOutcome::err("network_error");
5306                sem
5307            })
5308            .run();
5309
5310        assert_eq!(
5311            result.policy.provisional_class,
5312            ProvisionalDivergenceClass::SemanticMismatchAdmittedSurface
5313        );
5314        assert_eq!(
5315            result.policy.rerun_decision,
5316            RerunDecision::DeterministicLabReplayAndLiveConfirmations {
5317                additional_live_runs: 2,
5318            }
5319        );
5320        assert_eq!(result.policy.suggested_final_class, None);
5321        crate::test_complete!("harness_semantic_mismatch_policy_requests_reruns");
5322    }
5323
5324    #[test]
5325    fn harness_unsupported_surface_policy_short_circuits() {
5326        init_test("harness_unsupported_surface_policy_short_circuits");
5327        let ident =
5328            DualRunScenarioIdentity::phase1("test.unsupported", "browser.surface", "v1", "d", 42)
5329                .with_metadata("eligibility_verdict", "unsupported")
5330                .with_metadata("unsupported_reason", "browser timing surface not admitted");
5331
5332        let result = DualRunHarness::from_identity(ident)
5333            .lab(|_| make_happy_semantics())
5334            .live(|_, _| {
5335                let mut sem = make_happy_semantics();
5336                sem.terminal_outcome = TerminalOutcome::err("unsupported_surface");
5337                sem
5338            })
5339            .run();
5340
5341        assert_eq!(
5342            result.policy.provisional_class,
5343            ProvisionalDivergenceClass::UnsupportedSurface
5344        );
5345        assert_eq!(result.policy.rerun_decision, RerunDecision::None);
5346        assert_eq!(
5347            result.policy.suggested_final_class,
5348            Some(FinalDivergenceClass::UnsupportedSurface)
5349        );
5350        crate::test_complete!("harness_unsupported_surface_policy_short_circuits");
5351    }
5352
5353    #[test]
5354    fn harness_insufficient_observability_policy_marks_gap() {
5355        init_test("harness_insufficient_observability_policy_marks_gap");
5356        let ident =
5357            DualRunScenarioIdentity::phase1("test.observability", "timer.surface", "v1", "d", 42)
5358                .with_metadata("observability_status", "blocked_missing_live_timer_surface");
5359
5360        let result = DualRunHarness::from_identity(ident)
5361            .lab(|_| {
5362                let mut sem = make_happy_semantics();
5363                sem.resource_surface =
5364                    ResourceSurfaceRecord::empty("timer.surface").with_counter("timeouts", 1);
5365                sem
5366            })
5367            .live(|_, _| {
5368                let mut sem = make_happy_semantics();
5369                sem.resource_surface = ResourceSurfaceRecord::empty("timer.surface");
5370                sem
5371            })
5372            .run();
5373
5374        assert_eq!(
5375            result.policy.provisional_class,
5376            ProvisionalDivergenceClass::InsufficientObservability
5377        );
5378        assert_eq!(
5379            result.policy.rerun_decision,
5380            RerunDecision::ConfirmationIfRicherInstrumentationEnabled { additional_runs: 1 }
5381        );
5382        assert_eq!(
5383            result.policy.suggested_final_class,
5384            Some(FinalDivergenceClass::InsufficientObservability)
5385        );
5386        crate::test_complete!("harness_insufficient_observability_policy_marks_gap");
5387    }
5388
5389    #[test]
5390    fn harness_blocked_missing_observability_gate_is_not_a_pass() {
5391        init_test("harness_blocked_missing_observability_gate_is_not_a_pass");
5392        let ident = DualRunScenarioIdentity::phase1(
5393            "test.observability.gate",
5394            "timer.surface",
5395            "v1",
5396            "d",
5397            42,
5398        )
5399        .with_metadata("eligibility_verdict", "blocked_missing_observability");
5400
5401        let result = DualRunHarness::from_identity(ident)
5402            .lab(|_| make_happy_semantics())
5403            .live(|_, _| make_happy_semantics())
5404            .run();
5405
5406        assert_eq!(
5407            result.policy.provisional_class,
5408            ProvisionalDivergenceClass::InsufficientObservability
5409        );
5410        assert_eq!(
5411            result.policy.rerun_decision,
5412            RerunDecision::ConfirmationIfRicherInstrumentationEnabled { additional_runs: 1 }
5413        );
5414        assert_eq!(
5415            result.policy.suggested_final_class,
5416            Some(FinalDivergenceClass::InsufficientObservability)
5417        );
5418        crate::test_complete!("harness_blocked_missing_observability_gate_is_not_a_pass");
5419    }
5420
5421    #[test]
5422    fn harness_bridge_only_downgrade_can_still_be_an_admitted_surface() {
5423        init_test("harness_bridge_only_downgrade_can_still_be_an_admitted_surface");
5424        let ident = DualRunScenarioIdentity::phase1(
5425            "test.bridge_only_admitted",
5426            "browser.surface",
5427            "v1",
5428            "bridge-only downgrade remains comparable when admitted",
5429            42,
5430        )
5431        .with_metadata("eligibility_verdict", "eligible_for_pilot")
5432        .with_metadata("support_class", "bridge_only")
5433        .with_metadata("reason_code", "downgrade_to_server_bridge");
5434
5435        let result = DualRunHarness::from_identity(ident)
5436            .lab(|_| make_happy_semantics())
5437            .live(|_, _| make_happy_semantics())
5438            .run();
5439
5440        assert!(result.passed(), "{}", result.summary());
5441        assert_eq!(
5442            result.policy.provisional_class,
5443            ProvisionalDivergenceClass::Pass
5444        );
5445        assert_eq!(result.policy.rerun_decision, RerunDecision::None);
5446        assert_eq!(result.policy.suggested_final_class, None);
5447        crate::test_complete!("harness_bridge_only_downgrade_can_still_be_an_admitted_surface");
5448    }
5449
5450    #[test]
5451    fn harness_bridge_only_without_downgrade_reason_stays_unsupported() {
5452        init_test("harness_bridge_only_without_downgrade_reason_stays_unsupported");
5453        let ident = DualRunScenarioIdentity::phase1(
5454            "test.bridge_only_invalid_reason",
5455            "browser.surface",
5456            "v1",
5457            "bridge-only without a supported downgrade reason must fail closed",
5458            42,
5459        )
5460        .with_metadata("support_class", "bridge_only")
5461        .with_metadata("reason_code", "unsupported_runtime_context")
5462        .with_metadata(
5463            "unsupported_reason",
5464            "non-browser runtime context has no admitted downgrade lane",
5465        );
5466
5467        let result = DualRunHarness::from_identity(ident)
5468            .lab(|_| make_happy_semantics())
5469            .live(|_, _| make_happy_semantics())
5470            .run();
5471
5472        assert_eq!(
5473            result.policy.provisional_class,
5474            ProvisionalDivergenceClass::UnsupportedSurface
5475        );
5476        assert_eq!(result.policy.rerun_decision, RerunDecision::None);
5477        assert_eq!(
5478            result.policy.suggested_final_class,
5479            Some(FinalDivergenceClass::UnsupportedSurface)
5480        );
5481        crate::test_complete!("harness_bridge_only_without_downgrade_reason_stays_unsupported");
5482    }
5483
5484    #[test]
5485    fn harness_eligible_gate_does_not_override_unsupported_support_class() {
5486        init_test("harness_eligible_gate_does_not_override_unsupported_support_class");
5487        let ident = DualRunScenarioIdentity::phase1(
5488            "test.eligible_gate_conflict",
5489            "browser.surface",
5490            "v1",
5491            "contradictory unsupported support class must fail closed",
5492            42,
5493        )
5494        .with_metadata("eligibility_verdict", "eligible_for_pilot")
5495        .with_metadata("support_class", "unsupported")
5496        .with_metadata(
5497            "unsupported_reason",
5498            "shared worker direct runtime not shipped",
5499        );
5500
5501        let result = DualRunHarness::from_identity(ident)
5502            .lab(|_| make_happy_semantics())
5503            .live(|_, _| make_happy_semantics())
5504            .run();
5505
5506        assert_eq!(
5507            result.policy.provisional_class,
5508            ProvisionalDivergenceClass::UnsupportedSurface
5509        );
5510        assert_eq!(result.policy.rerun_decision, RerunDecision::None);
5511        assert_eq!(
5512            result.policy.suggested_final_class,
5513            Some(FinalDivergenceClass::UnsupportedSurface)
5514        );
5515        crate::test_complete!("harness_eligible_gate_does_not_override_unsupported_support_class");
5516    }
5517
5518    #[test]
5519    fn harness_blocked_missing_verification_gate_stays_unsupported() {
5520        init_test("harness_blocked_missing_verification_gate_stays_unsupported");
5521        let ident = DualRunScenarioIdentity::phase1(
5522            "test.verification.gate",
5523            "browser.surface",
5524            "v1",
5525            "d",
5526            42,
5527        )
5528        .with_metadata("eligibility_verdict", "blocked_missing_verification")
5529        .with_metadata("support_class", "bridge_only")
5530        .with_metadata("reason_code", "downgrade_to_server_bridge");
5531
5532        let result = DualRunHarness::from_identity(ident)
5533            .lab(|_| make_happy_semantics())
5534            .live(|_, _| make_happy_semantics())
5535            .run();
5536
5537        assert_eq!(
5538            result.policy.provisional_class,
5539            ProvisionalDivergenceClass::UnsupportedSurface
5540        );
5541        assert_eq!(result.policy.rerun_decision, RerunDecision::None);
5542        assert_eq!(
5543            result.policy.suggested_final_class,
5544            Some(FinalDivergenceClass::UnsupportedSurface)
5545        );
5546        crate::test_complete!("harness_blocked_missing_verification_gate_stays_unsupported");
5547    }
5548
5549    #[test]
5550    fn harness_hard_contract_break_policy_short_circuits() {
5551        init_test("harness_hard_contract_break_policy_short_circuits");
5552        let result = DualRunHarness::phase1("test.hard_break", "test.surface", "v1", "d", 42)
5553            .lab(|_| make_happy_semantics())
5554            .live(|_, _| {
5555                let mut sem = make_happy_semantics();
5556                sem.obligation_balance.leaked = 1;
5557                sem.obligation_balance.balanced = false;
5558                sem
5559            })
5560            .run();
5561
5562        assert_eq!(
5563            result.policy.provisional_class,
5564            ProvisionalDivergenceClass::HardContractBreak
5565        );
5566        assert_eq!(result.policy.rerun_decision, RerunDecision::None);
5567        assert_eq!(
5568            result.policy.suggested_final_class,
5569            Some(FinalDivergenceClass::RuntimeSemanticBug)
5570        );
5571        crate::test_complete!("harness_hard_contract_break_policy_short_circuits");
5572    }
5573
5574    #[test]
5575    #[should_panic(expected = "Dual-run test failed")]
5576    fn assert_dual_run_passes_panics_on_failure() {
5577        init_test("assert_dual_run_passes_panics_on_failure");
5578        let result = DualRunHarness::phase1("test", "s", "v1", "d", 42)
5579            .lab(|_| make_happy_semantics())
5580            .live(|_, _| {
5581                let mut sem = make_happy_semantics();
5582                sem.terminal_outcome = TerminalOutcome::err("oops");
5583                sem
5584            })
5585            .run();
5586        assert_dual_run_passes(&result);
5587    }
5588
5589    // --- LiveRunnerAdapter ---
5590
5591    #[test]
5592    fn live_runner_config_from_identity() {
5593        init_test("live_runner_config_from_identity");
5594        let ident = DualRunScenarioIdentity::phase1("test", "surface", "v1", "d", 0xBEEF);
5595        let config = LiveRunnerConfig::from_identity(&ident);
5596        assert_eq!(config.seed, 0xBEEF);
5597        assert_eq!(config.profile, LiveExecutionProfile::CurrentThread);
5598        assert_eq!(config.scenario_id, "test");
5599        assert_eq!(config.surface_id, "surface");
5600        crate::test_complete!("live_runner_config_from_identity");
5601    }
5602
5603    #[test]
5604    fn live_runner_config_from_plan() {
5605        init_test("live_runner_config_from_plan");
5606        let plan = SeedPlan::inherit(42, "lineage").with_live_override(0xCAFE);
5607        let config = LiveRunnerConfig::from_plan(&plan, "scenario", "surface");
5608        assert_eq!(config.seed, 0xCAFE);
5609        assert_eq!(config.seed_lineage_id, "lineage");
5610        crate::test_complete!("live_runner_config_from_plan");
5611    }
5612
5613    #[test]
5614    fn live_runner_config_display() {
5615        init_test("live_runner_config_display");
5616        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
5617        let config = LiveRunnerConfig::from_identity(&ident);
5618        let s = format!("{config}");
5619        assert!(s.contains("test"));
5620        assert!(s.contains("current_thread"));
5621        crate::test_complete!("live_runner_config_display");
5622    }
5623
5624    #[test]
5625    fn live_witness_collector_defaults() {
5626        init_test("live_witness_collector_defaults");
5627        let witness = LiveWitnessCollector::new("test.surface");
5628        let sem = witness.finalize();
5629        assert_eq!(sem.terminal_outcome.class, OutcomeClass::Ok);
5630        assert!(sem.region_close.quiescent);
5631        assert!(sem.obligation_balance.balanced);
5632        assert_eq!(sem.loser_drain.status, DrainStatus::NotApplicable);
5633        assert_eq!(sem.resource_surface.contract_scope, "test.surface");
5634        crate::test_complete!("live_witness_collector_defaults");
5635    }
5636
5637    #[test]
5638    fn live_witness_collector_records_evidence() {
5639        init_test("live_witness_collector_records_evidence");
5640        let mut witness = LiveWitnessCollector::new("test");
5641        witness.set_outcome(TerminalOutcome::cancelled("timeout"));
5642        witness.set_cancellation(CancellationRecord::completed());
5643        witness.set_loser_drain(LoserDrainRecord::complete(2));
5644        witness.set_obligation_balance(ObligationBalanceRecord::balanced(5, 4, 1));
5645        witness.record_counter("msgs_sent", 10);
5646        witness.record_counter_with_tolerance("bytes", 1024, CounterTolerance::AtLeast);
5647        witness.note_nondeterminism("scheduler ordering may vary");
5648
5649        assert_eq!(witness.nondeterminism_notes().len(), 1);
5650
5651        let sem = witness.finalize();
5652        assert_eq!(sem.terminal_outcome.class, OutcomeClass::Cancelled);
5653        assert!(sem.cancellation.requested);
5654        assert_eq!(sem.loser_drain.drained_losers, 2);
5655        assert_eq!(sem.obligation_balance.committed, 4);
5656        assert_eq!(sem.resource_surface.counters["msgs_sent"], 10);
5657        assert_eq!(
5658            sem.resource_surface.tolerances["bytes"],
5659            CounterTolerance::AtLeast
5660        );
5661        crate::test_complete!("live_witness_collector_records_evidence");
5662    }
5663
5664    #[test]
5665    fn run_live_adapter_happy_path() {
5666        init_test("run_live_adapter_happy_path");
5667        let ident = DualRunScenarioIdentity::phase1(
5668            "test.happy",
5669            "test.surface",
5670            "v1",
5671            "Happy path live adapter test",
5672            42,
5673        );
5674        let result = run_live_adapter(&ident, |config, witness| {
5675            assert_eq!(config.seed, 42);
5676            assert_eq!(config.profile, LiveExecutionProfile::CurrentThread);
5677            witness.set_outcome(TerminalOutcome::ok());
5678            witness.record_counter("items_processed", 5);
5679        });
5680        assert_eq!(result.semantics.terminal_outcome.class, OutcomeClass::Ok);
5681        assert_eq!(
5682            result.semantics.resource_surface.counters["items_processed"],
5683            5
5684        );
5685        assert_eq!(result.metadata.config.scenario_id, "test.happy");
5686        assert!(result.metadata.nondeterminism_notes.is_empty());
5687        assert_eq!(
5688            result
5689                .metadata
5690                .capture_manifest
5691                .describe_field_capture("semantics.terminal_outcome.class")
5692                .as_deref(),
5693            Some("observed via witness.set_outcome")
5694        );
5695        assert_eq!(
5696            result
5697                .metadata
5698                .capture_manifest
5699                .describe_field_capture("semantics.resource_surface.counters.items_processed")
5700                .as_deref(),
5701            Some("observed via witness.record_counter")
5702        );
5703        crate::test_complete!("run_live_adapter_happy_path");
5704    }
5705
5706    #[test]
5707    fn run_live_adapter_with_nondeterminism() {
5708        init_test("run_live_adapter_with_nondeterminism");
5709        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
5710        let result = run_live_adapter(&ident, |_config, witness| {
5711            witness.note_nondeterminism("timer resolution varies");
5712            witness.note_nondeterminism("thread scheduling");
5713        });
5714        assert_eq!(result.metadata.nondeterminism_notes.len(), 2);
5715        assert_eq!(
5716            result.metadata.replay.nondeterminism_notes,
5717            result.metadata.nondeterminism_notes
5718        );
5719        crate::test_complete!("run_live_adapter_with_nondeterminism");
5720    }
5721
5722    #[test]
5723    fn run_live_adapter_cancellation_scenario() {
5724        init_test("run_live_adapter_cancellation_scenario");
5725        let ident = DualRunScenarioIdentity::phase1(
5726            "cancel.race",
5727            "cancellation.race",
5728            "v1",
5729            "Cancel and drain",
5730            0xDEAD,
5731        );
5732        let result = run_live_adapter(&ident, |config, witness| {
5733            assert_eq!(config.seed, 0xDEAD);
5734            witness.set_outcome(TerminalOutcome::ok());
5735            witness.set_cancellation(CancellationRecord::completed());
5736            witness.set_loser_drain(LoserDrainRecord::complete(1));
5737        });
5738        assert!(result.semantics.cancellation.requested);
5739        assert!(result.semantics.cancellation.cleanup_completed);
5740        assert_eq!(result.semantics.loser_drain.status, DrainStatus::Complete);
5741        assert_eq!(
5742            result.metadata.replay.instance.runtime_kind,
5743            RuntimeKind::Live
5744        );
5745        assert_eq!(
5746            result
5747                .metadata
5748                .capture_manifest
5749                .describe_field_capture("semantics.cancellation.checkpoint_observed")
5750                .as_deref(),
5751            Some("observed via witness.set_cancellation")
5752        );
5753        crate::test_complete!("run_live_adapter_cancellation_scenario");
5754    }
5755
5756    #[test]
5757    fn run_live_adapter_metadata_serde() {
5758        init_test("run_live_adapter_metadata_serde");
5759        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
5760        let result = run_live_adapter(&ident, |_, _| {});
5761        let json = serde_json::to_string_pretty(&result.metadata).unwrap();
5762        let parsed: LiveRunMetadata = serde_json::from_str(&json).unwrap();
5763        assert_eq!(parsed.config.seed, 42);
5764        assert_eq!(parsed.config.profile, LiveExecutionProfile::CurrentThread);
5765        assert_eq!(
5766            parsed
5767                .capture_manifest
5768                .describe_field_capture("semantics.region_close.quiescent")
5769                .as_deref(),
5770            Some("inferred via run_live_adapter.default_quiescent")
5771        );
5772        crate::test_complete!("run_live_adapter_metadata_serde");
5773    }
5774
5775    #[test]
5776    fn live_adapter_integrates_with_harness() {
5777        init_test("live_adapter_integrates_with_harness");
5778        // Demonstrates the full pattern: use run_live_adapter inside
5779        // DualRunHarness.live() closure for structured live evidence.
5780        let result = DualRunHarness::phase1(
5781            "integration.test",
5782            "test.surface",
5783            "v1",
5784            "Full integration of live adapter with harness",
5785            0xBEEF,
5786        )
5787        .lab(|_config| make_happy_semantics())
5788        .live(|seed, _entropy| {
5789            let ident = DualRunScenarioIdentity::phase1(
5790                "integration.test",
5791                "test.surface",
5792                "v1",
5793                "d",
5794                seed,
5795            );
5796            let live_result = run_live_adapter(&ident, |_config, witness| {
5797                witness.set_outcome(TerminalOutcome::ok());
5798                witness.record_counter("items", 3);
5799            });
5800            live_result.semantics
5801        })
5802        .run();
5803
5804        // Resource counter won't match lab (which has no counters),
5805        // but that's expected — live has extra counters.
5806        // The harness detects this properly.
5807        assert!(!result.verdict.passed); // Different resource surfaces
5808        crate::test_complete!("live_adapter_integrates_with_harness");
5809    }
5810
5811    // --- Semantic Capture Hooks ---
5812
5813    #[test]
5814    fn capture_manifest_tracking() {
5815        init_test("capture_manifest_tracking");
5816        let mut manifest = CaptureManifest::new();
5817        manifest.observed("terminal_outcome", "outcome_match");
5818        manifest.inferred("cancellation.acknowledged", "task_handle.join");
5819        manifest.unsupported("cancellation.checkpoint_observed");
5820
5821        assert_eq!(manifest.total_fields(), 3);
5822        assert_eq!(manifest.unsupported_count(), 1);
5823        assert!(!manifest.fully_observed());
5824        assert_eq!(
5825            manifest.unsupported_fields,
5826            vec!["cancellation.checkpoint_observed"]
5827        );
5828        crate::test_complete!("capture_manifest_tracking");
5829    }
5830
5831    #[test]
5832    fn capture_manifest_fully_observed() {
5833        init_test("capture_manifest_fully_observed");
5834        let mut manifest = CaptureManifest::new();
5835        manifest.observed("outcome", "match");
5836        manifest.observed("cancel", "hook");
5837        assert!(manifest.fully_observed());
5838        crate::test_complete!("capture_manifest_fully_observed");
5839    }
5840
5841    #[test]
5842    fn capture_manifest_empty_is_not_fully_observed() {
5843        init_test("capture_manifest_empty_is_not_fully_observed");
5844        let manifest = CaptureManifest::new();
5845        assert!(!manifest.fully_observed());
5846        crate::test_complete!("capture_manifest_empty_is_not_fully_observed");
5847    }
5848
5849    #[test]
5850    fn capture_manifest_serde() {
5851        init_test("capture_manifest_serde");
5852        let mut manifest = CaptureManifest::new();
5853        manifest.observed("outcome", "match");
5854        manifest.unsupported("checkpoint");
5855        let json = serde_json::to_string(&manifest).unwrap();
5856        let parsed: CaptureManifest = serde_json::from_str(&json).unwrap();
5857        assert_eq!(parsed.total_fields(), 2);
5858        crate::test_complete!("capture_manifest_serde");
5859    }
5860
5861    #[test]
5862    fn capture_manifest_canonicalizes_and_resolves_parent_fields() {
5863        init_test("capture_manifest_canonicalizes_and_resolves_parent_fields");
5864        let mut manifest = CaptureManifest::new();
5865        manifest.unsupported("terminal_outcome");
5866        manifest.observed("resource_surface.counters.items", "counter");
5867        manifest.observed("terminal_outcome", "hook");
5868        manifest.inferred("cancellation", "fallback");
5869
5870        let fields: Vec<&str> = manifest
5871            .annotations
5872            .iter()
5873            .map(|annotation| annotation.field.as_str())
5874            .collect();
5875        assert_eq!(
5876            fields,
5877            vec![
5878                "cancellation",
5879                "resource_surface.counters.items",
5880                "terminal_outcome"
5881            ]
5882        );
5883        assert!(manifest.unsupported_fields.is_empty());
5884        assert_eq!(
5885            manifest
5886                .annotation_for_field("semantics.resource_surface.counters.items")
5887                .unwrap()
5888                .source,
5889            "counter"
5890        );
5891        assert_eq!(
5892            manifest
5893                .describe_field_capture("semantics.terminal_outcome.class")
5894                .as_deref(),
5895            Some("observed via hook")
5896        );
5897        crate::test_complete!("capture_manifest_canonicalizes_and_resolves_parent_fields");
5898    }
5899
5900    #[test]
5901    fn capture_terminal_from_outcome_ok() {
5902        init_test("capture_terminal_from_outcome_ok");
5903        let outcome: crate::types::outcome::Outcome<i32, String> =
5904            crate::types::outcome::Outcome::Ok(42);
5905        let t = capture_terminal_outcome(&outcome);
5906        assert_eq!(t.class, OutcomeClass::Ok);
5907        assert_eq!(t.severity, OutcomeClass::Ok);
5908        crate::test_complete!("capture_terminal_from_outcome_ok");
5909    }
5910
5911    #[test]
5912    fn capture_terminal_from_outcome_err() {
5913        init_test("capture_terminal_from_outcome_err");
5914        let outcome: crate::types::outcome::Outcome<i32, String> =
5915            crate::types::outcome::Outcome::Err("network_error".to_string());
5916        let t = capture_terminal_outcome(&outcome);
5917        assert_eq!(t.class, OutcomeClass::Err);
5918        assert_eq!(t.error_class.as_deref(), Some("network_error"));
5919        crate::test_complete!("capture_terminal_from_outcome_err");
5920    }
5921
5922    #[test]
5923    fn capture_terminal_from_outcome_cancelled() {
5924        init_test("capture_terminal_from_outcome_cancelled");
5925        let outcome: crate::types::outcome::Outcome<i32, String> =
5926            crate::types::outcome::Outcome::Cancelled(crate::types::CancelReason::new(
5927                crate::types::CancelKind::User,
5928            ));
5929        let t = capture_terminal_outcome(&outcome);
5930        assert_eq!(t.class, OutcomeClass::Cancelled);
5931        assert!(t.cancel_reason_class.is_some());
5932        crate::test_complete!("capture_terminal_from_outcome_cancelled");
5933    }
5934
5935    #[test]
5936    fn capture_terminal_from_result_ok_and_err() {
5937        init_test("capture_terminal_from_result_ok_and_err");
5938        let ok: Result<i32, String> = Ok(42);
5939        let err: Result<i32, String> = Err("fail".to_string());
5940        assert_eq!(
5941            super::capture_terminal_from_result(&ok).class,
5942            OutcomeClass::Ok
5943        );
5944        assert_eq!(
5945            super::capture_terminal_from_result(&err).class,
5946            OutcomeClass::Err
5947        );
5948        crate::test_complete!("capture_terminal_from_result_ok_and_err");
5949    }
5950
5951    #[test]
5952    fn capture_obligation_balanced() {
5953        init_test("capture_obligation_balanced");
5954        let b = capture_obligation_balance(10, 8, 2);
5955        assert!(b.balanced);
5956        assert_eq!(b.leaked, 0);
5957        assert_eq!(b.unresolved, 0);
5958        crate::test_complete!("capture_obligation_balanced");
5959    }
5960
5961    #[test]
5962    fn capture_obligation_leaked() {
5963        init_test("capture_obligation_leaked");
5964        let b = capture_obligation_balance(10, 5, 2);
5965        assert!(!b.balanced);
5966        assert_eq!(b.leaked, 3);
5967        crate::test_complete!("capture_obligation_leaked");
5968    }
5969
5970    #[test]
5971    fn capture_region_close_quiescent() {
5972        init_test("capture_region_close_quiescent");
5973        let r = capture_region_close(true, true);
5974        assert!(r.quiescent);
5975        assert!(r.close_completed);
5976        assert_eq!(r.root_state, RegionState::Closed);
5977        assert_eq!(r.live_children, 0);
5978        crate::test_complete!("capture_region_close_quiescent");
5979    }
5980
5981    #[test]
5982    fn capture_region_close_not_quiescent() {
5983        init_test("capture_region_close_not_quiescent");
5984        let r = capture_region_close(false, true);
5985        assert!(!r.quiescent);
5986        assert!(!r.close_completed);
5987        assert_eq!(r.root_state, RegionState::Draining);
5988        assert_eq!(r.live_children, 1);
5989        assert_eq!(r.finalizers_pending, 0);
5990        crate::test_complete!("capture_region_close_not_quiescent");
5991    }
5992
5993    #[test]
5994    fn capture_region_close_finalizing() {
5995        init_test("capture_region_close_finalizing");
5996        let r = capture_region_close(true, false);
5997        assert!(!r.quiescent);
5998        assert!(!r.close_completed);
5999        assert_eq!(r.root_state, RegionState::Finalizing);
6000        assert_eq!(r.live_children, 0);
6001        assert_eq!(r.finalizers_pending, 1);
6002        crate::test_complete!("capture_region_close_finalizing");
6003    }
6004
6005    #[test]
6006    fn capture_loser_drain_not_applicable() {
6007        init_test("capture_loser_drain_not_applicable");
6008        let d = capture_loser_drain(&[]);
6009        assert!(!d.applicable);
6010        assert_eq!(d.status, DrainStatus::NotApplicable);
6011        crate::test_complete!("capture_loser_drain_not_applicable");
6012    }
6013
6014    #[test]
6015    fn capture_loser_drain_all_drained() {
6016        init_test("capture_loser_drain_all_drained");
6017        let d = capture_loser_drain(&[true, true, true]);
6018        assert!(d.applicable);
6019        assert_eq!(d.status, DrainStatus::Complete);
6020        assert_eq!(d.expected_losers, 3);
6021        assert_eq!(d.drained_losers, 3);
6022        crate::test_complete!("capture_loser_drain_all_drained");
6023    }
6024
6025    #[test]
6026    fn capture_loser_drain_partial() {
6027        init_test("capture_loser_drain_partial");
6028        let d = capture_loser_drain(&[true, false, true]);
6029        assert_eq!(d.status, DrainStatus::Incomplete);
6030        assert_eq!(d.drained_losers, 2);
6031        crate::test_complete!("capture_loser_drain_partial");
6032    }
6033
6034    #[test]
6035    fn capture_cancellation_not_cancelled() {
6036        init_test("capture_cancellation_not_cancelled");
6037        let c = capture_cancellation(false, false, false, false, None);
6038        assert_eq!(c.terminal_phase, CancelTerminalPhase::NotCancelled);
6039        assert!(!c.requested);
6040        crate::test_complete!("capture_cancellation_not_cancelled");
6041    }
6042
6043    #[test]
6044    fn capture_cancellation_completed() {
6045        init_test("capture_cancellation_completed");
6046        let c = capture_cancellation(true, true, true, true, Some(true));
6047        assert_eq!(c.terminal_phase, CancelTerminalPhase::Completed);
6048        assert!(c.requested);
6049        assert!(c.acknowledged);
6050        assert!(c.cleanup_completed);
6051        assert!(c.finalization_completed);
6052        assert_eq!(c.checkpoint_observed, Some(true));
6053        crate::test_complete!("capture_cancellation_completed");
6054    }
6055
6056    #[test]
6057    fn capture_cancellation_in_progress() {
6058        init_test("capture_cancellation_in_progress");
6059        let c = capture_cancellation(true, true, false, false, None);
6060        assert_eq!(c.terminal_phase, CancelTerminalPhase::Cancelling);
6061        crate::test_complete!("capture_cancellation_in_progress");
6062    }
6063
6064    #[test]
6065    fn capture_cancellation_finalizing() {
6066        init_test("capture_cancellation_finalizing");
6067        let c = capture_cancellation(true, true, true, false, None);
6068        assert_eq!(c.terminal_phase, CancelTerminalPhase::Finalizing);
6069        crate::test_complete!("capture_cancellation_finalizing");
6070    }
6071
6072    // --- Lab Normalizer ---
6073
6074    fn make_passing_oracle_report() -> crate::lab::oracle::OracleReport {
6075        crate::lab::oracle::OracleReport {
6076            entries: vec![],
6077            total: 0,
6078            passed: 0,
6079            failed: 0,
6080            check_time_nanos: 0,
6081        }
6082    }
6083
6084    fn make_passing_lab_report(seed: u64) -> crate::lab::runtime::LabRunReport {
6085        crate::lab::runtime::LabRunReport {
6086            seed,
6087            steps_delta: 100,
6088            steps_total: 100,
6089            quiescent: true,
6090            now_nanos: 0,
6091            trace_len: 10,
6092            trace_fingerprint: 0xABCD,
6093            trace_certificate: crate::lab::runtime::LabTraceCertificateSummary {
6094                event_hash: 0x1234,
6095                event_count: 10,
6096                schedule_hash: 0x5678,
6097            },
6098            oracle_report: make_passing_oracle_report(),
6099            invariant_violations: vec![],
6100            temporal_invariant_failures: vec![],
6101            temporal_counterexample_prefix_len: None,
6102            refinement_firewall_rule_id: None,
6103            refinement_firewall_event_index: None,
6104            refinement_firewall_event_seq: None,
6105            refinement_counterexample_prefix_len: None,
6106            refinement_firewall_skipped_due_to_trace_truncation: false,
6107        }
6108    }
6109
6110    fn make_golden_live_result(identity: &DualRunScenarioIdentity) -> LiveRunResult {
6111        run_live_adapter(identity, |_, witness| {
6112            witness.set_outcome(TerminalOutcome::ok());
6113            witness.set_loser_drain(LoserDrainRecord::complete(2));
6114            witness.record_counter("items", 5);
6115            witness.record_counter_with_tolerance("bytes", 128, CounterTolerance::AtLeast);
6116            witness.note_nondeterminism("scheduler jitter");
6117        })
6118    }
6119
6120    #[test]
6121    fn normalize_lab_report_happy_path() {
6122        init_test("normalize_lab_report_happy_path");
6123        let report = make_passing_lab_report(42);
6124        let (sem, manifest) = normalize_lab_report(&report, "test.surface");
6125        assert_eq!(sem.terminal_outcome.class, OutcomeClass::Ok);
6126        assert!(sem.region_close.quiescent);
6127        assert!(sem.obligation_balance.balanced);
6128        assert!(manifest.total_fields() > 0);
6129        crate::test_complete!("normalize_lab_report_happy_path");
6130    }
6131
6132    #[test]
6133    #[allow(clippy::too_many_lines)]
6134    fn normalize_lab_report_matches_golden_record() {
6135        init_test("normalize_lab_report_matches_golden_record");
6136        let identity = DualRunScenarioIdentity::phase1(
6137            "golden.lab",
6138            "test.surface",
6139            "v1",
6140            "Golden lab normalization",
6141            42,
6142        );
6143        let report = make_passing_lab_report(42);
6144        let (semantics, manifest) = normalize_lab_report(&report, "test.surface");
6145        let observable = normalize_lab_observable(&identity, &report);
6146
6147        assert_eq!(observable.semantics, semantics);
6148        assert_eq!(
6149            serde_json::to_value(&manifest).unwrap(),
6150            serde_json::json!({
6151                "annotations": [
6152                    {
6153                        "field": "cancellation",
6154                        "observability": "inferred",
6155                        "source": "no_oracle_entry",
6156                    },
6157                    {
6158                        "field": "loser_drain",
6159                        "observability": "inferred",
6160                        "source": "no_oracle_entry",
6161                    },
6162                    {
6163                        "field": "obligation_balance",
6164                        "observability": "observed",
6165                        "source": "oracle.obligation_leak + invariants",
6166                    },
6167                    {
6168                        "field": "region_close.quiescent",
6169                        "observability": "observed",
6170                        "source": "LabRunReport.quiescent",
6171                    },
6172                    {
6173                        "field": "terminal_outcome",
6174                        "observability": "observed",
6175                        "source": "oracle_report.all_passed",
6176                    }
6177                ],
6178                "unsupported_fields": [],
6179            })
6180        );
6181        assert_eq!(
6182            serde_json::to_value(&observable).unwrap(),
6183            serde_json::json!({
6184                "schema_version": NORMALIZED_OBSERVABLE_SCHEMA_VERSION,
6185                "scenario_id": "golden.lab",
6186                "surface_id": "test.surface",
6187                "surface_contract_version": "v1",
6188                "runtime_kind": "lab",
6189                "semantics": {
6190                    "terminal_outcome": {
6191                        "class": "ok",
6192                        "severity": "ok",
6193                    },
6194                    "cancellation": {
6195                        "requested": false,
6196                        "acknowledged": false,
6197                        "cleanup_completed": false,
6198                        "finalization_completed": false,
6199                        "terminal_phase": "not_cancelled",
6200                    },
6201                    "loser_drain": {
6202                        "applicable": false,
6203                        "expected_losers": 0,
6204                        "drained_losers": 0,
6205                        "status": "not_applicable",
6206                    },
6207                    "region_close": {
6208                        "root_state": "closed",
6209                        "quiescent": true,
6210                        "live_children": 0,
6211                        "finalizers_pending": 0,
6212                        "close_completed": true,
6213                    },
6214                    "obligation_balance": {
6215                        "reserved": 0,
6216                        "committed": 0,
6217                        "aborted": 0,
6218                        "leaked": 0,
6219                        "unresolved": 0,
6220                        "balanced": true,
6221                    },
6222                    "resource_surface": {
6223                        "contract_scope": "test.surface",
6224                        "counters": {},
6225                        "tolerances": {},
6226                    },
6227                },
6228                "provenance": {
6229                    "family": {
6230                        "id": "golden.lab",
6231                        "surface_id": "test.surface",
6232                        "surface_contract_version": "v1",
6233                    },
6234                    "instance": {
6235                        "family_id": "golden.lab",
6236                        "effective_seed": 42,
6237                        "runtime_kind": "lab",
6238                        "run_index": 0,
6239                    },
6240                    "seed_plan": {
6241                        "canonical_seed": 42,
6242                        "seed_lineage_id": "golden.lab",
6243                        "lab_seed_mode": "inherit",
6244                        "live_seed_mode": "inherit",
6245                        "replay_policy": "single_seed",
6246                    },
6247                    "effective_seed": 42,
6248                    "effective_entropy_seed": derive_component_seed(42, "entropy"),
6249                    "trace_fingerprint": 43981,
6250                    "schedule_hash": 22136,
6251                    "event_hash": 4660,
6252                    "event_count": 10,
6253                    "steps_total": 100,
6254                },
6255            })
6256        );
6257        crate::test_complete!("normalize_lab_report_matches_golden_record");
6258    }
6259
6260    #[test]
6261    fn normalize_lab_report_invariant_violation() {
6262        init_test("normalize_lab_report_invariant_violation");
6263        let mut report = make_passing_lab_report(42);
6264        report.invariant_violations = vec!["obligation leak detected".to_string()];
6265        let (sem, _) = normalize_lab_report(&report, "test");
6266        assert_eq!(sem.terminal_outcome.class, OutcomeClass::Err);
6267        assert!(!sem.obligation_balance.balanced);
6268        crate::test_complete!("normalize_lab_report_invariant_violation");
6269    }
6270
6271    #[test]
6272    fn normalize_lab_report_not_quiescent() {
6273        init_test("normalize_lab_report_not_quiescent");
6274        let mut report = make_passing_lab_report(42);
6275        report.quiescent = false;
6276        let (sem, _) = normalize_lab_report(&report, "test");
6277        assert!(!sem.region_close.quiescent);
6278        assert!(!sem.region_close.close_completed);
6279        assert_eq!(sem.region_close.root_state, RegionState::Closing);
6280        crate::test_complete!("normalize_lab_report_not_quiescent");
6281    }
6282
6283    #[test]
6284    fn normalize_lab_observable_preserves_provenance() {
6285        init_test("normalize_lab_observable_preserves_provenance");
6286        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
6287        let report = make_passing_lab_report(42);
6288        let obs = normalize_lab_observable(&ident, &report);
6289        assert_eq!(obs.runtime_kind, RuntimeKind::Lab);
6290        assert_eq!(obs.provenance.trace_fingerprint, Some(0xABCD));
6291        assert_eq!(obs.provenance.event_hash, Some(0x1234));
6292        assert_eq!(obs.provenance.steps_total, Some(100));
6293        crate::test_complete!("normalize_lab_observable_preserves_provenance");
6294    }
6295
6296    #[test]
6297    fn normalize_live_observable_from_result() {
6298        init_test("normalize_live_observable_from_result");
6299        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
6300        let live_result = run_live_adapter(&ident, |_, witness| {
6301            witness.set_outcome(TerminalOutcome::ok());
6302            witness.record_counter("items", 5);
6303            witness.note_nondeterminism("thread scheduling");
6304        });
6305        let obs = normalize_live_observable(&ident, &live_result);
6306        assert_eq!(obs.runtime_kind, RuntimeKind::Live);
6307        assert_eq!(obs.semantics.terminal_outcome.class, OutcomeClass::Ok);
6308        assert_eq!(obs.semantics.resource_surface.counters["items"], 5);
6309        assert_eq!(obs.provenance.nondeterminism_notes, ["thread scheduling"]);
6310        crate::test_complete!("normalize_live_observable_from_result");
6311    }
6312
6313    #[test]
6314    #[allow(clippy::too_many_lines)]
6315    fn normalize_live_observable_matches_golden_record_and_manifest() {
6316        init_test("normalize_live_observable_matches_golden_record_and_manifest");
6317        let identity = DualRunScenarioIdentity::phase1(
6318            "golden.live",
6319            "test.surface",
6320            "v1",
6321            "Golden live normalization",
6322            42,
6323        );
6324        let live_result = make_golden_live_result(&identity);
6325        let observable = normalize_live_observable(&identity, &live_result);
6326
6327        assert_eq!(
6328            serde_json::to_value(&live_result.metadata.capture_manifest).unwrap(),
6329            serde_json::json!({
6330                "annotations": [
6331                    {
6332                        "field": "cancellation",
6333                        "observability": "inferred",
6334                        "source": "run_live_adapter.default_no_cancellation",
6335                    },
6336                    {
6337                        "field": "cancellation.checkpoint_observed",
6338                        "observability": "unsupported",
6339                        "source": "default",
6340                    },
6341                    {
6342                        "field": "loser_drain",
6343                        "observability": "observed",
6344                        "source": "witness.set_loser_drain",
6345                    },
6346                    {
6347                        "field": "obligation_balance",
6348                        "observability": "inferred",
6349                        "source": "run_live_adapter.default_balanced_obligations",
6350                    },
6351                    {
6352                        "field": "region_close",
6353                        "observability": "inferred",
6354                        "source": "run_live_adapter.default_quiescent",
6355                    },
6356                    {
6357                        "field": "resource_surface.contract_scope",
6358                        "observability": "observed",
6359                        "source": "scenario_identity.surface_id",
6360                    },
6361                    {
6362                        "field": "resource_surface.counters.bytes",
6363                        "observability": "observed",
6364                        "source": "witness.record_counter_with_tolerance",
6365                    },
6366                    {
6367                        "field": "resource_surface.counters.items",
6368                        "observability": "observed",
6369                        "source": "witness.record_counter",
6370                    },
6371                    {
6372                        "field": "resource_surface.tolerances.bytes",
6373                        "observability": "observed",
6374                        "source": "witness.record_counter_with_tolerance",
6375                    },
6376                    {
6377                        "field": "resource_surface.tolerances.items",
6378                        "observability": "observed",
6379                        "source": "witness.record_counter",
6380                    },
6381                    {
6382                        "field": "terminal_outcome",
6383                        "observability": "observed",
6384                        "source": "witness.set_outcome",
6385                    }
6386                ],
6387                "unsupported_fields": ["cancellation.checkpoint_observed"],
6388            })
6389        );
6390        assert_eq!(
6391            serde_json::to_value(&observable).unwrap(),
6392            serde_json::json!({
6393                "schema_version": NORMALIZED_OBSERVABLE_SCHEMA_VERSION,
6394                "scenario_id": "golden.live",
6395                "surface_id": "test.surface",
6396                "surface_contract_version": "v1",
6397                "runtime_kind": "live",
6398                "semantics": {
6399                    "terminal_outcome": {
6400                        "class": "ok",
6401                        "severity": "ok",
6402                    },
6403                    "cancellation": {
6404                        "requested": false,
6405                        "acknowledged": false,
6406                        "cleanup_completed": false,
6407                        "finalization_completed": false,
6408                        "terminal_phase": "not_cancelled",
6409                    },
6410                    "loser_drain": {
6411                        "applicable": true,
6412                        "expected_losers": 2,
6413                        "drained_losers": 2,
6414                        "status": "complete",
6415                    },
6416                    "region_close": {
6417                        "root_state": "closed",
6418                        "quiescent": true,
6419                        "live_children": 0,
6420                        "finalizers_pending": 0,
6421                        "close_completed": true,
6422                    },
6423                    "obligation_balance": {
6424                        "reserved": 0,
6425                        "committed": 0,
6426                        "aborted": 0,
6427                        "leaked": 0,
6428                        "unresolved": 0,
6429                        "balanced": true,
6430                    },
6431                    "resource_surface": {
6432                        "contract_scope": "test.surface",
6433                        "counters": {
6434                            "bytes": 128,
6435                            "items": 5,
6436                        },
6437                        "tolerances": {
6438                            "bytes": "at_least",
6439                            "items": "exact",
6440                        },
6441                    },
6442                },
6443                "provenance": {
6444                    "family": {
6445                        "id": "golden.live",
6446                        "surface_id": "test.surface",
6447                        "surface_contract_version": "v1",
6448                    },
6449                    "instance": {
6450                        "family_id": "golden.live",
6451                        "effective_seed": 42,
6452                        "runtime_kind": "live",
6453                        "run_index": 0,
6454                    },
6455                    "seed_plan": {
6456                        "canonical_seed": 42,
6457                        "seed_lineage_id": "golden.live",
6458                        "lab_seed_mode": "inherit",
6459                        "live_seed_mode": "inherit",
6460                        "replay_policy": "single_seed",
6461                    },
6462                    "effective_seed": 42,
6463                    "effective_entropy_seed": derive_component_seed(42, "entropy"),
6464                },
6465            })
6466        );
6467        crate::test_complete!("normalize_live_observable_matches_golden_record_and_manifest");
6468    }
6469
6470    #[test]
6471    fn normalize_and_compare_lab_vs_live() {
6472        init_test("normalize_and_compare_lab_vs_live");
6473        let ident = DualRunScenarioIdentity::phase1("test", "s", "v1", "d", 42);
6474
6475        // Lab side
6476        let report = make_passing_lab_report(42);
6477        let lab_obs = normalize_lab_observable(&ident, &report);
6478
6479        // Live side
6480        let live_result = run_live_adapter(&ident, |_, _| {});
6481        let live_obs = normalize_live_observable(&ident, &live_result);
6482
6483        // Compare
6484        let lineage = ident.seed_lineage();
6485        let verdict = compare_observables(&lab_obs, &live_obs, lineage);
6486        // Both should have ok outcomes and quiescent regions
6487        assert!(verdict.passed, "Verdict: {}", verdict.summary());
6488        crate::test_complete!("normalize_and_compare_lab_vs_live");
6489    }
6490
6491    #[test]
6492    fn mismatch_summary_with_manifests_includes_capture_sources() {
6493        init_test("mismatch_summary_with_manifests_includes_capture_sources");
6494        let identity = DualRunScenarioIdentity::phase1(
6495            "capture.summary",
6496            "test.surface",
6497            "v1",
6498            "Mismatch summaries should include capture provenance",
6499            42,
6500        );
6501
6502        let mut report = make_passing_lab_report(42);
6503        report.invariant_violations = vec!["obligation leak detected".to_string()];
6504        let (lab_semantics, lab_manifest) = normalize_lab_report(&report, "test.surface");
6505        let lab = NormalizedObservable::new(
6506            &identity,
6507            RuntimeKind::Lab,
6508            lab_semantics,
6509            identity.lab_replay_metadata(),
6510        );
6511
6512        let live_result = run_live_adapter(&identity, |_, witness| {
6513            witness.set_outcome(TerminalOutcome::ok());
6514        });
6515        let live = normalize_live_observable(&identity, &live_result);
6516
6517        let verdict = compare_observables(&lab, &live, identity.seed_lineage());
6518        assert!(!verdict.passed);
6519
6520        let summary = verdict.summary_with_manifests(
6521            Some(&lab_manifest),
6522            Some(&live_result.metadata.capture_manifest),
6523        );
6524        assert!(summary.contains("semantics.terminal_outcome.class"));
6525        assert!(summary.contains("lab_capture=observed via invariant_violations"));
6526        assert!(summary.contains("live_capture=observed via witness.set_outcome"));
6527        crate::test_complete!("mismatch_summary_with_manifests_includes_capture_sources");
6528    }
6529
6530    // --- Fuzz-to-Scenario Promotion ---
6531
6532    fn make_test_fuzz_finding(seed: u64) -> crate::lab::fuzz::FuzzFinding {
6533        crate::lab::fuzz::FuzzFinding {
6534            seed,
6535            entropy_seed: 0xFACE,
6536            steps: 500,
6537            violations: vec![],
6538            certificate_hash: 0xABCD,
6539            trace_fingerprint: 0x1234,
6540            minimized_seed: Some(seed.wrapping_add(1)),
6541        }
6542    }
6543
6544    #[test]
6545    fn promote_fuzz_finding_basic() {
6546        init_test("promote_fuzz_finding_basic");
6547        let finding = make_test_fuzz_finding(0xDEAD);
6548        let promoted = promote_fuzz_finding(&finding, "cancellation", "v1");
6549        assert!(promoted.identity.scenario_id.contains("fuzz"));
6550        assert!(promoted.identity.scenario_id.contains("cancellation"));
6551        assert_eq!(promoted.replay_seed, 0xDEAD + 1); // minimized
6552        assert_eq!(promoted.original_seed, 0xDEAD);
6553        assert_eq!(promoted.identity.seed_plan.canonical_seed, 0xDEAD + 1);
6554        assert_eq!(
6555            promoted.identity.seed_plan.entropy_seed_override,
6556            Some(0xFACE)
6557        );
6558        assert_eq!(promoted.identity.phase, Phase::Phase1);
6559        assert!(promoted.identity.metadata.contains_key("promoted_from"));
6560        crate::test_complete!("promote_fuzz_finding_basic");
6561    }
6562
6563    #[test]
6564    fn promote_fuzz_finding_no_minimized_seed() {
6565        init_test("promote_fuzz_finding_no_minimized_seed");
6566        let mut finding = make_test_fuzz_finding(0xBEEF);
6567        finding.minimized_seed = None;
6568        let promoted = promote_fuzz_finding(&finding, "obligation", "v1");
6569        assert_eq!(promoted.replay_seed, 0xBEEF); // falls back to original
6570        crate::test_complete!("promote_fuzz_finding_no_minimized_seed");
6571    }
6572
6573    #[test]
6574    fn promote_fuzz_finding_stabilizes_violation_categories_and_metadata() {
6575        init_test("promote_fuzz_finding_stabilizes_violation_categories_and_metadata");
6576        let mut finding = make_test_fuzz_finding(0xD00D);
6577        finding.violations = vec![
6578            crate::lab::runtime::InvariantViolation::QuiescenceViolation,
6579            crate::lab::runtime::InvariantViolation::Futurelock {
6580                task: crate::types::TaskId::new_for_test(1, 0),
6581                region: crate::types::RegionId::new_for_test(1, 0),
6582                idle_steps: 1,
6583                held: Vec::new(),
6584            },
6585            crate::lab::runtime::InvariantViolation::QuiescenceViolation,
6586        ];
6587
6588        let promoted = promote_fuzz_finding(&finding, "cancellation", "v1");
6589        assert_eq!(
6590            promoted.violation_categories,
6591            vec!["futurelock", "quiescence_violation"]
6592        );
6593        assert!(promoted.identity.scenario_id.contains("futurelock"));
6594        assert_eq!(
6595            promoted.identity.metadata.get("violation_categories"),
6596            Some(&"futurelock,quiescence_violation".to_string())
6597        );
6598        assert_eq!(
6599            promoted.identity.metadata.get("certificate_hash"),
6600            Some(&"0xABCD".to_string())
6601        );
6602        crate::test_complete!("promote_fuzz_finding_stabilizes_violation_categories_and_metadata");
6603    }
6604
6605    #[test]
6606    fn promote_fuzz_finding_repro_command() {
6607        init_test("promote_fuzz_finding_repro_command");
6608        let finding = make_test_fuzz_finding(42);
6609        let promoted = promote_fuzz_finding(&finding, "drain", "v1");
6610        let cmd = promoted.repro_command();
6611        assert!(cmd.contains("rch exec -- env ASUPERSYNC_SEED="));
6612        assert!(cmd.contains("ASUPERSYNC_SEED"));
6613        assert!(cmd.contains("cargo test"));
6614        crate::test_complete!("promote_fuzz_finding_repro_command");
6615    }
6616
6617    #[test]
6618    fn promote_fuzz_finding_display() {
6619        init_test("promote_fuzz_finding_display");
6620        let finding = make_test_fuzz_finding(42);
6621        let promoted = promote_fuzz_finding(&finding, "test", "v1");
6622        let s = format!("{promoted}");
6623        assert!(s.contains("PromotedFuzz"));
6624        crate::test_complete!("promote_fuzz_finding_display");
6625    }
6626
6627    #[test]
6628    fn promote_fuzz_finding_serde_roundtrip() {
6629        init_test("promote_fuzz_finding_serde_roundtrip");
6630        let finding = make_test_fuzz_finding(0xCAFE);
6631        let promoted = promote_fuzz_finding(&finding, "test", "v1")
6632            .with_source_artifact_path("/tmp/fuzz/report.json");
6633        let json = serde_json::to_string_pretty(&promoted).unwrap();
6634        let parsed: PromotedFuzzScenario = serde_json::from_str(&json).unwrap();
6635        assert_eq!(parsed.replay_seed, promoted.replay_seed);
6636        assert_eq!(parsed.original_seed, 0xCAFE);
6637        assert_eq!(
6638            parsed.source_artifact_path.as_deref(),
6639            Some("/tmp/fuzz/report.json")
6640        );
6641        crate::test_complete!("promote_fuzz_finding_serde_roundtrip");
6642    }
6643
6644    #[test]
6645    fn promoted_fuzz_scenario_replay_metadata_includes_artifact_and_repro() {
6646        init_test("promoted_fuzz_scenario_replay_metadata_includes_artifact_and_repro");
6647        let finding = make_test_fuzz_finding(0xCAFE);
6648        let promoted = promote_fuzz_finding(&finding, "test.surface", "v1")
6649            .with_source_artifact_path("/tmp/fuzz/report.json");
6650
6651        let metadata = promoted.lab_replay_metadata();
6652        assert_eq!(metadata.trace_fingerprint, Some(promoted.trace_fingerprint));
6653        assert_eq!(
6654            metadata.artifact_path.as_deref(),
6655            Some("/tmp/fuzz/report.json")
6656        );
6657        assert_eq!(
6658            metadata.repro_command.as_deref(),
6659            Some(promoted.repro_command().as_str())
6660        );
6661        crate::test_complete!("promoted_fuzz_scenario_replay_metadata_includes_artifact_and_repro");
6662    }
6663
6664    #[test]
6665    fn promote_regression_case_basic() {
6666        init_test("promote_regression_case_basic");
6667        let case = crate::lab::fuzz::FuzzRegressionCase {
6668            seed: 0xDEAD,
6669            replay_seed: 0xBEEF,
6670            entropy_seed: 0xCAFE,
6671            certificate_hash: 0x1111,
6672            trace_fingerprint: 0x2222,
6673            violation_categories: vec!["obligation_leak".to_string()],
6674        };
6675        let promoted = promote_regression_case(&case, "obligation", "v1");
6676        assert!(promoted.identity.scenario_id.contains("regression"));
6677        assert_eq!(promoted.replay_seed, 0xBEEF);
6678        assert_eq!(
6679            promoted.identity.seed_plan.entropy_seed_override,
6680            Some(0xCAFE)
6681        );
6682        assert_eq!(promoted.violation_categories, vec!["obligation_leak"]);
6683        crate::test_complete!("promote_regression_case_basic");
6684    }
6685
6686    #[test]
6687    fn promote_regression_corpus_preserves_order() {
6688        init_test("promote_regression_corpus_preserves_order");
6689        let corpus = crate::lab::fuzz::FuzzRegressionCorpus {
6690            schema_version: 1,
6691            base_seed: 42,
6692            entropy_seed: 0x777,
6693            iterations: 1000,
6694            cases: vec![
6695                crate::lab::fuzz::FuzzRegressionCase {
6696                    seed: 1,
6697                    replay_seed: 10,
6698                    entropy_seed: 0x777,
6699                    certificate_hash: 0,
6700                    trace_fingerprint: 0,
6701                    violation_categories: vec!["a".to_string()],
6702                },
6703                crate::lab::fuzz::FuzzRegressionCase {
6704                    seed: 2,
6705                    replay_seed: 20,
6706                    entropy_seed: 0x777,
6707                    certificate_hash: 0,
6708                    trace_fingerprint: 0,
6709                    violation_categories: vec!["b".to_string()],
6710                },
6711            ],
6712        };
6713        let promoted = promote_regression_corpus(&corpus, "test", "v1");
6714        assert_eq!(promoted.len(), 2);
6715        assert_eq!(promoted[0].replay_seed, 10);
6716        assert_eq!(promoted[1].replay_seed, 20);
6717        assert_eq!(promoted[0].campaign_base_seed, Some(42));
6718        assert_eq!(promoted[0].campaign_iteration, Some(0));
6719        assert_eq!(promoted[1].campaign_iteration, Some(1));
6720        assert_eq!(
6721            promoted[0].identity.seed_plan.entropy_seed_override,
6722            Some(0x777)
6723        );
6724        assert_eq!(
6725            promoted[0].identity.metadata.get("campaign_entropy_seed"),
6726            Some(&"0x777".to_string())
6727        );
6728        crate::test_complete!("promote_regression_corpus_preserves_order");
6729    }
6730
6731    #[test]
6732    fn promoted_fuzz_scenario_runs_through_harness() {
6733        init_test("promoted_fuzz_scenario_runs_through_harness");
6734        let finding = make_test_fuzz_finding(42);
6735        let promoted = promote_fuzz_finding(&finding, "test.surface", "v1");
6736
6737        // Use the promoted identity in a DualRunHarness
6738        let result = DualRunHarness::from_identity(promoted.identity)
6739            .lab(|_config| make_happy_semantics())
6740            .live(|_seed, _entropy| make_happy_semantics())
6741            .run();
6742
6743        assert!(result.passed());
6744        crate::test_complete!("promoted_fuzz_scenario_runs_through_harness");
6745    }
6746
6747    #[test]
6748    fn promoted_regression_corpus_case_runs_through_harness_with_campaign_metadata() {
6749        init_test("promoted_regression_corpus_case_runs_through_harness_with_campaign_metadata");
6750        let corpus = crate::lab::fuzz::FuzzRegressionCorpus {
6751            schema_version: 1,
6752            base_seed: 0x2A,
6753            entropy_seed: 0x2B,
6754            iterations: 3,
6755            cases: vec![crate::lab::fuzz::FuzzRegressionCase {
6756                seed: 0x10,
6757                replay_seed: 0x11,
6758                entropy_seed: 0x2B,
6759                certificate_hash: 0x2222,
6760                trace_fingerprint: 0x3333,
6761                violation_categories: vec!["obligation_leak".to_string()],
6762            }],
6763        };
6764        let promoted = promote_regression_corpus(&corpus, "test.surface", "v1");
6765        let promoted = promoted[0]
6766            .clone()
6767            .with_source_artifact_path("/tmp/fuzz/corpus.json");
6768
6769        assert_eq!(promoted.campaign_base_seed, Some(0x2A));
6770        assert_eq!(promoted.campaign_iteration, Some(0));
6771        assert_eq!(
6772            promoted.identity.metadata.get("campaign_base_seed"),
6773            Some(&"0x2A".to_string())
6774        );
6775        assert_eq!(
6776            promoted.identity.metadata.get("campaign_iteration"),
6777            Some(&"0".to_string())
6778        );
6779
6780        let metadata = promoted.lab_replay_metadata();
6781        assert_eq!(metadata.trace_fingerprint, Some(0x3333));
6782        assert_eq!(
6783            metadata.artifact_path.as_deref(),
6784            Some("/tmp/fuzz/corpus.json")
6785        );
6786
6787        let result = DualRunHarness::from_identity(promoted.identity)
6788            .lab(|_config| make_happy_semantics())
6789            .live(|_seed, _entropy| make_happy_semantics())
6790            .run();
6791        assert!(result.passed());
6792        crate::test_complete!(
6793            "promoted_regression_corpus_case_runs_through_harness_with_campaign_metadata"
6794        );
6795    }
6796
6797    fn make_test_exploration_report() -> crate::lab::explorer::ExplorationReport {
6798        use crate::lab::explorer::{
6799            CoverageMetrics, RunResult, SaturationMetrics, ViolationReport,
6800        };
6801        use crate::lab::runtime::InvariantViolation;
6802
6803        crate::lab::explorer::ExplorationReport {
6804            total_runs: 3,
6805            unique_classes: 2,
6806            violations: vec![ViolationReport {
6807                seed: 0x20,
6808                steps: 42,
6809                violations: vec![InvariantViolation::QuiescenceViolation],
6810                fingerprint: 0xAAAA,
6811            }],
6812            coverage: CoverageMetrics {
6813                equivalence_classes: 2,
6814                total_runs: 3,
6815                new_class_discoveries: 2,
6816                class_run_counts: BTreeMap::from([(0xAAAA, 2), (0xBBBB, 1)]),
6817                novelty_histogram: BTreeMap::from([(0, 1), (1, 2)]),
6818                saturation: SaturationMetrics {
6819                    window: 10,
6820                    saturated: false,
6821                    existing_class_hits: 1,
6822                    runs_since_last_new_class: Some(1),
6823                },
6824            },
6825            top_unexplored: Vec::new(),
6826            runs: vec![
6827                RunResult {
6828                    seed: 0x10,
6829                    steps: 10,
6830                    fingerprint: 0xAAAA,
6831                    is_new_class: true,
6832                    violations: Vec::new(),
6833                    certificate_hash: 0x100,
6834                },
6835                RunResult {
6836                    seed: 0x20,
6837                    steps: 42,
6838                    fingerprint: 0xAAAA,
6839                    is_new_class: false,
6840                    violations: vec![InvariantViolation::QuiescenceViolation],
6841                    certificate_hash: 0x200,
6842                },
6843                RunResult {
6844                    seed: 0x30,
6845                    steps: 11,
6846                    fingerprint: 0xBBBB,
6847                    is_new_class: true,
6848                    violations: Vec::new(),
6849                    certificate_hash: 0x300,
6850                },
6851            ],
6852        }
6853    }
6854
6855    #[test]
6856    fn promote_exploration_report_prefers_lowest_violation_seed_and_preserves_lineage() {
6857        init_test("promote_exploration_report_prefers_lowest_violation_seed_and_preserves_lineage");
6858        let report = make_test_exploration_report();
6859        let promoted = promote_exploration_report(&report, "schedule.surface", "v1");
6860        assert_eq!(promoted.len(), 2);
6861
6862        let promoted_class = promoted
6863            .iter()
6864            .find(|scenario| scenario.trace_fingerprint == 0xAAAA)
6865            .expect("class 0xAAAA should be promoted");
6866        assert_eq!(promoted_class.replay_seed, 0x20);
6867        assert_eq!(promoted_class.original_seeds, vec![0x10, 0x20]);
6868        assert_eq!(promoted_class.violation_seeds, vec![0x20]);
6869        assert_eq!(
6870            promoted_class.supporting_schedule_hashes,
6871            vec![0x100, 0x200]
6872        );
6873        assert!(
6874            promoted_class
6875                .violation_summaries
6876                .iter()
6877                .any(|summary| summary.contains("region closed without quiescence"))
6878        );
6879        assert_eq!(
6880            promoted_class.identity.metadata.get("promoted_from"),
6881            Some(&"exploration_report".to_owned())
6882        );
6883        assert_eq!(
6884            promoted_class
6885                .identity
6886                .metadata
6887                .get("representative_reason"),
6888            Some(&"lowest_violation_seed".to_owned())
6889        );
6890        crate::test_complete!(
6891            "promote_exploration_report_prefers_lowest_violation_seed_and_preserves_lineage"
6892        );
6893    }
6894
6895    #[test]
6896    fn promoted_exploration_scenario_replay_metadata_includes_artifact_and_repro() {
6897        init_test("promoted_exploration_scenario_replay_metadata_includes_artifact_and_repro");
6898        let report = make_test_exploration_report();
6899        let promoted = promote_exploration_report(&report, "schedule.surface", "v1");
6900        let promoted = promoted[0]
6901            .clone()
6902            .with_source_artifact_path("/tmp/dpor/report.json");
6903
6904        let metadata = promoted.lab_replay_metadata();
6905        assert_eq!(metadata.trace_fingerprint, Some(promoted.trace_fingerprint));
6906        assert_eq!(
6907            metadata.schedule_hash,
6908            Some(promoted.representative_schedule_hash)
6909        );
6910        assert_eq!(
6911            metadata.artifact_path.as_deref(),
6912            Some("/tmp/dpor/report.json")
6913        );
6914        assert_eq!(
6915            metadata.repro_command.as_deref(),
6916            Some(promoted.repro_command().as_str())
6917        );
6918        crate::test_complete!(
6919            "promoted_exploration_scenario_replay_metadata_includes_artifact_and_repro"
6920        );
6921    }
6922
6923    #[test]
6924    fn promote_exploration_report_serde_roundtrip() {
6925        init_test("promote_exploration_report_serde_roundtrip");
6926        let report = make_test_exploration_report();
6927        let promoted = promote_exploration_report(&report, "schedule.surface", "v1");
6928        let json = serde_json::to_string_pretty(&promoted).unwrap();
6929        let parsed: Vec<PromotedExplorationScenario> = serde_json::from_str(&json).unwrap();
6930        assert_eq!(parsed.len(), promoted.len());
6931        assert_eq!(parsed[0].trace_fingerprint, promoted[0].trace_fingerprint);
6932        crate::test_complete!("promote_exploration_report_serde_roundtrip");
6933    }
6934}