use std::collections::{HashMap, VecDeque};
use crate::model::{fmt_bytes, DeviceId, DynamicSample, ProcessSample, ThrottleReasons};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warning,
Critical,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
Fact,
Likely,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
ThrottleStart,
ThrottleEnd,
ProcessAttached,
ProcessExited,
VramPressure,
IdleGap,
CollectorStall,
HistoryReset,
HangSuspected,
DeviceLost,
DeviceReturned,
CpuSpillover,
RecordingStarted,
RecordingStopped,
RecordingDegraded,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Event {
pub ts_ms: u64,
pub device: DeviceId,
pub kind: EventKind,
pub severity: Severity,
pub confidence: Confidence,
pub title: String,
pub evidence: String,
}
const VRAM_WINDOW_MS: u64 = 180_000;
const VRAM_MIN_SPAN_MS: u64 = 60_000;
const VRAM_PRESSURE_FRAC: f64 = 0.85;
const VRAM_MIN_SLOPE_BYTES_PER_MIN: f64 = 16.0 * 1024.0 * 1024.0;
const VRAM_COOLDOWN_MS: u64 = 90_000;
const IDLE_ACTIVE_UTIL_PCT: f32 = 50.0;
const IDLE_ACTIVE_MIN_MS: u64 = 30_000;
const IDLE_GAP_UTIL_PCT: f32 = 10.0;
const IDLE_GAP_MIN_MS: u64 = 10_000;
const IDLE_HOLDER_MIN_BYTES: u64 = 256 * 1024 * 1024;
const HANG_DEVICE_UTIL_PCT: f32 = 2.0;
const HANG_PROC_UTIL_PCT: f32 = 2.0;
const HANG_HOLDER_MIN_BYTES: u64 = 1024 * 1024 * 1024;
const HANG_RESET_UTIL_PCT: f32 = 10.0;
const HANG_MIN_MS: u64 = 600_000;
const SPILLOVER_HOLDER_MIN_BYTES: u64 = 2 * 1024 * 1024 * 1024;
const SPILLOVER_WINDOW_MS: u64 = 90_000;
const SPILLOVER_MAX_MEAN_UTIL_PCT: f64 = 15.0;
const SPILLOVER_BUSY_UTIL_PCT: f32 = 30.0;
const SPILLOVER_MIN_MEAN_CPU_PCT: f64 = 150.0;
const SPILLOVER_MIN_CPU_SAMPLES: u32 = 3;
const SPILLOVER_MIN_UTIL_COVERAGE_PCT: u32 = 50;
#[derive(Default)]
struct DevState {
prev: Option<DynamicSample>,
procs: HashMap<u32, ProcessSample>,
seen_first_procs: bool,
throttle_since: Option<u64>,
pre_throttle_clock: Option<u32>,
vram_window: VecDeque<(u64, u64)>,
last_pressure_evt: Option<u64>,
active_since: Option<u64>,
idle_eligible: bool,
idle_gap: Option<IdleGap>,
hang: Option<HangEpisode>,
spillovers: HashMap<u32, Spillover>,
}
struct IdleGap {
start_ms: u64,
pre_util_pct: f32,
util_sum: f64,
util_n: u32,
holders: HashMap<u32, (String, u64)>,
hang_narrated: bool,
}
struct HangEpisode {
start_ms: u64,
holder_pid: u32,
holder_name: String,
holder_mem: u64,
util_sum: f64,
util_n: u32,
fired: bool,
}
struct Spillover {
name: String,
mem_bytes: u64,
start_ms: u64,
util_sum: f64,
util_n: u32,
cpu_sum: f64,
cpu_n: u32,
tick_n: u32,
}
#[derive(Default)]
pub struct EventEngine {
state: HashMap<DeviceId, DevState>,
short_names: HashMap<DeviceId, String>,
}
impl EventEngine {
pub fn new() -> Self {
Self::default()
}
pub fn register_device(&mut self, id: DeviceId, short_name: String) {
self.short_names.insert(id, short_name);
}
fn short(&self, id: &DeviceId) -> String {
self.short_names
.get(id)
.cloned()
.unwrap_or_else(|| id.0.clone())
}
pub fn observe(
&mut self,
device: &DeviceId,
sample: &DynamicSample,
processes: Option<&[ProcessSample]>,
mem_total: Option<u64>,
temp_slowdown_c: Option<f32>,
) -> Vec<Event> {
let name = self.short(device);
let st = self.state.entry(device.clone()).or_default();
let mut out = Vec::new();
throttle_events(st, device, &name, sample, temp_slowdown_c, &mut out);
match processes {
Some(processes) => {
spillover_events(st, device, &name, sample, processes, &mut out);
process_events(st, device, &name, sample.ts_ms, processes, &mut out);
hang_events(st, device, &name, sample, &mut out);
idle_gap_events(st, device, &name, sample, &mut out);
}
None => {
st.hang = None;
st.idle_gap = None;
st.spillovers.clear();
}
}
vram_pressure_events(st, device, &name, sample, mem_total, &mut out);
st.prev = Some(sample.clone());
out
}
}
fn throttle_events(
st: &mut DevState,
device: &DeviceId,
name: &str,
sample: &DynamicSample,
temp_slowdown_c: Option<f32>,
out: &mut Vec<Event>,
) {
let Some(throttle) = sample.throttle else {
st.throttle_since = None;
st.pre_throttle_clock = None;
return;
};
let prev_any = st
.prev
.as_ref()
.and_then(|p| p.throttle)
.map(|t| t.any())
.unwrap_or(false);
let now_any = throttle.any();
if !prev_any && now_any {
let labels = throttle.labels().join(", ");
let pre_clock = st.prev.as_ref().and_then(|p| p.sm_clock_mhz);
st.pre_throttle_clock = pre_clock;
st.throttle_since = Some(sample.ts_ms);
let clocks = match (pre_clock, sample.sm_clock_mhz) {
(Some(a), Some(b)) if b < a => format!(" — clocks {a}→{b} MHz"),
_ => String::new(),
};
let temp_part = match (sample.temp_c, temp_slowdown_c) {
(Some(t), Some(thr)) => format!("; {t:.0}°C vs {thr:.0}°C slowdown threshold"),
(Some(t), None) => format!("; {t:.0}°C"),
_ => String::new(),
};
out.push(Event {
ts_ms: sample.ts_ms,
device: device.clone(),
kind: EventKind::ThrottleStart,
severity: severity_for(&throttle),
confidence: Confidence::Fact,
title: format!("{name} began throttling ({labels}){clocks}"),
evidence: format!("throttle bits: [{labels}]{temp_part}"),
});
} else if prev_any && !now_any {
let dur = st
.throttle_since
.take()
.map(|t0| format!(" after {}", fmt_dur_ms(sample.ts_ms.saturating_sub(t0))))
.unwrap_or_default();
let clocks = match (st.pre_throttle_clock.take(), sample.sm_clock_mhz) {
(Some(a), Some(b)) if b as f64 >= a as f64 * 0.9 => {
format!("; clocks recovered to {b} MHz")
}
(Some(a), Some(b)) => format!("; clocks now {b} MHz ({a} MHz pre-throttle)"),
_ => String::new(),
};
out.push(Event {
ts_ms: sample.ts_ms,
device: device.clone(),
kind: EventKind::ThrottleEnd,
severity: Severity::Info,
confidence: Confidence::Fact,
title: format!("{name} stopped throttling{dur}"),
evidence: format!("throttle bits cleared{clocks}"),
});
}
}
fn process_events(
st: &mut DevState,
device: &DeviceId,
name: &str,
ts_ms: u64,
processes: &[ProcessSample],
out: &mut Vec<Event>,
) {
let now: HashMap<u32, &ProcessSample> = processes.iter().map(|p| (p.pid, p)).collect();
if st.seen_first_procs {
for (pid, p) in &now {
if !st.procs.contains_key(pid) {
let mem = p
.mem_bytes
.map(|b| format!(", using {}", fmt_bytes(b)))
.unwrap_or_default();
out.push(Event {
ts_ms,
device: device.clone(),
kind: EventKind::ProcessAttached,
severity: Severity::Info,
confidence: Confidence::Fact,
title: format!("{} (pid {}) attached to {name}{mem}", p.name, pid),
evidence: format!("new {} client in process list", p.kind.prose()),
});
}
}
let gone: Vec<ProcessSample> = st
.procs
.values()
.filter(|p| !now.contains_key(&p.pid))
.cloned()
.collect();
for p in gone {
let freed = p
.mem_bytes
.map(|b| format!(", freeing {}", fmt_bytes(b)))
.unwrap_or_default();
out.push(Event {
ts_ms,
device: device.clone(),
kind: EventKind::ProcessExited,
severity: Severity::Info,
confidence: Confidence::Fact,
title: format!("{} (pid {}) left {name}{freed}", p.name, p.pid),
evidence: format!(
"pid {} no longer in process list; last seen holding {}",
p.pid,
p.mem_bytes
.map(fmt_bytes)
.unwrap_or_else(|| "unknown memory".into())
),
});
}
}
st.seen_first_procs = true;
st.procs = now.into_iter().map(|(k, v)| (k, v.clone())).collect();
}
fn idle_gap_events(
st: &mut DevState,
device: &DeviceId,
name: &str,
sample: &DynamicSample,
out: &mut Vec<Event>,
) {
let Some(util) = sample.util_pct else {
st.active_since = None;
st.idle_eligible = false;
st.idle_gap = None;
return;
};
if let Some(mut gap) = st.idle_gap.take() {
gap.holders.retain(|pid, _| st.procs.contains_key(pid));
if util < IDLE_ACTIVE_UTIL_PCT {
gap.util_sum += util as f64;
gap.util_n += 1;
st.idle_gap = Some(gap);
return;
}
let dur_ms = sample.ts_ms.saturating_sub(gap.start_ms);
let holder = gap
.holders
.iter()
.max_by_key(|(_, (_, mem))| *mem)
.map(|(pid, (pname, mem))| (*pid, pname.clone(), *mem));
if dur_ms >= IDLE_GAP_MIN_MS && !gap.hang_narrated {
if let Some((pid, pname, mem)) = holder {
let dur = fmt_dur_ms(dur_ms);
let mean_util = gap.util_sum / gap.util_n.max(1) as f64;
out.push(Event {
ts_ms: sample.ts_ms,
device: device.clone(),
kind: EventKind::IdleGap,
severity: Severity::Info,
confidence: Confidence::Likely,
title: format!(
"{name} sat idle {dur} while {pname} (pid {pid}) stayed attached \
— likely a dataloader or checkpoint stall"
),
evidence: format!(
"util {:.0}% → mean {mean_util:.1}% over {dur} ({}..{} ms); \
{pname} (pid {pid}) held {} for the whole gap",
gap.pre_util_pct,
gap.start_ms,
sample.ts_ms,
fmt_bytes(mem),
),
});
}
}
st.active_since = Some(sample.ts_ms);
st.idle_eligible = false;
return;
}
if util >= IDLE_ACTIVE_UTIL_PCT {
let since = *st.active_since.get_or_insert(sample.ts_ms);
if sample.ts_ms.saturating_sub(since) >= IDLE_ACTIVE_MIN_MS {
st.idle_eligible = true;
}
return;
}
st.active_since = None;
if util >= IDLE_GAP_UTIL_PCT || !st.idle_eligible {
return;
}
let holders: HashMap<u32, (String, u64)> = st
.procs
.values()
.filter_map(|p| {
let mem = p.mem_bytes?;
(mem >= IDLE_HOLDER_MIN_BYTES).then(|| (p.pid, (p.name.clone(), mem)))
})
.collect();
st.idle_gap = Some(IdleGap {
start_ms: sample.ts_ms,
pre_util_pct: st.prev.as_ref().and_then(|p| p.util_pct).unwrap_or(util),
util_sum: util as f64,
util_n: 1,
holders,
hang_narrated: false,
});
}
fn hang_events(
st: &mut DevState,
device: &DeviceId,
name: &str,
sample: &DynamicSample,
out: &mut Vec<Event>,
) {
let Some(util) = sample.util_pct else {
st.hang = None;
return;
};
let candidate = st
.procs
.values()
.filter(|p| p.mem_bytes.unwrap_or(0) >= HANG_HOLDER_MIN_BYTES)
.filter(|p| p.util_pct.map(|u| u <= HANG_PROC_UTIL_PCT).unwrap_or(true))
.max_by_key(|p| p.mem_bytes.unwrap_or(0));
let condition = util <= HANG_DEVICE_UTIL_PCT && candidate.is_some();
if let Some(mut ep) = st.hang.take() {
let holder_alive = st.procs.contains_key(&ep.holder_pid);
if util > HANG_RESET_UTIL_PCT || !holder_alive || !condition {
return;
}
ep.util_sum += util as f64;
ep.util_n += 1;
let elapsed = sample.ts_ms.saturating_sub(ep.start_ms);
if elapsed >= HANG_MIN_MS && !ep.fired {
ep.fired = true;
let mean_util = ep.util_sum / ep.util_n.max(1) as f64;
let dur = fmt_dur_ms(elapsed);
out.push(Event {
ts_ms: sample.ts_ms,
device: device.clone(),
kind: EventKind::HangSuspected,
severity: Severity::Warning,
confidence: Confidence::Likely,
title: format!(
"{name}: {} (pid {}) likely hung — held {} for {dur} with zero GPU \
activity, process still alive",
ep.holder_name,
ep.holder_pid,
fmt_bytes(ep.holder_mem),
),
evidence: format!(
"device util mean {mean_util:.1}% over {dur} ({}..{} ms); \
{} (pid {}) held {} throughout while its own engine activity stayed flat",
ep.start_ms,
sample.ts_ms,
ep.holder_name,
ep.holder_pid,
fmt_bytes(ep.holder_mem),
),
});
if let Some(gap) = st.idle_gap.as_mut() {
gap.hang_narrated = true;
}
}
st.hang = Some(ep);
return;
}
if condition {
let holder = candidate.expect("condition implies a candidate");
st.hang = Some(HangEpisode {
start_ms: sample.ts_ms,
holder_pid: holder.pid,
holder_name: holder.name.clone(),
holder_mem: holder.mem_bytes.unwrap_or(0),
util_sum: util as f64,
util_n: 1,
fired: false,
});
}
}
fn spillover_events(
st: &mut DevState,
device: &DeviceId,
name: &str,
sample: &DynamicSample,
processes: &[ProcessSample],
out: &mut Vec<Event>,
) {
let now: HashMap<u32, &ProcessSample> = processes.iter().map(|p| (p.pid, p)).collect();
if st.seen_first_procs {
for (pid, p) in &now {
if st.procs.contains_key(pid) || st.spillovers.contains_key(pid) {
continue;
}
if p.mem_bytes.unwrap_or(0) >= SPILLOVER_HOLDER_MIN_BYTES {
st.spillovers.insert(
*pid,
Spillover {
name: p.name.clone(),
mem_bytes: p.mem_bytes.unwrap_or(0),
start_ms: sample.ts_ms,
util_sum: 0.0,
util_n: 0,
cpu_sum: 0.0,
cpu_n: 0,
tick_n: 0,
},
);
}
}
}
if st.spillovers.is_empty() {
return;
}
let gpu_busy = sample
.util_pct
.map(|u| u >= SPILLOVER_BUSY_UTIL_PCT)
.unwrap_or(false);
let mut to_emit: Vec<Event> = Vec::new();
st.spillovers.retain(|pid, sp| {
if gpu_busy {
return false;
}
let Some(p) = now.get(pid) else {
return false;
};
sp.tick_n += 1;
if let Some(u) = sample.util_pct {
sp.util_sum += u as f64;
sp.util_n += 1;
}
if let Some(c) = p.cpu_pct {
sp.cpu_sum += c as f64;
sp.cpu_n += 1;
}
if sample.ts_ms.saturating_sub(sp.start_ms) < SPILLOVER_WINDOW_MS {
return true; }
let util_grounded =
sp.util_n > 0 && sp.util_n * 100 >= sp.tick_n * SPILLOVER_MIN_UTIL_COVERAGE_PCT;
let mean_util = sp.util_sum / sp.util_n.max(1) as f64;
let mean_cpu = sp.cpu_sum / sp.cpu_n.max(1) as f64;
if util_grounded
&& sp.cpu_n >= SPILLOVER_MIN_CPU_SAMPLES
&& mean_util < SPILLOVER_MAX_MEAN_UTIL_PCT
&& mean_cpu >= SPILLOVER_MIN_MEAN_CPU_PCT
{
let span = fmt_dur_ms(sample.ts_ms.saturating_sub(sp.start_ms));
to_emit.push(Event {
ts_ms: sample.ts_ms,
device: device.clone(),
kind: EventKind::CpuSpillover,
severity: Severity::Warning,
confidence: Confidence::Likely,
title: format!(
"{} (pid {pid}) loaded {} but {name} is ~idle while its CPU runs hot \
— likely partial CPU offload (model may not fit in VRAM)",
sp.name,
fmt_bytes(sp.mem_bytes),
),
evidence: format!(
"over {span} ({}..{} ms): {name} util mean {mean_util:.1}%, \
{} (pid {pid}) CPU mean {mean_cpu:.0}% of one core ({} samples)",
sp.start_ms, sample.ts_ms, sp.name, sp.cpu_n,
),
});
}
false });
out.extend(to_emit);
}
fn vram_pressure_events(
st: &mut DevState,
device: &DeviceId,
name: &str,
sample: &DynamicSample,
mem_total: Option<u64>,
out: &mut Vec<Event>,
) {
let (Some(used), Some(total)) = (sample.mem_used_bytes, mem_total) else {
return;
};
if total == 0 {
return;
}
if let Some(&(_, last_used)) = st.vram_window.back() {
if last_used.saturating_sub(used) > total / 20 {
st.vram_window.clear();
}
}
st.vram_window.push_back((sample.ts_ms, used));
while let Some(&(t0, _)) = st.vram_window.front() {
if sample.ts_ms.saturating_sub(t0) > VRAM_WINDOW_MS {
st.vram_window.pop_front();
} else {
break;
}
}
let frac = used as f64 / total as f64;
if frac < VRAM_PRESSURE_FRAC {
return;
}
let (&(t0, b0), &(t1, b1)) = match (st.vram_window.front(), st.vram_window.back()) {
(Some(a), Some(b)) if t_span(a, b) >= VRAM_MIN_SPAN_MS => (a, b),
_ => return,
};
let span_min = (t1 - t0) as f64 / 60_000.0;
let slope_per_min = (b1 as f64 - b0 as f64) / span_min;
if slope_per_min < VRAM_MIN_SLOPE_BYTES_PER_MIN {
return;
}
if let Some(last) = st.last_pressure_evt {
if sample.ts_ms.saturating_sub(last) < VRAM_COOLDOWN_MS {
return;
}
}
st.last_pressure_evt = Some(sample.ts_ms);
let headroom = total.saturating_sub(used) as f64;
let eta_min = headroom / slope_per_min;
let grower = st
.procs
.values()
.filter(|p| p.mem_bytes.is_some())
.max_by_key(|p| p.mem_bytes)
.map(|p| format!(" (largest holder: {} pid {})", p.name, p.pid))
.unwrap_or_default();
out.push(Event {
ts_ms: sample.ts_ms,
device: device.clone(),
kind: EventKind::VramPressure,
severity: Severity::Warning,
confidence: Confidence::Likely,
title: format!(
"{name} VRAM {:.0}% and climbing ~{}/min — likely full in ~{:.0} min{grower}",
frac * 100.0,
fmt_bytes(slope_per_min as u64),
eta_min
),
evidence: format!(
"used {}/{} ({:.1}%); slope +{}/min over last {:.1} min (linear extrapolation)",
fmt_bytes(used),
fmt_bytes(total),
frac * 100.0,
fmt_bytes(slope_per_min as u64),
span_min
),
});
}
fn severity_for(t: &ThrottleReasons) -> Severity {
if t.hw_slowdown {
Severity::Critical
} else {
Severity::Warning
}
}
fn t_span(a: &(u64, u64), b: &(u64, u64)) -> u64 {
b.0.saturating_sub(a.0)
}
fn fmt_dur_ms(ms: u64) -> String {
let s = (ms + 500) / 1000;
if s >= 60 {
format!("{}m {}s", s / 60, s % 60)
} else {
format!("{s}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::ProcessKind;
fn opt_sample(ts_ms: u64, util_pct: Option<f32>) -> DynamicSample {
DynamicSample {
ts_ms,
util_pct,
util_engine: None,
mem_used_bytes: Some(8 << 30),
power_mw: None,
temp_c: None,
fan_pct: None,
sm_clock_mhz: None,
mem_clock_mhz: None,
encoder_pct: None,
decoder_pct: None,
throttle: Some(ThrottleReasons::default()),
}
}
fn proc_with(
pid: u32,
name: &str,
mem: u64,
util_pct: Option<f32>,
cpu_pct: Option<f32>,
) -> ProcessSample {
ProcessSample {
pid,
name: name.into(),
kind: ProcessKind::Compute,
mem_bytes: Some(mem),
util_pct,
cpu_pct,
container: None,
}
}
fn drive_opt(
engine: &mut EventEngine,
dev: &DeviceId,
ts_range: std::ops::RangeInclusive<u64>,
util_pct: Option<f32>,
procs: &[ProcessSample],
) -> Vec<Event> {
let mut out = Vec::new();
for ts in ts_range.step_by(1000) {
out.extend(engine.observe(
dev,
&opt_sample(ts, util_pct),
Some(procs),
Some(16 << 30),
None,
));
}
out
}
#[test]
fn spillover_silent_when_util_unobserved_all_window() {
let mut engine = EventEngine::new();
let dev = DeviceId("test".into());
drive_opt(&mut engine, &dev, 0..=0, None, &[]);
let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
let out = drive_opt(&mut engine, &dev, 1_000..=91_000, None, &ollama);
assert!(
out.iter().all(|e| e.kind != EventKind::CpuSpillover),
"util never observed — the GPU-idle claim has no evidence and must stay silent"
);
}
#[test]
fn spillover_silent_when_util_coverage_below_half_window() {
let mut engine = EventEngine::new();
let dev = DeviceId("test".into());
drive_opt(&mut engine, &dev, 0..=0, None, &[]);
let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
let mut out = drive_opt(&mut engine, &dev, 1_000..=60_000, None, &ollama);
out.extend(drive_opt(
&mut engine,
&dev,
61_000..=91_000,
Some(5.0),
&ollama,
));
assert!(
out.iter().all(|e| e.kind != EventKind::CpuSpillover),
"31 of 91 ticks observed is below half-window coverage — must stay silent"
);
}
#[test]
fn spillover_fires_once_coverage_reaches_half_window() {
let mut engine = EventEngine::new();
let dev = DeviceId("test".into());
engine.register_device(dev.clone(), "GPU0".into());
drive_opt(&mut engine, &dev, 0..=0, None, &[]);
let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
let mut out = drive_opt(&mut engine, &dev, 1_000..=45_000, None, &ollama);
out.extend(drive_opt(
&mut engine,
&dev,
46_000..=91_000,
Some(5.0),
&ollama,
));
let n = out
.iter()
.filter(|e| e.kind == EventKind::CpuSpillover)
.count();
assert_eq!(
n, 1,
"46 of 91 ticks observed clears the floor — the grounded claim must narrate once"
);
}
#[test]
fn spillover_still_fires_with_observed_low_util() {
let mut engine = EventEngine::new();
let dev = DeviceId("test".into());
engine.register_device(dev.clone(), "GPU0".into());
drive_opt(&mut engine, &dev, 0..=0, Some(3.0), &[]);
let ollama = vec![proc_with(7777, "ollama", 12 << 30, Some(0.0), Some(310.0))];
let out = drive_opt(&mut engine, &dev, 1_000..=91_000, Some(5.0), &ollama);
let n = out
.iter()
.filter(|e| e.kind == EventKind::CpuSpillover)
.count();
assert_eq!(
n, 1,
"fully-observed near-idle GPU plus hot CPU must still narrate exactly once"
);
}
}