Skip to main content

gpuviewer_core/
events.rs

1//! Event derivation — the "story" layer.
2//!
3//! Two-tier honesty contract (non-negotiable, see docs/research/04-synthesis.md §5 risk 2):
4//! - `Confidence::Fact` events assert observed state transitions plainly (throttle bit set,
5//!   process exited). They carry the raw evidence that produced them.
6//! - `Confidence::Likely` events are inferences (extrapolated OOM ETA, suspected dataloader
7//!   stall) and must always read as hedged.
8
9use std::collections::{HashMap, VecDeque};
10
11use crate::model::{fmt_bytes, DeviceId, DynamicSample, ProcessSample, ThrottleReasons};
12use serde::{Deserialize, Serialize};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Severity {
17    Info,
18    Warning,
19    Critical,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum Confidence {
25    Fact,
26    Likely,
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum EventKind {
32    ThrottleStart,
33    ThrottleEnd,
34    ProcessAttached,
35    ProcessExited,
36    VramPressure,
37    IdleGap,
38    /// The collector itself fell behind its tick cadence — the recording has a hole, and
39    /// the recorder must say so rather than let the gap masquerade as device idleness.
40    /// Emitted by the tui collector (the engine owns tick timing, not this module).
41    CollectorStall,
42    /// History was truncated or restarted (ring wrap on resize, store re-init); consumers
43    /// must not treat the discontinuity as device behavior.
44    HistoryReset,
45    /// Device stopped answering queries while a workload was attached — possibly a hung
46    /// kernel or driver. An inference by nature: always `Confidence::Likely`.
47    HangSuspected,
48    /// A registered device stopped answering its dynamic probe entirely (consecutive
49    /// whole-probe failures, not per-metric absence — `NOT_SUPPORTED` stays `None` in the
50    /// sample and is never this). Driver reset, NVML dying, eGPU unplug, and an xe rebind
51    /// all look identical from this side of the probe, so the kind asserts only the
52    /// observed silence — the CAUSE is never claimed. Emitted by the tui collector (it
53    /// owns the probe loop and its tick counting). Always `Confidence::Fact`.
54    DeviceLost,
55    /// A device previously declared lost answered again. The samples between loss and
56    /// return were never collected; that gap stays blank in history — a hole, never
57    /// zeros. Always `Confidence::Fact`.
58    DeviceReturned,
59    /// A GPU-attached process is burning CPU while the GPU sits idle — the classic
60    /// CPU-bound dataloader. An inference: always `Confidence::Likely`.
61    CpuSpillover,
62    /// A recording session began folding history into the database — the flight
63    /// recorder's own power-on mark. Without it (and its stop twin) the timeline cannot
64    /// distinguish "the GPU sat idle" from "gpuviewer wasn't running": unrecorded time
65    /// renders blank, and the boundary events are what make that blank legible. Emitted
66    /// by the tui collector (it owns the recorder lifecycle). Always `Confidence::Fact`.
67    RecordingStarted,
68    /// The recording session ended cleanly (the stop mark and the partial rollup tail
69    /// reached the store). A session that dies without this mark — SIGKILL, OOM kill,
70    /// power loss — writes nothing, which is itself information: the NEXT session's
71    /// start mark narrates the missing stop. Always `Confidence::Fact`.
72    RecordingStopped,
73    /// Writes to the history database started failing (disk full, permissions revoked,
74    /// the file removed under a running session) — or, on the recovery edge, started
75    /// working again. The recorder swallows the error itself so a bad disk never takes
76    /// the live view down with it, but swallowing it SILENTLY is the one thing it must
77    /// not do: this product's entire promise is that you can scroll back, and a recorder
78    /// that quietly stopped recording breaks that promise exactly when it matters. The
79    /// same rule the collector applies to its own stalls, applied to its own storage.
80    /// Emitted by the tui collector. Always `Confidence::Fact`.
81    RecordingDegraded,
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize)]
85pub struct Event {
86    pub ts_ms: u64,
87    pub device: DeviceId,
88    pub kind: EventKind,
89    pub severity: Severity,
90    pub confidence: Confidence,
91    /// One-line human narration ("GPU0 thermal throttling began — clocks 2520→1815 MHz").
92    pub title: String,
93    /// The raw evidence behind the narration, always auditable.
94    pub evidence: String,
95}
96
97/// VRAM-trend window length and event cooldown.
98const VRAM_WINDOW_MS: u64 = 180_000;
99const VRAM_MIN_SPAN_MS: u64 = 60_000;
100const VRAM_PRESSURE_FRAC: f64 = 0.85;
101const VRAM_MIN_SLOPE_BYTES_PER_MIN: f64 = 16.0 * 1024.0 * 1024.0;
102const VRAM_COOLDOWN_MS: u64 = 90_000;
103
104/// Idle-gap (training stall) thresholds: a gap only narrates after sustained activity,
105/// only once it lasted long enough to matter, and only if a real allocation stayed
106/// attached throughout — otherwise it is just an idle GPU, not a stall.
107const IDLE_ACTIVE_UTIL_PCT: f32 = 50.0;
108const IDLE_ACTIVE_MIN_MS: u64 = 30_000;
109const IDLE_GAP_UTIL_PCT: f32 = 10.0;
110const IDLE_GAP_MIN_MS: u64 = 10_000;
111const IDLE_HOLDER_MIN_BYTES: u64 = 256 * 1024 * 1024;
112
113/// Hang-suspicion thresholds. The most confidently-wrong-prone inference in the product, so
114/// the bar is deliberately steep: VRAM held but engines flat-dead, the holder's own util
115/// also flat, and the whole pattern sustained for ten unbroken minutes before we dare say
116/// "likely hung". A live trough that recovers, or any GPU activity, must not trip it.
117const HANG_DEVICE_UTIL_PCT: f32 = 2.0;
118const HANG_PROC_UTIL_PCT: f32 = 2.0;
119const HANG_HOLDER_MIN_BYTES: u64 = 1024 * 1024 * 1024;
120const HANG_RESET_UTIL_PCT: f32 = 10.0;
121const HANG_MIN_MS: u64 = 600_000;
122
123/// CPU-spillover thresholds. A freshly-loaded model that sits on a near-idle GPU while its
124/// own process pegs multiple cores is the signature of a partial CPU offload (the model did
125/// not fit in VRAM). We assess over a fixed window so a model still warming up is not judged
126/// prematurely, and demand a high CPU bar plus a near-dead GPU before claiming it.
127const SPILLOVER_HOLDER_MIN_BYTES: u64 = 2 * 1024 * 1024 * 1024;
128const SPILLOVER_WINDOW_MS: u64 = 90_000;
129const SPILLOVER_MAX_MEAN_UTIL_PCT: f64 = 15.0;
130const SPILLOVER_BUSY_UTIL_PCT: f32 = 30.0;
131const SPILLOVER_MIN_MEAN_CPU_PCT: f64 = 150.0;
132const SPILLOVER_MIN_CPU_SAMPLES: u32 = 3;
133/// Minimum observed-util coverage of the assessment window, as a percentage of its ticks.
134/// The "GPU stayed ~idle" half of the claim needs the same grounding as the CPU half: when
135/// util is `None` throughout (source cannot observe it), the mean would read as 0% and pass
136/// the idle gate with zero actual GPU evidence — exactly the confidently-wrong narration the
137/// honesty contract bans. Below half-window coverage the judgment is refused, silently.
138const SPILLOVER_MIN_UTIL_COVERAGE_PCT: u32 = 50;
139
140#[derive(Default)]
141struct DevState {
142    prev: Option<DynamicSample>,
143    procs: HashMap<u32, ProcessSample>,
144    seen_first_procs: bool,
145    throttle_since: Option<u64>,
146    /// Clock just before throttling began, for the "2520→1815 MHz" narration.
147    pre_throttle_clock: Option<u32>,
148    vram_window: VecDeque<(u64, u64)>,
149    last_pressure_evt: Option<u64>,
150    /// ts when util first crossed `IDLE_ACTIVE_UTIL_PCT`; None while below it.
151    active_since: Option<u64>,
152    /// Latched after `IDLE_ACTIVE_MIN_MS` of sustained activity: a trough only reads
153    /// as a training stall if real work preceded it (a desktop idling is no story).
154    idle_eligible: bool,
155    idle_gap: Option<IdleGap>,
156    hang: Option<HangEpisode>,
157    /// Open CPU-spillover assessments keyed by the holder's pid; one per new big-memory
158    /// process, closed (and judged) when its window elapses or it is cancelled.
159    spillovers: HashMap<u32, Spillover>,
160}
161
162/// An idle gap in flight: narrated (or discarded) only once util recovers and the
163/// gap's duration is actually known.
164struct IdleGap {
165    start_ms: u64,
166    /// Util of the sample just before the drop, for the "92% → 2%" evidence.
167    pre_util_pct: f32,
168    /// Running mean of util inside the gap (sum and sample count).
169    util_sum: f64,
170    util_n: u32,
171    /// pid → (name, mem at gap start) for processes holding ≥ `IDLE_HOLDER_MIN_BYTES`
172    /// when the gap opened; pruned as they exit. Empty at gap end means nobody stayed
173    /// attached, so the process_exited event already tells the story.
174    holders: HashMap<u32, (String, u64)>,
175    /// Set when a `HangSuspected` event fired while this gap was open. A hang is just an
176    /// idle gap that lasted long enough to look dead; narrating both for one trough would
177    /// double-count the same incident, so the gap stays silent on recovery.
178    hang_narrated: bool,
179}
180
181/// A hang suspicion in flight: VRAM held with both device and holder engines flat-dead,
182/// anchored to the largest qualifying holder. Emits once the pattern survives `HANG_MIN_MS`
183/// unbroken; reset (without emitting) the moment activity returns, the holder exits, or
184/// util goes unobservable.
185struct HangEpisode {
186    start_ms: u64,
187    /// The largest qualifying holder when the episode opened — the anchor of the narration.
188    /// A different (or larger) holder appearing later does not move the anchor; the claim is
189    /// about *this* allocation having gone quiet.
190    holder_pid: u32,
191    holder_name: String,
192    holder_mem: u64,
193    /// Running mean of device util across the episode, for the evidence line.
194    util_sum: f64,
195    util_n: u32,
196    /// Latched once the event has been emitted, so a sustained hang narrates exactly once.
197    fired: bool,
198}
199
200/// A CPU-spillover assessment in flight for one freshly-attached big-memory process.
201struct Spillover {
202    name: String,
203    mem_bytes: u64,
204    start_ms: u64,
205    util_sum: f64,
206    util_n: u32,
207    cpu_sum: f64,
208    cpu_n: u32,
209    /// Total ticks the assessment has lived through, observed-util or not — the denominator
210    /// for the `SPILLOVER_MIN_UTIL_COVERAGE_PCT` floor.
211    tick_n: u32,
212}
213
214#[derive(Default)]
215pub struct EventEngine {
216    state: HashMap<DeviceId, DevState>,
217    short_names: HashMap<DeviceId, String>,
218}
219
220impl EventEngine {
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Register a friendly short name ("GPU0") used in narration.
226    pub fn register_device(&mut self, id: DeviceId, short_name: String) {
227        self.short_names.insert(id, short_name);
228    }
229
230    fn short(&self, id: &DeviceId) -> String {
231        self.short_names
232            .get(id)
233            .cloned()
234            .unwrap_or_else(|| id.0.clone())
235    }
236
237    /// Derive this tick's events.
238    ///
239    /// `processes` is `None` when the process list was **unobservable** — the backend's
240    /// process probe failed outright. That is emphatically not the same as `Some(&[])`,
241    /// which asserts the device really had no processes: an empty list makes every
242    /// previously-seen pid look like it exited, and `process_events` would narrate a
243    /// fact-grade "python (pid 4521) left GPU0, freeing 21.3 GiB" off a driver hiccup.
244    /// Fact-grade narration of an event that never happened is the failure mode this
245    /// project treats as fatal, so the unobservable case gets its own arm: the four
246    /// process-dependent derivations are skipped, and any inference resting on
247    /// *continuous* process observation is reset, because this tick broke that premise.
248    /// Device-level derivations (throttle, VRAM pressure) are unaffected and keep running.
249    pub fn observe(
250        &mut self,
251        device: &DeviceId,
252        sample: &DynamicSample,
253        processes: Option<&[ProcessSample]>,
254        mem_total: Option<u64>,
255        temp_slowdown_c: Option<f32>,
256    ) -> Vec<Event> {
257        let name = self.short(device);
258        let st = self.state.entry(device.clone()).or_default();
259        let mut out = Vec::new();
260
261        throttle_events(st, device, &name, sample, temp_slowdown_c, &mut out);
262        match processes {
263            Some(processes) => {
264                // Before process_events flips `seen_first_procs` / overwrites `st.procs`,
265                // so the newness diff that opens a spillover window sees this tick's
266                // arrivals.
267                spillover_events(st, device, &name, sample, processes, &mut out);
268                process_events(st, device, &name, sample.ts_ms, processes, &mut out);
269                // After process_events, so holder tracking sees this tick's process list.
270                hang_events(st, device, &name, sample, &mut out);
271                // After hang_events, so a hang that just fired can suppress the gap it
272                // lived in.
273                idle_gap_events(st, device, &name, sample, &mut out);
274            }
275            None => {
276                // Same discipline as `util_pct: None` below: drop the in-flight
277                // inferences rather than carry them across a blind tick. A hang claims a
278                // holder was resident for 10 unbroken minutes and an idle gap claims one
279                // stayed attached throughout — neither can be said across a tick where
280                // nobody could see the process list, and an open spillover window is
281                // judging a pid it can no longer observe.
282                st.hang = None;
283                st.idle_gap = None;
284                st.spillovers.clear();
285                // `st.procs` is deliberately LEFT INTACT: it is the last observed truth,
286                // not a claim about now. Clearing it would make every held process look
287                // like it exited on the next good tick — the same false narration by a
288                // slower route.
289            }
290        }
291        vram_pressure_events(st, device, &name, sample, mem_total, &mut out);
292
293        st.prev = Some(sample.clone());
294        out
295    }
296}
297
298fn throttle_events(
299    st: &mut DevState,
300    device: &DeviceId,
301    name: &str,
302    sample: &DynamicSample,
303    temp_slowdown_c: Option<f32>,
304    out: &mut Vec<Event>,
305) {
306    // Throttle unobservable on this source (`None` ≠ "not throttling" — design §5.4):
307    // neither a start nor an end can be asserted, so drop the open episode silently —
308    // the same blind-spot rule util uses for idle gaps and hangs. Narrating an "end"
309    // off a blind spot would be a fabricated fact.
310    let Some(throttle) = sample.throttle else {
311        st.throttle_since = None;
312        st.pre_throttle_clock = None;
313        return;
314    };
315    let prev_any = st
316        .prev
317        .as_ref()
318        .and_then(|p| p.throttle)
319        .map(|t| t.any())
320        .unwrap_or(false);
321    let now_any = throttle.any();
322
323    if !prev_any && now_any {
324        let labels = throttle.labels().join(", ");
325        let pre_clock = st.prev.as_ref().and_then(|p| p.sm_clock_mhz);
326        st.pre_throttle_clock = pre_clock;
327        st.throttle_since = Some(sample.ts_ms);
328
329        let clocks = match (pre_clock, sample.sm_clock_mhz) {
330            (Some(a), Some(b)) if b < a => format!(" — clocks {a}→{b} MHz"),
331            _ => String::new(),
332        };
333        let temp_part = match (sample.temp_c, temp_slowdown_c) {
334            (Some(t), Some(thr)) => format!("; {t:.0}°C vs {thr:.0}°C slowdown threshold"),
335            (Some(t), None) => format!("; {t:.0}°C"),
336            _ => String::new(),
337        };
338        out.push(Event {
339            ts_ms: sample.ts_ms,
340            device: device.clone(),
341            kind: EventKind::ThrottleStart,
342            severity: severity_for(&throttle),
343            confidence: Confidence::Fact,
344            title: format!("{name} began throttling ({labels}){clocks}"),
345            evidence: format!("throttle bits: [{labels}]{temp_part}"),
346        });
347    } else if prev_any && !now_any {
348        let dur = st
349            .throttle_since
350            .take()
351            .map(|t0| format!(" after {}", fmt_dur_ms(sample.ts_ms.saturating_sub(t0))))
352            .unwrap_or_default();
353        // Only claim "recovered" when clocks are actually back near pre-throttle levels;
354        // a throttle that ends because the GPU went idle is not a recovery.
355        let clocks = match (st.pre_throttle_clock.take(), sample.sm_clock_mhz) {
356            (Some(a), Some(b)) if b as f64 >= a as f64 * 0.9 => {
357                format!("; clocks recovered to {b} MHz")
358            }
359            (Some(a), Some(b)) => format!("; clocks now {b} MHz ({a} MHz pre-throttle)"),
360            _ => String::new(),
361        };
362        out.push(Event {
363            ts_ms: sample.ts_ms,
364            device: device.clone(),
365            kind: EventKind::ThrottleEnd,
366            severity: Severity::Info,
367            confidence: Confidence::Fact,
368            title: format!("{name} stopped throttling{dur}"),
369            evidence: format!("throttle bits cleared{clocks}"),
370        });
371    }
372}
373
374fn process_events(
375    st: &mut DevState,
376    device: &DeviceId,
377    name: &str,
378    ts_ms: u64,
379    processes: &[ProcessSample],
380    out: &mut Vec<Event>,
381) {
382    let now: HashMap<u32, &ProcessSample> = processes.iter().map(|p| (p.pid, p)).collect();
383
384    // Suppress the attach-flood on the very first observation: those processes were already
385    // there; narrating them as new would be a lie.
386    if st.seen_first_procs {
387        for (pid, p) in &now {
388            if !st.procs.contains_key(pid) {
389                let mem = p
390                    .mem_bytes
391                    .map(|b| format!(", using {}", fmt_bytes(b)))
392                    .unwrap_or_default();
393                out.push(Event {
394                    ts_ms,
395                    device: device.clone(),
396                    kind: EventKind::ProcessAttached,
397                    severity: Severity::Info,
398                    confidence: Confidence::Fact,
399                    title: format!("{} (pid {}) attached to {name}{mem}", p.name, pid),
400                    evidence: format!("new {} client in process list", p.kind.prose()),
401                });
402            }
403        }
404        let gone: Vec<ProcessSample> = st
405            .procs
406            .values()
407            .filter(|p| !now.contains_key(&p.pid))
408            .cloned()
409            .collect();
410        for p in gone {
411            let freed = p
412                .mem_bytes
413                .map(|b| format!(", freeing {}", fmt_bytes(b)))
414                .unwrap_or_default();
415            out.push(Event {
416                ts_ms,
417                device: device.clone(),
418                kind: EventKind::ProcessExited,
419                severity: Severity::Info,
420                confidence: Confidence::Fact,
421                title: format!("{} (pid {}) left {name}{freed}", p.name, p.pid),
422                evidence: format!(
423                    "pid {} no longer in process list; last seen holding {}",
424                    p.pid,
425                    p.mem_bytes
426                        .map(fmt_bytes)
427                        .unwrap_or_else(|| "unknown memory".into())
428                ),
429            });
430        }
431    }
432    st.seen_first_procs = true;
433    st.procs = now.into_iter().map(|(k, v)| (k, v.clone())).collect();
434}
435
436fn idle_gap_events(
437    st: &mut DevState,
438    device: &DeviceId,
439    name: &str,
440    sample: &DynamicSample,
441    out: &mut Vec<Event>,
442) {
443    let Some(util) = sample.util_pct else {
444        // Utilization went unavailable: we can no longer see activity or idleness, so
445        // any gap claim from here on would be guesswork. Drop all tracking instead.
446        st.active_since = None;
447        st.idle_eligible = false;
448        st.idle_gap = None;
449        return;
450    };
451
452    if let Some(mut gap) = st.idle_gap.take() {
453        // Holders must stay attached for the WHOLE gap; one that exits mid-gap is
454        // already narrated by process_exited — an idle_gap on top would double-count.
455        gap.holders.retain(|pid, _| st.procs.contains_key(pid));
456
457        if util < IDLE_ACTIVE_UTIL_PCT {
458            gap.util_sum += util as f64;
459            gap.util_n += 1;
460            st.idle_gap = Some(gap);
461            return;
462        }
463
464        // Gap over — its duration is finally known, so decide whether it narrates.
465        let dur_ms = sample.ts_ms.saturating_sub(gap.start_ms);
466        let holder = gap
467            .holders
468            .iter()
469            .max_by_key(|(_, (_, mem))| *mem)
470            .map(|(pid, (pname, mem))| (*pid, pname.clone(), *mem));
471        if dur_ms >= IDLE_GAP_MIN_MS && !gap.hang_narrated {
472            if let Some((pid, pname, mem)) = holder {
473                let dur = fmt_dur_ms(dur_ms);
474                let mean_util = gap.util_sum / gap.util_n.max(1) as f64;
475                out.push(Event {
476                    ts_ms: sample.ts_ms,
477                    device: device.clone(),
478                    kind: EventKind::IdleGap,
479                    severity: Severity::Info,
480                    confidence: Confidence::Likely,
481                    title: format!(
482                        "{name} sat idle {dur} while {pname} (pid {pid}) stayed attached \
483                         — likely a dataloader or checkpoint stall"
484                    ),
485                    evidence: format!(
486                        "util {:.0}% → mean {mean_util:.1}% over {dur} ({}..{} ms); \
487                         {pname} (pid {pid}) held {} for the whole gap",
488                        gap.pre_util_pct,
489                        gap.start_ms,
490                        sample.ts_ms,
491                        fmt_bytes(mem),
492                    ),
493                });
494            }
495        }
496        // Recovery starts a fresh activity clock: the next gap only narrates after the
497        // device has re-earned IDLE_ACTIVE_MIN_MS of sustained work.
498        st.active_since = Some(sample.ts_ms);
499        st.idle_eligible = false;
500        return;
501    }
502
503    if util >= IDLE_ACTIVE_UTIL_PCT {
504        let since = *st.active_since.get_or_insert(sample.ts_ms);
505        if sample.ts_ms.saturating_sub(since) >= IDLE_ACTIVE_MIN_MS {
506            st.idle_eligible = true;
507        }
508        return;
509    }
510
511    st.active_since = None;
512    if util >= IDLE_GAP_UTIL_PCT || !st.idle_eligible {
513        return;
514    }
515    // Gap opens. Capture who is attached with a real allocation right now; only they
516    // can anchor the "stayed attached" claim when the gap ends.
517    let holders: HashMap<u32, (String, u64)> = st
518        .procs
519        .values()
520        .filter_map(|p| {
521            let mem = p.mem_bytes?;
522            (mem >= IDLE_HOLDER_MIN_BYTES).then(|| (p.pid, (p.name.clone(), mem)))
523        })
524        .collect();
525    st.idle_gap = Some(IdleGap {
526        start_ms: sample.ts_ms,
527        pre_util_pct: st.prev.as_ref().and_then(|p| p.util_pct).unwrap_or(util),
528        util_sum: util as f64,
529        util_n: 1,
530        holders,
531        hang_narrated: false,
532    });
533}
534
535/// `HangSuspected` — VRAM held, engines flat-dead, holder alive: the job has likely hung.
536///
537/// An inference of the riskiest kind, so the gate is steep: the device must be effectively
538/// idle (`≤ HANG_DEVICE_UTIL_PCT`), a holder must be sitting on ≥ 1 GiB while its *own*
539/// engine activity is also flat (or unreported), and that exact pattern must survive a full
540/// `HANG_MIN_MS` without a break before we narrate. We anchor to the largest qualifying
541/// holder at episode start and never re-anchor: the claim is that *this* allocation went
542/// quiet. The episode is dropped (never narrated) the instant any premise stops holding —
543/// the device wakes up, the holder exits, util goes unobservable, or even a sub-throttle
544/// flicker of activity — because a hang we cannot stand fully behind is worse than silence.
545fn hang_events(
546    st: &mut DevState,
547    device: &DeviceId,
548    name: &str,
549    sample: &DynamicSample,
550    out: &mut Vec<Event>,
551) {
552    let Some(util) = sample.util_pct else {
553        // Util unobservable: we cannot see "zero engine activity", so we cannot claim a
554        // hang. Drop the episode rather than freeze a stale window across the blind spot.
555        st.hang = None;
556        return;
557    };
558
559    // The largest holder that is itself quiet: ≥ 1 GiB resident with its own util flat or
560    // simply not reported (a hung kernel reports no per-process util — absence is expected).
561    let candidate = st
562        .procs
563        .values()
564        .filter(|p| p.mem_bytes.unwrap_or(0) >= HANG_HOLDER_MIN_BYTES)
565        .filter(|p| p.util_pct.map(|u| u <= HANG_PROC_UTIL_PCT).unwrap_or(true))
566        .max_by_key(|p| p.mem_bytes.unwrap_or(0));
567    let condition = util <= HANG_DEVICE_UTIL_PCT && candidate.is_some();
568
569    if let Some(mut ep) = st.hang.take() {
570        // The anchored holder must still be alive; if it exited, `process_exited` already
571        // told the story and the premise ("process still alive") is gone.
572        let holder_alive = st.procs.contains_key(&ep.holder_pid);
573        if util > HANG_RESET_UTIL_PCT || !holder_alive || !condition {
574            // Any break ends the episode silently — continuity is the whole claim.
575            return;
576        }
577        ep.util_sum += util as f64;
578        ep.util_n += 1;
579        let elapsed = sample.ts_ms.saturating_sub(ep.start_ms);
580        if elapsed >= HANG_MIN_MS && !ep.fired {
581            ep.fired = true;
582            let mean_util = ep.util_sum / ep.util_n.max(1) as f64;
583            let dur = fmt_dur_ms(elapsed);
584            out.push(Event {
585                ts_ms: sample.ts_ms,
586                device: device.clone(),
587                kind: EventKind::HangSuspected,
588                severity: Severity::Warning,
589                confidence: Confidence::Likely,
590                title: format!(
591                    "{name}: {} (pid {}) likely hung — held {} for {dur} with zero GPU \
592                     activity, process still alive",
593                    ep.holder_name,
594                    ep.holder_pid,
595                    fmt_bytes(ep.holder_mem),
596                ),
597                evidence: format!(
598                    "device util mean {mean_util:.1}% over {dur} ({}..{} ms); \
599                     {} (pid {}) held {} throughout while its own engine activity stayed flat",
600                    ep.start_ms,
601                    sample.ts_ms,
602                    ep.holder_name,
603                    ep.holder_pid,
604                    fmt_bytes(ep.holder_mem),
605                ),
606            });
607            // A hang is an idle gap that lasted too long to look alive; if a gap is still
608            // open over this same trough, mute it so one incident is narrated once.
609            if let Some(gap) = st.idle_gap.as_mut() {
610                gap.hang_narrated = true;
611            }
612        }
613        st.hang = Some(ep);
614        return;
615    }
616
617    if condition {
618        let holder = candidate.expect("condition implies a candidate");
619        st.hang = Some(HangEpisode {
620            start_ms: sample.ts_ms,
621            holder_pid: holder.pid,
622            holder_name: holder.name.clone(),
623            holder_mem: holder.mem_bytes.unwrap_or(0),
624            util_sum: util as f64,
625            util_n: 1,
626            fired: false,
627        });
628    }
629}
630
631/// `CpuSpillover` — a freshly-loaded model whose GPU stays idle while its process burns
632/// CPU: the signature of a partial CPU offload (the model did not fit in VRAM).
633///
634/// We open a fixed `SPILLOVER_WINDOW_MS` assessment when a *new* process attaches holding
635/// ≥ 2 GiB, then judge at window close: narrate only if the GPU averaged near-idle while the
636/// process averaged multiple busy cores, with enough CPU samples to mean it. The assessment
637/// is cancelled silently — never narrated — if the process exits mid-window, the GPU shows
638/// real use at any point, or (honesty rule) we never once saw its CPU: with no CPU
639/// visibility we cannot claim it is "burning CPU", so we say nothing rather than guess.
640/// The same honesty rule covers the GPU side: util must have been observed on at least
641/// half the window's ticks, else "the GPU is ~idle" would rest on no observation at all.
642fn spillover_events(
643    st: &mut DevState,
644    device: &DeviceId,
645    name: &str,
646    sample: &DynamicSample,
647    processes: &[ProcessSample],
648    out: &mut Vec<Event>,
649) {
650    let now: HashMap<u32, &ProcessSample> = processes.iter().map(|p| (p.pid, p)).collect();
651
652    // Open a window for each newly-attached big-memory holder. Skip the first observation:
653    // those processes were already resident, not freshly loaded, so they are no story.
654    if st.seen_first_procs {
655        for (pid, p) in &now {
656            if st.procs.contains_key(pid) || st.spillovers.contains_key(pid) {
657                continue;
658            }
659            if p.mem_bytes.unwrap_or(0) >= SPILLOVER_HOLDER_MIN_BYTES {
660                st.spillovers.insert(
661                    *pid,
662                    Spillover {
663                        name: p.name.clone(),
664                        mem_bytes: p.mem_bytes.unwrap_or(0),
665                        start_ms: sample.ts_ms,
666                        util_sum: 0.0,
667                        util_n: 0,
668                        cpu_sum: 0.0,
669                        cpu_n: 0,
670                        tick_n: 0,
671                    },
672                );
673            }
674        }
675    }
676
677    if st.spillovers.is_empty() {
678        return;
679    }
680
681    // A device showing real use cancels every open assessment at once: the premise of the
682    // whole inference is that the GPU is idle, and one busy reading refutes it.
683    let gpu_busy = sample
684        .util_pct
685        .map(|u| u >= SPILLOVER_BUSY_UTIL_PCT)
686        .unwrap_or(false);
687
688    let mut to_emit: Vec<Event> = Vec::new();
689    st.spillovers.retain(|pid, sp| {
690        if gpu_busy {
691            return false;
692        }
693        let Some(p) = now.get(pid) else {
694            // Exited mid-window: cancelled silently (its `process_exited` fact stands).
695            return false;
696        };
697        sp.tick_n += 1;
698        if let Some(u) = sample.util_pct {
699            sp.util_sum += u as f64;
700            sp.util_n += 1;
701        }
702        if let Some(c) = p.cpu_pct {
703            sp.cpu_sum += c as f64;
704            sp.cpu_n += 1;
705        }
706
707        if sample.ts_ms.saturating_sub(sp.start_ms) < SPILLOVER_WINDOW_MS {
708            return true; // window still open
709        }
710
711        // Window closed — judge. Means require samples; no CPU sample at all means no CPU
712        // visibility, and we refuse to claim a CPU burn we never observed. Symmetrically,
713        // util must have been *observed* on at least half the window's ticks: a blind
714        // window (util None throughout) would otherwise mean 0% and fabricate "the GPU
715        // is ~idle" with zero actual GPU evidence.
716        let util_grounded =
717            sp.util_n > 0 && sp.util_n * 100 >= sp.tick_n * SPILLOVER_MIN_UTIL_COVERAGE_PCT;
718        let mean_util = sp.util_sum / sp.util_n.max(1) as f64;
719        let mean_cpu = sp.cpu_sum / sp.cpu_n.max(1) as f64;
720        if util_grounded
721            && sp.cpu_n >= SPILLOVER_MIN_CPU_SAMPLES
722            && mean_util < SPILLOVER_MAX_MEAN_UTIL_PCT
723            && mean_cpu >= SPILLOVER_MIN_MEAN_CPU_PCT
724        {
725            let span = fmt_dur_ms(sample.ts_ms.saturating_sub(sp.start_ms));
726            to_emit.push(Event {
727                ts_ms: sample.ts_ms,
728                device: device.clone(),
729                kind: EventKind::CpuSpillover,
730                severity: Severity::Warning,
731                confidence: Confidence::Likely,
732                title: format!(
733                    "{} (pid {pid}) loaded {} but {name} is ~idle while its CPU runs hot \
734                     — likely partial CPU offload (model may not fit in VRAM)",
735                    sp.name,
736                    fmt_bytes(sp.mem_bytes),
737                ),
738                evidence: format!(
739                    "over {span} ({}..{} ms): {name} util mean {mean_util:.1}%, \
740                     {} (pid {pid}) CPU mean {mean_cpu:.0}% of one core ({} samples)",
741                    sp.start_ms, sample.ts_ms, sp.name, sp.cpu_n,
742                ),
743            });
744        }
745        false // window done either way
746    });
747    out.extend(to_emit);
748}
749
750fn vram_pressure_events(
751    st: &mut DevState,
752    device: &DeviceId,
753    name: &str,
754    sample: &DynamicSample,
755    mem_total: Option<u64>,
756    out: &mut Vec<Event>,
757) {
758    let (Some(used), Some(total)) = (sample.mem_used_bytes, mem_total) else {
759        return;
760    };
761    if total == 0 {
762        return;
763    }
764
765    // A sharp drop (process exit, allocator reset) invalidates the trend: an endpoint
766    // slope over a window straddling the old peak would understate the *current* climb
767    // rate — wrong in the dangerous direction. Restart the window instead.
768    if let Some(&(_, last_used)) = st.vram_window.back() {
769        if last_used.saturating_sub(used) > total / 20 {
770            st.vram_window.clear();
771        }
772    }
773
774    st.vram_window.push_back((sample.ts_ms, used));
775    while let Some(&(t0, _)) = st.vram_window.front() {
776        if sample.ts_ms.saturating_sub(t0) > VRAM_WINDOW_MS {
777            st.vram_window.pop_front();
778        } else {
779            break;
780        }
781    }
782
783    let frac = used as f64 / total as f64;
784    if frac < VRAM_PRESSURE_FRAC {
785        return;
786    }
787    let (&(t0, b0), &(t1, b1)) = match (st.vram_window.front(), st.vram_window.back()) {
788        (Some(a), Some(b)) if t_span(a, b) >= VRAM_MIN_SPAN_MS => (a, b),
789        _ => return,
790    };
791    let span_min = (t1 - t0) as f64 / 60_000.0;
792    let slope_per_min = (b1 as f64 - b0 as f64) / span_min;
793    if slope_per_min < VRAM_MIN_SLOPE_BYTES_PER_MIN {
794        return;
795    }
796    if let Some(last) = st.last_pressure_evt {
797        if sample.ts_ms.saturating_sub(last) < VRAM_COOLDOWN_MS {
798            return;
799        }
800    }
801    st.last_pressure_evt = Some(sample.ts_ms);
802
803    let headroom = total.saturating_sub(used) as f64;
804    let eta_min = headroom / slope_per_min;
805    // Only name a "largest holder" when at least one process has a *known* size —
806    // with mem_bytes all-None (WSL2, unprivileged fdinfo) max_by_key would crown an
807    // arbitrary process on zero evidence.
808    let grower = st
809        .procs
810        .values()
811        .filter(|p| p.mem_bytes.is_some())
812        .max_by_key(|p| p.mem_bytes)
813        .map(|p| format!(" (largest holder: {} pid {})", p.name, p.pid))
814        .unwrap_or_default();
815
816    out.push(Event {
817        ts_ms: sample.ts_ms,
818        device: device.clone(),
819        kind: EventKind::VramPressure,
820        severity: Severity::Warning,
821        confidence: Confidence::Likely,
822        title: format!(
823            "{name} VRAM {:.0}% and climbing ~{}/min — likely full in ~{:.0} min{grower}",
824            frac * 100.0,
825            fmt_bytes(slope_per_min as u64),
826            eta_min
827        ),
828        evidence: format!(
829            "used {}/{} ({:.1}%); slope +{}/min over last {:.1} min (linear extrapolation)",
830            fmt_bytes(used),
831            fmt_bytes(total),
832            frac * 100.0,
833            fmt_bytes(slope_per_min as u64),
834            span_min
835        ),
836    });
837}
838
839fn severity_for(t: &ThrottleReasons) -> Severity {
840    if t.hw_slowdown {
841        Severity::Critical
842    } else {
843        Severity::Warning
844    }
845}
846
847fn t_span(a: &(u64, u64), b: &(u64, u64)) -> u64 {
848    b.0.saturating_sub(a.0)
849}
850
851fn fmt_dur_ms(ms: u64) -> String {
852    // Round to nearest second — a 3.9s episode is "4s", not "3s".
853    let s = (ms + 500) / 1000;
854    if s >= 60 {
855        format!("{}m {}s", s / 60, s % 60)
856    } else {
857        format!("{s}s")
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    //! CPU-spillover observed-util coverage floor — regression tests for the honesty rule
864    //! that "the GPU is ~idle" must rest on real util observations, never on a mean over
865    //! zero (or too few) samples. The broader event-derivation suite lives in `lib.rs`;
866    //! these mirror its synthetic 1 Hz trace style.
867
868    use super::*;
869    use crate::model::ProcessKind;
870
871    /// A 1 Hz sample whose util may be absent — exercises the coverage-floor paths.
872    fn opt_sample(ts_ms: u64, util_pct: Option<f32>) -> DynamicSample {
873        DynamicSample {
874            ts_ms,
875            util_pct,
876            util_engine: None,
877            mem_used_bytes: Some(8 << 30),
878            power_mw: None,
879            temp_c: None,
880            fan_pct: None,
881            sm_clock_mhz: None,
882            mem_clock_mhz: None,
883            encoder_pct: None,
884            decoder_pct: None,
885            throttle: Some(ThrottleReasons::default()),
886        }
887    }
888
889    /// Build a process holding `mem` bytes, with optional self-util and CPU%.
890    fn proc_with(
891        pid: u32,
892        name: &str,
893        mem: u64,
894        util_pct: Option<f32>,
895        cpu_pct: Option<f32>,
896    ) -> ProcessSample {
897        ProcessSample {
898            pid,
899            name: name.into(),
900            kind: ProcessKind::Compute,
901            mem_bytes: Some(mem),
902            util_pct,
903            cpu_pct,
904            container: None,
905        }
906    }
907
908    /// Drive a 1 Hz trace with an optional util value, holding `procs` constant across it.
909    fn drive_opt(
910        engine: &mut EventEngine,
911        dev: &DeviceId,
912        ts_range: std::ops::RangeInclusive<u64>,
913        util_pct: Option<f32>,
914        procs: &[ProcessSample],
915    ) -> Vec<Event> {
916        let mut out = Vec::new();
917        for ts in ts_range.step_by(1000) {
918            out.extend(engine.observe(
919                dev,
920                &opt_sample(ts, util_pct),
921                Some(procs),
922                Some(16 << 30),
923                None,
924            ));
925        }
926        out
927    }
928
929    /// Util `None` on every tick of the window: the source cannot observe the GPU at all.
930    /// Before the coverage floor, the mean read as 0/max(1) = 0% and passed the idle gate —
931    /// narrating "the GPU is ~idle" with zero actual GPU evidence. Must stay silent.
932    #[test]
933    fn spillover_silent_when_util_unobserved_all_window() {
934        let mut engine = EventEngine::new();
935        let dev = DeviceId("test".into());
936
937        // Baseline tick with no procs so the model reads as freshly attached.
938        drive_opt(&mut engine, &dev, 0..=0, None, &[]);
939        // Hot CPU the whole window, but device util is None throughout.
940        let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
941        let out = drive_opt(&mut engine, &dev, 1_000..=91_000, None, &ollama);
942        assert!(
943            out.iter().all(|e| e.kind != EventKind::CpuSpillover),
944            "util never observed — the GPU-idle claim has no evidence and must stay silent"
945        );
946    }
947
948    /// Util observed on fewer than half the window's ticks (31 of 91), all of them low:
949    /// the observed mean would pass the idle gate, but the coverage floor refuses the
950    /// judgment — too much of the window is a blind spot to mean it.
951    #[test]
952    fn spillover_silent_when_util_coverage_below_half_window() {
953        let mut engine = EventEngine::new();
954        let dev = DeviceId("test".into());
955
956        drive_opt(&mut engine, &dev, 0..=0, None, &[]);
957        let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
958        // Blind for the first 60 ticks, observed-low for the last 31.
959        let mut out = drive_opt(&mut engine, &dev, 1_000..=60_000, None, &ollama);
960        out.extend(drive_opt(
961            &mut engine,
962            &dev,
963            61_000..=91_000,
964            Some(5.0),
965            &ollama,
966        ));
967        assert!(
968            out.iter().all(|e| e.kind != EventKind::CpuSpillover),
969            "31 of 91 ticks observed is below half-window coverage — must stay silent"
970        );
971    }
972
973    /// Coverage just over the floor (46 of 91 ticks observed, all low) restores the claim:
974    /// the floor blocks blindness, not legitimate partial visibility.
975    #[test]
976    fn spillover_fires_once_coverage_reaches_half_window() {
977        let mut engine = EventEngine::new();
978        let dev = DeviceId("test".into());
979        engine.register_device(dev.clone(), "GPU0".into());
980
981        drive_opt(&mut engine, &dev, 0..=0, None, &[]);
982        let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
983        // Blind for 45 ticks, observed-low for 46: 46/91 clears the half-window floor.
984        let mut out = drive_opt(&mut engine, &dev, 1_000..=45_000, None, &ollama);
985        out.extend(drive_opt(
986            &mut engine,
987            &dev,
988            46_000..=91_000,
989            Some(5.0),
990            &ollama,
991        ));
992        let n = out
993            .iter()
994            .filter(|e| e.kind == EventKind::CpuSpillover)
995            .count();
996        assert_eq!(
997            n, 1,
998            "46 of 91 ticks observed clears the floor — the grounded claim must narrate once"
999        );
1000    }
1001
1002    /// The fully-observed near-idle window still narrates: the floor must not silence the
1003    /// textbook case it exists to protect.
1004    #[test]
1005    fn spillover_still_fires_with_observed_low_util() {
1006        let mut engine = EventEngine::new();
1007        let dev = DeviceId("test".into());
1008        engine.register_device(dev.clone(), "GPU0".into());
1009
1010        drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
1011        let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
1012        let out = drive_opt(&mut engine, &dev, 1_000..=91_000, Some(5.0), &ollama);
1013        let n = out
1014            .iter()
1015            .filter(|e| e.kind == EventKind::CpuSpillover)
1016            .count();
1017        assert_eq!(
1018            n, 1,
1019            "fully-observed near-idle GPU plus hot CPU must still narrate exactly once"
1020        );
1021    }
1022}