Skip to main content

ftui_runtime/
schedule_trace.rs

1//! Schedule Trace Module (bd-gyi5).
2//!
3//! Provides deterministic golden trace infrastructure for async task manager testing.
4//! Records scheduler events (task start/stop, wakeups, yields, cancellations) and
5//! generates stable checksums for regression detection.
6//!
7//! # Core Algorithm
8//!
9//! - Events are recorded with monotonic sequence numbers (not wall-clock)
10//! - Traces are hashed using FNV-1a for stability across platforms
11//! - Isomorphism proofs validate that behavioral changes preserve invariants
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use ftui_runtime::schedule_trace::{ScheduleTrace, TaskEvent};
17//!
18//! let mut trace = ScheduleTrace::new();
19//!
20//! // Record events
21//! trace.record(TaskEvent::Spawn { task_id: 1, priority: 0 });
22//! trace.record(TaskEvent::Start { task_id: 1 });
23//! trace.record(TaskEvent::Complete { task_id: 1 });
24//!
25//! // Generate checksum
26//! let checksum = trace.checksum();
27//!
28//! // Export for golden comparison
29//! let json = trace.to_jsonl();
30//! ```
31
32#![forbid(unsafe_code)]
33
34use std::collections::VecDeque;
35use std::fmt;
36use web_time::Instant;
37
38use crate::voi_sampling::{VoiConfig, VoiSampler, VoiSummary};
39
40// =============================================================================
41// Event Types
42// =============================================================================
43
44/// A scheduler event with deterministic ordering.
45#[derive(Debug, Clone, PartialEq)]
46pub enum TaskEvent {
47    /// Task spawned into the queue.
48    Spawn {
49        task_id: u64,
50        priority: u8,
51        name: Option<String>,
52    },
53    /// Task started execution.
54    Start { task_id: u64 },
55    /// Task yielded voluntarily.
56    Yield { task_id: u64 },
57    /// Task woken up (external trigger).
58    Wakeup { task_id: u64, reason: WakeupReason },
59    /// Task completed successfully.
60    Complete { task_id: u64 },
61    /// Task failed with error.
62    Failed { task_id: u64, error: String },
63    /// Task cancelled.
64    Cancelled { task_id: u64, reason: CancelReason },
65    /// Scheduler policy changed.
66    PolicyChange {
67        from: SchedulerPolicy,
68        to: SchedulerPolicy,
69    },
70    /// Queue state snapshot (for debugging).
71    QueueSnapshot { queued: usize, running: usize },
72    /// Custom event for extensibility.
73    Custom { tag: String, data: String },
74}
75
76/// Reason for task wakeup.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum WakeupReason {
79    /// Timer expired.
80    Timer,
81    /// I/O ready.
82    IoReady,
83    /// Dependency completed.
84    Dependency { task_id: u64 },
85    /// User action.
86    UserAction,
87    /// Explicit wake call.
88    Explicit,
89    /// Unknown/other.
90    Other(String),
91}
92
93/// Reason for task cancellation.
94#[derive(Debug, Clone, PartialEq)]
95pub enum CancelReason {
96    /// User requested cancellation.
97    UserRequest,
98    /// Timeout exceeded.
99    Timeout,
100    /// Hazard-based policy decision.
101    HazardPolicy { expected_loss: f64 },
102    /// System shutdown.
103    Shutdown,
104    /// Other reason.
105    Other(String),
106}
107
108/// Scheduler policy identifier.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum SchedulerPolicy {
111    /// First-in, first-out.
112    Fifo,
113    /// Priority-based (highest first).
114    Priority,
115    /// Shortest remaining time first.
116    ShortestFirst,
117    /// Round-robin with time slices.
118    RoundRobin,
119    /// Weighted fair queuing.
120    WeightedFair,
121}
122
123impl fmt::Display for SchedulerPolicy {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Self::Fifo => write!(f, "fifo"),
127            Self::Priority => write!(f, "priority"),
128            Self::ShortestFirst => write!(f, "shortest_first"),
129            Self::RoundRobin => write!(f, "round_robin"),
130            Self::WeightedFair => write!(f, "weighted_fair"),
131        }
132    }
133}
134
135// =============================================================================
136// Trace Entry
137// =============================================================================
138
139/// A timestamped trace entry.
140#[derive(Debug, Clone)]
141pub struct TraceEntry {
142    /// Monotonic sequence number (not wall-clock).
143    pub seq: u64,
144    /// Logical tick when event occurred.
145    pub tick: u64,
146    /// The event itself.
147    pub event: TaskEvent,
148}
149
150impl TraceEntry {
151    /// Serialize to JSONL format.
152    pub fn to_jsonl(&self) -> String {
153        let event_type = match &self.event {
154            TaskEvent::Spawn { .. } => "spawn",
155            TaskEvent::Start { .. } => "start",
156            TaskEvent::Yield { .. } => "yield",
157            TaskEvent::Wakeup { .. } => "wakeup",
158            TaskEvent::Complete { .. } => "complete",
159            TaskEvent::Failed { .. } => "failed",
160            TaskEvent::Cancelled { .. } => "cancelled",
161            TaskEvent::PolicyChange { .. } => "policy_change",
162            TaskEvent::QueueSnapshot { .. } => "queue_snapshot",
163            TaskEvent::Custom { .. } => "custom",
164        };
165
166        let details = match &self.event {
167            TaskEvent::Spawn {
168                task_id,
169                priority,
170                name,
171            } => {
172                format!(
173                    "\"task_id\":{},\"priority\":{},\"name\":{}",
174                    task_id,
175                    priority,
176                    name.as_ref()
177                        .map(|n| format!("\"{}\"", n))
178                        .unwrap_or_else(|| "null".to_string())
179                )
180            }
181            TaskEvent::Start { task_id } => format!("\"task_id\":{}", task_id),
182            TaskEvent::Yield { task_id } => format!("\"task_id\":{}", task_id),
183            TaskEvent::Wakeup { task_id, reason } => {
184                let reason_str = match reason {
185                    WakeupReason::Timer => "timer".to_string(),
186                    WakeupReason::IoReady => "io_ready".to_string(),
187                    WakeupReason::Dependency { task_id } => format!("dependency:{}", task_id),
188                    WakeupReason::UserAction => "user_action".to_string(),
189                    WakeupReason::Explicit => "explicit".to_string(),
190                    WakeupReason::Other(s) => format!("other:{}", s),
191                };
192                format!("\"task_id\":{},\"reason\":\"{}\"", task_id, reason_str)
193            }
194            TaskEvent::Complete { task_id } => format!("\"task_id\":{}", task_id),
195            TaskEvent::Failed { task_id, error } => {
196                format!("\"task_id\":{},\"error\":\"{}\"", task_id, error)
197            }
198            TaskEvent::Cancelled { task_id, reason } => {
199                let reason_str = match reason {
200                    CancelReason::UserRequest => "user_request".to_string(),
201                    CancelReason::Timeout => "timeout".to_string(),
202                    CancelReason::HazardPolicy { expected_loss } => {
203                        format!("hazard_policy:{:.4}", expected_loss)
204                    }
205                    CancelReason::Shutdown => "shutdown".to_string(),
206                    CancelReason::Other(s) => format!("other:{}", s),
207                };
208                format!("\"task_id\":{},\"reason\":\"{}\"", task_id, reason_str)
209            }
210            TaskEvent::PolicyChange { from, to } => {
211                format!("\"from\":\"{}\",\"to\":\"{}\"", from, to)
212            }
213            TaskEvent::QueueSnapshot { queued, running } => {
214                format!("\"queued\":{},\"running\":{}", queued, running)
215            }
216            TaskEvent::Custom { tag, data } => {
217                format!("\"tag\":\"{}\",\"data\":\"{}\"", tag, data)
218            }
219        };
220
221        format!(
222            "{{\"seq\":{},\"tick\":{},\"event\":\"{}\",{}}}",
223            self.seq, self.tick, event_type, details
224        )
225    }
226}
227
228// =============================================================================
229// Schedule Trace
230// =============================================================================
231
232/// Configuration for the schedule trace.
233#[derive(Debug, Clone)]
234pub struct TraceConfig {
235    /// Maximum entries to retain (0 = unlimited).
236    pub max_entries: usize,
237    /// Include queue snapshots after each event.
238    ///
239    /// If `snapshot_sampling` is set, snapshots are sampled via VOI.
240    pub auto_snapshot: bool,
241    /// Optional VOI sampling policy for queue snapshots.
242    pub snapshot_sampling: Option<VoiConfig>,
243    /// Minimum absolute queue delta to mark a snapshot as "violated".
244    pub snapshot_change_threshold: usize,
245    /// Seed for deterministic tie-breaking.
246    pub seed: u64,
247}
248
249impl Default for TraceConfig {
250    fn default() -> Self {
251        Self {
252            max_entries: 10_000,
253            auto_snapshot: false,
254            snapshot_sampling: None,
255            snapshot_change_threshold: 1,
256            seed: 0,
257        }
258    }
259}
260
261/// The main schedule trace recorder.
262#[derive(Debug, Clone)]
263pub struct ScheduleTrace {
264    /// Configuration.
265    config: TraceConfig,
266    /// Recorded entries.
267    entries: VecDeque<TraceEntry>,
268    /// Monotonic sequence counter.
269    seq: u64,
270    /// Current logical tick.
271    tick: u64,
272    /// Optional VOI sampler for queue snapshots.
273    snapshot_sampler: Option<VoiSampler>,
274    /// Last recorded queue snapshot (queued, running).
275    last_snapshot: Option<(usize, usize)>,
276}
277
278impl ScheduleTrace {
279    /// Create a new trace recorder.
280    #[must_use]
281    pub fn new() -> Self {
282        Self::with_config(TraceConfig::default())
283    }
284
285    /// Create with custom configuration.
286    #[must_use]
287    pub fn with_config(config: TraceConfig) -> Self {
288        let capacity = if config.max_entries > 0 {
289            config.max_entries
290        } else {
291            1024
292        };
293        let snapshot_sampler = config.snapshot_sampling.clone().map(VoiSampler::new);
294        Self {
295            config,
296            entries: VecDeque::with_capacity(capacity),
297            seq: 0,
298            tick: 0,
299            snapshot_sampler,
300            last_snapshot: None,
301        }
302    }
303
304    /// Advance the logical tick.
305    pub fn advance_tick(&mut self) {
306        self.tick += 1;
307    }
308
309    /// Set the logical tick explicitly.
310    pub fn set_tick(&mut self, tick: u64) {
311        self.tick = tick;
312    }
313
314    /// Get current tick.
315    #[must_use]
316    pub fn tick(&self) -> u64 {
317        self.tick
318    }
319
320    /// Record an event.
321    pub fn record(&mut self, event: TaskEvent) {
322        let entry = TraceEntry {
323            seq: self.seq,
324            tick: self.tick,
325            event,
326        };
327        self.seq += 1;
328
329        // Enforce max entries
330        if self.config.max_entries > 0 && self.entries.len() >= self.config.max_entries {
331            self.entries.pop_front();
332        }
333
334        self.entries.push_back(entry);
335    }
336
337    /// Record an event with queue state and optional auto-snapshot.
338    pub fn record_with_queue_state(&mut self, event: TaskEvent, queued: usize, running: usize) {
339        self.record_with_queue_state_at(event, queued, running, Instant::now());
340    }
341
342    /// Record an event with queue state at a specific time (deterministic tests).
343    pub fn record_with_queue_state_at(
344        &mut self,
345        event: TaskEvent,
346        queued: usize,
347        running: usize,
348        now: Instant,
349    ) {
350        self.record(event);
351        if self.config.auto_snapshot {
352            self.maybe_snapshot(queued, running, now);
353        }
354    }
355
356    /// Decide whether to record a queue snapshot and update VOI evidence.
357    fn maybe_snapshot(&mut self, queued: usize, running: usize, now: Instant) {
358        let should_sample = if let Some(ref mut sampler) = self.snapshot_sampler {
359            let decision = sampler.decide(now);
360            if !decision.should_sample {
361                return;
362            }
363            let violated = self
364                .last_snapshot
365                .map(|(prev_q, prev_r)| {
366                    let delta = prev_q.abs_diff(queued) + prev_r.abs_diff(running);
367                    delta >= self.config.snapshot_change_threshold
368                })
369                .unwrap_or(false);
370            sampler.observe_at(violated, now);
371            true
372        } else {
373            true
374        };
375
376        if should_sample {
377            self.record(TaskEvent::QueueSnapshot { queued, running });
378            self.last_snapshot = Some((queued, running));
379        }
380    }
381
382    /// Record a spawn event.
383    pub fn spawn(&mut self, task_id: u64, priority: u8, name: Option<String>) {
384        self.record(TaskEvent::Spawn {
385            task_id,
386            priority,
387            name,
388        });
389    }
390
391    /// Record a start event.
392    pub fn start(&mut self, task_id: u64) {
393        self.record(TaskEvent::Start { task_id });
394    }
395
396    /// Record a complete event.
397    pub fn complete(&mut self, task_id: u64) {
398        self.record(TaskEvent::Complete { task_id });
399    }
400
401    /// Record a cancelled event.
402    pub fn cancel(&mut self, task_id: u64, reason: CancelReason) {
403        self.record(TaskEvent::Cancelled { task_id, reason });
404    }
405
406    /// Get all entries.
407    #[must_use]
408    pub fn entries(&self) -> &VecDeque<TraceEntry> {
409        &self.entries
410    }
411
412    /// Get entry count.
413    #[must_use]
414    pub fn len(&self) -> usize {
415        self.entries.len()
416    }
417
418    /// Check if empty.
419    #[must_use]
420    pub fn is_empty(&self) -> bool {
421        self.entries.is_empty()
422    }
423
424    /// Clear all entries.
425    pub fn clear(&mut self) {
426        self.entries.clear();
427        self.seq = 0;
428        self.last_snapshot = None;
429        if let Some(ref mut sampler) = self.snapshot_sampler {
430            let config = sampler.config().clone();
431            *sampler = VoiSampler::new(config);
432        }
433    }
434
435    /// Snapshot sampling summary, if enabled.
436    #[must_use]
437    pub fn snapshot_sampling_summary(&self) -> Option<VoiSummary> {
438        self.snapshot_sampler.as_ref().map(VoiSampler::summary)
439    }
440
441    /// Snapshot sampling logs rendered as JSONL, if enabled.
442    #[must_use]
443    pub fn snapshot_sampling_logs_jsonl(&self) -> Option<String> {
444        self.snapshot_sampler
445            .as_ref()
446            .map(VoiSampler::logs_to_jsonl)
447    }
448
449    /// Export to JSONL format.
450    #[must_use]
451    pub fn to_jsonl(&self) -> String {
452        self.entries
453            .iter()
454            .map(|e| e.to_jsonl())
455            .collect::<Vec<_>>()
456            .join("\n")
457    }
458
459    /// Compute FNV-1a checksum of the trace.
460    ///
461    /// This checksum is stable across platforms and can be used for golden comparisons.
462    #[must_use]
463    pub fn checksum(&self) -> u64 {
464        // FNV-1a 64-bit
465        const FNV_OFFSET: u64 = 0xcbf29ce484222325;
466        const FNV_PRIME: u64 = 0x100000001b3;
467
468        let mut hash = FNV_OFFSET;
469
470        for entry in &self.entries {
471            // Hash seq
472            for byte in entry.seq.to_le_bytes() {
473                hash ^= byte as u64;
474                hash = hash.wrapping_mul(FNV_PRIME);
475            }
476
477            // Hash tick
478            for byte in entry.tick.to_le_bytes() {
479                hash ^= byte as u64;
480                hash = hash.wrapping_mul(FNV_PRIME);
481            }
482
483            // Hash event type discriminant + key data
484            let event_bytes = self.event_to_bytes(&entry.event);
485            for byte in event_bytes {
486                hash ^= byte as u64;
487                hash = hash.wrapping_mul(FNV_PRIME);
488            }
489        }
490
491        hash
492    }
493
494    /// Compute checksum as hex string.
495    #[must_use]
496    pub fn checksum_hex(&self) -> String {
497        format!("{:016x}", self.checksum())
498    }
499
500    /// Convert event to bytes for hashing.
501    fn event_to_bytes(&self, event: &TaskEvent) -> Vec<u8> {
502        let mut bytes = Vec::new();
503
504        match event {
505            TaskEvent::Spawn {
506                task_id, priority, ..
507            } => {
508                bytes.push(0x01);
509                bytes.extend_from_slice(&task_id.to_le_bytes());
510                bytes.push(*priority);
511            }
512            TaskEvent::Start { task_id } => {
513                bytes.push(0x02);
514                bytes.extend_from_slice(&task_id.to_le_bytes());
515            }
516            TaskEvent::Yield { task_id } => {
517                bytes.push(0x03);
518                bytes.extend_from_slice(&task_id.to_le_bytes());
519            }
520            TaskEvent::Wakeup { task_id, .. } => {
521                bytes.push(0x04);
522                bytes.extend_from_slice(&task_id.to_le_bytes());
523            }
524            TaskEvent::Complete { task_id } => {
525                bytes.push(0x05);
526                bytes.extend_from_slice(&task_id.to_le_bytes());
527            }
528            TaskEvent::Failed { task_id, .. } => {
529                bytes.push(0x06);
530                bytes.extend_from_slice(&task_id.to_le_bytes());
531            }
532            TaskEvent::Cancelled { task_id, .. } => {
533                bytes.push(0x07);
534                bytes.extend_from_slice(&task_id.to_le_bytes());
535            }
536            TaskEvent::PolicyChange { from, to } => {
537                bytes.push(0x08);
538                bytes.push(*from as u8);
539                bytes.push(*to as u8);
540            }
541            TaskEvent::QueueSnapshot { queued, running } => {
542                bytes.push(0x09);
543                bytes.extend_from_slice(&(*queued as u64).to_le_bytes());
544                bytes.extend_from_slice(&(*running as u64).to_le_bytes());
545            }
546            TaskEvent::Custom { tag, data } => {
547                bytes.push(0x0A);
548                bytes.extend_from_slice(tag.as_bytes());
549                bytes.push(0x00); // separator
550                bytes.extend_from_slice(data.as_bytes());
551            }
552        }
553
554        bytes
555    }
556}
557
558impl Default for ScheduleTrace {
559    fn default() -> Self {
560        Self::new()
561    }
562}
563
564// =============================================================================
565// Golden Comparison
566// =============================================================================
567
568/// Result of comparing a trace against a golden checksum.
569#[derive(Debug, Clone, PartialEq, Eq)]
570pub enum GoldenCompareResult {
571    /// Checksums match.
572    Match,
573    /// Checksums differ.
574    Mismatch { expected: u64, actual: u64 },
575    /// Golden file not found.
576    MissingGolden,
577}
578
579impl GoldenCompareResult {
580    /// Check if the comparison passed.
581    #[must_use]
582    pub fn is_match(&self) -> bool {
583        matches!(self, Self::Match)
584    }
585}
586
587/// Compare trace against expected golden checksum.
588#[must_use]
589pub fn compare_golden(trace: &ScheduleTrace, expected: u64) -> GoldenCompareResult {
590    let actual = trace.checksum();
591    if actual == expected {
592        GoldenCompareResult::Match
593    } else {
594        GoldenCompareResult::Mismatch { expected, actual }
595    }
596}
597
598// =============================================================================
599// Isomorphism Proof
600// =============================================================================
601
602/// Evidence for an isomorphism proof.
603///
604/// When scheduler behavior changes, this documents why the change preserves
605/// correctness despite producing a different trace.
606#[derive(Debug, Clone)]
607pub struct IsomorphismProof {
608    /// Description of the change.
609    pub change_description: String,
610    /// Old checksum before the change.
611    pub old_checksum: u64,
612    /// New checksum after the change.
613    pub new_checksum: u64,
614    /// Invariants that are preserved.
615    pub preserved_invariants: Vec<String>,
616    /// Justification for why the traces are equivalent.
617    pub justification: String,
618    /// Who approved this change.
619    pub approved_by: Option<String>,
620    /// Timestamp of approval.
621    pub approved_at: Option<String>,
622}
623
624impl IsomorphismProof {
625    /// Create a new proof.
626    pub fn new(
627        change_description: impl Into<String>,
628        old_checksum: u64,
629        new_checksum: u64,
630    ) -> Self {
631        Self {
632            change_description: change_description.into(),
633            old_checksum,
634            new_checksum,
635            preserved_invariants: Vec::new(),
636            justification: String::new(),
637            approved_by: None,
638            approved_at: None,
639        }
640    }
641
642    /// Add a preserved invariant.
643    #[must_use]
644    pub fn with_invariant(mut self, invariant: impl Into<String>) -> Self {
645        self.preserved_invariants.push(invariant.into());
646        self
647    }
648
649    /// Add justification.
650    #[must_use]
651    pub fn with_justification(mut self, justification: impl Into<String>) -> Self {
652        self.justification = justification.into();
653        self
654    }
655
656    /// Export to JSON.
657    #[must_use]
658    pub fn to_json(&self) -> String {
659        let invariants = self
660            .preserved_invariants
661            .iter()
662            .map(|i| format!("\"{}\"", i))
663            .collect::<Vec<_>>()
664            .join(",");
665
666        let old_checksum = format!("{:016x}", self.old_checksum);
667        let new_checksum = format!("{:016x}", self.new_checksum);
668        let approved_by = self
669            .approved_by
670            .as_ref()
671            .map(|s| format!("\"{}\"", s))
672            .unwrap_or_else(|| "null".to_string());
673        let approved_at = self
674            .approved_at
675            .as_ref()
676            .map(|s| format!("\"{}\"", s))
677            .unwrap_or_else(|| "null".to_string());
678
679        format!(
680            r#"{{"change":"{}","old_checksum":"{}","new_checksum":"{}","invariants":[{}],"justification":"{}","approved_by":{},"approved_at":{}}}"#,
681            self.change_description,
682            old_checksum,
683            new_checksum,
684            invariants,
685            self.justification,
686            approved_by,
687            approved_at,
688        )
689    }
690}
691
692// =============================================================================
693// Trace Summary
694// =============================================================================
695
696/// Summary statistics for a trace.
697#[derive(Debug, Clone, Default)]
698pub struct TraceSummary {
699    /// Total events.
700    pub total_events: usize,
701    /// Spawn events.
702    pub spawns: usize,
703    /// Complete events.
704    pub completes: usize,
705    /// Failed events.
706    pub failures: usize,
707    /// Cancelled events.
708    pub cancellations: usize,
709    /// Yield events.
710    pub yields: usize,
711    /// Wakeup events.
712    pub wakeups: usize,
713    /// First tick.
714    pub first_tick: u64,
715    /// Last tick.
716    pub last_tick: u64,
717    /// Checksum.
718    pub checksum: u64,
719}
720
721impl ScheduleTrace {
722    /// Generate summary statistics.
723    #[must_use]
724    pub fn summary(&self) -> TraceSummary {
725        let mut summary = TraceSummary {
726            total_events: self.entries.len(),
727            checksum: self.checksum(),
728            ..Default::default()
729        };
730
731        if let Some(first) = self.entries.front() {
732            summary.first_tick = first.tick;
733        }
734        if let Some(last) = self.entries.back() {
735            summary.last_tick = last.tick;
736        }
737
738        for entry in &self.entries {
739            match &entry.event {
740                TaskEvent::Spawn { .. } => summary.spawns += 1,
741                TaskEvent::Complete { .. } => summary.completes += 1,
742                TaskEvent::Failed { .. } => summary.failures += 1,
743                TaskEvent::Cancelled { .. } => summary.cancellations += 1,
744                TaskEvent::Yield { .. } => summary.yields += 1,
745                TaskEvent::Wakeup { .. } => summary.wakeups += 1,
746                _ => {}
747            }
748        }
749
750        summary
751    }
752}
753
754// =============================================================================
755// Tests
756// =============================================================================
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn unit_trace_ordering() {
764        let mut trace = ScheduleTrace::new();
765
766        trace.spawn(1, 0, Some("task_a".to_string()));
767        trace.advance_tick();
768        trace.start(1);
769        trace.advance_tick();
770        trace.complete(1);
771
772        assert_eq!(trace.len(), 3);
773
774        // Verify ordering
775        let entries: Vec<_> = trace.entries().iter().collect();
776        assert_eq!(entries[0].seq, 0);
777        assert_eq!(entries[1].seq, 1);
778        assert_eq!(entries[2].seq, 2);
779        assert_eq!(entries[0].tick, 0);
780        assert_eq!(entries[1].tick, 1);
781        assert_eq!(entries[2].tick, 2);
782    }
783
784    #[test]
785    fn unit_trace_hash_stable() {
786        // Create identical traces and verify they produce the same hash
787        let mut trace1 = ScheduleTrace::new();
788        let mut trace2 = ScheduleTrace::new();
789
790        for trace in [&mut trace1, &mut trace2] {
791            trace.spawn(1, 0, None);
792            trace.advance_tick();
793            trace.start(1);
794            trace.advance_tick();
795            trace.spawn(2, 1, None);
796            trace.advance_tick();
797            trace.complete(1);
798            trace.start(2);
799            trace.advance_tick();
800            trace.cancel(2, CancelReason::UserRequest);
801        }
802
803        assert_eq!(trace1.checksum(), trace2.checksum());
804        assert_eq!(trace1.checksum_hex(), trace2.checksum_hex());
805    }
806
807    #[test]
808    fn unit_hash_differs_on_order_change() {
809        let mut trace1 = ScheduleTrace::new();
810        trace1.spawn(1, 0, None);
811        trace1.spawn(2, 0, None);
812
813        let mut trace2 = ScheduleTrace::new();
814        trace2.spawn(2, 0, None);
815        trace2.spawn(1, 0, None);
816
817        assert_ne!(trace1.checksum(), trace2.checksum());
818    }
819
820    #[test]
821    fn unit_jsonl_format() {
822        let mut trace = ScheduleTrace::new();
823        trace.spawn(1, 0, Some("test".to_string()));
824
825        let jsonl = trace.to_jsonl();
826        assert!(jsonl.contains("\"event\":\"spawn\""));
827        assert!(jsonl.contains("\"task_id\":1"));
828        assert!(jsonl.contains("\"name\":\"test\""));
829    }
830
831    #[test]
832    fn unit_summary_counts() {
833        let mut trace = ScheduleTrace::new();
834
835        trace.spawn(1, 0, None);
836        trace.spawn(2, 0, None);
837        trace.start(1);
838        trace.complete(1);
839        trace.start(2);
840        trace.cancel(2, CancelReason::Timeout);
841
842        let summary = trace.summary();
843        assert_eq!(summary.total_events, 6);
844        assert_eq!(summary.spawns, 2);
845        assert_eq!(summary.completes, 1);
846        assert_eq!(summary.cancellations, 1);
847    }
848
849    #[test]
850    fn unit_golden_compare_match() {
851        let mut trace = ScheduleTrace::new();
852        trace.spawn(1, 0, None);
853        trace.complete(1);
854
855        let expected = trace.checksum();
856        let result = compare_golden(&trace, expected);
857        assert!(result.is_match());
858    }
859
860    #[test]
861    fn unit_golden_compare_mismatch() {
862        let mut trace = ScheduleTrace::new();
863        trace.spawn(1, 0, None);
864
865        let result = compare_golden(&trace, 0xDEADBEEF);
866        assert!(!result.is_match());
867
868        match result {
869            GoldenCompareResult::Mismatch { expected, actual } => {
870                assert_eq!(expected, 0xDEADBEEF);
871                assert_ne!(actual, 0xDEADBEEF);
872            }
873            _ => unreachable!("Expected mismatch"),
874        }
875    }
876
877    #[test]
878    fn unit_isomorphism_proof_json() {
879        let proof = IsomorphismProof::new("Optimized scheduler loop", 0x1234, 0x5678)
880            .with_invariant("All tasks complete in same order")
881            .with_invariant("No task starves")
882            .with_justification("Loop unrolling only affects timing, not ordering");
883
884        let json = proof.to_json();
885        assert!(json.contains("Optimized scheduler loop"));
886        assert!(json.contains("0000000000001234"));
887        assert!(json.contains("0000000000005678"));
888    }
889
890    #[test]
891    fn unit_max_entries_enforced() {
892        let config = TraceConfig {
893            max_entries: 3,
894            ..Default::default()
895        };
896        let mut trace = ScheduleTrace::with_config(config);
897
898        for i in 0..10 {
899            trace.spawn(i, 0, None);
900        }
901
902        assert_eq!(trace.len(), 3);
903
904        // Should have the last 3 entries (task_id 7, 8, 9)
905        let entries: Vec<_> = trace.entries().iter().collect();
906        if let TaskEvent::Spawn { task_id, .. } = &entries[0].event {
907            assert_eq!(*task_id, 7);
908        }
909    }
910
911    #[test]
912    fn unit_clear_resets_state() {
913        let mut trace = ScheduleTrace::new();
914        trace.spawn(1, 0, None);
915        trace.spawn(2, 0, None);
916
917        trace.clear();
918
919        assert!(trace.is_empty());
920        assert_eq!(trace.len(), 0);
921    }
922
923    #[test]
924    fn unit_wakeup_reasons() {
925        let mut trace = ScheduleTrace::new();
926
927        trace.record(TaskEvent::Wakeup {
928            task_id: 1,
929            reason: WakeupReason::Timer,
930        });
931        trace.record(TaskEvent::Wakeup {
932            task_id: 2,
933            reason: WakeupReason::Dependency { task_id: 1 },
934        });
935        trace.record(TaskEvent::Wakeup {
936            task_id: 3,
937            reason: WakeupReason::IoReady,
938        });
939
940        let jsonl = trace.to_jsonl();
941        assert!(jsonl.contains("\"reason\":\"timer\""));
942        assert!(jsonl.contains("\"reason\":\"dependency:1\""));
943        assert!(jsonl.contains("\"reason\":\"io_ready\""));
944    }
945
946    #[test]
947    fn unit_auto_snapshot_with_sampling_records_queue() {
948        let config = TraceConfig {
949            auto_snapshot: true,
950            snapshot_sampling: Some(VoiConfig {
951                max_interval_events: 1,
952                sample_cost: 1.0,
953                ..Default::default()
954            }),
955            snapshot_change_threshold: 1,
956            ..Default::default()
957        };
958        let mut trace = ScheduleTrace::with_config(config);
959        let now = Instant::now();
960
961        trace.record_with_queue_state_at(
962            TaskEvent::Spawn {
963                task_id: 1,
964                priority: 0,
965                name: None,
966            },
967            3,
968            1,
969            now,
970        );
971
972        assert!(
973            trace
974                .entries()
975                .iter()
976                .any(|entry| matches!(entry.event, TaskEvent::QueueSnapshot { .. }))
977        );
978        let summary = trace.snapshot_sampling_summary().expect("sampling enabled");
979        assert_eq!(summary.total_samples, 1);
980    }
981
982    #[test]
983    fn unit_cancel_reasons() {
984        let mut trace = ScheduleTrace::new();
985
986        trace.cancel(1, CancelReason::UserRequest);
987        trace.cancel(2, CancelReason::Timeout);
988        trace.cancel(
989            3,
990            CancelReason::HazardPolicy {
991                expected_loss: 0.75,
992            },
993        );
994
995        let jsonl = trace.to_jsonl();
996        assert!(jsonl.contains("\"reason\":\"user_request\""));
997        assert!(jsonl.contains("\"reason\":\"timeout\""));
998        assert!(jsonl.contains("\"reason\":\"hazard_policy:0.7500\""));
999    }
1000
1001    #[test]
1002    fn unit_policy_change() {
1003        let mut trace = ScheduleTrace::new();
1004
1005        trace.record(TaskEvent::PolicyChange {
1006            from: SchedulerPolicy::Fifo,
1007            to: SchedulerPolicy::Priority,
1008        });
1009
1010        let jsonl = trace.to_jsonl();
1011        assert!(jsonl.contains("\"from\":\"fifo\""));
1012        assert!(jsonl.contains("\"to\":\"priority\""));
1013    }
1014
1015    // ── TraceConfig defaults ────────────────────────────────────────────
1016
1017    #[test]
1018    fn trace_config_default_values() {
1019        let config = TraceConfig::default();
1020        assert_eq!(config.max_entries, 10_000);
1021        assert!(!config.auto_snapshot);
1022        assert!(config.snapshot_sampling.is_none());
1023        assert_eq!(config.snapshot_change_threshold, 1);
1024        assert_eq!(config.seed, 0);
1025    }
1026
1027    // ── ScheduleTrace constructors ──────────────────────────────────────
1028
1029    #[test]
1030    fn schedule_trace_default_impl() {
1031        let trace = ScheduleTrace::default();
1032        assert!(trace.is_empty());
1033        assert_eq!(trace.len(), 0);
1034        assert_eq!(trace.tick(), 0);
1035    }
1036
1037    #[test]
1038    fn with_config_unlimited_entries() {
1039        let config = TraceConfig {
1040            max_entries: 0,
1041            ..Default::default()
1042        };
1043        let mut trace = ScheduleTrace::with_config(config);
1044        for i in 0..50 {
1045            trace.spawn(i, 0, None);
1046        }
1047        assert_eq!(trace.len(), 50);
1048    }
1049
1050    // ── Tick management ─────────────────────────────────────────────────
1051
1052    #[test]
1053    fn set_tick_explicit() {
1054        let mut trace = ScheduleTrace::new();
1055        assert_eq!(trace.tick(), 0);
1056        trace.set_tick(42);
1057        assert_eq!(trace.tick(), 42);
1058        trace.advance_tick();
1059        assert_eq!(trace.tick(), 43);
1060    }
1061
1062    #[test]
1063    fn advance_tick_increments() {
1064        let mut trace = ScheduleTrace::new();
1065        trace.advance_tick();
1066        trace.advance_tick();
1067        trace.advance_tick();
1068        assert_eq!(trace.tick(), 3);
1069    }
1070
1071    // ── record_with_queue_state_at without auto_snapshot ─────────────────
1072
1073    #[test]
1074    fn record_with_queue_state_no_auto_snapshot() {
1075        let config = TraceConfig {
1076            auto_snapshot: false,
1077            ..Default::default()
1078        };
1079        let mut trace = ScheduleTrace::with_config(config);
1080        let now = Instant::now();
1081        trace.record_with_queue_state_at(TaskEvent::Start { task_id: 1 }, 5, 2, now);
1082        // Only the Start event, no snapshot
1083        assert_eq!(trace.len(), 1);
1084        assert!(matches!(
1085            trace.entries().front().unwrap().event,
1086            TaskEvent::Start { task_id: 1 }
1087        ));
1088    }
1089
1090    // ── snapshot_sampling_summary / logs when no sampler ─────────────────
1091
1092    #[test]
1093    fn snapshot_sampling_summary_none_without_sampler() {
1094        let trace = ScheduleTrace::new();
1095        assert!(trace.snapshot_sampling_summary().is_none());
1096    }
1097
1098    #[test]
1099    fn snapshot_sampling_logs_none_without_sampler() {
1100        let trace = ScheduleTrace::new();
1101        assert!(trace.snapshot_sampling_logs_jsonl().is_none());
1102    }
1103
1104    #[test]
1105    fn snapshot_sampling_logs_some_with_sampler() {
1106        let config = TraceConfig {
1107            auto_snapshot: true,
1108            snapshot_sampling: Some(VoiConfig::default()),
1109            ..Default::default()
1110        };
1111        let trace = ScheduleTrace::with_config(config);
1112        assert!(trace.snapshot_sampling_logs_jsonl().is_some());
1113    }
1114
1115    // ── clear resets sequence counter ────────────────────────────────────
1116
1117    #[test]
1118    fn clear_resets_seq_counter() {
1119        let mut trace = ScheduleTrace::new();
1120        trace.spawn(1, 0, None);
1121        trace.spawn(2, 0, None);
1122        assert_eq!(trace.entries().back().unwrap().seq, 1);
1123
1124        trace.clear();
1125        trace.spawn(3, 0, None);
1126        // After clear, seq restarts from 0
1127        assert_eq!(trace.entries().front().unwrap().seq, 0);
1128    }
1129
1130    #[test]
1131    fn clear_resets_sampler() {
1132        let config = TraceConfig {
1133            auto_snapshot: true,
1134            snapshot_sampling: Some(VoiConfig {
1135                max_interval_events: 1,
1136                sample_cost: 1.0,
1137                ..Default::default()
1138            }),
1139            ..Default::default()
1140        };
1141        let mut trace = ScheduleTrace::with_config(config);
1142        let now = Instant::now();
1143        trace.record_with_queue_state_at(
1144            TaskEvent::Spawn {
1145                task_id: 1,
1146                priority: 0,
1147                name: None,
1148            },
1149            3,
1150            1,
1151            now,
1152        );
1153        trace.clear();
1154        assert!(trace.is_empty());
1155        // Sampler still exists but is fresh
1156        let summary = trace.snapshot_sampling_summary().unwrap();
1157        assert_eq!(summary.total_samples, 0);
1158    }
1159
1160    // ── Checksum edge cases ─────────────────────────────────────────────
1161
1162    #[test]
1163    fn checksum_empty_trace() {
1164        let trace = ScheduleTrace::new();
1165        // FNV-1a offset basis for empty input
1166        assert_eq!(trace.checksum(), 0xcbf29ce484222325);
1167    }
1168
1169    #[test]
1170    fn checksum_hex_format() {
1171        let trace = ScheduleTrace::new();
1172        let hex = trace.checksum_hex();
1173        assert_eq!(hex.len(), 16);
1174        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
1175    }
1176
1177    #[test]
1178    fn checksum_differs_for_different_events() {
1179        let mut t1 = ScheduleTrace::new();
1180        t1.spawn(1, 0, None);
1181
1182        let mut t2 = ScheduleTrace::new();
1183        t2.start(1);
1184
1185        assert_ne!(t1.checksum(), t2.checksum());
1186    }
1187
1188    // ── GoldenCompareResult ─────────────────────────────────────────────
1189
1190    #[test]
1191    fn golden_missing_golden_variant() {
1192        let result = GoldenCompareResult::MissingGolden;
1193        assert!(!result.is_match());
1194    }
1195
1196    #[test]
1197    fn golden_match_variant() {
1198        assert!(GoldenCompareResult::Match.is_match());
1199    }
1200
1201    // ── SchedulerPolicy Display ─────────────────────────────────────────
1202
1203    #[test]
1204    fn scheduler_policy_display_all_variants() {
1205        assert_eq!(format!("{}", SchedulerPolicy::Fifo), "fifo");
1206        assert_eq!(format!("{}", SchedulerPolicy::Priority), "priority");
1207        assert_eq!(
1208            format!("{}", SchedulerPolicy::ShortestFirst),
1209            "shortest_first"
1210        );
1211        assert_eq!(format!("{}", SchedulerPolicy::RoundRobin), "round_robin");
1212        assert_eq!(
1213            format!("{}", SchedulerPolicy::WeightedFair),
1214            "weighted_fair"
1215        );
1216    }
1217
1218    // ── TraceSummary coverage ───────────────────────────────────────────
1219
1220    #[test]
1221    fn summary_yields_wakeups_failures() {
1222        let mut trace = ScheduleTrace::new();
1223        trace.spawn(1, 0, None);
1224        trace.start(1);
1225        trace.record(TaskEvent::Yield { task_id: 1 });
1226        trace.record(TaskEvent::Wakeup {
1227            task_id: 1,
1228            reason: WakeupReason::Timer,
1229        });
1230        trace.record(TaskEvent::Failed {
1231            task_id: 1,
1232            error: "oops".to_string(),
1233        });
1234
1235        let summary = trace.summary();
1236        assert_eq!(summary.yields, 1);
1237        assert_eq!(summary.wakeups, 1);
1238        assert_eq!(summary.failures, 1);
1239        assert_eq!(summary.spawns, 1);
1240        assert_eq!(summary.completes, 0);
1241        assert_eq!(summary.cancellations, 0);
1242    }
1243
1244    #[test]
1245    fn summary_tick_range() {
1246        let mut trace = ScheduleTrace::new();
1247        trace.set_tick(10);
1248        trace.spawn(1, 0, None);
1249        trace.set_tick(50);
1250        trace.complete(1);
1251
1252        let summary = trace.summary();
1253        assert_eq!(summary.first_tick, 10);
1254        assert_eq!(summary.last_tick, 50);
1255    }
1256
1257    #[test]
1258    fn summary_empty_trace() {
1259        let trace = ScheduleTrace::new();
1260        let summary = trace.summary();
1261        assert_eq!(summary.total_events, 0);
1262        assert_eq!(summary.first_tick, 0);
1263        assert_eq!(summary.last_tick, 0);
1264    }
1265
1266    #[test]
1267    fn trace_summary_default() {
1268        let summary = TraceSummary::default();
1269        assert_eq!(summary.total_events, 0);
1270        assert_eq!(summary.spawns, 0);
1271        assert_eq!(summary.checksum, 0);
1272    }
1273
1274    // ── JSONL format for uncovered event types ──────────────────────────
1275
1276    #[test]
1277    fn jsonl_yield_event() {
1278        let mut trace = ScheduleTrace::new();
1279        trace.record(TaskEvent::Yield { task_id: 7 });
1280        let jsonl = trace.to_jsonl();
1281        assert!(jsonl.contains("\"event\":\"yield\""));
1282        assert!(jsonl.contains("\"task_id\":7"));
1283    }
1284
1285    #[test]
1286    fn jsonl_failed_event() {
1287        let mut trace = ScheduleTrace::new();
1288        trace.record(TaskEvent::Failed {
1289            task_id: 3,
1290            error: "timeout".to_string(),
1291        });
1292        let jsonl = trace.to_jsonl();
1293        assert!(jsonl.contains("\"event\":\"failed\""));
1294        assert!(jsonl.contains("\"error\":\"timeout\""));
1295    }
1296
1297    #[test]
1298    fn jsonl_custom_event() {
1299        let mut trace = ScheduleTrace::new();
1300        trace.record(TaskEvent::Custom {
1301            tag: "metric".to_string(),
1302            data: "cpu=42".to_string(),
1303        });
1304        let jsonl = trace.to_jsonl();
1305        assert!(jsonl.contains("\"event\":\"custom\""));
1306        assert!(jsonl.contains("\"tag\":\"metric\""));
1307        assert!(jsonl.contains("\"data\":\"cpu=42\""));
1308    }
1309
1310    #[test]
1311    fn jsonl_queue_snapshot_event() {
1312        let mut trace = ScheduleTrace::new();
1313        trace.record(TaskEvent::QueueSnapshot {
1314            queued: 5,
1315            running: 2,
1316        });
1317        let jsonl = trace.to_jsonl();
1318        assert!(jsonl.contains("\"event\":\"queue_snapshot\""));
1319        assert!(jsonl.contains("\"queued\":5"));
1320        assert!(jsonl.contains("\"running\":2"));
1321    }
1322
1323    #[test]
1324    fn jsonl_cancelled_event() {
1325        let mut trace = ScheduleTrace::new();
1326        trace.cancel(4, CancelReason::Shutdown);
1327        let jsonl = trace.to_jsonl();
1328        assert!(jsonl.contains("\"event\":\"cancelled\""));
1329        assert!(jsonl.contains("\"reason\":\"shutdown\""));
1330    }
1331
1332    #[test]
1333    fn jsonl_cancel_other_reason() {
1334        let mut trace = ScheduleTrace::new();
1335        trace.cancel(5, CancelReason::Other("oom".to_string()));
1336        let jsonl = trace.to_jsonl();
1337        assert!(jsonl.contains("\"reason\":\"other:oom\""));
1338    }
1339
1340    #[test]
1341    fn jsonl_spawn_without_name() {
1342        let mut trace = ScheduleTrace::new();
1343        trace.spawn(1, 3, None);
1344        let jsonl = trace.to_jsonl();
1345        assert!(jsonl.contains("\"name\":null"));
1346        assert!(jsonl.contains("\"priority\":3"));
1347    }
1348
1349    #[test]
1350    fn jsonl_complete_event() {
1351        let mut trace = ScheduleTrace::new();
1352        trace.complete(99);
1353        let jsonl = trace.to_jsonl();
1354        assert!(jsonl.contains("\"event\":\"complete\""));
1355        assert!(jsonl.contains("\"task_id\":99"));
1356    }
1357
1358    #[test]
1359    fn jsonl_start_event() {
1360        let mut trace = ScheduleTrace::new();
1361        trace.start(42);
1362        let jsonl = trace.to_jsonl();
1363        assert!(jsonl.contains("\"event\":\"start\""));
1364        assert!(jsonl.contains("\"task_id\":42"));
1365    }
1366
1367    #[test]
1368    fn jsonl_empty_trace() {
1369        let trace = ScheduleTrace::new();
1370        assert_eq!(trace.to_jsonl(), "");
1371    }
1372
1373    // ── Wakeup reasons JSONL ────────────────────────────────────────────
1374
1375    #[test]
1376    fn jsonl_wakeup_user_action() {
1377        let mut trace = ScheduleTrace::new();
1378        trace.record(TaskEvent::Wakeup {
1379            task_id: 1,
1380            reason: WakeupReason::UserAction,
1381        });
1382        let jsonl = trace.to_jsonl();
1383        assert!(jsonl.contains("\"reason\":\"user_action\""));
1384    }
1385
1386    #[test]
1387    fn jsonl_wakeup_explicit() {
1388        let mut trace = ScheduleTrace::new();
1389        trace.record(TaskEvent::Wakeup {
1390            task_id: 1,
1391            reason: WakeupReason::Explicit,
1392        });
1393        let jsonl = trace.to_jsonl();
1394        assert!(jsonl.contains("\"reason\":\"explicit\""));
1395    }
1396
1397    #[test]
1398    fn jsonl_wakeup_other() {
1399        let mut trace = ScheduleTrace::new();
1400        trace.record(TaskEvent::Wakeup {
1401            task_id: 1,
1402            reason: WakeupReason::Other("custom".to_string()),
1403        });
1404        let jsonl = trace.to_jsonl();
1405        assert!(jsonl.contains("\"reason\":\"other:custom\""));
1406    }
1407
1408    // ── IsomorphismProof ────────────────────────────────────────────────
1409
1410    #[test]
1411    fn isomorphism_proof_with_approval() {
1412        let mut proof = IsomorphismProof::new("test change", 0xAA, 0xBB);
1413        proof.approved_by = Some("reviewer".to_string());
1414        proof.approved_at = Some("2026-01-01".to_string());
1415
1416        let json = proof.to_json();
1417        assert!(json.contains("\"approved_by\":\"reviewer\""));
1418        assert!(json.contains("\"approved_at\":\"2026-01-01\""));
1419    }
1420
1421    #[test]
1422    fn isomorphism_proof_without_approval() {
1423        let proof = IsomorphismProof::new("refactor", 0x11, 0x22);
1424        let json = proof.to_json();
1425        assert!(json.contains("\"approved_by\":null"));
1426        assert!(json.contains("\"approved_at\":null"));
1427    }
1428
1429    #[test]
1430    fn isomorphism_proof_builder_chain() {
1431        let proof = IsomorphismProof::new("change", 1, 2)
1432            .with_invariant("ordering preserved")
1433            .with_invariant("no data loss")
1434            .with_justification("pure refactor");
1435
1436        assert_eq!(proof.preserved_invariants.len(), 2);
1437        assert_eq!(proof.justification, "pure refactor");
1438        let json = proof.to_json();
1439        assert!(json.contains("ordering preserved"));
1440        assert!(json.contains("no data loss"));
1441        assert!(json.contains("pure refactor"));
1442    }
1443
1444    // ── TraceEntry to_jsonl seq/tick ────────────────────────────────────
1445
1446    #[test]
1447    fn trace_entry_jsonl_includes_seq_tick() {
1448        let mut trace = ScheduleTrace::new();
1449        trace.set_tick(7);
1450        trace.spawn(1, 0, None);
1451        let jsonl = trace.to_jsonl();
1452        assert!(jsonl.contains("\"seq\":0"));
1453        assert!(jsonl.contains("\"tick\":7"));
1454    }
1455
1456    // ── Multiple events JSONL produces one line per entry ────────────────
1457
1458    #[test]
1459    fn jsonl_multiple_entries_newline_separated() {
1460        let mut trace = ScheduleTrace::new();
1461        trace.spawn(1, 0, None);
1462        trace.start(1);
1463        trace.complete(1);
1464
1465        let jsonl = trace.to_jsonl();
1466        let lines: Vec<_> = jsonl.lines().collect();
1467        assert_eq!(lines.len(), 3);
1468    }
1469
1470    // ── snapshot_change_threshold ────────────────────────────────────────
1471
1472    #[test]
1473    fn auto_snapshot_no_violation_below_threshold() {
1474        let config = TraceConfig {
1475            auto_snapshot: true,
1476            snapshot_sampling: Some(VoiConfig {
1477                max_interval_events: 1,
1478                sample_cost: 1.0,
1479                ..Default::default()
1480            }),
1481            snapshot_change_threshold: 10,
1482            ..Default::default()
1483        };
1484        let mut trace = ScheduleTrace::with_config(config);
1485        let now = Instant::now();
1486
1487        // First event sets last_snapshot
1488        trace.record_with_queue_state_at(
1489            TaskEvent::Spawn {
1490                task_id: 1,
1491                priority: 0,
1492                name: None,
1493            },
1494            5,
1495            1,
1496            now,
1497        );
1498        // Second event: delta = |5-6| + |1-1| = 1, below threshold of 10
1499        trace.record_with_queue_state_at(TaskEvent::Start { task_id: 1 }, 6, 1, now);
1500
1501        // Should have recorded snapshots (sampling says yes) but observe with violated=false
1502        let summary = trace.snapshot_sampling_summary().unwrap();
1503        assert_eq!(summary.total_samples, 2);
1504    }
1505
1506    // ── event_to_bytes covers PolicyChange discriminant ──────────────────
1507
1508    #[test]
1509    fn checksum_includes_policy_change() {
1510        let mut t1 = ScheduleTrace::new();
1511        t1.record(TaskEvent::PolicyChange {
1512            from: SchedulerPolicy::Fifo,
1513            to: SchedulerPolicy::Priority,
1514        });
1515
1516        let mut t2 = ScheduleTrace::new();
1517        t2.record(TaskEvent::PolicyChange {
1518            from: SchedulerPolicy::Priority,
1519            to: SchedulerPolicy::Fifo,
1520        });
1521
1522        assert_ne!(t1.checksum(), t2.checksum());
1523    }
1524
1525    #[test]
1526    fn checksum_includes_custom_event_data() {
1527        let mut t1 = ScheduleTrace::new();
1528        t1.record(TaskEvent::Custom {
1529            tag: "a".to_string(),
1530            data: "1".to_string(),
1531        });
1532
1533        let mut t2 = ScheduleTrace::new();
1534        t2.record(TaskEvent::Custom {
1535            tag: "b".to_string(),
1536            data: "1".to_string(),
1537        });
1538
1539        assert_ne!(t1.checksum(), t2.checksum());
1540    }
1541}