Skip to main content

gpuviewer_core/
lib.rs

1//! gpuviewer-core — telemetry collection, data model, and event derivation.
2//!
3//! Architecture (see CLAUDE.md and docs/research/04-synthesis.md):
4//! - [`backend::GpuBackend`]: nvtop's vendor-vtable split (static / dynamic / processes),
5//!   per-field `Option<T>`, runtime-loaded vendor libs, failed init = skipped backend.
6//! - [`events::EventEngine`]: derives the narrated "story" events from raw samples.
7//! - [`mock::MockBackend`]: scripted simulation for CI/demos; the contract for real backends.
8
9#[cfg(target_os = "linux")]
10pub mod amd;
11// macOS Apple Silicon backend (design §4). OS-facing tiers are macOS-only, but the module
12// also carries the pure IOReport/PerformanceStatistics parsing+maths layer, which must
13// compile and run its fixture tests on every OS (design §10) — hence the extra `test`
14// arm: non-test host builds never see this module, CI fixture tests always do.
15#[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// Deliberately no target_os fence (unlike the design §9 sketch): the module's
28// counter-instance grammar and aggregation math are pure and their unit tests must run
29// on every OS (design §10 — CI has no GPUs); everything that touches a Windows API is
30// cfg(windows) inside.
31#[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        // Run enough simulated ticks to cross a throttle cycle and an ollama attach/exit.
74        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    /// A failed process probe (`None`) must never look like an empty process list.
133    /// `Some(&[])` says "nobody is attached" and correctly narrates the exit; `None` says
134    /// "nobody could look", and narrating a fact-grade exit off that invents an event that
135    /// never happened — the failure this engine exists to prevent.
136    #[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        // Baseline: an OBSERVED empty list is a real exit and must still narrate.
164        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        // The fix: the same transition, unobservable, narrates nothing.
174        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        // The holder is not forgotten either: when the probe recovers with the same
184        // process still attached, nothing spurious fires in either direction.
185        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    /// "recovered" is only honest when clocks are actually back near pre-throttle levels;
196    /// a throttle that ends because the GPU went idle must not claim recovery.
197    #[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        // Clocks back at ≥90% of pre-throttle (2400): honest to say "recovered".
248        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        // Throttle ended at idle clocks: claiming recovery would be a lie.
256        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    /// Throttle going unobservable (`None`, §5.4) is a blind spot, not a state change:
270    /// no narration may fire off it, and an episode open when the blind spot starts is
271    /// dropped — narrating its "end" later off a blind spot would be a fabricated fact.
272    /// Mirrors the util-None blind-spot rule for idle gaps and hangs.
273    #[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        // A source that never observes throttle (wddm/apple): zero throttle events ever.
298        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        // An open episode interrupted by a blind spot is dropped: when observation
311        // returns quiet, NO ThrottleEnd fires (the end was never observed).
312        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        // And a throttle observed AFTER the blind spot opens a fresh episode normally.
330        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    /// A sharp VRAM drop (allocator reset / process exit) must restart the trend window —
344    /// an endpoint slope over a sawtooth understates the current climb rate.
345    #[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        // Climb to just under the pressure threshold, then drop sharply (>5% of total).
366        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; // +64 MiB/s
372        }
373        engine.observe(&dev, &mk(ts, 4 << 30), Some(&[]), Some(total), None);
374        ts += 1000;
375
376        // Immediately at high usage again: window restarted, so the minimum span is not
377        // yet met and no event may fire off the stale pre-drop trend.
378        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    /// With every process size unknown (WSL2, unprivileged fdinfo) the pressure narration
393    /// must not crown an arbitrary process "largest holder" on zero evidence — and must
394    /// still name one as soon as a real size is known.
395    #[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        // Climb from 86% toward 95% at ~600 MiB/min; the pressure event fires once the
423        // 60 s minimum trend span is met. Returns the first pressure narration.
424        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    // ---- idle-gap (training stall) tests: synthetic 1 Hz traces, controlled ts_ms ----
461
462    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    /// Drive a 1 Hz constant-util trace through the engine; returns every event emitted.
492    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    /// A 14s trough after 30s of sustained activity, with python attached throughout,
513    /// narrates exactly one IdleGap when util recovers — hedged, with raw evidence.
514    #[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        // Continued activity must not replay the gap.
564        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    /// A 5s trough is a normal scheduling hiccup, not a stall — below the floor, silent.
569    #[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    /// A trough with no process attached is just an idle GPU — narrating a "stall"
585    /// with nobody there to stall would be confidently wrong.
586    #[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    /// If the big-memory holder exits mid-gap, the process_exited fact already tells
601    /// the story; an IdleGap on top of it would double-count the same incident.
602    #[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        // python exits with the gap still open; the rest of the gap runs holder-less.
611        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    /// Without ≥30s of sustained activity beforehand, a trough is not a training stall
625    /// (a GPU that was barely working cannot "stall").
626    #[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        // Only 10s of activity: never qualifies as "active".
633        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    // ---- hang-suspicion and CPU-spillover tests: synthetic 1 Hz traces, controlled ts_ms ----
643
644    /// A sample whose util may be absent — used to exercise the "util goes None" reset paths.
645    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    /// Build a process holding `mem` bytes, with optional self-util and CPU%.
663    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    /// Drive a 1 Hz trace with an optional util value, holding `procs` constant across it.
682    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    /// A 6 GiB holder whose own util is unreported (the normal hung-kernel case): VRAM held,
703    /// device dead, holder alive for ten unbroken minutes narrates exactly one HangSuspected,
704    /// stamped at the 10-minute mark — not a second early — and hedged with raw evidence.
705    #[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        // Episode opens at ts=0. At 9m59s (599_000) it has not yet reached HANG_MIN_MS.
713        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        // One more tick lands exactly on 10m: fires once.
720        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        // A sustained hang narrates once, never again.
746        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    /// Util returning to life before the 10-minute mark resets the episode: no hang.
754    #[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        // Nine minutes of apparent death, then the GPU wakes up — episode dropped.
761        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        // Back to idle, but only briefly — nowhere near a fresh 10 minutes.
770        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    /// The anchored holder exiting at 5 minutes ends the episode silently; the exit itself
784    /// is still narrated as a plain fact (the two narrations must not collide).
785    #[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        // python exits at 5m; the device stays dead for another 10 minutes holder-less.
793        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    /// Util going unobservable mid-window drops the episode: we cannot claim "zero engine
812    /// activity" through a blind spot. A short re-idle afterwards must not reach 10m.
813    #[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        // Util unobservable for a stretch — no claim possible.
821        all.extend(drive_opt(
822            &mut engine,
823            &dev,
824            301_000..=320_000,
825            None,
826            &procs,
827        ));
828        // Idle resumes, but the window restarted and the test span is far short of 10m.
829        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    /// A hang is an idle gap that never recovered. When activity finally returns after a
843    /// 12-minute trough that already narrated a hang, the IdleGap must stay silent — one
844    /// incident, one narration.
845    #[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        // 30s+ of real work makes the device idle-gap-eligible, then it drops dead.
853        let mut all = drive(&mut engine, &dev, 0..=40_000, 92.0, &procs);
854        // A 12-minute trough at ~1% — long enough to both open an idle gap and trip a hang.
855        all.extend(drive_opt(
856            &mut engine,
857            &dev,
858            41_000..=761_000,
859            Some(1.0),
860            &procs,
861        ));
862        // Activity returns: the idle gap closes here, and would normally narrate.
863        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    /// The textbook spillover: an 11.3 GiB model attaches, the GPU stays near-idle for the
878    /// whole 90s window while the process pegs ~3 cores. Fires once, hedged, with means.
879    #[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; // ~11.3 GiB
886                                     // First observation establishes the baseline (no procs) so the model reads as NEW.
887        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        // The 90s window: ts 1000..=91000 is the close (91000 - 1000 = 90000 = window).
890        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    /// If the GPU shows real use at any point in the window, the model IS using it — the
921    /// spillover premise is refuted and nothing narrates.
922    #[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        // Idle most of the window, then a clear burst of GPU use before it closes.
931        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    /// A process that exits mid-window is cancelled silently; only its exit fact narrates.
946    #[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        // ollama exits well before the 90s window would close.
956        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    /// With no CPU visibility for the whole window we cannot say the process "burns CPU";
974    /// the honesty rule forbids the claim, so it stays silent even with a near-idle GPU.
975    #[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        // cpu_pct None throughout — the GPU is idle, but we never saw the CPU.
983        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    /// Mostly-blind CPU visibility (only two readings, both hot) clears the CPU *mean* but
992    /// not the sample-count floor: too few samples to mean it, so the claim is withheld.
993    /// This isolates SPILLOVER_MIN_CPU_SAMPLES from the mean-CPU gate.
994    #[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        // CPU unseen for almost the whole window, then exactly two hot readings: the mean
1002        // of those two (310%) would pass, but two samples is below the floor of three.
1003        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    /// A small (1 GiB) attachment is below SPILLOVER_HOLDER_MIN_BYTES: no window opens, so
1027    /// even an idle GPU and a hot CPU never narrate a spillover.
1028    #[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    /// Every new inference narration must read as hedged and carry Confidence::Likely —
1043    /// a grep-style guard against a fact-tier slip on the riskiest events.
1044    #[test]
1045    fn hang_and_spillover_titles_are_hedged_inferences() {
1046        // Drive a hang to completion.
1047        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        // Drive a spillover to completion on a separate device.
1054        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}