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 std::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    pub fn with_invariant(mut self, invariant: impl Into<String>) -> Self {
644        self.preserved_invariants.push(invariant.into());
645        self
646    }
647
648    /// Add justification.
649    pub fn with_justification(mut self, justification: impl Into<String>) -> Self {
650        self.justification = justification.into();
651        self
652    }
653
654    /// Export to JSON.
655    #[must_use]
656    pub fn to_json(&self) -> String {
657        let invariants = self
658            .preserved_invariants
659            .iter()
660            .map(|i| format!("\"{}\"", i))
661            .collect::<Vec<_>>()
662            .join(",");
663
664        let old_checksum = format!("{:016x}", self.old_checksum);
665        let new_checksum = format!("{:016x}", self.new_checksum);
666        let approved_by = self
667            .approved_by
668            .as_ref()
669            .map(|s| format!("\"{}\"", s))
670            .unwrap_or_else(|| "null".to_string());
671        let approved_at = self
672            .approved_at
673            .as_ref()
674            .map(|s| format!("\"{}\"", s))
675            .unwrap_or_else(|| "null".to_string());
676
677        format!(
678            r#"{{"change":"{}","old_checksum":"{}","new_checksum":"{}","invariants":[{}],"justification":"{}","approved_by":{},"approved_at":{}}}"#,
679            self.change_description,
680            old_checksum,
681            new_checksum,
682            invariants,
683            self.justification,
684            approved_by,
685            approved_at,
686        )
687    }
688}
689
690// =============================================================================
691// Trace Summary
692// =============================================================================
693
694/// Summary statistics for a trace.
695#[derive(Debug, Clone, Default)]
696pub struct TraceSummary {
697    /// Total events.
698    pub total_events: usize,
699    /// Spawn events.
700    pub spawns: usize,
701    /// Complete events.
702    pub completes: usize,
703    /// Failed events.
704    pub failures: usize,
705    /// Cancelled events.
706    pub cancellations: usize,
707    /// Yield events.
708    pub yields: usize,
709    /// Wakeup events.
710    pub wakeups: usize,
711    /// First tick.
712    pub first_tick: u64,
713    /// Last tick.
714    pub last_tick: u64,
715    /// Checksum.
716    pub checksum: u64,
717}
718
719impl ScheduleTrace {
720    /// Generate summary statistics.
721    #[must_use]
722    pub fn summary(&self) -> TraceSummary {
723        let mut summary = TraceSummary {
724            total_events: self.entries.len(),
725            checksum: self.checksum(),
726            ..Default::default()
727        };
728
729        if let Some(first) = self.entries.front() {
730            summary.first_tick = first.tick;
731        }
732        if let Some(last) = self.entries.back() {
733            summary.last_tick = last.tick;
734        }
735
736        for entry in &self.entries {
737            match &entry.event {
738                TaskEvent::Spawn { .. } => summary.spawns += 1,
739                TaskEvent::Complete { .. } => summary.completes += 1,
740                TaskEvent::Failed { .. } => summary.failures += 1,
741                TaskEvent::Cancelled { .. } => summary.cancellations += 1,
742                TaskEvent::Yield { .. } => summary.yields += 1,
743                TaskEvent::Wakeup { .. } => summary.wakeups += 1,
744                _ => {}
745            }
746        }
747
748        summary
749    }
750}
751
752// =============================================================================
753// Tests
754// =============================================================================
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn unit_trace_ordering() {
762        let mut trace = ScheduleTrace::new();
763
764        trace.spawn(1, 0, Some("task_a".to_string()));
765        trace.advance_tick();
766        trace.start(1);
767        trace.advance_tick();
768        trace.complete(1);
769
770        assert_eq!(trace.len(), 3);
771
772        // Verify ordering
773        let entries: Vec<_> = trace.entries().iter().collect();
774        assert_eq!(entries[0].seq, 0);
775        assert_eq!(entries[1].seq, 1);
776        assert_eq!(entries[2].seq, 2);
777        assert_eq!(entries[0].tick, 0);
778        assert_eq!(entries[1].tick, 1);
779        assert_eq!(entries[2].tick, 2);
780    }
781
782    #[test]
783    fn unit_trace_hash_stable() {
784        // Create identical traces and verify they produce the same hash
785        let mut trace1 = ScheduleTrace::new();
786        let mut trace2 = ScheduleTrace::new();
787
788        for trace in [&mut trace1, &mut trace2] {
789            trace.spawn(1, 0, None);
790            trace.advance_tick();
791            trace.start(1);
792            trace.advance_tick();
793            trace.spawn(2, 1, None);
794            trace.advance_tick();
795            trace.complete(1);
796            trace.start(2);
797            trace.advance_tick();
798            trace.cancel(2, CancelReason::UserRequest);
799        }
800
801        assert_eq!(trace1.checksum(), trace2.checksum());
802        assert_eq!(trace1.checksum_hex(), trace2.checksum_hex());
803    }
804
805    #[test]
806    fn unit_hash_differs_on_order_change() {
807        let mut trace1 = ScheduleTrace::new();
808        trace1.spawn(1, 0, None);
809        trace1.spawn(2, 0, None);
810
811        let mut trace2 = ScheduleTrace::new();
812        trace2.spawn(2, 0, None);
813        trace2.spawn(1, 0, None);
814
815        assert_ne!(trace1.checksum(), trace2.checksum());
816    }
817
818    #[test]
819    fn unit_jsonl_format() {
820        let mut trace = ScheduleTrace::new();
821        trace.spawn(1, 0, Some("test".to_string()));
822
823        let jsonl = trace.to_jsonl();
824        assert!(jsonl.contains("\"event\":\"spawn\""));
825        assert!(jsonl.contains("\"task_id\":1"));
826        assert!(jsonl.contains("\"name\":\"test\""));
827    }
828
829    #[test]
830    fn unit_summary_counts() {
831        let mut trace = ScheduleTrace::new();
832
833        trace.spawn(1, 0, None);
834        trace.spawn(2, 0, None);
835        trace.start(1);
836        trace.complete(1);
837        trace.start(2);
838        trace.cancel(2, CancelReason::Timeout);
839
840        let summary = trace.summary();
841        assert_eq!(summary.total_events, 6);
842        assert_eq!(summary.spawns, 2);
843        assert_eq!(summary.completes, 1);
844        assert_eq!(summary.cancellations, 1);
845    }
846
847    #[test]
848    fn unit_golden_compare_match() {
849        let mut trace = ScheduleTrace::new();
850        trace.spawn(1, 0, None);
851        trace.complete(1);
852
853        let expected = trace.checksum();
854        let result = compare_golden(&trace, expected);
855        assert!(result.is_match());
856    }
857
858    #[test]
859    fn unit_golden_compare_mismatch() {
860        let mut trace = ScheduleTrace::new();
861        trace.spawn(1, 0, None);
862
863        let result = compare_golden(&trace, 0xDEADBEEF);
864        assert!(!result.is_match());
865
866        match result {
867            GoldenCompareResult::Mismatch { expected, actual } => {
868                assert_eq!(expected, 0xDEADBEEF);
869                assert_ne!(actual, 0xDEADBEEF);
870            }
871            _ => unreachable!("Expected mismatch"),
872        }
873    }
874
875    #[test]
876    fn unit_isomorphism_proof_json() {
877        let proof = IsomorphismProof::new("Optimized scheduler loop", 0x1234, 0x5678)
878            .with_invariant("All tasks complete in same order")
879            .with_invariant("No task starves")
880            .with_justification("Loop unrolling only affects timing, not ordering");
881
882        let json = proof.to_json();
883        assert!(json.contains("Optimized scheduler loop"));
884        assert!(json.contains("0000000000001234"));
885        assert!(json.contains("0000000000005678"));
886    }
887
888    #[test]
889    fn unit_max_entries_enforced() {
890        let config = TraceConfig {
891            max_entries: 3,
892            ..Default::default()
893        };
894        let mut trace = ScheduleTrace::with_config(config);
895
896        for i in 0..10 {
897            trace.spawn(i, 0, None);
898        }
899
900        assert_eq!(trace.len(), 3);
901
902        // Should have the last 3 entries (task_id 7, 8, 9)
903        let entries: Vec<_> = trace.entries().iter().collect();
904        if let TaskEvent::Spawn { task_id, .. } = &entries[0].event {
905            assert_eq!(*task_id, 7);
906        }
907    }
908
909    #[test]
910    fn unit_clear_resets_state() {
911        let mut trace = ScheduleTrace::new();
912        trace.spawn(1, 0, None);
913        trace.spawn(2, 0, None);
914
915        trace.clear();
916
917        assert!(trace.is_empty());
918        assert_eq!(trace.len(), 0);
919    }
920
921    #[test]
922    fn unit_wakeup_reasons() {
923        let mut trace = ScheduleTrace::new();
924
925        trace.record(TaskEvent::Wakeup {
926            task_id: 1,
927            reason: WakeupReason::Timer,
928        });
929        trace.record(TaskEvent::Wakeup {
930            task_id: 2,
931            reason: WakeupReason::Dependency { task_id: 1 },
932        });
933        trace.record(TaskEvent::Wakeup {
934            task_id: 3,
935            reason: WakeupReason::IoReady,
936        });
937
938        let jsonl = trace.to_jsonl();
939        assert!(jsonl.contains("\"reason\":\"timer\""));
940        assert!(jsonl.contains("\"reason\":\"dependency:1\""));
941        assert!(jsonl.contains("\"reason\":\"io_ready\""));
942    }
943
944    #[test]
945    fn unit_auto_snapshot_with_sampling_records_queue() {
946        let config = TraceConfig {
947            auto_snapshot: true,
948            snapshot_sampling: Some(VoiConfig {
949                max_interval_events: 1,
950                sample_cost: 1.0,
951                ..Default::default()
952            }),
953            snapshot_change_threshold: 1,
954            ..Default::default()
955        };
956        let mut trace = ScheduleTrace::with_config(config);
957        let now = Instant::now();
958
959        trace.record_with_queue_state_at(
960            TaskEvent::Spawn {
961                task_id: 1,
962                priority: 0,
963                name: None,
964            },
965            3,
966            1,
967            now,
968        );
969
970        assert!(
971            trace
972                .entries()
973                .iter()
974                .any(|entry| matches!(entry.event, TaskEvent::QueueSnapshot { .. }))
975        );
976        let summary = trace.snapshot_sampling_summary().expect("sampling enabled");
977        assert_eq!(summary.total_samples, 1);
978    }
979
980    #[test]
981    fn unit_cancel_reasons() {
982        let mut trace = ScheduleTrace::new();
983
984        trace.cancel(1, CancelReason::UserRequest);
985        trace.cancel(2, CancelReason::Timeout);
986        trace.cancel(
987            3,
988            CancelReason::HazardPolicy {
989                expected_loss: 0.75,
990            },
991        );
992
993        let jsonl = trace.to_jsonl();
994        assert!(jsonl.contains("\"reason\":\"user_request\""));
995        assert!(jsonl.contains("\"reason\":\"timeout\""));
996        assert!(jsonl.contains("\"reason\":\"hazard_policy:0.7500\""));
997    }
998
999    #[test]
1000    fn unit_policy_change() {
1001        let mut trace = ScheduleTrace::new();
1002
1003        trace.record(TaskEvent::PolicyChange {
1004            from: SchedulerPolicy::Fifo,
1005            to: SchedulerPolicy::Priority,
1006        });
1007
1008        let jsonl = trace.to_jsonl();
1009        assert!(jsonl.contains("\"from\":\"fifo\""));
1010        assert!(jsonl.contains("\"to\":\"priority\""));
1011    }
1012}