1#![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#[derive(Debug, Clone, PartialEq)]
46pub enum TaskEvent {
47 Spawn {
49 task_id: u64,
50 priority: u8,
51 name: Option<String>,
52 },
53 Start { task_id: u64 },
55 Yield { task_id: u64 },
57 Wakeup { task_id: u64, reason: WakeupReason },
59 Complete { task_id: u64 },
61 Failed { task_id: u64, error: String },
63 Cancelled { task_id: u64, reason: CancelReason },
65 PolicyChange {
67 from: SchedulerPolicy,
68 to: SchedulerPolicy,
69 },
70 QueueSnapshot { queued: usize, running: usize },
72 Custom { tag: String, data: String },
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum WakeupReason {
79 Timer,
81 IoReady,
83 Dependency { task_id: u64 },
85 UserAction,
87 Explicit,
89 Other(String),
91}
92
93#[derive(Debug, Clone, PartialEq)]
95pub enum CancelReason {
96 UserRequest,
98 Timeout,
100 HazardPolicy { expected_loss: f64 },
102 Shutdown,
104 Other(String),
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum SchedulerPolicy {
111 Fifo,
113 Priority,
115 ShortestFirst,
117 RoundRobin,
119 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#[derive(Debug, Clone)]
141pub struct TraceEntry {
142 pub seq: u64,
144 pub tick: u64,
146 pub event: TaskEvent,
148}
149
150impl TraceEntry {
151 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#[derive(Debug, Clone)]
234pub struct TraceConfig {
235 pub max_entries: usize,
237 pub auto_snapshot: bool,
241 pub snapshot_sampling: Option<VoiConfig>,
243 pub snapshot_change_threshold: usize,
245 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#[derive(Debug, Clone)]
263pub struct ScheduleTrace {
264 config: TraceConfig,
266 entries: VecDeque<TraceEntry>,
268 seq: u64,
270 tick: u64,
272 snapshot_sampler: Option<VoiSampler>,
274 last_snapshot: Option<(usize, usize)>,
276}
277
278impl ScheduleTrace {
279 #[must_use]
281 pub fn new() -> Self {
282 Self::with_config(TraceConfig::default())
283 }
284
285 #[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 pub fn advance_tick(&mut self) {
306 self.tick += 1;
307 }
308
309 pub fn set_tick(&mut self, tick: u64) {
311 self.tick = tick;
312 }
313
314 #[must_use]
316 pub fn tick(&self) -> u64 {
317 self.tick
318 }
319
320 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 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 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 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 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 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 pub fn start(&mut self, task_id: u64) {
393 self.record(TaskEvent::Start { task_id });
394 }
395
396 pub fn complete(&mut self, task_id: u64) {
398 self.record(TaskEvent::Complete { task_id });
399 }
400
401 pub fn cancel(&mut self, task_id: u64, reason: CancelReason) {
403 self.record(TaskEvent::Cancelled { task_id, reason });
404 }
405
406 #[must_use]
408 pub fn entries(&self) -> &VecDeque<TraceEntry> {
409 &self.entries
410 }
411
412 #[must_use]
414 pub fn len(&self) -> usize {
415 self.entries.len()
416 }
417
418 #[must_use]
420 pub fn is_empty(&self) -> bool {
421 self.entries.is_empty()
422 }
423
424 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 #[must_use]
437 pub fn snapshot_sampling_summary(&self) -> Option<VoiSummary> {
438 self.snapshot_sampler.as_ref().map(VoiSampler::summary)
439 }
440
441 #[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 #[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 #[must_use]
463 pub fn checksum(&self) -> u64 {
464 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 for byte in entry.seq.to_le_bytes() {
473 hash ^= byte as u64;
474 hash = hash.wrapping_mul(FNV_PRIME);
475 }
476
477 for byte in entry.tick.to_le_bytes() {
479 hash ^= byte as u64;
480 hash = hash.wrapping_mul(FNV_PRIME);
481 }
482
483 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 #[must_use]
496 pub fn checksum_hex(&self) -> String {
497 format!("{:016x}", self.checksum())
498 }
499
500 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); 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#[derive(Debug, Clone, PartialEq, Eq)]
570pub enum GoldenCompareResult {
571 Match,
573 Mismatch { expected: u64, actual: u64 },
575 MissingGolden,
577}
578
579impl GoldenCompareResult {
580 #[must_use]
582 pub fn is_match(&self) -> bool {
583 matches!(self, Self::Match)
584 }
585}
586
587#[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#[derive(Debug, Clone)]
607pub struct IsomorphismProof {
608 pub change_description: String,
610 pub old_checksum: u64,
612 pub new_checksum: u64,
614 pub preserved_invariants: Vec<String>,
616 pub justification: String,
618 pub approved_by: Option<String>,
620 pub approved_at: Option<String>,
622}
623
624impl IsomorphismProof {
625 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 pub fn with_invariant(mut self, invariant: impl Into<String>) -> Self {
644 self.preserved_invariants.push(invariant.into());
645 self
646 }
647
648 pub fn with_justification(mut self, justification: impl Into<String>) -> Self {
650 self.justification = justification.into();
651 self
652 }
653
654 #[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#[derive(Debug, Clone, Default)]
696pub struct TraceSummary {
697 pub total_events: usize,
699 pub spawns: usize,
701 pub completes: usize,
703 pub failures: usize,
705 pub cancellations: usize,
707 pub yields: usize,
709 pub wakeups: usize,
711 pub first_tick: u64,
713 pub last_tick: u64,
715 pub checksum: u64,
717}
718
719impl ScheduleTrace {
720 #[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#[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 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 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 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}