1#[cfg(target_os = "linux")]
10pub mod amd;
11#[cfg(any(all(feature = "apple", target_os = "macos"), test))]
16pub mod apple;
17pub mod backend;
18pub mod events;
19#[cfg(target_os = "linux")]
20pub mod intel;
21pub mod mock;
22pub mod model;
23#[cfg(all(feature = "nvidia", any(target_os = "linux", target_os = "windows")))]
24pub mod nvidia;
25#[cfg(target_os = "linux")]
26pub mod proc_meta;
27#[cfg(feature = "wddm")]
32pub mod wddm;
33
34pub use backend::{all_backends, BackendError, GpuBackend};
35pub use events::{Confidence, Event, EventEngine, EventKind, Severity};
36pub use model::{
37 fmt_bytes, normalize_pci_id, now_ms, DeviceId, DynamicSample, ProcessKind, ProcessSample,
38 StaticInfo, ThrottleReasons, Vendor,
39};
40#[cfg(target_os = "linux")]
41pub use proc_meta::{container_of, parse_cgroup, CpuTracker};
42
43#[cfg(test)]
44mod tests {
45 use super::*;
46
47 #[test]
48 fn mock_backend_produces_samples_and_processes() {
49 let mut b = mock::MockBackend::new();
50 let devs = b.devices();
51 assert_eq!(devs.len(), 2);
52 for d in &devs {
53 let info = b.static_info(d).unwrap();
54 assert!(info.mem_total_bytes.is_some());
55 let s = b.refresh_dynamic(d).unwrap();
56 assert!(s.util_pct.is_some());
57 let procs = b.refresh_processes(d).unwrap();
58 assert!(!procs.is_empty());
59 }
60 }
61
62 #[test]
63 fn event_engine_emits_throttle_and_process_events() {
64 let mut b = mock::MockBackend::new();
65 let devs = b.devices();
66 let mut engine = EventEngine::new();
67 for (i, d) in devs.iter().enumerate() {
68 engine.register_device(d.clone(), format!("GPU{i}"));
69 }
70 let infos: Vec<StaticInfo> = devs.iter().map(|d| b.static_info(d).unwrap()).collect();
71
72 let mut kinds = std::collections::HashSet::new();
73 for _ in 0..400 {
75 for (d, info) in devs.iter().zip(&infos) {
76 let s = b.refresh_dynamic(d).unwrap();
77 let p = b.refresh_processes(d).unwrap();
78 for e in engine.observe(d, &s, Some(&p), info.mem_total_bytes, info.temp_slowdown_c)
79 {
80 kinds.insert(e.kind);
81 }
82 }
83 }
84 assert!(
85 kinds.contains(&EventKind::ThrottleStart),
86 "no throttle event in 400 ticks"
87 );
88 assert!(
89 kinds.contains(&EventKind::ProcessAttached),
90 "no attach event in 400 ticks"
91 );
92 assert!(
93 kinds.contains(&EventKind::ProcessExited),
94 "no exit event in 400 ticks"
95 );
96 }
97
98 #[test]
99 fn first_process_observation_does_not_flood_events() {
100 let mut engine = EventEngine::new();
101 let dev = DeviceId("test".into());
102 let procs = vec![ProcessSample {
103 pid: 1,
104 name: "preexisting".into(),
105 kind: ProcessKind::Compute,
106 mem_bytes: Some(1024),
107 util_pct: None,
108 cpu_pct: None,
109 container: None,
110 }];
111 let sample = DynamicSample {
112 ts_ms: 1000,
113 util_pct: Some(50.0),
114 util_engine: None,
115 mem_used_bytes: Some(1024),
116 power_mw: None,
117 temp_c: None,
118 fan_pct: None,
119 sm_clock_mhz: None,
120 mem_clock_mhz: None,
121 encoder_pct: None,
122 decoder_pct: None,
123 throttle: Some(ThrottleReasons::default()),
124 };
125 let events = engine.observe(&dev, &sample, Some(&procs), Some(1 << 30), None);
126 assert!(
127 events.is_empty(),
128 "first observation must not narrate preexisting processes"
129 );
130 }
131
132 #[test]
137 fn unobservable_process_list_narrates_no_exit() {
138 let mk = |ts_ms: u64| DynamicSample {
139 ts_ms,
140 util_pct: Some(90.0),
141 util_engine: None,
142 mem_used_bytes: Some(20 << 30),
143 power_mw: None,
144 temp_c: None,
145 fan_pct: None,
146 sm_clock_mhz: None,
147 mem_clock_mhz: None,
148 encoder_pct: None,
149 decoder_pct: None,
150 throttle: Some(ThrottleReasons::default()),
151 };
152 let procs = vec![ProcessSample {
153 pid: 4521,
154 name: "python".into(),
155 kind: ProcessKind::Compute,
156 mem_bytes: Some(20 << 30),
157 util_pct: Some(96.0),
158 cpu_pct: None,
159 container: None,
160 }];
161 let dev = DeviceId("test".into());
162
163 let mut engine = EventEngine::new();
165 engine.register_device(dev.clone(), "GPU0".into());
166 engine.observe(&dev, &mk(1_000), Some(&procs), Some(1 << 35), None);
167 let seen = engine.observe(&dev, &mk(2_000), Some(&[]), Some(1 << 35), None);
168 assert!(
169 seen.iter().any(|e| e.kind == EventKind::ProcessExited),
170 "an observed empty list is a real exit and must narrate it"
171 );
172
173 let mut engine = EventEngine::new();
175 engine.register_device(dev.clone(), "GPU0".into());
176 engine.observe(&dev, &mk(1_000), Some(&procs), Some(1 << 35), None);
177 let blind = engine.observe(&dev, &mk(2_000), None, Some(1 << 35), None);
178 assert!(
179 !blind.iter().any(|e| e.kind == EventKind::ProcessExited),
180 "a failed probe must not narrate an exit: {blind:?}"
181 );
182
183 let back = engine.observe(&dev, &mk(3_000), Some(&procs), Some(1 << 35), None);
186 assert!(
187 !back.iter().any(|e| matches!(
188 e.kind,
189 EventKind::ProcessExited | EventKind::ProcessAttached
190 )),
191 "a process present before and after a blind tick neither left nor arrived: {back:?}"
192 );
193 }
194
195 #[test]
198 fn throttle_end_narrates_recovery_honestly() {
199 fn sample(ts_ms: u64, sm_clock_mhz: u32, thermal: bool) -> DynamicSample {
200 DynamicSample {
201 ts_ms,
202 util_pct: Some(90.0),
203 util_engine: None,
204 mem_used_bytes: Some(1 << 30),
205 power_mw: None,
206 temp_c: Some(80.0),
207 fan_pct: None,
208 sm_clock_mhz: Some(sm_clock_mhz),
209 mem_clock_mhz: None,
210 encoder_pct: None,
211 decoder_pct: None,
212 throttle: Some(ThrottleReasons {
213 thermal,
214 ..Default::default()
215 }),
216 }
217 }
218 let run = |end_clock: u32| -> Event {
219 let mut engine = EventEngine::new();
220 let dev = DeviceId("test".into());
221 engine.observe(
222 &dev,
223 &sample(1_000, 2400, false),
224 Some(&[]),
225 Some(1 << 34),
226 None,
227 );
228 engine.observe(
229 &dev,
230 &sample(2_000, 1800, true),
231 Some(&[]),
232 Some(1 << 34),
233 None,
234 );
235 let events = engine.observe(
236 &dev,
237 &sample(3_000, end_clock, false),
238 Some(&[]),
239 Some(1 << 34),
240 None,
241 );
242 assert_eq!(events.len(), 1, "throttle end must emit exactly one event");
243 assert_eq!(events[0].kind, EventKind::ThrottleEnd);
244 events[0].clone()
245 };
246
247 let recovered = run(2350);
249 assert!(
250 recovered.evidence.contains("recovered to 2350 MHz"),
251 "expected recovery narration, got: {}",
252 recovered.evidence
253 );
254
255 let idle_end = run(300);
257 assert!(
258 !idle_end.evidence.contains("recovered"),
259 "must not claim recovery at idle clocks, got: {}",
260 idle_end.evidence
261 );
262 assert!(
263 idle_end.evidence.contains("300 MHz") && idle_end.evidence.contains("2400 MHz"),
264 "must state current and pre-throttle clocks, got: {}",
265 idle_end.evidence
266 );
267 }
268
269 #[test]
274 fn throttle_none_never_narrates_and_resets_the_episode() {
275 fn sample(ts_ms: u64, throttle: Option<ThrottleReasons>) -> DynamicSample {
276 DynamicSample {
277 ts_ms,
278 util_pct: Some(90.0),
279 util_engine: None,
280 mem_used_bytes: Some(1 << 30),
281 power_mw: None,
282 temp_c: None,
283 fan_pct: None,
284 sm_clock_mhz: Some(2400),
285 mem_clock_mhz: None,
286 encoder_pct: None,
287 decoder_pct: None,
288 throttle,
289 }
290 }
291 let thermal = Some(ThrottleReasons {
292 thermal: true,
293 ..Default::default()
294 });
295 let quiet = Some(ThrottleReasons::default());
296
297 let mut engine = EventEngine::new();
299 let dev = DeviceId("blind".into());
300 for ts in (1_000..=20_000).step_by(1000) {
301 let events = engine.observe(&dev, &sample(ts, None), Some(&[]), Some(1 << 34), None);
302 assert!(
303 events.iter().all(
304 |e| e.kind != EventKind::ThrottleStart && e.kind != EventKind::ThrottleEnd
305 ),
306 "an unobservable source must never narrate throttle"
307 );
308 }
309
310 let mut engine = EventEngine::new();
313 let dev = DeviceId("gap".into());
314 engine.observe(&dev, &sample(1_000, quiet), Some(&[]), Some(1 << 34), None);
315 let started = engine.observe(
316 &dev,
317 &sample(2_000, thermal),
318 Some(&[]),
319 Some(1 << 34),
320 None,
321 );
322 assert!(started.iter().any(|e| e.kind == EventKind::ThrottleStart));
323 engine.observe(&dev, &sample(3_000, None), Some(&[]), Some(1 << 34), None);
324 let resumed = engine.observe(&dev, &sample(4_000, quiet), Some(&[]), Some(1 << 34), None);
325 assert!(
326 resumed.iter().all(|e| e.kind != EventKind::ThrottleEnd),
327 "an end never observed must not be narrated after a blind spot"
328 );
329 let restarted = engine.observe(
331 &dev,
332 &sample(5_000, thermal),
333 Some(&[]),
334 Some(1 << 34),
335 None,
336 );
337 assert!(
338 restarted.iter().any(|e| e.kind == EventKind::ThrottleStart),
339 "observation returning must re-arm the episode tracker"
340 );
341 }
342
343 #[test]
346 fn vram_window_resets_on_sharp_drop() {
347 let total: u64 = 16 << 30;
348 let mk = |ts_ms: u64, used: u64| DynamicSample {
349 ts_ms,
350 util_pct: Some(90.0),
351 util_engine: None,
352 mem_used_bytes: Some(used),
353 power_mw: None,
354 temp_c: None,
355 fan_pct: None,
356 sm_clock_mhz: None,
357 mem_clock_mhz: None,
358 encoder_pct: None,
359 decoder_pct: None,
360 throttle: Some(ThrottleReasons::default()),
361 };
362 let mut engine = EventEngine::new();
363 let dev = DeviceId("test".into());
364
365 let mut ts = 0u64;
367 let mut used = 8 << 30;
368 for _ in 0..70 {
369 engine.observe(&dev, &mk(ts, used), Some(&[]), Some(total), None);
370 ts += 1000;
371 used += 64 << 20; }
373 engine.observe(&dev, &mk(ts, 4 << 30), Some(&[]), Some(total), None);
374 ts += 1000;
375
376 let events = engine.observe(
379 &dev,
380 &mk(ts, (total as f64 * 0.9) as u64),
381 Some(&[]),
382 Some(total),
383 None,
384 );
385 assert!(
386 events.is_empty(),
387 "no pressure event may fire from a stale window after a sharp drop: {:?}",
388 events.iter().map(|e| &e.title).collect::<Vec<_>>()
389 );
390 }
391
392 #[test]
396 fn vram_pressure_holder_requires_known_size() {
397 let total: u64 = 16 << 30;
398 let mk = |ts_ms: u64, used: u64| DynamicSample {
399 ts_ms,
400 util_pct: Some(90.0),
401 util_engine: None,
402 mem_used_bytes: Some(used),
403 power_mw: None,
404 temp_c: None,
405 fan_pct: None,
406 sm_clock_mhz: None,
407 mem_clock_mhz: None,
408 encoder_pct: None,
409 decoder_pct: None,
410 throttle: Some(ThrottleReasons::default()),
411 };
412 let proc_named = |pid: u32, name: &str, mem: Option<u64>| ProcessSample {
413 pid,
414 name: name.into(),
415 kind: ProcessKind::Compute,
416 mem_bytes: mem,
417 util_pct: None,
418 cpu_pct: None,
419 container: None,
420 };
421
422 let run = |mem_a: Option<u64>, mem_b: Option<u64>| -> Event {
425 let mut engine = EventEngine::new();
426 let dev = DeviceId("test".into());
427 let procs = vec![proc_named(1, "alpha", mem_a), proc_named(2, "beta", mem_b)];
428 let mut ts = 0u64;
429 let mut used = (total as f64 * 0.86) as u64;
430 let mut hits = Vec::new();
431 for _ in 0..=13 {
432 let events = engine.observe(&dev, &mk(ts, used), Some(&procs), Some(total), None);
433 hits.extend(
434 events
435 .into_iter()
436 .filter(|e| e.kind == EventKind::VramPressure),
437 );
438 ts += 10_000;
439 used += 100 << 20;
440 }
441 assert!(!hits.is_empty(), "pressure event never fired");
442 hits.remove(0)
443 };
444
445 let unknown = run(None, None);
446 assert!(
447 !unknown.title.contains("largest holder"),
448 "must not name a holder when every process size is unknown: {}",
449 unknown.title
450 );
451
452 let known = run(None, Some(2 << 30));
453 assert!(
454 known.title.contains("largest holder: beta pid 2"),
455 "must name the process whose size is known: {}",
456 known.title
457 );
458 }
459
460 fn idle_sample(ts_ms: u64, util_pct: f32) -> DynamicSample {
463 DynamicSample {
464 ts_ms,
465 util_pct: Some(util_pct),
466 util_engine: None,
467 mem_used_bytes: Some(8 << 30),
468 power_mw: None,
469 temp_c: None,
470 fan_pct: None,
471 sm_clock_mhz: None,
472 mem_clock_mhz: None,
473 encoder_pct: None,
474 decoder_pct: None,
475 throttle: Some(ThrottleReasons::default()),
476 }
477 }
478
479 fn python_proc() -> ProcessSample {
480 ProcessSample {
481 pid: 4521,
482 name: "python".into(),
483 kind: ProcessKind::Compute,
484 mem_bytes: Some(6 << 30),
485 util_pct: None,
486 cpu_pct: None,
487 container: None,
488 }
489 }
490
491 fn drive(
493 engine: &mut EventEngine,
494 dev: &DeviceId,
495 ts_range: std::ops::RangeInclusive<u64>,
496 util_pct: f32,
497 procs: &[ProcessSample],
498 ) -> Vec<Event> {
499 let mut out = Vec::new();
500 for ts in ts_range.step_by(1000) {
501 out.extend(engine.observe(
502 dev,
503 &idle_sample(ts, util_pct),
504 Some(procs),
505 Some(16 << 30),
506 None,
507 ));
508 }
509 out
510 }
511
512 #[test]
515 fn idle_gap_fires_once_on_recovery_and_is_hedged() {
516 let mut engine = EventEngine::new();
517 let dev = DeviceId("test".into());
518 engine.register_device(dev.clone(), "GPU0".into());
519 let procs = vec![python_proc()];
520
521 let active = drive(&mut engine, &dev, 0..=30_000, 92.0, &procs);
522 let during = drive(&mut engine, &dev, 31_000..=44_000, 2.0, &procs);
523 assert!(
524 active
525 .iter()
526 .chain(&during)
527 .all(|e| e.kind != EventKind::IdleGap),
528 "the gap must not narrate before it ends (its duration is unknown)"
529 );
530
531 let recovery = drive(&mut engine, &dev, 45_000..=45_000, 93.0, &procs);
532 let gaps: Vec<&Event> = recovery
533 .iter()
534 .filter(|e| e.kind == EventKind::IdleGap)
535 .collect();
536 assert_eq!(gaps.len(), 1, "exactly one IdleGap on recovery");
537 let e = gaps[0];
538 assert_eq!(e.confidence, Confidence::Likely, "a stall is an inference");
539 assert_eq!(e.severity, Severity::Info);
540 assert_eq!(
541 e.ts_ms, 45_000,
542 "event is stamped at gap end, from sample.ts_ms"
543 );
544 assert!(
545 e.title.contains("GPU0 sat idle 14s") && e.title.contains("python (pid 4521)"),
546 "title must name the span and the holder, got: {}",
547 e.title
548 );
549 assert!(
550 e.title.contains("likely"),
551 "inference must hedge: {}",
552 e.title
553 );
554 assert!(
555 e.evidence.contains("92%")
556 && e.evidence.contains("31000..45000")
557 && e.evidence.contains("pid 4521")
558 && e.evidence.contains("6.0 GiB"),
559 "evidence must carry the raw numbers, got: {}",
560 e.evidence
561 );
562
563 let after = drive(&mut engine, &dev, 46_000..=60_000, 93.0, &procs);
565 assert!(after.iter().all(|e| e.kind != EventKind::IdleGap));
566 }
567
568 #[test]
570 fn idle_gap_below_minimum_duration_is_silent() {
571 let mut engine = EventEngine::new();
572 let dev = DeviceId("test".into());
573 let procs = vec![python_proc()];
574
575 let mut all = drive(&mut engine, &dev, 0..=30_000, 92.0, &procs);
576 all.extend(drive(&mut engine, &dev, 31_000..=35_000, 2.0, &procs));
577 all.extend(drive(&mut engine, &dev, 36_000..=40_000, 93.0, &procs));
578 assert!(
579 all.iter().all(|e| e.kind != EventKind::IdleGap),
580 "a 5s gap is below IDLE_GAP_MIN_MS and must not narrate"
581 );
582 }
583
584 #[test]
587 fn idle_gap_without_attached_process_is_silent() {
588 let mut engine = EventEngine::new();
589 let dev = DeviceId("test".into());
590
591 let mut all = drive(&mut engine, &dev, 0..=30_000, 92.0, &[]);
592 all.extend(drive(&mut engine, &dev, 31_000..=44_000, 2.0, &[]));
593 all.extend(drive(&mut engine, &dev, 45_000..=50_000, 93.0, &[]));
594 assert!(
595 all.iter().all(|e| e.kind != EventKind::IdleGap),
596 "no holder process means no stall narration"
597 );
598 }
599
600 #[test]
603 fn idle_gap_suppressed_when_holder_exits_mid_gap() {
604 let mut engine = EventEngine::new();
605 let dev = DeviceId("test".into());
606 let procs = vec![python_proc()];
607
608 let mut all = drive(&mut engine, &dev, 0..=30_000, 92.0, &procs);
609 all.extend(drive(&mut engine, &dev, 31_000..=36_000, 2.0, &procs));
610 all.extend(drive(&mut engine, &dev, 37_000..=44_000, 2.0, &[]));
612 all.extend(drive(&mut engine, &dev, 45_000..=50_000, 93.0, &[]));
613
614 assert!(
615 all.iter().any(|e| e.kind == EventKind::ProcessExited),
616 "the exit itself must still be narrated as a fact"
617 );
618 assert!(
619 all.iter().all(|e| e.kind != EventKind::IdleGap),
620 "holder exited mid-gap: the exit event covers it, IdleGap must stay silent"
621 );
622 }
623
624 #[test]
627 fn idle_gap_requires_prior_sustained_activity() {
628 let mut engine = EventEngine::new();
629 let dev = DeviceId("test".into());
630 let procs = vec![python_proc()];
631
632 let mut all = drive(&mut engine, &dev, 0..=10_000, 92.0, &procs);
634 all.extend(drive(&mut engine, &dev, 11_000..=24_000, 2.0, &procs));
635 all.extend(drive(&mut engine, &dev, 25_000..=30_000, 93.0, &procs));
636 assert!(
637 all.iter().all(|e| e.kind != EventKind::IdleGap),
638 "no sustained activity before the trough — no stall to narrate"
639 );
640 }
641
642 fn opt_sample(ts_ms: u64, util_pct: Option<f32>) -> DynamicSample {
646 DynamicSample {
647 ts_ms,
648 util_pct,
649 util_engine: None,
650 mem_used_bytes: Some(8 << 30),
651 power_mw: None,
652 temp_c: None,
653 fan_pct: None,
654 sm_clock_mhz: None,
655 mem_clock_mhz: None,
656 encoder_pct: None,
657 decoder_pct: None,
658 throttle: Some(ThrottleReasons::default()),
659 }
660 }
661
662 fn proc_with(
664 pid: u32,
665 name: &str,
666 mem: u64,
667 util_pct: Option<f32>,
668 cpu_pct: Option<f32>,
669 ) -> ProcessSample {
670 ProcessSample {
671 pid,
672 name: name.into(),
673 kind: ProcessKind::Compute,
674 mem_bytes: Some(mem),
675 util_pct,
676 cpu_pct,
677 container: None,
678 }
679 }
680
681 fn drive_opt(
683 engine: &mut EventEngine,
684 dev: &DeviceId,
685 ts_range: std::ops::RangeInclusive<u64>,
686 util_pct: Option<f32>,
687 procs: &[ProcessSample],
688 ) -> Vec<Event> {
689 let mut out = Vec::new();
690 for ts in ts_range.step_by(1000) {
691 out.extend(engine.observe(
692 dev,
693 &opt_sample(ts, util_pct),
694 Some(procs),
695 Some(16 << 30),
696 None,
697 ));
698 }
699 out
700 }
701
702 #[test]
706 fn hang_fires_once_exactly_at_ten_minutes() {
707 let mut engine = EventEngine::new();
708 let dev = DeviceId("test".into());
709 engine.register_device(dev.clone(), "GPU0".into());
710 let procs = vec![proc_with(4521, "python", 6 << 30, None, None)];
711
712 let before = drive_opt(&mut engine, &dev, 0..=599_000, Some(1.0), &procs);
714 assert!(
715 before.iter().all(|e| e.kind != EventKind::HangSuspected),
716 "must not fire at 9m59s — that is below HANG_MIN_MS"
717 );
718
719 let at_ten = drive_opt(&mut engine, &dev, 600_000..=600_000, Some(1.0), &procs);
721 let hangs: Vec<&Event> = at_ten
722 .iter()
723 .filter(|e| e.kind == EventKind::HangSuspected)
724 .collect();
725 assert_eq!(hangs.len(), 1, "exactly one HangSuspected at the 10m mark");
726 let e = hangs[0];
727 assert_eq!(e.confidence, Confidence::Likely, "a hang is an inference");
728 assert_eq!(e.severity, Severity::Warning);
729 assert_eq!(e.ts_ms, 600_000, "stamped at the 10-minute mark");
730 assert!(
731 e.title.contains("likely hung")
732 && e.title.contains("python (pid 4521)")
733 && e.title.contains("6.0 GiB"),
734 "title must hedge and name the holder/mem, got: {}",
735 e.title
736 );
737 assert!(
738 e.evidence.contains("0..600000")
739 && e.evidence.contains("pid 4521")
740 && e.evidence.contains("6.0 GiB"),
741 "evidence must carry the raw window and holder, got: {}",
742 e.evidence
743 );
744
745 let after = drive_opt(&mut engine, &dev, 601_000..=900_000, Some(1.0), &procs);
747 assert!(
748 after.iter().all(|e| e.kind != EventKind::HangSuspected),
749 "a single episode must narrate exactly once"
750 );
751 }
752
753 #[test]
755 fn hang_does_not_fire_when_util_recovers_early() {
756 let mut engine = EventEngine::new();
757 let dev = DeviceId("test".into());
758 let procs = vec![proc_with(4521, "python", 6 << 30, None, None)];
759
760 let mut all = drive_opt(&mut engine, &dev, 0..=539_000, Some(1.0), &procs);
762 all.extend(drive_opt(
763 &mut engine,
764 &dev,
765 540_000..=545_000,
766 Some(93.0),
767 &procs,
768 ));
769 all.extend(drive_opt(
771 &mut engine,
772 &dev,
773 546_000..=560_000,
774 Some(1.0),
775 &procs,
776 ));
777 assert!(
778 all.iter().all(|e| e.kind != EventKind::HangSuspected),
779 "recovery before 10m must reset the episode — no hang"
780 );
781 }
782
783 #[test]
786 fn hang_does_not_fire_when_holder_exits_and_exit_still_narrates() {
787 let mut engine = EventEngine::new();
788 let dev = DeviceId("test".into());
789 let procs = vec![proc_with(4521, "python", 6 << 30, None, None)];
790
791 let mut all = drive_opt(&mut engine, &dev, 0..=300_000, Some(1.0), &procs);
792 all.extend(drive_opt(
794 &mut engine,
795 &dev,
796 301_000..=900_000,
797 Some(1.0),
798 &[],
799 ));
800
801 assert!(
802 all.iter().any(|e| e.kind == EventKind::ProcessExited),
803 "the holder's exit must still be narrated as a fact"
804 );
805 assert!(
806 all.iter().all(|e| e.kind != EventKind::HangSuspected),
807 "anchored holder exited — no hang to claim"
808 );
809 }
810
811 #[test]
814 fn hang_does_not_fire_when_util_goes_none_midwindow() {
815 let mut engine = EventEngine::new();
816 let dev = DeviceId("test".into());
817 let procs = vec![proc_with(4521, "python", 6 << 30, None, None)];
818
819 let mut all = drive_opt(&mut engine, &dev, 0..=300_000, Some(1.0), &procs);
820 all.extend(drive_opt(
822 &mut engine,
823 &dev,
824 301_000..=320_000,
825 None,
826 &procs,
827 ));
828 all.extend(drive_opt(
830 &mut engine,
831 &dev,
832 321_000..=360_000,
833 Some(1.0),
834 &procs,
835 ));
836 assert!(
837 all.iter().all(|e| e.kind != EventKind::HangSuspected),
838 "a blind spot mid-window must reset the episode — no hang"
839 );
840 }
841
842 #[test]
846 fn hang_suppresses_the_idle_gap_it_lived_in() {
847 let mut engine = EventEngine::new();
848 let dev = DeviceId("test".into());
849 engine.register_device(dev.clone(), "GPU0".into());
850 let procs = vec![proc_with(4521, "python", 6 << 30, None, None)];
851
852 let mut all = drive(&mut engine, &dev, 0..=40_000, 92.0, &procs);
854 all.extend(drive_opt(
856 &mut engine,
857 &dev,
858 41_000..=761_000,
859 Some(1.0),
860 &procs,
861 ));
862 all.extend(drive(&mut engine, &dev, 762_000..=765_000, 93.0, &procs));
864
865 let hangs = all
866 .iter()
867 .filter(|e| e.kind == EventKind::HangSuspected)
868 .count();
869 let gaps = all.iter().filter(|e| e.kind == EventKind::IdleGap).count();
870 assert_eq!(hangs, 1, "the trough must narrate exactly one hang");
871 assert_eq!(
872 gaps, 0,
873 "the hang already covered this trough — the IdleGap must be suppressed"
874 );
875 }
876
877 #[test]
880 fn spillover_fires_on_textbook_case() {
881 let mut engine = EventEngine::new();
882 let dev = DeviceId("test".into());
883 engine.register_device(dev.clone(), "GPU0".into());
884
885 let mem = 12_133_000_000u64; drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
888 let ollama = vec![proc_with(7777, "ollama", mem, Some(0.0), Some(310.0))];
889 let out = drive_opt(&mut engine, &dev, 1_000..=91_000, Some(5.0), &ollama);
891
892 let evts: Vec<&Event> = out
893 .iter()
894 .filter(|e| e.kind == EventKind::CpuSpillover)
895 .collect();
896 assert_eq!(evts.len(), 1, "exactly one CpuSpillover at window close");
897 let e = evts[0];
898 assert_eq!(
899 e.confidence,
900 Confidence::Likely,
901 "spillover is an inference"
902 );
903 assert_eq!(e.severity, Severity::Warning);
904 assert!(
905 e.title.contains("likely partial CPU offload")
906 && e.title.contains("ollama (pid 7777)")
907 && e.title.contains("11.3 GiB"),
908 "title must hedge and name the model/mem, got: {}",
909 e.title
910 );
911 assert!(
912 e.evidence.contains("1000..91000")
913 && e.evidence.contains("CPU mean")
914 && e.evidence.contains("util mean"),
915 "evidence must carry the window and both means, got: {}",
916 e.evidence
917 );
918 }
919
920 #[test]
923 fn spillover_cancels_when_gpu_gets_busy() {
924 let mut engine = EventEngine::new();
925 let dev = DeviceId("test".into());
926
927 let mem = 12 << 30;
928 drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
929 let ollama = vec![proc_with(7777, "ollama", mem, Some(0.0), Some(310.0))];
930 let mut out = drive_opt(&mut engine, &dev, 1_000..=60_000, Some(5.0), &ollama);
932 out.extend(drive_opt(
933 &mut engine,
934 &dev,
935 61_000..=91_000,
936 Some(80.0),
937 &ollama,
938 ));
939 assert!(
940 out.iter().all(|e| e.kind != EventKind::CpuSpillover),
941 "a busy GPU refutes the offload claim — no spillover"
942 );
943 }
944
945 #[test]
947 fn spillover_cancels_when_process_exits_midwindow() {
948 let mut engine = EventEngine::new();
949 let dev = DeviceId("test".into());
950
951 let mem = 12 << 30;
952 drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
953 let ollama = vec![proc_with(7777, "ollama", mem, Some(0.0), Some(310.0))];
954 let mut out = drive_opt(&mut engine, &dev, 1_000..=40_000, Some(5.0), &ollama);
955 out.extend(drive_opt(
957 &mut engine,
958 &dev,
959 41_000..=91_000,
960 Some(5.0),
961 &[],
962 ));
963 assert!(
964 out.iter().all(|e| e.kind != EventKind::CpuSpillover),
965 "process exited mid-window — spillover must be cancelled silently"
966 );
967 assert!(
968 out.iter().any(|e| e.kind == EventKind::ProcessExited),
969 "the exit itself must still narrate"
970 );
971 }
972
973 #[test]
976 fn spillover_silent_without_cpu_visibility() {
977 let mut engine = EventEngine::new();
978 let dev = DeviceId("test".into());
979
980 let mem = 12 << 30;
981 drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
982 let ollama = vec![proc_with(7777, "ollama", mem, Some(0.0), None)];
984 let out = drive_opt(&mut engine, &dev, 1_000..=91_000, Some(5.0), &ollama);
985 assert!(
986 out.iter().all(|e| e.kind != EventKind::CpuSpillover),
987 "no CPU visibility means no claim — must stay silent"
988 );
989 }
990
991 #[test]
995 fn spillover_silent_with_too_few_cpu_samples() {
996 let mut engine = EventEngine::new();
997 let dev = DeviceId("test".into());
998
999 let mem = 12 << 30;
1000 drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
1001 let blind = vec![proc_with(7777, "ollama", mem, Some(0.0), None)];
1004 let hot = vec![proc_with(7777, "ollama", mem, Some(0.0), Some(310.0))];
1005 let mut out = drive_opt(&mut engine, &dev, 1_000..=88_000, Some(5.0), &blind);
1006 out.extend(drive_opt(
1007 &mut engine,
1008 &dev,
1009 89_000..=90_000,
1010 Some(5.0),
1011 &hot,
1012 ));
1013 out.extend(drive_opt(
1014 &mut engine,
1015 &dev,
1016 91_000..=91_000,
1017 Some(5.0),
1018 &blind,
1019 ));
1020 assert!(
1021 out.iter().all(|e| e.kind != EventKind::CpuSpillover),
1022 "two CPU samples is below SPILLOVER_MIN_CPU_SAMPLES — must stay silent"
1023 );
1024 }
1025
1026 #[test]
1029 fn spillover_silent_for_small_attachment() {
1030 let mut engine = EventEngine::new();
1031 let dev = DeviceId("test".into());
1032
1033 drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
1034 let small = vec![proc_with(7777, "ollama", 1 << 30, Some(0.0), Some(310.0))];
1035 let out = drive_opt(&mut engine, &dev, 1_000..=91_000, Some(5.0), &small);
1036 assert!(
1037 out.iter().all(|e| e.kind != EventKind::CpuSpillover),
1038 "a 1 GiB holder is below the spillover threshold — no assessment opens"
1039 );
1040 }
1041
1042 #[test]
1045 fn hang_and_spillover_titles_are_hedged_inferences() {
1046 let mut engine = EventEngine::new();
1048 let dev = DeviceId("hang".into());
1049 engine.register_device(dev.clone(), "GPU0".into());
1050 let procs = vec![proc_with(4521, "python", 6 << 30, None, None)];
1051 let mut all = drive_opt(&mut engine, &dev, 0..=600_000, Some(1.0), &procs);
1052
1053 let dev2 = DeviceId("spill".into());
1055 engine.register_device(dev2.clone(), "GPU1".into());
1056 drive_opt(&mut engine, &dev2, 0..=0, Some(3.0), &[]);
1057 let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
1058 all.extend(drive_opt(
1059 &mut engine,
1060 &dev2,
1061 1_000..=91_000,
1062 Some(5.0),
1063 &ollama,
1064 ));
1065
1066 for e in all
1067 .iter()
1068 .filter(|e| matches!(e.kind, EventKind::HangSuspected | EventKind::CpuSpillover))
1069 {
1070 assert_eq!(
1071 e.confidence,
1072 Confidence::Likely,
1073 "{:?} must be an inference, not a fact",
1074 e.kind
1075 );
1076 assert!(
1077 e.title.to_lowercase().contains("likely"),
1078 "{:?} title must hedge with \"likely\": {}",
1079 e.kind,
1080 e.title
1081 );
1082 }
1083 assert!(
1084 all.iter().any(|e| e.kind == EventKind::HangSuspected),
1085 "the hang fixture must actually fire a hang"
1086 );
1087 assert!(
1088 all.iter().any(|e| e.kind == EventKind::CpuSpillover),
1089 "the spillover fixture must actually fire a spillover"
1090 );
1091 }
1092}