diff --git a/jcp/README.md b/jcp/README.md index c247027d7..0f49aa0bb 100644 --- a/jcp/README.md +++ b/jcp/README.md @@ -45,8 +45,8 @@ jcp/ - **`jcp-trace`** — ingests an agent run's **OpenTelemetry GenAI trace** (`gen_ai.*` semantic conventions: `chat` / `tool` / `retriever` spans with token usage) and its **evaluation assessments** (judge / human / code feedback), attaches **per-span joules** (measured where a meter is present, else `Estimator` from token counts — provenance never silently upgrades), and seals one **`eval/1` JCR-1 receipt**: the per-span token + joule breakdown with the headline `uj_per_accepted_token` at the **weakest** provenance present, the assessments, a deterministic **quality-gate** verdict (the sealed, declarative analog of a CI assert), and a **readiness projection** an ARL validation-axis claim consumes. The observability stacks *record* a trace and *log* a judge score in a table; this is the interop projection that **seals** the same trace, puts a joule on it, and hash-chains it via `EvalLog` — no new cryptography. The **gate is content-addressed**, so the sealed receipt commits to exactly which bar it was judged against (a tamper-evident artifact, not a mutable in-code `assert`), and **`align_threshold` / `Judge`** calibrate a judge's decision threshold to human pass/fail labels — judge alignment as a reproducible number, not a UI click. Behind the `server` feature, a std-only **OTLP `/v1/traces` receiver** lets any OpenTelemetry exporter POST a `gen_ai.*` trace (with its assessments) and get the sealed `eval/1` receipt back the instant the run ends. - **`jcp-intercept`** — **attested interceptor chains**: a composable, ordered pipeline of transforms *around* each model and tool call — the `before_model` / `after_model` / `before_tool` seams an agent's middleware stack runs, composed onion-order (forward in, reverse out) and stopping at the first refusal or human-approval hold. An interceptor may inspect or **rewrite** the request (redact a secret before it reaches the model, trim stale context), **refuse** it, or **hold** it for a human. The difference from the incumbent's arbitrary in-process hooks: every step seals an **`intercept/1`** JCR-1 receipt carrying the request digest **before and after** the step (so a redaction or a trim is provable — the content itself never sealed, only its hash), the seam, the outcome, and the **metered joules** at their honest provenance, hash-chained — the exact transform pipeline that produced a model request becomes reconstructable and verifiable by anyone, and a step cannot run un-sealed. Three reference interceptors — `Redact` (a content transform), `CallLimit` (a guardrail), `Approve` (a human-in-the-loop hold) — each reuse a primitive the family already holds rather than reinventing it. - **`jcp-efficiency`** — the **energy-efficiency spec**, surfaced as a sealed receipt. The dark-factory economics turn on four figures — **joules per accepted token, tokens per second, value per token, and the tokens/joules/dollars spent per COMPLETED, VERIFIED unit of work** — and the incumbents publish, at best, a *rate* (cost per token) that can't tell you whether the spend bought a result. An **`efficiency/1`** JCR-1 receipt records all four at honest provenance, hash-chained, with the one thing the rates omit: the **outcome anchor** (`completed` + a verdict). A measurement that didn't complete is spend that bought nothing, and the receipt says so. `aggregate` then splits value-delivered from total spend across a chain — charging *every* attempt (including the failed ones) against the results actually delivered — so `tokens_per_completed_unit` / `uj_per_completed_unit` / `value_per_joule` become the honest efficiency a "cost per token" number is designed to hide. Integers and strings only (JCR-1 forbids bools/floats); the rate figures recompute from the sealed inputs on read. -- **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`), NVML GPU energy behind the `nvml` feature (`HwShunt`), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. -- **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered on real hardware** (the sibling Sandbox standard's energy probe — Apple Silicon IOReport, RAPL, NVML), and the **measured** joules are debited from the grant — so the budget that stops a runaway agent is denominated in energy actually spent, not a token estimate. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. +- **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`, only while every package counter is live — `rapl_status()`), NVML GPU energy behind the `nvml` feature (`HwShunt` and `Partial`, since it does not read the CPU's share; only while live — `nvml_status()`), Apple Silicon IOReport (`HwShunt`, only while every CPU energy rail, each die's on a multi-die part, is live — on macOS 27.0.1 that rail is republished in lumps, so there the rung is declined and `ioreport_status()` says why), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). A vendor counter's reading is real but vendor-opaque and relayed by the platform, and it counts as a reading only while the counter is live (SPEC §5). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. +- **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered** by the sibling Sandbox standard's energy probe (Apple Silicon IOReport, RAPL, NVML — vendor energy counters, whose readings are real but vendor-opaque and relayed by the platform, and which count as readings only while the counter is live for the work, SPEC §5; otherwise the figure is an estimate and is graded as one), and the metered joules are debited from the grant — so the budget that stops a runaway agent is denominated in joules, not a token estimate. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. - **`jcp-wire`** — the `jcp/1` wire binding (SPEC §11): 4-byte length prefix + canonical det-CBOR over any byte stream, with TCP and Unix-socket endpoints and a client. The decoder rejects non-canonical bodies, so a message has exactly one wire form. Requests carry the full delegation chain and are **caller-signed** — the endpoint authenticates from the request itself and denies an impersonation `bad-signature`, sealed like any other decision. Responses carry the deterministic decision and the sealed receipt. - **`jcp-threshold`** — threshold actors (SPEC §2.2): a `did:key` whose private key is split k-of-n with FROST (RFC 9591); the group key is an ordinary Ed25519 key, so a 2-of-3 team issues, holds, and signs wire requests with nothing else in JCP changing. Shares come from a trusted dealer or **distributed key generation** — with DKG the group's private key never exists anywhere, not even momentarily. The two-agent rule for irreversible actions, refused by arithmetic rather than policy. - **`jcp-channel`** — receipt-settled budget channels (SPEC §4.5, the OPTIONAL profile): N enforcement points spend optimistically against one grant and settle by exchanging signed statements of cumulative spend — forged, stale, and regressing statements refused; between settlements the exposure is bounded by the window, and after settlement any overdraft is provable from the signed statements. Where bounded overdraft is unacceptable, `shard()` remains the baseline. diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 6625422d6..8800979b4 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -383,6 +383,31 @@ absent). The runtime debits `cost.uj()` from a grant's remaining budget on every allow; the coupling MUST be enforced. The energy axis is what no existing agentic protocol standardizes — JCP makes it a precondition, not a report. +`HwShunt` covers a vendor energy counter as well as a dedicated shunt. A vendor +counter's count is a real reading, not an estimate; what it lacks is an +independent root — how the vendor derives it is undisclosed, and the +platform's own software relays it — which is a gap in provenance, not in +accuracy. A reading earns the grade only while its counter is live: + +**Liveness (REQUIRED).** A reading from a vendor energy counter (RAPL, NVML, +Apple IOReport, ...) MAY be graded as a hardware reading only while the counter +is demonstrably live for the domain the figure claims: the implementation MUST +have observed the counter covering that domain advance under load (for Apple +IOReport: the CPU energy rail itself advancing in at least 5 sample intervals, +in at least 4 of every 5, over at least 300 ms while the probing thread burns +CPU). Where the figure sums several counters of the domain — a RAPL package +counter per package, an IOReport CPU energy rail per die — every one of them +MUST be observed live: a frozen one beside live ones would be left out of a +figure that claims the whole domain. A counter that is present but not live — +frozen, or republished in lumps — MUST NOT produce a hardware-graded figure; the implementation falls back to +its estimator or reports the work unmetered, and SHOULD record why. A figure +MUST NOT claim coverage of a domain its counter does not read; where the format +has a coverage field, such a figure is Partial. + +The record of why is the implementation's to choose; the reference meter +exposes the IOReport liveness status it was selected or declined on. Coverage +is defined in §5.2. + ### 5.1 The metering ladder (REQUIRED behavior, RECOMMENDED backends) The rule: **measured when the platform exposes it, estimated when it doesn't — @@ -395,11 +420,47 @@ hardware backend fails mid-reading and the figure degrades to a model. Recommended ladder, best first: 1. **RAPL** (Linux `powercap`) — package energy counters, wraparound-corrected - → `HwShunt`. -2. **NVML** — the GPU total-energy counter → `HwShunt`. -3. **Time × calibrated power** — wall-clock duration times a host-calibrated + → `HwShunt`, selected only while every package counter is live under the + probe's CPU burn, by the rule above as IOReport's CPU rails are. A span over + which no package counter advanced is metered by a lower rung. The package + counters are the top-level zones whose `name` is `package-`, every + socket's, each corrected at its own `max_energy_range_uj`; no other zone is + summed or probed. A `psys` zone already includes the packages, so summing it + beside them counts their energy twice, and a `dram` zone is not CPU energy. +2. **NVML** — the GPU total-energy counter → `HwShunt`, `Partial` (§5.2): it + reads the GPU and not the CPU's share of the work. Selected only while the + counter is live by the same rule; the probe cannot load the GPU, so for + this counter live means published continuously, advancing on the device's + own draw. A span over which it did not advance is metered by a lower rung. +3. **Apple IOReport** (the Energy Model group) — the SoC energy counters + → `HwShunt`, selected only while every CPU energy rail (one per die on a + multi-die part) is live by the rule above. A span over which that rail did not advance is metered by the next + rung, never graded `HwShunt`. Where the CPU rail is not live but the GPU rail + is, the rung is declined rather than reporting the GPU rail as a `Partial` + reading of the work: the GPU rail is machine-wide (display compositing + moves it), so booking it to a task misattributes energy the task did not + spend, and the partial figure would under-debit the budget, the CPU's share + going unmetered. The whole-task estimate below is the honest figure. +4. **Time × calibrated power** — wall-clock duration times a host-calibrated active power → `ModelBased`. Always available. -4. **Constant per-operation estimate** → `Estimator`. +5. **Constant per-operation estimate** → `Estimator`. + +### 5.2 Coverage (REQUIRED where a figure is partial) + +Provenance says how a figure was obtained; **coverage** says how much of the +work it accounts for: `Whole` (the default) or `Partial`. A reading can be +hardware-rooted and still cover only part of the work — an accelerator counter +on a host whose CPU has none — and its remainder is unmeasured, not zero. A +`Partial` figure MUST NOT be presented as a task total. + +On the wire coverage is carried only when it is `Partial`, and omitted when +`Whole`, so a figure sealed before the field existed keeps its exact bytes; a +verifier reads an absent field as `Whole`. The `jcp/2` receipt carries it as +`energy_coverage` (§8); the `intercept/1` step receipt of the reference +interceptor chain (`jcp-intercept`) carries it as `coverage`, beside its +`provenance`. In `intercept/1` the field, when present, has exactly one value, +`"Partial"`; a verifier MUST refuse any other value, `"Whole"` included, rather +than read it either way. ## 6. The Frame (REQUIRED) @@ -473,6 +534,7 @@ verdict = "allow" | "deny" deny_reason = string ; omitted on allow cost_uj = uint energy_provenance = "HwShunt" | "ModelBased" | "Estimator" +energy_coverage = "Partial" ; omitted when Whole (§5.2) budget_remaining_uj, budget_uj = uint steward = the signer's id (the COSE kid) ``` diff --git a/jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md b/jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md index 7149a318c..a876ae1d7 100644 --- a/jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md +++ b/jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md @@ -97,10 +97,15 @@ here the entire field — TX included — sits on an **unsolved problem, one of provenance and tamper-evidence rather than accuracy**: - **Vendor counters are opaque and platform-relayed, not independently rooted**: - RAPL/NVML/IOReport are *real* power-sensor readings, not fabricated estimates — but - their derivation and error bounds are undisclosed, nothing binds the number to the - sensor, and the reading reaches a verifier through the platform's own (untrusted) - software. RAPL has separately been an unprivileged-readable power side-channel + RAPL/NVML/IOReport are *real* readings of the vendor's own energy counter, not + fabricated estimates — which is why JCP grades a live one `HwShunt` (SPEC §5) — but + how the vendor derives the count (a sensor, a model, or both) and its error bounds + are undisclosed, nothing binds the number to its source, and the reading reaches a + verifier through the platform's own (untrusted) software. And a counter is a reading + only while it is live: one that is present but frozen, or republished in lumps (as + Apple's CPU energy rails are to an unprivileged reader on macOS 27.0.1), reads + nothing of the work and must not yield a hardware-graded figure. + RAPL has separately been an unprivileged-readable power side-channel (PLATYPUS), and its accuracy against an external meter is itself contested (arXiv:2505.09375). - **NVML / `nvidia-smi`** is a real on-board sensor, but its coarse sampling misses @@ -157,11 +162,12 @@ re-verifies both from scratch, and derives `IntegrityAttested`. **What `PhysicallyRooted` actually requires (an honest clarification).** It is *not* reached by standing up a cloud confidential VM. A cloud SEV-SNP/TDX instance gives a -hardware-grounded *report*, but its energy counter is still RAPL — a model, not a -meter — so the derived level is `IntegrityAttested`, not `PhysicallyRooted`. That is -the whole "confidential computing attests integrity, not energy" thesis, enforced at -the type level: the ladder refuses to promote a non-physical counter no matter how -genuine the TEE. `PhysicallyRooted` needs *both* a hardware-grounded report *and* a +hardware-grounded *report*, but its energy counter is still RAPL — a vendor counter +the platform relays, not a reading signed at its source — so the derived level is +`IntegrityAttested`, not `PhysicallyRooted`. That is the whole "confidential +computing attests integrity, not energy" thesis, enforced at the type level: the +ladder refuses to promote a counter not signed at its source, no matter how genuine +the TEE. `PhysicallyRooted` needs *both* a hardware-grounded report *and* a **physical counter whose reading is signed at the source** — a current shunt with a secure element — co-located with confidential compute. That is a custom-hardware build, not an instance type. Its software-reachable precursor — deriving diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index eab3c3ff2..a20556a00 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -7,15 +7,53 @@ //! figure, it just carries `ModelBased` or `Estimator` provenance instead of //! `HwShunt`. A reading never silently upgrades its own provenance. //! -//! The ladder, best first: +//! The ladder, best first. Every vendor counter rung is selected only while +//! its counter is live by one rule (SPEC §5): every rail of it that the figure +//! sums kept advancing over a probe, each in at least five sample intervals, +//! at least 80% of them, over at least 300 ms. A figure that sums a frozen rail +//! with live ones would leave out the frozen rail's share while claiming the +//! whole domain. Each probe runs once per process, and each rung's status says +//! why it was declined. //! 1. **RAPL** (`/sys/class/powercap`, Linux) — hardware package energy, -//! `Provenance::HwShunt`, wraparound-corrected. +//! `Provenance::HwShunt`, **only while every package counter is live** +//! under the probe's CPU burn ([`rapl_status`]). The figure sums the +//! top-level zones whose `name` is `package-`, every socket's, each +//! wraparound-corrected at its own range; never `psys` (the platform zone, +//! which already includes the packages), a top-level `dram` zone, or a +//! package's subzones. A span over which no package counter advanced is the +//! time×power model's figure. //! 2. **NVML** (feature `nvml`) — GPU energy via `libnvidia-ml`, -//! `Provenance::HwShunt`. -//! 3. **IOReport** (Apple Silicon) — real on-die SoC Energy Model counters via -//! Apple's IOReport C API (no sudo, no external binary). A real reading but a -//! vendor model, so `Provenance::ModelBased` — strictly better than the -//! wall-clock model below. +//! `Provenance::HwShunt` with `Coverage::Partial`: the counter reads the +//! GPU, so the CPU's share of the work is unmetered and the figure never +//! claims the whole task. Only while the counter is live ([`nvml_status`]); +//! a span over which it did not advance is the model's whole-task figure. +//! 3. **IOReport** (Apple Silicon) — the on-die SoC Energy Model counters via +//! Apple's IOReport C API (no sudo, no external binary), `Provenance::HwShunt`. +//! Like RAPL and NVML it is a vendor energy counter: the count is a real +//! reading, not an estimate, and what it lacks is an independent root — how +//! Apple derives it is undisclosed and the platform relays it — which is a +//! question of provenance, not accuracy, so it takes the same grade. **Only +//! while its CPU energy rail is live** ([`ioreport_status`]): a subscription +//! can open while every CPU rail holds its value, refreshed only in rare +//! batches, and then the summed total advances through the GPU rail — GPU +//! energy reported as the energy of CPU work — or by a whole refresh at +//! once. Live means every CPU rail kept advancing while the probe burned +//! CPU — `CPU Energy`, or on a multi-die part each die's `DIE__CPU +//! Energy`, since the counter sums them all: each in at least five separate +//! sample intervals, in at least 80% of the intervals observed, over a +//! window of at least 300 ms. A batch refresh, even one published in a few +//! steps, is not a live rail, and one live die beside a frozen one is not a +//! live CPU: the figure would miss the frozen die. Otherwise the +//! platform is not exposing CPU energy, so by the rule above this rung is +//! declined and the time×power model estimates instead. A span whose CPU +//! rail did not advance is the model's figure too, never a reading. +//! +//! Declined, the rung does not report the GPU rail as a partial reading of +//! the work, though on an M5 Max that rail stays live: it is machine-wide — +//! display compositing moves it — so booking it to a task misattributes +//! energy the task did not spend, and a partial figure would under-debit the +//! budget, the CPU's share of the work going unmetered. The whole-task +//! estimate stands. //! 4. **Time × power model** — wall-clock duration times a calibrated active //! power, `Provenance::ModelBased`. Always available. //! 5. **Constant estimator** — a fixed per-operation figure, @@ -32,45 +70,88 @@ use jcp_schema::{Energy, Provenance}; pub const DEFAULT_ACTIVE_MILLIWATTS: u64 = 15_000; /// RAPL package-energy reader (Linux `powercap`). Reads `energy_uj` for every -/// top-level `intel-rapl:N` package and corrects counter wraparound with +/// top-level zone (`intel-rapl:N`) whose `name` file reads `package-` — every +/// socket's package — and corrects each counter's wraparound at that zone's own /// `max_energy_range_uj`. +/// +/// The figure is the packages' energy and nothing else. A top-level zone is not +/// necessarily a package: many Intel hosts expose `psys`, the platform zone, +/// whose count already includes the packages', so summing it beside them counts +/// that energy twice; some expose a top-level `dram` zone, which is not CPU +/// energy at all. Subzones (`intel-rapl:0:0`, a package's `core`, `uncore` or +/// `dram`) are a share of their package and never summed, and a second +/// interface to the same packages (`intel-rapl-mmio:N`) is not read either. #[derive(Debug)] pub struct Rapl { - /// `(energy_uj path, max_energy_range_uj)` per package. - packages: Vec<(std::path::PathBuf, u64)>, + /// `(energy_uj path, max_energy_range_uj)` per package, in zone order. The + /// range is `None` where it could not be read, or read as zero: a counter + /// that falls across a span then has no known wrap, and gives no figure. + packages: Vec<(std::path::PathBuf, Option)>, +} + +/// Whether a powercap zone's `name` file names a CPU package: `package-`, +/// `` one or more ASCII digits, trailing newline allowed. Not `psys`, `dram`, +/// `core`, `uncore`, or anything else. +fn is_rapl_package_name(name: &str) -> bool { + name.trim() + .strip_prefix("package-") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) +} + +/// The zone index of a top-level RAPL zone directory, `intel-rapl:`; `None` +/// for anything else — the `intel-rapl` control type itself, a subzone +/// (`intel-rapl:0:0`), or another interface (`intel-rapl-mmio:0`). +fn rapl_top_level_zone(dir_name: &str) -> Option { + let n = dir_name.strip_prefix("intel-rapl:")?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + n.parse().ok() } impl Rapl { /// Open the RAPL interface. `None` when the platform has no readable - /// powercap tree (non-Linux, locked-down sysfs, no Intel/AMD RAPL). + /// powercap tree (non-Linux, locked-down sysfs, no Intel/AMD RAPL), no + /// package zone, or a package zone whose counter cannot be read. + /// Opening is not selection: [`Meter::detect`] selects the rung only while + /// [`rapl_status`] is [`CounterStatus::Live`]. pub fn open() -> Option { + Self::open_in(std::path::Path::new("/sys/class/powercap")) + } + + /// [`Rapl::open`] on the powercap tree at `root`, so the probe runs on fake + /// trees in tests. + /// + /// Every package zone or none: the figure claims the whole CPU, so a + /// package whose `energy_uj` cannot be read is not left out of it — the + /// reader does not open. Nor does it open when a top-level zone's `name` + /// cannot be read, since that zone may be a package. + fn open_in(root: &std::path::Path) -> Option { let mut packages = Vec::new(); - let dir = std::fs::read_dir("/sys/class/powercap").ok()?; + let dir = std::fs::read_dir(root).ok()?; for entry in dir.flatten() { - let name = entry.file_name(); - let name = name.to_string_lossy(); - // top-level packages only: "intel-rapl:0", not "intel-rapl:0:0" - if !name.starts_with("intel-rapl:") || name.matches(':').count() != 1 { + let dir_name = entry.file_name(); + let Some(index) = rapl_top_level_zone(&dir_name.to_string_lossy()) else { + continue; + }; + let name = std::fs::read_to_string(entry.path().join("name")).ok()?; + if !is_rapl_package_name(&name) { continue; } let energy = entry.path().join("energy_uj"); // must actually be readable, not just present - let readable = std::fs::read_to_string(&energy) - .map(|s| s.trim().parse::().is_ok()) - .unwrap_or(false); - if !readable { - continue; - } + std::fs::read_to_string(&energy).ok()?.trim().parse::().ok()?; let range = std::fs::read_to_string(entry.path().join("max_energy_range_uj")) .ok() .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(u64::MAX); - packages.push((energy, range)); + .filter(|&r| r > 0); + packages.push((index, energy, range)); } + packages.sort_by_key(|(index, _, _)| *index); if packages.is_empty() { None } else { - Some(Self { packages }) + Some(Self { packages: packages.into_iter().map(|(_, energy, range)| (energy, range)).collect() }) } } @@ -82,14 +163,53 @@ impl Rapl { .collect() } - /// Energy spent between two counter snapshots, wraparound-corrected. - fn delta(&self, before: &[u64], after: &[u64]) -> u64 { - self.packages - .iter() - .zip(before.iter().zip(after)) - .map(|((_, range), (b, a))| if a >= b { a - b } else { range.saturating_sub(*b).saturating_add(*a) }) - .sum() + /// Energy spent between two counter snapshots: the sum of each package's + /// own delta, each wraparound-corrected at that package's own + /// `max_energy_range_uj` ([`rapl_zone_delta`]). `None` — no figure, never + /// an invented one — if any package's delta is unknown. + fn delta(&self, before: &[u64], after: &[u64]) -> Option { + if before.len() != self.packages.len() || after.len() != self.packages.len() { + return None; + } + let mut total = 0u64; + for ((_, range), (b, a)) in self.packages.iter().zip(before.iter().zip(after)) { + total = total.checked_add(rapl_zone_delta(*b, *a, *range)?)?; + } + Some(total) } + + /// One liveness sample: each package counter as a rail, named by its + /// powercap directory (`intel-rapl:0`, ...), with its raw `energy_uj`. + /// Only the package zones the figure sums: `psys`, `dram` and subzones are + /// neither summed nor probed. `None` when a package cannot be read, which + /// the probe skips. A wrap shows as a fall, which the rule does not count + /// as an advance. + fn liveness_sample(&self) -> Option> { + let values = self.read()?; + let named: Vec<(String, i64)> = self + .packages + .iter() + .zip(values) + .map(|((path, _), uj)| { + let package = path.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); + (package.unwrap_or_default(), i64::try_from(uj).unwrap_or(i64::MAX)) + }) + .collect(); + Some(liveness::every_rail(&named)) + } +} + +/// One RAPL zone's energy between two reads of its `energy_uj`, modulo its own +/// wrap. A counter that went down wrapped at `max_range` (its +/// `max_energy_range_uj`): the energy is what it had left to the wrap plus what +/// it counted since. Without a known range, or with a first read above it, the +/// delta is unknown: `None`. It cannot see more than one wrap in a span — +/// minutes at package power. +fn rapl_zone_delta(before: u64, after: u64, max_range: Option) -> Option { + if after >= before { + return Some(after - before); + } + max_range?.checked_sub(before)?.checked_add(after) } /// NVML GPU-energy reader (feature `nvml`). Uses the device's total-energy @@ -114,6 +234,17 @@ impl Nvml { let mj = self.nvml.device_by_index(self.device_index).ok()?.total_energy_consumption().ok()?; Some(mj.saturating_mul(1_000)) } + + /// One liveness sample: the device's total-energy counter as its one + /// rail, in its raw millijoules. `None` when the read fails, which the + /// probe skips. + fn liveness_sample(&self) -> Option> { + let mj = self.nvml.device_by_index(self.device_index).ok()?.total_energy_consumption().ok()?; + Some(liveness::every_rail(&[( + format!("nvml device {} total energy", self.device_index), + i64::try_from(mj).unwrap_or(i64::MAX), + )])) + } } #[cfg(feature = "nvml")] @@ -123,18 +254,520 @@ impl std::fmt::Debug for Nvml { } } +/// The sustained-advance liveness rule (SPEC §5), one rule for every vendor +/// counter this crate reads: a counter's count is a hardware reading only while +/// the counter is live, and live means **every one of its rails kept +/// advancing** over a probe — each in at least [`MIN_LIVE_ADVANCES`] separate +/// sample intervals, in at least 80% of the intervals observed, over at least +/// [`MIN_LIVE_WINDOW`] of wall time. A rail is one cumulative count within a +/// sample: a RAPL package's `energy_uj`, NVML's total-energy counter, an +/// IOReport `CPU Energy` channel. Every rail, because each rung's figure sums +/// every rail it hands the rule — RAPL every package, IOReport every CPU rail +/// (both dies' on a two-die part) — and a figure summing a live rail with a +/// frozen one leaves the frozen rail's share out while claiming the whole +/// domain. +/// +/// Kept apart from every counter's FFI, so the rule, its loop and its stopping +/// rule run on scripted samples and a scripted clock in tests, and so the RAPL, +/// NVML and IOReport rungs apply this one rule rather than copies of it. What a +/// rung hands the rule is its own business: IOReport picks its CPU energy rails +/// out of an Energy Model sample; RAPL hands every package counter; NVML its +/// one device counter. +/// +/// The thresholds were set against the batch refreshes an unprivileged reader +/// sees on IOReport's CPU rails (see `ioreport::RailWatch`): a count republished +/// in one step, two, or a few, is not a live count, whichever counter it comes +/// from. +#[allow(unsafe_code)] +mod liveness { + use std::os::raw::{c_int, c_long}; + use std::time::{Duration, Instant}; + + use crate::CounterStatus; + + /// CPU time the probing thread burns, by its own CPU clock + /// ([`thread_cpu_time`]), before each liveness re-sample. Thread CPU time, + /// not wall time: on a loaded host a wall-clock spin of 25 ms can get a + /// few milliseconds of CPU, and the rail would be watched while barely + /// loaded. + pub(crate) const PROBE_TICK: Duration = Duration::from_millis(25); + + /// The most wall time one tick may take. A thread that cannot get + /// [`PROBE_TICK`] of CPU in this long (under 5% of a core) stops its tick + /// here, so a starved probe still ends. + pub(crate) const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); + + /// In how many separate sample intervals each rail must advance before + /// it counts as live. Two is not enough: see `ioreport::RailWatch`. + /// Also the fewest intervals the probe observes before it may call a rail + /// frozen. + pub(crate) const MIN_LIVE_ADVANCES: u32 = 5; + + /// The shortest observation window, by wall clock from the first usable + /// sample, over which a rail may be called live. The batch refreshes seen + /// were published within 64 ms, so the steps of one cannot fill this + /// window, nor could those of one spread over twice as long. + pub(crate) const MIN_LIVE_WINDOW: Duration = Duration::from_millis(300); + + /// Has a rail that advanced in `advancing` of `intervals` intervals + /// advanced in at least 80% of them? Integer arithmetic, so the boundary is + /// exact: 4 of 5 is enough, 3 of 4 is not. + pub(crate) fn sustained(advancing: u32, intervals: u32) -> bool { + u64::from(advancing) * 5 >= u64::from(intervals) * 4 + } + + /// However slow a counter is, or however many of its samples fail, the + /// probe stops after this many budgets. + pub(crate) const PROBE_BUDGET_CAP: u32 = 5; + + /// The probe budget of the counter rungs (RAPL, NVML): one second, as + /// IOReport's `CPU_RAIL_LIVENESS_BUDGET`. A live counter is called live + /// about 300 ms in (five intervals in, on a loaded host); the budget only + /// bounds how long a frozen one is watched. Paid once per process. + pub(crate) const BUDGET: Duration = Duration::from_millis(1000); + + /// A rail within one sample: its name, and its ordinal among the rails of + /// that name in the same sample. Names are not unique on every counter + /// (an M3 Ultra's Energy Model group has 264 duplicated names), and keyed + /// by name alone, two same-named rails stepping together in one refresh + /// would count as two advances of one rail. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct RailKey { + pub(crate) name: String, + pub(crate) ordinal: u32, + } + + /// The channels of one sample whose names `pick` accepts, each keyed by + /// [`RailKey`], with its absolute raw value. Item by item: no arithmetic + /// across channels. + pub(crate) fn rails_where(sample: &[(String, i64)], pick: fn(&str) -> bool) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in sample { + if pick(name) { + let ordinal = rails.iter().filter(|(k, _)| k.name == *name).count() as u32; + rails.push((RailKey { name: name.clone(), ordinal }, *raw)); + } + } + rails + } + + /// Every channel of one sample as a rail: for a counter whose every + /// channel is a count of the domain it claims (RAPL's packages, NVML's one + /// counter). + pub(crate) fn every_rail(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + rails_where(sample, |_| true) + } + + /// The liveness rule over plain samples of rails, kept apart from the FFI + /// so all of it, including the pairing of each sample with the previous + /// one, runs on scripted samples in tests. + /// + /// A counter is live only when **every** rail seen keeps advancing, each + /// on its own count: it advanced in at least [`MIN_LIVE_ADVANCES`] + /// separate sample intervals, those are at least 80% of all the intervals + /// observed so far ([`sustained`]), and the window observed so far spans + /// at least [`MIN_LIVE_WINDOW`] of wall time from the first usable sample. + /// The three are checked after each interval, and the counter is live from + /// the first interval at which all three hold for every rail. A counter + /// with no rail is never live. + /// + /// Every rail, not one: each rung sums every rail it hands the rule into + /// its figure. On a two-die part (an M3 Ultra's `DIE_0_CPU Energy` and + /// `DIE_1_CPU Energy`), one live die beside a frozen one would give a + /// figure that misses the frozen die while claiming the whole CPU, and on a + /// two-package RAPL host, one that misses a package. A rail seen in only + /// some samples is still counted against every interval observed, so one + /// missing from many of them fails the share and holds the counter frozen: + /// a rail that comes and goes is not a live count either. + /// + /// Each condition closes a way for a refresh to pass. Five advances: a + /// refresh is published in one step, two, or a few. 80% of the intervals: the + /// steps of a refresh sit together, and are a small share of any window + /// that also holds the flat intervals around them. 300 ms: a refresh + /// published in five quick steps at the very start of a window is 100% of a + /// 155 ms window, but not of a 300 ms one. A live rail advances in nearly + /// every interval, so it meets all three about 300 ms in, or after five + /// intervals on a host loaded enough that five take longer. Counting per + /// rail keeps a step on each of several rails from adding up to a false + /// Live. + /// + /// Each interval is a sample and the one **before** it. Compared with the + /// first sample instead, a rail that stepped once would show a rise in + /// every interval after the step, and one refresh would read as live. + #[derive(Debug, Default, Clone)] + pub(crate) struct Advances { + /// Sample intervals observed so far: pairs of consecutive usable samples. + pub(crate) intervals: u32, + /// The previous usable sample's rails, which the next one is compared + /// with. + prev: Option>, + /// Every rail seen, with the number of intervals in which it advanced. + pub(crate) rails: Vec<(RailKey, u32)>, + /// When the first usable sample was taken, by the probe's clock: where + /// the window starts. + first_at: Option, + /// How much wall time the window spans so far: from the first usable + /// sample to the latest. + pub(crate) window: Duration, + /// Set at the first interval after which every rail met all three + /// conditions, and never unset. + live: bool, + } + + impl Advances { + /// Fold in the next usable sample's rails, taken at `at` by the probe's + /// wall clock. The first only sets the baseline, and starts the window. + /// Each later one closes an interval with the sample before it, and a + /// rail advanced over that interval when its raw value rose. A rail is + /// counted at most once per interval. A rail whose value FELL did not + /// advance: a cumulative counter that goes down was reset or wrapped, + /// and that is not energy spent. A rail missing from the previous + /// sample has nothing to be compared with, so this sample only sets its + /// baseline. After each interval, the rule is checked. + pub(crate) fn observe(&mut self, cur: Vec<(RailKey, i64)>, at: Duration) { + for (key, _) in &cur { + if !self.rails.iter().any(|(k, _)| k == key) { + self.rails.push((key.clone(), 0)); + } + } + let first_at = *self.first_at.get_or_insert(at); + self.window = at.saturating_sub(first_at); + if let Some(prev) = &self.prev { + self.intervals += 1; + for (key, now) in &cur { + let rose = prev.iter().any(|(k, before)| k == key && now > before); + if let Some((_, count)) = self.rails.iter_mut().find(|(k, _)| k == key) { + *count += u32::from(rose); + } + } + let intervals = self.intervals; + // Every rail, and at least one: `all` over no rails is true, + // and a sample with no rail in it is no evidence of anything. + self.live |= self.window >= MIN_LIVE_WINDOW + && !self.rails.is_empty() + && self + .rails + .iter() + .all(|&(_, advancing)| advancing >= MIN_LIVE_ADVANCES && sustained(advancing, intervals)); + } + self.prev = Some(cur); + } + + /// Has every rail kept advancing: enough advances, a high enough + /// share of the intervals, over a long enough window? + pub(crate) fn is_live(&self) -> bool { + self.live + } + + /// Keep sampling? Until the rail proves live, or until `budget` has + /// elapsed **and** at least [`MIN_LIVE_ADVANCES`] intervals were + /// observed, so a live rail could have shown that many advances (slow + /// samples must not make a live rail look frozen) — and never past + /// [`PROBE_BUDGET_CAP`] budgets. + pub(crate) fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { + !self.is_live() + && elapsed < budget * PROBE_BUDGET_CAP + && (elapsed < budget || self.intervals < MIN_LIVE_ADVANCES) + } + } + + /// The rule's loop, generic over where samples come from, what happens + /// between them and the clock, so tests drive it with scripted samples and + /// a scripted clock exactly as the probes drive it. Take a sample and read + /// the clock; hand the rule the sample with that reading; stop when + /// [`Advances::wants_more`] says so; otherwise `tick` and sample again. A + /// sample that fails is skipped, so the next usable one is compared with + /// the last usable one. `elapsed` is the wall time since the probe started. + pub(crate) fn watch( + mut sample: impl FnMut() -> Option>, + mut tick: impl FnMut(), + elapsed: impl Fn() -> Duration, + budget: Duration, + ) -> Advances { + let mut advances = Advances::default(); + loop { + let got = sample(); + let now = elapsed(); + if let Some(rails) = got { + advances.observe(rails, now); + } + if !advances.wants_more(now, budget) { + return advances; + } + tick(); + } + } + + /// One liveness probe of a counter rung: `open` the counter; if it opens, + /// [`watch`] its samples, `tick` between them, by the clock `now`, for + /// `budget`. `None` when it does not open: then nothing is sampled and + /// nothing ticks. Generic over each part, so the tests drive this very + /// function with a scripted counter, tick and clock; the rungs' cached + /// probes pass the real ones. + pub(crate) fn probe( + open: impl FnOnce() -> Option, + sample: impl Fn(&C) -> Option>, + tick: impl FnMut(), + now: impl Fn() -> Instant, + budget: Duration, + ) -> Option { + let started = now(); + let counter = open()?; + Some(watch(|| sample(&counter), tick, || now().saturating_duration_since(started), budget)) + } + + /// The status a counter probe reports. No probe (the counter did not + /// open) and no interval observed (no two usable samples in five budgets) + /// are both `Unavailable`, never `Live`. + pub(crate) fn status_of(probed: Option<&Advances>) -> CounterStatus { + match probed { + None => CounterStatus::Unavailable, + Some(a) if a.intervals == 0 => CounterStatus::Unavailable, + Some(a) if a.is_live() => CounterStatus::Live, + Some(_) => CounterStatus::Frozen, + } + } + + /// `struct timespec` on 64-bit macOS and Linux. + #[repr(C)] + struct Timespec { + tv_sec: c_long, + tv_nsec: c_long, + } + + /// `CLOCK_THREAD_CPUTIME_ID`: 16 in macOS ``, 3 in Linux + /// ``. + #[cfg(target_os = "macos")] + const CLOCK_THREAD_CPUTIME_ID: c_int = 16; + #[cfg(any(target_os = "linux", target_os = "android"))] + const CLOCK_THREAD_CPUTIME_ID: c_int = 3; + + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + unsafe extern "C" { + fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; + } + + /// CPU time this thread has used, user and system, by + /// `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`. `None` if the clock cannot be + /// read, and on a platform without that clock. The one thread clock of the + /// probes: [`burn`] and the IOReport probe's clocks both read it. + pub(crate) fn thread_cpu_time() -> Option { + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + { + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + (rc == 0).then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "android")))] + { + let _ = Timespec { tv_sec: 0, tv_nsec: 0 }; + None + } + } + + /// Spin this thread until it has used `cpu` of CPU time by its own CPU + /// clock, or until `wall_cap` of wall time has passed, whichever comes + /// first. However loaded the host, the thread has then burned `cpu`, unless + /// it got under `cpu / wall_cap` of a core. With no readable thread clock + /// only the wall cap ends the spin: slower, never shorter. + pub(crate) fn burn(cpu: Duration, wall_cap: Duration) { + let wall = Instant::now(); + let start = thread_cpu_time(); + let mut acc: u64 = 0; + loop { + for _ in 0..1_000 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + let used = match (start, thread_cpu_time()) { + (Some(a), Some(b)) => b.saturating_sub(a), + _ => Duration::ZERO, + }; + if used >= cpu || wall.elapsed() >= wall_cap { + return; + } + } + } + + /// The real probes' tick for a CPU-energy counter: spin until this thread + /// has burned [`PROBE_TICK`] of its own CPU time, or + /// [`PROBE_TICK_WALL_CAP`] of wall time has passed. + pub(crate) fn burn_tick() { + burn(PROBE_TICK, PROBE_TICK_WALL_CAP); + } +} + +/// The liveness status of a single vendor energy counter rung — RAPL's +/// package counters, NVML's total-energy counter — as its probe found it on +/// this host. Probed once per process and cached; see [`rapl_status`] and +/// [`nvml_status`]. Only [`CounterStatus::Live`] admits the rung (SPEC §5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CounterStatus { + /// The counter could not be opened or read — absent (not this platform, + /// no such device, the feature not built, for RAPL no zone named + /// `package-`), unreadable (a locked-down sysfs), or it gave fewer than + /// two usable samples in five budgets. Never selected. + Unavailable, + /// The counter opened, but not every rail of it kept advancing while the + /// probe ran: one held, or stepped a few times together — republished in + /// lumps, not a live count. Never selected: a lump landing in one span + /// would be graded a hardware reading of that span's work, and a held rail + /// beside live ones would be left out of a figure that claims it. + Frozen, + /// Every rail of the counter kept advancing while the probe ran: each in + /// at least five separate sample intervals, making up at least 80% of + /// those observed, over at least 300 ms. The rung is selected. + Live, +} + +/// Whether a counter's liveness status admits its rung: [`CounterStatus::Live`] +/// and nothing else. The one gate the RAPL and NVML rungs go through, pure so +/// that every status is checked in a test. +fn admits_counter(status: CounterStatus) -> bool { + matches!(status, CounterStatus::Live) +} + +/// A rung's counter, opened only when its status admits the rung: `open` is +/// not even called otherwise. What [`Meter::detect`] selects a counter rung +/// with. +fn select(status: CounterStatus, open: impl FnOnce() -> Option) -> Option { + if admits_counter(status) { + open() + } else { + None + } +} + +/// How many times the RAPL liveness probe has run in this process: the first +/// thing [`rapl_host_status`] does. [`rapl_status`] caches its answer, so this +/// never passes one; the fresh-process pin counts it. +static RAPL_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// [`RAPL_PROBE_RUNS`]. +#[cfg_attr(not(test), allow(dead_code))] +fn rapl_probe_runs() -> usize { + RAPL_PROBE_RUNS.load(std::sync::atomic::Ordering::SeqCst) +} + +/// This host's RAPL status, probed once per process and cached. +/// +/// [`Meter::detect`] selects the RAPL rung only when this is +/// [`CounterStatus::Live`]: every package counter (the `energy_uj` of each +/// top-level `intel-rapl:N` zone whose `name` is `package-`) kept advancing +/// while the probing thread burned CPU, each in at least five sample +/// intervals, at least 80% of them, over at least 300 ms — the rule IOReport's +/// CPU rails are held to. Every package, because a span's figure +/// sums them all: one live package beside a frozen one would leave the frozen +/// package's energy out of a figure graded whole. A counter that holds, or is +/// republished in lumps (a virtualised powercap tree can be either), is +/// declined to the time×power model. Only the package zones are probed, as +/// only they are summed: a live `psys` or `dram` zone beside a held package +/// does not make the counter live. Where there is no readable powercap tree — +/// not Linux, no RAPL, no package zone (a host exposing only `psys`), or an +/// `energy_uj` only root may read — it is [`CounterStatus::Unavailable`] +/// without burning anything, as RAPL was declined before. The first call on a +/// host with a readable tree burns about 300 ms of the calling thread's CPU +/// (one second if the counter is not live). +pub fn rapl_status() -> CounterStatus { + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(rapl_host_status) +} + +/// The real RAPL probe: the real opener, sampler and tick. Counts itself, +/// then composes; its only caller is the cache in [`rapl_status`]. +fn rapl_host_status() -> CounterStatus { + RAPL_PROBE_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + rapl_status_on(Rapl::open) +} + +/// The RAPL probe on a given opener: [`liveness::probe`] with RAPL's sampler, +/// the CPU-burning tick and the real clock, for one budget. The tests run it on +/// fake powercap trees. +fn rapl_status_on(open: impl FnOnce() -> Option) -> CounterStatus { + liveness::status_of( + liveness::probe(open, Rapl::liveness_sample, liveness::burn_tick, Instant::now, liveness::BUDGET).as_ref(), + ) +} + +/// How many times the NVML liveness probe has run in this process. Always 0 +/// without the `nvml` feature, which has no probe. +#[cfg_attr(not(all(test, feature = "nvml")), allow(dead_code))] +fn nvml_probe_runs() -> usize { + #[cfg(feature = "nvml")] + { + NVML_PROBE_RUNS.load(std::sync::atomic::Ordering::SeqCst) + } + #[cfg(not(feature = "nvml"))] + { + 0 + } +} + +#[cfg(feature = "nvml")] +static NVML_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How long the NVML probe waits between samples. NVML's counter is the GPU's, +/// and burning this thread's CPU does not load the GPU, so the probe does not +/// burn: it waits, and a live counter advances on the device's own draw (a +/// powered GPU never draws zero). The wait is longer than the CPU probes' tick +/// so that a counter the driver refreshes up to every 100 ms still advances in +/// every interval; one refreshed less often fails the 80% share and is +/// declined — estimated, never over-claimed. +#[cfg(feature = "nvml")] +const NVML_PROBE_WAIT: std::time::Duration = std::time::Duration::from_millis(100); + +/// This host's NVML status, probed once per process and cached. Always +/// [`CounterStatus::Unavailable`] without the `nvml` feature. +/// +/// [`Meter::detect`] selects the NVML rung only when this is +/// [`CounterStatus::Live`]: device 0's total-energy counter advanced in at +/// least five sample intervals, at least 80% of them, over at least 300 ms. The +/// probe has no way to load the GPU, so this shows the counter is published +/// continuously, not that it tracks a load; a counter that holds or steps in +/// lumps is declined. The first call on a host where NVML opens waits about +/// half a second (one second if the counter is not live). +pub fn nvml_status() -> CounterStatus { + #[cfg(feature = "nvml")] + { + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(|| { + NVML_PROBE_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + liveness::status_of( + liveness::probe( + Nvml::open, + Nvml::liveness_sample, + || std::thread::sleep(NVML_PROBE_WAIT), + Instant::now, + liveness::BUDGET, + ) + .as_ref(), + ) + }) + } + #[cfg(not(feature = "nvml"))] + { + CounterStatus::Unavailable + } +} + /// Apple Silicon IOReport energy reader — a real, no-sudo, no-external-binary /// reading of the SoC Energy Model channels through Apple's private IOReport C -/// API. Apple's Energy Model is a vendor model (frequency/voltage/utilisation), -/// not a coulomb shunt, so a reading is honestly `ModelBased` — but it is the -/// actual on-die energy, strictly better than the wall-clock × assumed-power -/// `TimePower` model it sits ahead of. Ported from jouleclaw-energy's validated +/// API. Like RAPL and NVML it is a vendor energy counter: how Apple derives the +/// count is undisclosed and the platform relays it, which is a gap in +/// provenance, not in accuracy, so a live reading is graded `HwShunt` as theirs +/// are. It counts as a reading only while every CPU rail is live +/// ([`crate::ioreport_status`]) and only for a span over which a CPU rail +/// advanced (`ioreport_span`). Ported from jouleclaw-energy's validated /// `apple_ioreport` backend. #[cfg(target_os = "macos")] #[allow(non_camel_case_types, unsafe_code)] mod ioreport { use std::os::raw::{c_int, c_long, c_void}; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; + use std::time::{Duration, Instant}; + + use crate::liveness::{self, rails_where, thread_cpu_time, Advances, RailKey}; + use crate::{IoReportReading, IoReportStatus}; type CFTypeRef = *const c_void; type CFStringRef = *const c_void; @@ -146,6 +779,13 @@ mod ioreport { unsafe extern "C" { fn IOReportCopyAllChannels(a: u64, b: u64) -> CFMutableDictionaryRef; + fn IOReportCopyChannelsInGroup( + group: CFStringRef, + subgroup: CFStringRef, + a: u64, + b: u64, + c: u64, + ) -> CFMutableDictionaryRef; fn IOReportCreateSubscription( allowed: *const c_void, desired_channels: CFMutableDictionaryRef, @@ -196,7 +836,7 @@ mod ioreport { sub: IOReportSubscriptionRef, channels: CFMutableDictionaryRef, prior_sample: Option, - cumulative_uj: u64, + cumulative: IoReportReading, } impl Drop for State { @@ -252,6 +892,9 @@ mod ioreport { if !sub.is_null() { CFRelease(sub); } + if !subscribed.is_null() { + CFRelease(subscribed as CFTypeRef); + } return None; } Some(Self { @@ -259,7 +902,7 @@ mod ioreport { sub, channels: subscribed, prior_sample: None, - cumulative_uj: 0, + cumulative: IoReportReading { total_uj: 0, cpu_uj: 0 }, }), }) } @@ -268,6 +911,14 @@ mod ioreport { /// Cumulative SoC energy (µJ) since the first read. Subtract two reads /// for an interval. `None` if the interface vanished mid-read. pub fn read_uj(&self) -> Option { + self.read().map(|r| r.total_uj) + } + + /// Cumulative energy (µJ) since the first read: the SoC total the + /// counter sums, and within it the CPU energy rails' own share, both + /// from the same pair of samples. Subtract two reads for an interval. + /// `None` if the interface vanished mid-read. + pub(crate) fn read(&self) -> Option { let mut state = self.state.lock().ok()?; unsafe { let current = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); @@ -277,34 +928,563 @@ mod ioreport { if let Some(prior) = state.prior_sample { let delta = IOReportCreateSamplesDelta(prior, current, std::ptr::null()); if !delta.is_null() { - state.cumulative_uj = - state.cumulative_uj.saturating_add(sum_energy_uj(delta)); + let (total_uj, cpu_uj) = sum_energy_uj(delta); + state.cumulative = IoReportReading { + total_uj: state.cumulative.total_uj.saturating_add(total_uj), + cpu_uj: state.cumulative.cpu_uj.saturating_add(cpu_uj), + }; CFRelease(delta); } CFRelease(prior); } state.prior_sample = Some(current); } - Some(state.cumulative_uj) + Some(state.cumulative) } } - unsafe fn sum_energy_uj(delta: CFDictionaryRef) -> u64 { + // ── CPU-rail liveness ─────────────────────────────────────────────────── + // + // The production chain, outermost first, and what pins each link: + // + // - `crate::ioreport_status()` caches one status per process in a + // `OnceLock`. `tests/ioreport_probed_once.rs`, and the lib test + // `the_liveness_probe_runs_once_in_a_fresh_process`, run it cold, from + // eight threads at once, in a process of their own, and count real runs. + // - `host_status()` is the whole composition, with no branch: the real + // opener, tick and clocks handed to `probe_with`, and `status_of` on the + // verdict. The tests run this very function, with its subscription + // failing, with scripted samples, and against the hardware. + // - `probe_with()` is the loop and its record, generic over opener, + // sampler, tick and clocks. The tests drive it with fakes. + // - `watch()` and `RailWatch` are the rule (the crate's shared + // `liveness` rule, fed the CPU rails), on scripted samples. + // `status_of()` is a pure mapping. + // - The real parts: `EnergyModelSubscription` (opener and FFI read), checked + // against an independent oracle, structurally and value by value; + // `liveness::burn_tick()` and `HostClocks`, checked on the tests' own + // clocks. + + /// How many times the real probe, [`host_status`], has run in this process. + /// It is the first thing `host_status` does. [`crate::ioreport_status`] + /// caches its answer, so this never passes one; a test pins that by + /// counting rather than by timing. + static HOST_PROBE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// How many times this process has opened a subscription to the Energy + /// Model group: once per probe, whatever runs it. A path that probed + /// without going through [`host_status`] would still show here. + static ENERGY_MODEL_OPENS: AtomicUsize = AtomicUsize::new(0); + + /// [`HOST_PROBE_RUNS`], for `crate::ioreport_probe_runs`. + pub(crate) fn host_probe_runs() -> usize { + HOST_PROBE_RUNS.load(Ordering::SeqCst) + } + + /// [`ENERGY_MODEL_OPENS`], for `crate::ioreport_energy_model_opens`. + pub(crate) fn energy_model_opens() -> usize { + ENERGY_MODEL_OPENS.load(Ordering::SeqCst) + } + + /// What the liveness probe saw of the CPU energy rails. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum CpuRail { + /// Every CPU energy rail kept advancing while the probing thread + /// burned CPU: each in at least [`liveness::MIN_LIVE_ADVANCES`] + /// separate sample intervals, in at least 80% of the intervals + /// observed, over at least [`liveness::MIN_LIVE_WINDOW`]. + Live, + /// A CPU energy rail exists, but not every one kept advancing: one + /// held, or moved in a few steps (a batch refresh) — on a two-die part, + /// possibly beside a live die. + Frozen, + /// No `CPU Energy` channel exists in the Energy Model group. + Absent, + } + + /// The Energy Model channels of one sample, as the rule is handed them: + /// every in-group channel's name and its ABSOLUTE raw cumulative integer, + /// in the order IOReport lists them. [`RailWatch::observe`] pairs each + /// sample with the one before it. + type Sample = Vec<(String, i64)>; + + /// The CPU energy rails in one sample, each with its absolute raw value. + fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + rails_where(sample, is_cpu_energy_rail) + } + + /// The `GPU Energy` rails in one sample. The rule never reads them. The + /// probe's record keeps them, because where the GPU draws they move, and a + /// test can then tell a fresh sample from a stale one. + fn gpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + rails_where(sample, is_gpu_energy_rail) + } + + /// The shared liveness rule ([`liveness::Advances`]) fed IOReport's CPU + /// energy rails: [`RailWatch::observe`] picks the CPU rails out of an + /// Energy Model sample and hands the rule those alone, so the GPU rail, + /// which rises wherever the GPU draws, is never counted. Every CPU rail + /// handed over must keep advancing (the rule's every-rail condition): on a + /// multi-die part the counter sums each die's `DIE__CPU Energy`, so one + /// live die beside a frozen one is Frozen, never a Live that would leave + /// the frozen die out of a whole-CPU figure. It dereferences to the rule's + /// state, which is all it holds. + /// + /// Why the rule asks for five advances in 80% of the intervals over + /// 300 ms: on macOS 27.0.1 an unprivileged reader's CPU rails are not + /// permanently frozen, they are refreshed in rare batches, ~30 minutes + /// apart. On an M3 Ultra both dies' rails held for at least 22 minutes, + /// stepped +1,344,869 and +1,391,637 mJ in one 100 ms sample, then held + /// again; an M5 Max's `CPU Energy` did not change in 13,614 samples over 25 + /// minutes, yet had moved from 480,083,160 to 541,893,638 mJ earlier that + /// hour. And a refresh is not always one step. On an M5 Max, a watcher + /// sampling the group every ~16 ms saw `CPU Energy` go 2,643,843,378 -> + /// 2,705,607,863 (+61,764,485), then 2,705,607,863 -> 2,705,611,298 + /// (+3,435) 64 ms later, and `MCPU0`, `MCPU1` and `PCPU` each step twice. + /// The probe's intervals are ~31 ms (a 25 ms tick and a ~6 ms sample), so a + /// window holding that refresh sees two advancing intervals. Two advances + /// of one rail would call that Live, and a Live is cached for the process: + /// the rung is selected, and every later ~1.3 kJ refresh is attributed + /// whole to whichever metered span straddles it. A live rail advances in + /// nearly every interval (the M5 Max GPU rail changes every ~28 ms), so it + /// meets the rule about 300 ms in. + #[derive(Debug, Default, Clone)] + struct RailWatch(Advances); + + impl std::ops::Deref for RailWatch { + type Target = Advances; + + fn deref(&self) -> &Advances { + &self.0 + } + } + + impl RailWatch { + /// Fold in the next usable Energy Model sample, taken at `at` by the + /// probe's wall clock: its CPU energy rails ([`cpu_rails`]), and + /// nothing else, go to the rule ([`Advances::observe`]). + #[cfg_attr(not(test), allow(dead_code))] + fn observe(&mut self, sample: &[(String, i64)], at: Duration) { + self.0.observe(cpu_rails(sample), at); + } + + /// The verdict, or `None` if not a single interval could be observed. + fn verdict(&self) -> Option { + if self.intervals == 0 { + None + } else if self.is_live() { + Some(CpuRail::Live) + } else if self.rails.is_empty() { + Some(CpuRail::Absent) + } else { + Some(CpuRail::Frozen) + } + } + } + + /// The shared rule's loop ([`liveness::watch`]) on Energy Model samples, + /// each handed over as its CPU rails: take a sample and read the clock; + /// hand the rule the sample with that reading; stop when + /// [`Advances::wants_more`] says so; otherwise `tick` and sample again. A + /// sample that fails is skipped, so the next usable one is compared with + /// the last usable one. `elapsed` is the wall time since the probe started. + fn watch( + mut sample: impl FnMut() -> Option, + tick: impl FnMut(), + elapsed: impl Fn() -> Duration, + budget: Duration, + ) -> RailWatch { + RailWatch(liveness::watch(|| sample().map(|s| cpu_rails(&s)), tick, elapsed, budget)) + } + + /// One channel of one sample, as the FFI reads it, and nothing more: its + /// group, its name and its raw integer. Everything done with it after that + /// is [`group_sample`]'s, which tests can run. + #[derive(Debug, Clone)] + struct Item { + group: Option, + name: Option, + raw: i64, + } + + /// One sample of the probe's subscription: how many channels it carried, + /// how many of those were outside the Energy Model group, and the in-group + /// channels the rule is handed. + #[derive(Debug)] + struct GroupSample { + channels: usize, + outside_group: usize, + values: Sample, + } + + /// A [`GroupSample`] from one read's channels, one channel at a time. A + /// channel's value is its own raw integer; nothing is summed or carried + /// across channels. A channel outside the Energy Model group is counted + /// and not handed on. A channel with no name cannot be keyed, so it is not + /// handed on either. + fn group_sample(items: Vec) -> GroupSample { + let channels = items.len(); + let mut outside_group = 0; + let mut values = Vec::with_capacity(channels); + for item in items { + if !item.group.as_deref().is_some_and(|g| g.eq_ignore_ascii_case("Energy Model")) { + outside_group += 1; + continue; + } + if let Some(name) = item.name { + values.push((name, item.raw)); + } + } + GroupSample { channels, outside_group, values } + } + + /// Where the probe's samples come from: the real subscription, or, in + /// tests, a script. + trait Sampler { + fn sample(&self) -> Option; + } + + /// The liveness probe's own subscription, to the Energy Model group only. + /// + /// The counter's subscription covers every channel (~11,890 on an M5 Max, + /// ~14,350 on an M3 Ultra), and one sample of it takes 0.13-0.8 s of wall + /// time and ~0.1 s of CPU. The group alone (364 channels on an M5 Max, 645 + /// on an M3 Ultra) samples in ~5-12 ms and ~8 ms of CPU, and carries the + /// same absolute CPU rail values. + struct EnergyModelSubscription { + sub: IOReportSubscriptionRef, + channels: CFMutableDictionaryRef, + } + + impl Drop for EnergyModelSubscription { + fn drop(&mut self) { + unsafe { + CFRelease(self.sub); + CFRelease(self.channels as CFTypeRef); + } + } + } + + impl EnergyModelSubscription { + /// `None` when IOReport cannot open, or has no Energy Model group. + /// Counted in [`ENERGY_MODEL_OPENS`], opened or not. In this crate's + /// unit tests a test can make it fail (`tests::seam`); nothing else can. + fn open() -> Option { + ENERGY_MODEL_OPENS.fetch_add(1, Ordering::SeqCst); + #[cfg(test)] + if tests::seam::open_fails() { + return None; + } + unsafe { + let group = CFStringCreateWithCString( + std::ptr::null_mut(), + c"Energy Model".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + if group.is_null() { + return None; + } + let desired = IOReportCopyChannelsInGroup(group, std::ptr::null(), 0, 0, 0); + CFRelease(group); + if desired.is_null() { + return None; + } + let mut channels: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription( + std::ptr::null(), + desired, + &mut channels as *mut _, + 0, + std::ptr::null(), + ); + CFRelease(desired as CFTypeRef); + if sub.is_null() || channels.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + if !channels.is_null() { + CFRelease(channels as CFTypeRef); + } + return None; + } + Some(Self { sub, channels }) + } + } + + /// One read's channels. In this crate's unit tests a test can stand in + /// for the FFI read, passing it through or replacing it + /// (`tests::seam`); nothing else can. + fn items(&self) -> Option> { + let read = || unsafe { self.read_items() }; + #[cfg(test)] + if let Some(items) = tests::seam::sample(&read) { + return items; + } + read() + } + + /// One plain sample (no delta) of this subscription, channel by + /// channel. `None` when IOReport yields no usable sample. + unsafe fn read_items(&self) -> Option> { + let sample = IOReportCreateSamples(self.sub, self.channels, std::ptr::null()); + if sample.is_null() { + return None; + } + let items = sample_items(sample); + CFRelease(sample); + items + } + } + + impl Sampler for EnergyModelSubscription { + fn sample(&self) -> Option { + self.items().map(group_sample) + } + } + + /// Every channel of a plain sample, as `(group, name, raw integer)` in + /// IOReport's order. Raw integers, before unit conversion, so a small + /// advance in a fine unit cannot truncate to zero. `None` when the sample + /// carries no channel array. + unsafe fn sample_items(sample: CFDictionaryRef) -> Option> { let key = CFStringCreateWithCString( std::ptr::null_mut(), c"IOReportChannels".as_ptr() as *const _, K_CFSTRING_ENCODING_UTF8, ); if key.is_null() { - return 0; + return None; + } + let arr = CFDictionaryGetValue(sample, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + if arr.is_null() { + return None; + } + let count = CFArrayGetCount(arr); + let mut items = Vec::with_capacity(count.max(0) as usize); + for i in 0..count { + let item = CFArrayGetValueAtIndex(arr, i); + if item.is_null() { + continue; + } + items.push(Item { + group: cfstring_to_string(IOReportChannelGetGroup(item)), + name: cfstring_to_string(IOReportChannelGetChannelName(item)), + raw: IOReportSimpleGetIntegerValue(item, 0) as i64, + }); + } + Some(items) + } + + /// The clocks the probe reads: wall time, for its budget and its record, + /// and this thread's CPU time, for its record. The tick reads the thread + /// clock for itself. + trait Clocks { + fn now(&self) -> Instant; + fn thread_cpu(&self) -> Option; + } + + /// The real clocks: [`Instant`] and [`thread_cpu_time`]. + struct HostClocks; + + impl Clocks for HostClocks { + fn now(&self) -> Instant { + Instant::now() + } + + fn thread_cpu(&self) -> Option { + thread_cpu_time() + } + } + + /// What one sample call handed the rule, and how big the sample was. Read + /// by the tests alone; see [`ProbeRun`]. + #[cfg_attr(not(test), allow(dead_code))] + #[derive(Debug, Clone)] + struct Taken { + /// Channels in the sample. + channels: usize, + /// How many of them were outside the Energy Model group. + outside_group: usize, + /// The CPU rails of the sample handed to the rule, keyed and valued as + /// the rule reads them ([`cpu_rails`]). + cpu_rails: Vec<(RailKey, i64)>, + /// The `GPU Energy` rails of the same sample. + gpu_rails: Vec<(RailKey, i64)>, + } + + /// One sample call of a probe run. + #[cfg_attr(not(test), allow(dead_code))] + #[derive(Debug, Clone)] + struct SampleCall { + /// When the call began, by the probe's wall clock. + at: Instant, + /// Its wall time. + wall: Duration, + /// This thread's CPU time over it; `None` if the clock could not be read. + cpu: Option, + /// What it handed the rule; `None` if it gave no sample. + taken: Option, + } + + /// One tick of a probe run, as the probe's clocks saw it. + #[cfg_attr(not(test), allow(dead_code))] + #[derive(Debug, Clone, Copy)] + struct Tick { + /// This thread's CPU time over the tick; `None` if unreadable. + cpu: Option, + /// Wall time over the tick. + wall: Duration, + } + + /// One probe run: the rule's watch (`None` when no subscription opened), + /// and a record of every sample call and every tick, in order. Only the + /// watch decides anything. The record is kept in every build, so the probe + /// the tests read is the probe that ships, and only the tests read it. + #[cfg_attr(not(test), allow(dead_code))] + #[derive(Debug, Clone)] + struct ProbeRun { + /// When the run began, by the probe's wall clock. + started: Instant, + watch: Option, + samples: Vec, + ticks: Vec, + } + + impl ProbeRun { + /// The run's verdict: none when no subscription opened, or when not + /// one interval was observed on it. + fn verdict(&self) -> Option { + self.watch.as_ref().and_then(RailWatch::verdict) + } + } + + /// CPU time between two readings of a thread clock, if both were read. + fn cpu_between(from: Option, to: Option) -> Option { + Some(to?.saturating_sub(from?)) + } + + /// The probe: `open` a subscription and, if one opens, [`watch`] it. Take + /// a sample; until [`RailWatch::wants_more`] says stop, `tick` and sample + /// again. Every sample call and every tick is recorded by `clocks`. With no + /// subscription there is no sample, no tick and no verdict. + /// + /// Generic over each part, so the tests drive this very loop with a + /// scripted subscription, tick and clocks. [`host_status`] passes the real + /// ones, and is its only production caller. Each record is taken from the + /// sample as it is handed to the rule, so what the tests read is what the + /// rule saw. + fn probe_with( + open: impl FnOnce() -> Option, + mut tick: impl FnMut(), + clocks: &impl Clocks, + budget: Duration, + ) -> ProbeRun { + let started = clocks.now(); + let mut samples = Vec::new(); + let mut ticks = Vec::new(); + let rail_watch = open().map(|sub| { + watch( + || { + // Wall, then CPU; at the end CPU, then wall: the CPU + // interval sits inside the wall one. + let (at, cpu) = (clocks.now(), clocks.thread_cpu()); + let got = sub.sample(); + let cpu = cpu_between(cpu, clocks.thread_cpu()); + let wall = clocks.now().saturating_duration_since(at); + let taken = got.as_ref().map(|g| Taken { + channels: g.channels, + outside_group: g.outside_group, + cpu_rails: cpu_rails(&g.values), + gpu_rails: gpu_rails(&g.values), + }); + samples.push(SampleCall { at, wall, cpu, taken }); + got.map(|g| g.values) + }, + || { + let (at, cpu) = (clocks.now(), clocks.thread_cpu()); + tick(); + let cpu = cpu_between(cpu, clocks.thread_cpu()); + ticks.push(Tick { cpu, wall: clocks.now().saturating_duration_since(at) }); + }, + || clocks.now().saturating_duration_since(started), + budget, + ) + }); + let run = ProbeRun { started, watch: rail_watch, samples, ticks }; + #[cfg(test)] + tests::seam::deliver(&run); + run + } + + /// The status a probe verdict reports. No verdict — no Energy Model + /// subscription, or not one interval observed on it — is `Unavailable`: + /// never `Live`, and never `CpuRailAbsent`, which says a group was read + /// and had no CPU rail. + fn status_of(verdict: Option) -> IoReportStatus { + match verdict { + None => IoReportStatus::Unavailable, + Some(CpuRail::Live) => IoReportStatus::Live, + Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + } + } + + /// This host's liveness status: [`probe_with`] on the real opener + /// ([`EnergyModelSubscription::open`]), the real tick + /// ([`liveness::burn_tick`]) and + /// the real clocks ([`HostClocks`]) for + /// [`crate::CPU_RAIL_LIVENESS_BUDGET`], mapped by [`status_of`]. It counts + /// itself, then composes, and has no branch of its own: every decision is + /// in `probe_with` and `status_of`. The tests run this function too, with + /// its subscription failing, with scripted samples, and against the + /// hardware. The only production caller of `probe_with`; its own only + /// caller is the cache in [`crate::ioreport_status`]. + pub(crate) fn host_status() -> IoReportStatus { + HOST_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); + status_of( + probe_with( + EnergyModelSubscription::open, + liveness::burn_tick, + &HostClocks, + crate::CPU_RAIL_LIVENESS_BUDGET, + ) + .verdict(), + ) + } + + /// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally + /// `DIE__`-prefixed, compared case-insensitively. + fn is_cpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") + } + + /// Whether a channel is the GPU energy aggregate, named like + /// [`is_cpu_energy_rail`]'s. + fn is_gpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("GPU Energy") + } + + /// Over one delta sample: the energy of every Energy Model rail the + /// counter sums ([`counts_once`]), and of the CPU energy rails alone + /// ([`is_cpu_energy_rail`], a share of the first), in µJ. + unsafe fn sum_energy_uj(delta: CFDictionaryRef) -> (u64, u64) { + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + if key.is_null() { + return (0, 0); } let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); if arr.is_null() { - return 0; + return (0, 0); } let count = CFArrayGetCount(arr); let mut total_uj: u64 = 0; + let mut cpu_uj: u64 = 0; for i in 0..count { let item = CFArrayGetValueAtIndex(arr, i); if item.is_null() { @@ -325,9 +1505,13 @@ mod ioreport { continue; } let unit = cfstring_to_string(IOReportChannelGetUnitLabel(item)).unwrap_or_default(); - total_uj = total_uj.saturating_add(energy_to_uj(raw, &unit)); + let uj = energy_to_uj(raw, &unit); + total_uj = total_uj.saturating_add(uj); + if is_cpu_energy_rail(&name) { + cpu_uj = cpu_uj.saturating_add(uj); + } } - total_uj + (total_uj, cpu_uj) } fn energy_to_uj(raw: c_long, unit: &str) -> u64 { @@ -353,6 +1537,19 @@ mod ioreport { name } + /// Whether the counter sums this rail, so that energy is counted once. The + /// aggregates `CPU Energy`, `GPU Energy` and `ANE Energy` (optionally + /// `DIE__`-prefixed) are summed; their summands are not. A summand is + /// any other rail whose name says CPU, GPU or ANE, and also the CPU + /// summands whose names do not: per-cluster accumulators (`PACC*`, `EACC*`, + /// `MACC*`) and CPU power managers (`*CPM*`, with their `_SRAM` rails). In + /// one absolute sample on an M5 Max, `CPU Energy` = `MCPU0` + `MCPU1` + + /// `PCPU`, and `PCPU` = `PACC_0..5` + `PCPU0..5_SRAM` + `PCPM` + + /// `PCPM_SRAM`. On an M3 Ultra, each `DIE__CPU Energy` is the sum of + /// its `EACC_CPU`, `PACC0_CPU` and `PACC1_CPU`, and each of those is the + /// sum of its cores, their SRAM and its `_CPM` rails (all to within 1-9 + /// mJ). Summing `PACC_*` or `*_CPM` beside `CPU Energy` would count that + /// CPU energy twice. The same rule as eoc-meter's (7aa42418). fn counts_once(name: &str) -> bool { let base = strip_die_prefix(name); if base.eq_ignore_ascii_case("CPU Energy") @@ -361,11 +1558,12 @@ mod ioreport { { return true; } - let l = name.to_ascii_lowercase(); - if l.contains("cpu") || l.contains("gpu") || l.contains("ane") { + let u = base.to_ascii_uppercase(); + if u.contains("CPU") || u.contains("GPU") || u.contains("ANE") { return false; } - true + let cpu_summand = ["PACC", "EACC", "MACC"].iter().any(|p| u.starts_with(p)) || u.contains("CPM"); + !cpu_summand } unsafe fn cfstring_to_string(s: CFStringRef) -> Option { @@ -391,49 +1589,2860 @@ mod ioreport { #[cfg(test)] mod tests { use super::*; + use crate::liveness::{PROBE_BUDGET_CAP, PROBE_TICK}; + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + use std::sync::atomic::AtomicBool; + + /// Seams through which the tests reach into the production chain. They + /// exist only in this crate's unit-test build: the library a dependant + /// or an integration test links has none of them. Each is + /// thread-local, so it touches only the test thread that sets it, and + /// each setter returns a guard that unsets it when dropped, on a panic + /// too. + pub(super) mod seam { + use super::super::{Item, ProbeRun}; + use std::cell::{Cell, RefCell}; + + /// Given the real FFI read, returns what the probe gets instead. + type StandIn = Box Option>) -> Option>>; + + thread_local! { + static OPEN_FAILS: Cell = const { Cell::new(false) }; + static STAND_IN: RefCell> = const { RefCell::new(None) }; + static TAPPED: RefCell>> = const { RefCell::new(None) }; + } + + /// Asked by `EnergyModelSubscription::open`: fail, as on a host + /// with no Energy Model group? + pub fn open_fails() -> bool { + OPEN_FAILS.with(Cell::get) + } + + /// Asked by `EnergyModelSubscription::items`: `None` when no + /// stand-in is set, or what the stand-in makes of the real read. + pub fn sample(read: &dyn Fn() -> Option>) -> Option>> { + STAND_IN.with(|s| s.borrow_mut().as_mut().map(|stand_in| stand_in(read))) + } + + /// Told by `probe_with` of every run it makes; kept while tapped. + pub fn deliver(run: &ProbeRun) { + TAPPED.with(|t| { + if let Some(runs) = t.borrow_mut().as_mut() { + runs.push(run.clone()); + } + }); + } + + /// While alive, every Energy Model subscription this thread opens fails. + pub struct FailingOpens; + + impl Drop for FailingOpens { + fn drop(&mut self) { + OPEN_FAILS.with(|f| f.set(false)); + } + } + + pub fn fail_opens() -> FailingOpens { + OPEN_FAILS.with(|f| f.set(true)); + FailingOpens + } + + /// While alive, every FFI read of this thread's Energy Model + /// subscriptions goes through the stand-in. + pub struct StandingIn; + + impl Drop for StandingIn { + fn drop(&mut self) { + STAND_IN.with(|s| *s.borrow_mut() = None); + } + } + + pub fn stand_in( + f: impl FnMut(&dyn Fn() -> Option>) -> Option> + 'static, + ) -> StandingIn { + STAND_IN.with(|s| *s.borrow_mut() = Some(Box::new(f))); + StandingIn + } + + /// While alive, keeps every run `probe_with` makes on this thread. + pub struct Tap; + + impl Tap { + pub fn runs(&self) -> Vec { + TAPPED.with(|t| t.borrow_mut().as_mut().map(std::mem::take).unwrap_or_default()) + } + + /// The one run `of` made, or a failure if it made none or several. + pub fn one_run(&self, of: &str) -> ProbeRun { + let mut runs = self.runs(); + assert_eq!(runs.len(), 1, "{of} made {} probe runs, not one", runs.len()); + runs.remove(0) + } + } + + impl Drop for Tap { + fn drop(&mut self) { + TAPPED.with(|t| *t.borrow_mut() = None); + } + } + + pub fn tap() -> Tap { + TAPPED.with(|t| *t.borrow_mut() = Some(Vec::new())); + Tap + } + } #[test] fn energy_to_uj_scales_by_unit() { assert_eq!(energy_to_uj(5, "mJ"), 5_000); assert_eq!(energy_to_uj(5_000, "nJ"), 5); + assert_eq!(energy_to_uj(5_000_000, "pJ"), 5); + assert_eq!(energy_to_uj(5, "uJ"), 5); + assert_eq!(energy_to_uj(5, "µJ"), 5); + assert_eq!(energy_to_uj(2, "J"), 2_000_000); + assert_eq!(energy_to_uj(-5, "mJ"), 0, "a negative raw value is no energy"); } + /// The counter sums each aggregate once and none of its summands, on + /// the real Energy Model channel names of both chips. Checked on one + /// absolute sample of each, macOS 27.0.1: + /// - M5 Max: `CPU Energy` = `MCPU0` + `MCPU1` + `PCPU` (2,413,310,607 + /// vs 2,413,310,605 mJ); `PCPU` = `PACC_0..5` + `PCPU0..5_SRAM` + + /// `PCPM` + `PCPM_SRAM` (7 mJ apart); `MCPUn` = its six cores + their + /// SRAM + `MCPMn` + `MCPMn_SRAM` (5 mJ apart). + /// - M3 Ultra: `DIE_n_CPU Energy` = its `EACC_CPU` + `PACC0_CPU` + + /// `PACC1_CPU` (1-2 mJ apart), each = its cores + their SRAM + its + /// `_CPM` + `_CPM_SRAM` (4-9 mJ apart). + /// + /// Before, the counter also summed `PACC_*`, `MCPM*`, `PCPM` and their + /// `_SRAM` rails (M5 Max) and all twelve `_CPM` rails (M3 Ultra), beside + /// the aggregate they add up to. The summed lists below are everything + /// the counter sums on each chip; every other name there is a summand. #[test] fn counts_once_drops_subrails() { - assert!(counts_once("CPU Energy")); - assert!(!counts_once("DIE_0_PACC0_CPU0")); + let m5_max_summed = [ + "CPU Energy", "GPU Energy", "AFR0", "ISP0", "AVE0", "MSR0", "AMCC0", "DCS0", "DRAM0", "DISP0", + "DISPEXT0", "FAB0", "PCIe Port 0 Energy", "PCIe Port 1 Energy", "apciec0 Energy", "apciec1 Energy", + "apciec2 Energy", + ]; + let m3_ultra_summed = [ + "DIE_0_CPU Energy", "DIE_1_CPU Energy", "GPU Energy", "AMCC0_0", "AMCC0_1", "AVE0_0", "AVE0_1", + "DCS0_0", "DCS0_1", "DISP0_0", "DISP0_1", "DISPEXT0_0", "DISPEXT0_1", "DRAM0_0", "DRAM0_1", "ISP0_0", + "ISP0_1", "MSR0_0", "MSR0_1", "PCIe Port 0 Energy", "PCIe Port 1 Energy", "PCIe Port 2 Energy", + "PCIe Port 3 Energy", "apciec0 Energy", "apciec1 Energy", "apciec2 Energy", "apciec3 Energy", + "apciec4 Energy", "apciec5 Energy", + ]; + for name in m5_max_summed.iter().chain(&m3_ultra_summed) { + assert!(counts_once(name), "{name} is not summed"); + } + // M5 Max: the summands of `CPU Energy`, every family of them. + for name in [ + "MCPU0", "MCPU1", "PCPU", "MCPU0_0", "MCPU0_5", "MCPU1_3", "MCPU0_2_SRAM", "MCPU1_5_SRAM", + "PCPU0_SRAM", "PCPU5_SRAM", "PACC_0", "PACC_1", "PACC_2", "PACC_3", "PACC_4", "PACC_5", "MCPM0", + "MCPM1", "PCPM", "MCPM0_SRAM", "MCPM1_SRAM", "PCPM_SRAM", "MCPU0DTL00", "MCPU0DTL0e", "MCPU1DTL5e", + "PCPUDTL00", "PCPUDTL513", + ] { + assert!(!counts_once(name), "{name}, a summand of CPU Energy on an M5 Max, is summed beside it"); + } + // M3 Ultra: the summands of `DIE_n_CPU Energy`, every family of them. + for name in [ + "DIE_0_EACC_CPU", "DIE_1_EACC_CPU", "DIE_0_PACC0_CPU", "DIE_1_PACC1_CPU", "DIE_0_EACC_CPU3", + "DIE_1_PACC0_CPU5", "DIE_0_EACC_CPU0_SRAM", "DIE_1_PACC1_CPU4_SRAM", "DIE_0_EACC_CPM", + "DIE_1_EACC_CPM", "DIE_0_PACC0_CPM", "DIE_1_PACC1_CPM", "DIE_0_EACC_CPM_SRAM", + "DIE_1_PACC0_CPM_SRAM", "DIE_0_PACC1_CPM_SRAM", "ECPUDTL00", "PCPUDTL00", "PCPU1DTL00", + ] { + assert!(!counts_once(name), "{name}, a summand of DIE_n_CPU Energy on an M3 Ultra, is summed beside it"); + } + // The GPU's own rails, beside the `GPU Energy` aggregate. + for name in ["GPU0", "GPU0_0", "GPU CS0_0", "GPU SRAM0_0", "GPU CS SRAM0_0"] { + assert!(!counts_once(name), "{name} is summed beside GPU Energy"); + } + // Neither chip has an `ANE Energy` aggregate, and its rails are + // not summed either: ANE energy is not counted at all. Pinned as it + // is, so a change to it is a decision, not an accident. + for name in ["ANE0", "ANE0_0", "ANE0_1"] { + assert!(!counts_once(name), "{name} is summed"); + } } #[test] - fn cumulative_advances_after_cpu_burn() { - let Some(c) = IoReport::open() else { - return; // not Apple Silicon - }; - let _ = c.read_uj(); - let before = c.read_uj().unwrap(); - let start = std::time::Instant::now(); - let mut acc: u64 = 0; - while start.elapsed() < std::time::Duration::from_millis(200) { - acc = acc.wrapping_add(1); - } - std::hint::black_box(acc); - let _ = c.read_uj(); - let after = c.read_uj().unwrap(); - assert!(after > before, "energy must advance: before={before} after={after}"); + fn only_the_cpu_energy_aggregate_is_a_cpu_rail() { + assert!(is_cpu_energy_rail("CPU Energy")); + assert!(is_cpu_energy_rail("cpu energy")); + assert!(is_cpu_energy_rail("DIE_0_CPU Energy")); + assert!(is_cpu_energy_rail("DIE_1_CPU Energy")); + assert!(is_cpu_energy_rail("DIE_12_CPU Energy")); + assert!(!is_cpu_energy_rail("GPU Energy")); + assert!(!is_cpu_energy_rail("DIE_0_GPU Energy")); + assert!(!is_cpu_energy_rail("ANE Energy")); + assert!(!is_cpu_energy_rail("PCPU")); + assert!(!is_cpu_energy_rail("MCPU0")); + assert!(!is_cpu_energy_rail("PACC_0")); + assert!(!is_cpu_energy_rail("DIE_0_PACC0_CPU0")); + assert!(is_gpu_energy_rail("GPU Energy")); + assert!(is_gpu_energy_rail("DIE_1_GPU Energy")); + assert!(!is_gpu_energy_rail("GPU0")); + assert!(!is_gpu_energy_rail("CPU Energy")); } + + // ── The liveness rule, on scripted samples and a scripted clock ───── + + /// A probe budget, as in production. + const BUDGET: Duration = Duration::from_millis(1000); + + /// One scripted tick: a 25 ms spin plus a ~6 ms Energy Model sample, the + /// interval measured on an idle M5 Max. + const STEP: Duration = Duration::from_millis(31); + + /// One interval on a loaded host. + const LOADED_STEP: Duration = Duration::from_millis(100); + + /// The tests' own copies of the rule's thresholds: literals, not the + /// implementation's constants, so a change to one cannot silently move + /// the other. + const TEST_MIN_ADVANCES: u32 = 5; + const TEST_MIN_WINDOW: Duration = Duration::from_millis(300); + + /// An M5 Max-shaped sample: `CPU Energy` at `cpu`, beside a `GPU + /// Energy` that rises at every sample, as the M5 Max's does while + /// anything draws. A rule that counted the GPU rail would call every + /// host live. + fn m5(i: usize, cpu: i64) -> Sample { + vec![ + ("GPU Energy".to_string(), 112_249_654_932_804 + 1_000_000 * i as i64), + ("CPU Energy".to_string(), cpu), + ] + } + + /// An M3 Ultra-shaped sample: each die's `CPU Energy` at its own + /// value, `d0` and `d1`, among a duplicated unprefixed name + /// (`ECPUDTL00`, once per die, rising) and a `GPU Energy` rising at + /// every sample. Neither of those is a CPU rail. + fn m3(i: usize, d0: i64, d1: i64) -> Sample { + vec![ + ("ECPUDTL00".to_string(), 7_000 + i as i64), + ("DIE_0_CPU Energy".to_string(), d0), + ("ECPUDTL00".to_string(), 9_000 + i as i64), + ("DIE_1_CPU Energy".to_string(), d1), + ("GPU Energy".to_string(), 5_000_000 * i as i64), + ] + } + + /// One probe run through [`watch`] on a script: sample `i` is + /// `script(i)`, and each tick moves the clock on by `step`, so sample + /// `i` is taken at `step * i`. Returns the watch, how many samples were + /// taken, and the clock when it stopped. + fn scripted( + mut script: impl FnMut(usize) -> Option, + step: Duration, + budget: Duration, + ) -> (RailWatch, usize, Duration) { + let clock = Cell::new(Duration::ZERO); + let mut taken = 0; + let rail_watch = watch( + || { + let s = script(taken); + taken += 1; + s + }, + || clock.set(clock.get() + step), + || clock.get(), + budget, + ); + (rail_watch, taken, clock.get()) + } + + /// Advances per CPU rail, as `("name#ordinal", intervals advanced)`. + fn advances(w: &RailWatch) -> Vec<(String, u32)> { + w.rails.iter().map(|(k, n)| (format!("{}#{}", k.name, k.ordinal), *n)).collect() + } + + fn rail(name: &str, n: u32) -> (String, u32) { + (name.to_string(), n) + } + + /// The first interval, counting from 1, after which the rule calls + /// the rail live when fed `script(0..=max)` by hand, sample `i` at + /// `step * i`, with no stopping rule; `None` if it never does. + fn first_live_interval(script: impl Fn(usize) -> Sample, step: Duration, max: usize) -> Option { + let mut w = RailWatch::default(); + w.observe(&script(0), Duration::ZERO); + (1..=max).find(|&i| { + w.observe(&script(i), step * i as u32); + w.is_live() + }) + } + + /// `CPU Energy` through the two-step batch refresh seen on an M5 Max + /// (macOS 27.0.1) by a group-only watcher sampling every ~16 ms: + /// 2,643,843,378 -> 2,705,607,863 (+61,764,485), then -> 2,705,611,298 + /// (+3,435) 64 ms later, flat around both. Here the first step lands + /// by sample `first` and the second by sample `second`. + fn two_step(i: usize, first: usize, second: usize) -> i64 { + if i < first { + 2_643_843_378 + } else if i < second { + 2_705_607_863 + } else { + 2_705_611_298 + } + } + + /// An M5 Max-shaped sample through [`two_step`], with `MCPU0`, + /// `MCPU1` and `PCPU`, the summands of `CPU Energy`, stepping twice + /// with it as they were seen to (their values here are made up). They + /// are not CPU rails, and the rule never reads them. + fn m5_two_step(i: usize, first: usize, second: usize) -> Sample { + let steps = i64::from(i >= first) + i64::from(i >= second); + let mut s = m5(i, two_step(i, first, second)); + for (name, base) in [("MCPU0", 118_503_220), ("MCPU1", 121_847_018), ("PCPU", 2_403_493_140)] { + s.push((name.to_string(), base + 1_000 * steps)); + } + s + } + + /// (a) The refresh seen on an M5 Max: `CPU Energy` steps once, from + /// 480,083,160 to 541,893,638 mJ, and holds, while `GPU Energy` rises + /// at every sample. Frozen, with the step counted once, after the whole + /// budget. Compared with the FIRST sample (dfec5d44's defect), the rail + /// would show a rise in every interval after the step and read Live. + #[test] + fn an_m5_max_batch_refresh_mid_probe_is_frozen() { + let (w, taken, stopped) = + scripted(|i| Some(m5(i, if i < 2 { 480_083_160 } else { 541_893_638 })), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a batch refresh read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 1)]); + // The whole budget: at 31 ms a tick, the clock reaches 1 s at the 33rd. + assert_eq!((w.intervals, taken, stopped), (33, 34, STEP * 33)); + } + + /// (*) The two-step refresh seen on an M5 Max ([`two_step`]), with + /// the steps in consecutive intervals and 62 ms apart: two advancing + /// intervals of one rail, which the two-advance rule called Live. + /// Frozen, after the whole budget. And on a loaded host, where two + /// 160 ms intervals span 320 ms and both advanced (100%), two advances + /// are still not five: Frozen. + #[test] + fn the_observed_two_step_refresh_is_frozen() { + for (first, second) in [(3, 4), (3, 5)] { + assert_eq!(first_live_interval(|i| m5_two_step(i, first, second), STEP, 200), None); + let (w, taken, stopped) = scripted(|i| Some(m5_two_step(i, first, second)), STEP, BUDGET); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "a two-step refresh (steps by samples {first} and {second}) read as live: {:?}", + advances(&w) + ); + assert_eq!(advances(&w), [rail("CPU Energy#0", 2)]); + assert_eq!((w.intervals, taken, stopped), (33, 34, STEP * 33)); + } + let slow = Duration::from_millis(160); + assert_eq!(first_live_interval(|i| m5_two_step(i, 1, 2), slow, 200), None); + let (w, _, _) = scripted(|i| Some(m5_two_step(i, 1, 2)), slow, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a two-step refresh on a loaded host read as live: {:?}", advances(&w)); + assert_eq!((w.intervals, advances(&w)), (7, vec![rail("CPU Energy#0", 2)])); + } + + /// (*) A refresh published in three, four or five steps within + /// 150 ms, each landing in its own interval, at the very start of the + /// window. Three or four are fewer than five advances. Five are five + /// advances in five intervals (100%), but then the window spans 155 ms; + /// by the tenth interval, when it first spans 300 ms, five of ten is + /// 50%. The same five steps later in the window (intervals 11 to 15) + /// are a third of the intervals. Frozen, each after the whole budget. + #[test] + fn a_refresh_published_in_three_to_five_steps_is_frozen() { + for steps in 3..=5 { + let refresh = move |i: usize| m5(i, 2_643_843_378 + 1_000_000 * i.min(steps) as i64); + assert_eq!(first_live_interval(refresh, STEP, 200), None, "{steps} steps at the start"); + let (w, taken, _) = scripted(|i| Some(refresh(i)), STEP, BUDGET); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "a refresh in {steps} steps at the start of the window read as live: {:?}", + advances(&w) + ); + assert_eq!((advances(&w), w.intervals, taken), (vec![rail("CPU Energy#0", steps as u32)], 33, 34)); + } + let later = |i: usize| m5(i, 2_643_843_378 + 1_000_000 * (i.clamp(10, 15) - 10) as i64); + assert_eq!(first_live_interval(later, STEP, 200), None, "five steps later in the window"); + let (w, _, _) = scripted(|i| Some(later(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a refresh in 5 steps mid-window read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 5)]); + } + + /// (*) The same two-step publication on both of an M3 Ultra's dies: + /// each die's `CPU Energy` steps twice within 64 ms (DIE_0 by samples + /// 3 and 5, DIE_1 by samples 3 and 4), the first steps the sizes seen + /// there, beside a duplicated unprefixed name and a GPU rail, both + /// rising. Frozen: two advances per die, where the two-advance rule + /// called each die live. + #[test] + fn an_m3_ultra_refresh_stepping_each_die_twice_is_frozen() { + // The second step's size is not from a sample: 2,113 mJ stands in. + let die = |i: usize, base: i64, first_step: i64, second_by: usize| { + base + i64::from(i >= 3) * first_step + i64::from(i >= second_by) * 2_113 + }; + let (w, _, _) = scripted( + |i| { + Some(vec![ + ("ECPUDTL00".to_string(), 7_000 + i as i64), + ("DIE_0_CPU Energy".to_string(), die(i, 21_170_718, 1_344_869, 5)), + ("ECPUDTL00".to_string(), 9_000 + i as i64), + ("DIE_1_CPU Energy".to_string(), die(i, 23_300_102, 1_391_637, 4)), + ("GPU Energy".to_string(), 5_000_000 * i as i64), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "each die stepping twice read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 2), rail("DIE_1_CPU Energy#0", 2)]); + } + + /// (b) The refresh seen on an M3 Ultra: both dies' `CPU Energy` step in + /// the same sample, +1,344,869 and +1,391,637 mJ, among a duplicated + /// unprefixed name (`ECPUDTL00`, once per die, rising) and a rising GPU + /// rail. Frozen: one advance per die, never summed into a Live. + #[test] + fn an_m3_ultra_two_die_refresh_in_one_sample_is_frozen() { + let (w, _, _) = scripted( + |i| { + let (d0, d1) = if i < 3 { (21_170_718, 23_300_102) } else { (22_515_587, 24_691_739) }; + Some(vec![ + ("ECPUDTL00".to_string(), 7_000 + i as i64), + ("DIE_0_CPU Energy".to_string(), d0), + ("ECPUDTL00".to_string(), 9_000 + i as i64), + ("DIE_1_CPU Energy".to_string(), d1), + ("GPU Energy".to_string(), 5_000_000 * i as i64), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one refresh of both dies read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); + } + + /// (c) Two channels both named `CPU Energy`, stepping together once: + /// two rails, one advance each, Frozen. Keyed by name alone they would + /// be one rail, advancing twice in one interval. + #[test] + fn two_same_named_rails_stepping_together_once_are_frozen() { + let (w, _, _) = scripted( + |i| { + let step = if i < 2 { 0 } else { 1_300_000 }; + Some(vec![ + ("CPU Energy".to_string(), 21_170_718 + step), + ("CPU Energy".to_string(), 23_300_102 + step), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "two same-named rails read as one live rail: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 1), rail("CPU Energy#1", 1)]); + } + + /// (d) A live rail: `CPU Energy` rising by the smallest possible step, + /// one raw unit, at every sample, 31 ms apart. It has five advances at + /// the fifth interval, but the window then spans 155 ms. Live at the + /// tenth, the first interval at which the window spans 300 ms (310 ms), + /// and the probe stops there: eleven samples, ten ticks. The same on a + /// two-die part with both dies rising: the second rail does not make it + /// later. + #[test] + fn a_rail_advancing_at_every_interval_is_live_at_five_advances_and_300_ms() { + let rising = |i: usize| m5(i, 541_893_638 + i as i64); + assert_eq!(first_live_interval(rising, STEP, 200), Some(10), "31 ms intervals first span 300 ms at the 10th"); + let (w, taken, stopped) = scripted(|i| Some(rising(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "a rail rising at every sample: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 10)]); + assert_eq!((w.intervals, taken, stopped), (10, 11, STEP * 10), "a live rail must stop the probe at once"); + assert_eq!(w.window, Duration::from_millis(310)); + assert!(w.window >= TEST_MIN_WINDOW && w.window - STEP < TEST_MIN_WINDOW, "live at {:?}", w.window); + let both_dies = |i: usize| m3(i, 21_170_718 + i as i64, 23_300_102 + i as i64); + assert_eq!(first_live_interval(both_dies, STEP, 200), Some(10)); + let (w, _, _) = scripted(|i| Some(both_dies(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 10), rail("DIE_1_CPU Energy#0", 10)]); + } + + /// A live rail on a loaded host, 100 ms an interval: the window spans + /// 300 ms at the third interval, but three advances are not five. Live + /// at the fifth, 500 ms in, and not before. + #[test] + fn a_live_rail_on_a_loaded_host_is_live_after_five_intervals() { + let rising = |i: usize| m5(i, 541_893_638 + i as i64); + assert_eq!(first_live_interval(rising, LOADED_STEP, 200), Some(5), "live before its fifth advance"); + let (w, taken, stopped) = scripted(|i| Some(rising(i)), LOADED_STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); + assert_eq!( + (w.intervals, taken, stopped, w.window), + (5, 6, Duration::from_millis(500), Duration::from_millis(500)) + ); + } + + /// A live rail that misses one interval in five advances in exactly + /// 80% of them, and that is enough: Live at the tenth interval (8 of + /// 10, 310 ms). One that misses two in five advances in 60% or less, + /// and is never live: Frozen after the whole budget. And on a loaded + /// host, where the window and the five advances come early, a rail + /// that missed two of its first five intervals is not yet live at five + /// advances in seven (71%), nor at 6 of 8 or 7 of 9, and is live once + /// its share reaches 80%, at 8 of 10. + #[test] + fn a_rail_must_advance_in_80_percent_of_the_intervals() { + // Sample `i` holds how many of intervals 1..=i advanced, so + // interval `k` advances unless `missed(k)`. + let missing = |missed: fn(usize) -> bool| { + move |i: usize| m5(i, 541_893_638 + (1..=i).filter(|&k| !missed(k)).count() as i64) + }; + let one_in_five = missing(|k| k % 5 == 0); + assert_eq!(first_live_interval(one_in_five, STEP, 200), Some(10)); + let (w, _, _) = scripted(|i| Some(one_in_five(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "a rail advancing in 80% of the intervals: {:?}", advances(&w)); + assert_eq!((w.intervals, advances(&w)), (10, vec![rail("CPU Energy#0", 8)])); + let two_in_five = missing(|k| k % 5 == 0 || k % 5 == 1); + assert_eq!(first_live_interval(two_in_five, STEP, 200), None); + let (w, _, _) = scripted(|i| Some(two_in_five(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail advancing in 60% of the intervals: {:?}", advances(&w)); + assert_eq!((w.intervals, advances(&w)), (33, vec![rail("CPU Energy#0", 20)])); + let two_early = missing(|k| k == 2 || k == 4); + assert_eq!(first_live_interval(two_early, LOADED_STEP, 200), Some(10), "live under 80%"); + let (w, taken, _) = scripted(|i| Some(two_early(i)), LOADED_STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); + assert_eq!((w.intervals, taken, advances(&w)), (10, 11, vec![rail("CPU Energy#0", 8)])); + } + + /// (e) Every sample fails: the probe keeps sampling a tick apart up to + /// the five-budget cap, and gives no verdict, which reports Unavailable. + #[test] + fn failed_samples_keep_sampling_to_the_cap_and_give_no_verdict() { + let (w, taken, stopped) = scripted(|_| None, PROBE_TICK, BUDGET); + assert_eq!(w.verdict(), None); + assert_eq!(status_of(w.verdict()), crate::IoReportStatus::Unavailable); + assert_eq!(stopped, BUDGET * PROBE_BUDGET_CAP, "the probe stopped at {stopped:?}, not at the cap"); + // 5 s of 25 ms ticks, plus the first sample. + assert_eq!(taken, 201); + } + + /// A failed sample is skipped, not a reset: the next usable sample is + /// compared with the last usable one. + #[test] + fn a_failed_sample_is_skipped_and_the_pairing_goes_on() { + // Usable, failed, +2, failed, +2, ...: each interval spans two + // ticks, 62 ms, and advances. The window is wall time between + // usable samples, so it spans 310 ms at the fifth interval: Live + // there, after eleven samples. + let (w, taken, _) = scripted(|i| (i % 2 == 0).then(|| m5(i, 541_893_638 + i as i64)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); + assert_eq!((w.intervals, taken, w.window), (5, 11, Duration::from_millis(310))); + // A refresh between two failures is still one step. + let (w, _, _) = scripted( + |i| (i % 3 != 1).then(|| m5(i, if i < 4 { 480_083_160 } else { 541_893_638 })), + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 1)]); + } + + /// (f) Past the budget, the probe keeps sampling until it has five + /// intervals, and stops at the five-budget cap even without them: + /// slow samples must not make a live rail look frozen. + #[test] + fn past_the_budget_the_probe_stops_only_with_five_intervals_or_at_the_cap() { + let held = |i| Some(m5(i, 541_893_638)); + // 600 ms a tick: the budget runs out at the second interval; the + // probe goes on to the fifth. + let (w, taken, stopped) = scripted(held, Duration::from_millis(600), BUDGET); + assert_eq!( + (w.verdict(), w.intervals, taken, stopped), + (Some(CpuRail::Frozen), 5, 6, Duration::from_millis(3000)), + "past the budget with fewer than five intervals, the probe must keep sampling" + ); + // And that is what lets a live rail on such a host prove itself. + let (w, _, stopped) = scripted(|i| Some(m5(i, 541_893_638 + i as i64)), Duration::from_millis(600), BUDGET); + assert_eq!((w.verdict(), w.intervals, stopped), (Some(CpuRail::Live), 5, Duration::from_millis(3000))); + // 1.5 s a tick: four intervals reach 6 s, past the 5 s cap: stop, + // Frozen, with four. + let (w, taken, stopped) = scripted(held, Duration::from_millis(1500), BUDGET); + assert_eq!( + (w.verdict(), w.intervals, taken, stopped), + (Some(CpuRail::Frozen), 4, 5, Duration::from_millis(6000)), + "the probe must stop at the cap" + ); + } + + /// (g) DIE_0 steps once and DIE_1 steps once, in different intervals: + /// Frozen. Advances are counted per rail, never summed across dies. + #[test] + fn one_step_on_each_die_in_different_intervals_is_frozen() { + let (w, _, _) = scripted( + |i| { + Some(vec![ + ("DIE_0_CPU Energy".to_string(), if i < 2 { 21_170_718 } else { 22_515_587 }), + ("DIE_1_CPU Energy".to_string(), if i < 5 { 23_300_102 } else { 24_691_739 }), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one step per die read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); + } + + /// Every CPU rail must keep advancing, not one. The counter sums both + /// dies' `CPU Energy` on an M3 Ultra-shaped part, so a Live on one die + /// beside a frozen one would grade a figure that misses the frozen die + /// as a whole-CPU reading. + /// - Both dies rising at every interval: Live at the tenth interval + /// (310 ms), exactly where one rail alone is (a single-die host is + /// judged as before). + /// - DIE_0 rising at every interval, DIE_1 holding: Frozen after the + /// whole budget, DIE_0 advancing in all 33 intervals and DIE_1 in + /// none. Fed by hand for 200 intervals, never live. DIE_0 alone meets + /// the rule at the tenth interval, so a rule satisfied by any one + /// rail calls this Live. + /// - The mirror, DIE_0 holding and DIE_1 rising: Frozen. + /// - DIE_0 rising, DIE_1 republished once in a batch refresh: Frozen. + #[test] + fn one_live_die_beside_a_frozen_die_is_frozen() { + let single = |i: usize| m5(i, 541_893_638 + i as i64); + let both = |i: usize| m3(i, 21_170_718 + i as i64, 23_300_102 + i as i64); + assert_eq!(first_live_interval(single, STEP, 200), Some(10)); + assert_eq!(first_live_interval(both, STEP, 200), Some(10), "a second live die moved the verdict"); + let (w, _, _) = scripted(|i| Some(both(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "both dies rising: {:?}", advances(&w)); + + let die_0_live = |i: usize| m3(i, 21_170_718 + i as i64, 23_300_102); + assert_eq!(first_live_interval(die_0_live, STEP, 200), None, "DIE_0 beside a frozen DIE_1 became live"); + let (w, taken, _) = scripted(|i| Some(die_0_live(i)), STEP, BUDGET); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "DIE_0 live beside a frozen DIE_1 read as live: {:?}", + advances(&w) + ); + assert_eq!(status_of(w.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!((w.intervals, taken), (33, 34), "a mixed part must be watched for the whole budget"); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 33), rail("DIE_1_CPU Energy#0", 0)]); + + let die_1_live = |i: usize| m3(i, 21_170_718, 23_300_102 + i as i64); + let (w, _, _) = scripted(|i| Some(die_1_live(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "DIE_1 live beside a frozen DIE_0: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 0), rail("DIE_1_CPU Energy#0", 33)]); + + let die_1_refresh = |i: usize| m3(i, 21_170_718 + i as i64, if i < 3 { 23_300_102 } else { 24_691_739 }); + let (w, _, _) = scripted(|i| Some(die_1_refresh(i)), STEP, BUDGET); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "DIE_0 live beside a batch-refreshed DIE_1: {:?}", + advances(&w) + ); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 33), rail("DIE_1_CPU Energy#0", 1)]); + } + + /// No interval at all is no verdict: a baseline alone is not one. + /// Intervals with no CPU rail are Absent, however much the GPU moves, + /// and however long they are watched: over the whole budget, past the + /// 300 ms window, no rail is not every rail advancing (`all` over no + /// rails is vacuously true, and would read as Live). + #[test] + fn no_interval_is_no_verdict_and_no_cpu_rail_is_absent() { + let mut w = RailWatch::default(); + assert_eq!(w.verdict(), None); + w.observe(&[("GPU Energy".to_string(), 1)], Duration::ZERO); + assert_eq!(w.verdict(), None, "a baseline alone is not an interval"); + w.observe(&[("GPU Energy".to_string(), 2)], STEP); + w.observe(&[("GPU Energy".to_string(), 3)], STEP * 2); + assert_eq!(w.verdict(), Some(CpuRail::Absent)); + let (w, taken, _) = + scripted(|i| Some(vec![("GPU Energy".to_string(), 1_000_000 * i as i64)]), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Absent), "a group with no CPU rail, watched {:?}", w.window); + assert_eq!(status_of(w.verdict()), crate::IoReportStatus::CpuRailAbsent); + assert_eq!((w.intervals, taken), (33, 34), "a group with no CPU rail must be watched for the whole budget"); + } + + /// The stopping rule on its own: within the budget keep watching until + /// the rail proves live; past it only until a live rail could have + /// advanced five times; never past five budgets. + #[test] + fn the_probe_samples_until_live_or_long_enough_to_tell() { + let budget = BUDGET; + let held = m5(0, 541_893_638); + let mut w = RailWatch::default(); + assert!(w.wants_more(Duration::ZERO, budget), "nothing observed yet"); + assert!(w.wants_more(budget * 2, budget), "no interval yet, past the budget"); + for k in 0..=4 { + w.observe(&held, STEP * k); + } + assert_eq!(w.intervals, 4); + assert!(w.wants_more(budget * 2, budget), "four intervals cannot show five advances"); + assert!(w.wants_more(budget * 5 - Duration::from_millis(1), budget)); + assert!(!w.wants_more(budget * 5, budget), "hard cap: five budgets"); + assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap with no interval"); + w.observe(&held, STEP * 5); + assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); + assert!(!w.wants_more(budget, budget), "budget spent, five intervals: stop"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + let mut live = RailWatch::default(); + for i in 0..10 { + live.observe(&m5(i, i as i64), STEP * i as u32); + assert!(live.wants_more(STEP * i as u32, budget), "sample {i}: not yet live, keep watching"); + } + live.observe(&m5(10, 10), STEP * 10); + assert!(!live.wants_more(STEP * 10, budget), "live: stop at once"); + } + + /// Each verdict reports its status, and no verdict (not one interval + /// observed) is Unavailable, never Live. + #[test] + fn each_verdict_reports_its_status() { + use crate::IoReportStatus as S; + assert_eq!(status_of(None), S::Unavailable); + assert_eq!(status_of(Some(CpuRail::Absent)), S::CpuRailAbsent); + assert_eq!(status_of(Some(CpuRail::Frozen)), S::CpuRailFrozen); + assert_eq!(status_of(Some(CpuRail::Live)), S::Live); + } + + /// No verdict is `Unavailable`: not `Live`, and not `CpuRailAbsent`. + #[test] + fn no_verdict_is_unavailable() { + assert_eq!(status_of(None), crate::IoReportStatus::Unavailable); + } + + /// A rail whose raw value FELL between consecutive samples did not + /// advance: a reset or a wrap is not energy spent. A rail falling at + /// every sample is Frozen with no advance; one real rise and one wrap + /// is one advance, not two. + #[test] + fn a_decrease_between_samples_is_not_an_advance() { + let (w, _, _) = scripted(|i| Some(m5(i, 541_893_638 - i as i64)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail falling at every sample read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 0)], "a fall counted as an advance"); + let wraps = |i: usize| match i { + 0 => 4_294_967_000, + 1 => 4_294_967_200, + _ => 150, + }; + let (w, _, _) = scripted(|i| Some(m5(i, wraps(i))), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rise and a wrap read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 1)], "a wrap counted as an advance"); + } + + /// A rail missing from the previous sample has nothing to be compared + /// with: the sample it appears in only sets its baseline. A `CPU + /// Energy` present in every other sample never advances, and a second + /// die that first shows up mid-probe and holds does not either. + #[test] + fn a_rail_missing_from_the_previous_sample_only_sets_its_baseline() { + let gpu_only = |i: usize| vec![("GPU Energy".to_string(), 1_000_000 * i as i64)]; + let (w, _, _) = + scripted(|i| Some(if i % 2 == 0 { m5(i, 541_893_638) } else { gpu_only(i) }), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail read as live for reappearing: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 0)], "a reappearing rail counted as an advance"); + let (w, _, _) = scripted( + |i| { + let mut s = vec![("DIE_0_CPU Energy".to_string(), 21_170_718)]; + if i >= 5 { + s.push(("DIE_1_CPU Energy".to_string(), 23_300_102)); + } + Some(s) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{:?}", advances(&w)); + assert_eq!( + advances(&w), + [rail("DIE_0_CPU Energy#0", 0), rail("DIE_1_CPU Energy#0", 0)], + "a rail new in a sample counted as an advance" + ); + } + + // ── probe_with, with a stand-in for every part ────────────────────── + + /// A channel of the Energy Model group, as the FFI would read it. + fn item(name: &str, raw: i64) -> Item { + Item { group: Some("Energy Model".to_string()), name: Some(name.to_string()), raw } + } + + /// An M5 Max-shaped read: `GPU Energy` rising at every sample beside + /// `CPU Energy` at `cpu`. + fn m5_items(i: usize, cpu: i64) -> Vec { + vec![item("GPU Energy", m5_gpu(i)), item("CPU Energy", cpu)] + } + + fn m5_gpu(i: usize) -> i64 { + 112_249_654_932_804 + 1_000_000 * i as i64 + } + + /// The batch refresh seen on an M5 Max: `CPU Energy` steps once, after + /// the second sample, and holds. + fn m5_refresh(i: usize) -> i64 { + if i < 2 { 480_083_160 } else { 541_893_638 } + } + + /// An M3 Ultra-shaped read in which the two dies' `CPU Energy` advance + /// in turn: DIE_0 in odd intervals (sample 0 to 1, 2 to 3, ...), DIE_1 + /// in even ones. Each die advances in half the intervals, and is not + /// live. Summed, the two dies would be one rail advancing in every + /// interval, which is live. Beside them, a duplicated unprefixed name + /// rising on both dies and a rising GPU rail. + fn two_dies_advancing_in_turn(i: usize) -> Vec { + vec![ + item("ECPUDTL00", 7_000 + i as i64), + item("DIE_0_CPU Energy", die_0(i)), + item("ECPUDTL00", 9_000 + i as i64), + item("DIE_1_CPU Energy", die_1(i)), + item("GPU Energy", 5_000_000 * i as i64), + ] + } + + /// [`m3`] as the FFI hands it over: each channel an Energy Model item. + fn m3_items(i: usize, d0: i64, d1: i64) -> Vec { + m3(i, d0, d1).into_iter().map(|(name, raw)| item(&name, raw)).collect() + } + + fn die_0(i: usize) -> i64 { + 21_170_718 + 1_000 * i.div_ceil(2) as i64 + } + + fn die_1(i: usize) -> i64 { + 23_300_102 + 1_000 * (i / 2) as i64 + } + + /// Each die's advances when [`two_dies_advancing_in_turn`] ran for + /// `intervals` intervals: DIE_0 in the odd ones, DIE_1 in the even. + fn in_turn_advances(intervals: u32) -> [(String, u32); 2] { + [rail("DIE_0_CPU Energy#0", intervals.div_ceil(2)), rail("DIE_1_CPU Energy#0", intervals / 2)] + } + + /// A scripted subscription: read `i` is `script(i)`, made into a + /// [`GroupSample`] by [`group_sample`] as the real subscription's are. + /// Its sample calls are counted in `calls`, which the test keeps. + struct Scripted { + script: F, + calls: Rc>, + } + + impl Option>> Sampler for Scripted { + fn sample(&self) -> Option { + let i = self.calls.get(); + self.calls.set(i + 1); + (self.script)(i).map(group_sample) + } + } + + /// The subscription type of an opener that never opens one. + type NoSubscription = Scripted Option>>; + + /// Scripted clocks: wall time and thread CPU time stand still, except + /// when [`fake_tick`] moves them. + struct FakeClocks { + base: Instant, + wall: Cell, + cpu: Cell, + } + + impl FakeClocks { + fn new() -> Self { + Self { base: Instant::now(), wall: Cell::new(Duration::ZERO), cpu: Cell::new(Duration::ZERO) } + } + } + + impl Clocks for FakeClocks { + fn now(&self) -> Instant { + self.base + self.wall.get() + } + + fn thread_cpu(&self) -> Option { + Some(self.cpu.get()) + } + } + + /// A scripted tick: [`STEP`] of wall time and [`PROBE_TICK`] of thread + /// CPU time pass on `clocks`, and `count` goes up. + fn fake_tick<'a>(clocks: &'a FakeClocks, count: &'a Cell) -> impl FnMut() + 'a { + move || { + clocks.wall.set(clocks.wall.get() + STEP); + clocks.cpu.set(clocks.cpu.get() + PROBE_TICK); + count.set(count.get() + 1); + } + } + + /// Rails as `("name#ordinal", raw)`. + fn named(rails: &[(RailKey, i64)]) -> Vec<(String, i64)> { + rails.iter().map(|(k, v)| (format!("{}#{}", k.name, k.ordinal), *v)).collect() + } + + /// [`probe_with`], the composition [`host_status`] runs, with an opener + /// that finds no subscription: the opener is asked once, nothing is + /// sampled, nothing ticks, and there is no verdict, which is + /// Unavailable. + #[test] + fn probe_with_no_subscription_samples_nothing_and_never_ticks() { + let (clocks, ticks, opened) = (FakeClocks::new(), Cell::new(0), Cell::new(0)); + let run = probe_with( + || { + opened.set(opened.get() + 1); + None:: + }, + fake_tick(&clocks, &ticks), + &clocks, + BUDGET, + ); + assert_eq!(opened.get(), 1, "the opener was asked {} times", opened.get()); + assert_eq!( + (run.samples.len(), ticks.get(), run.ticks.len()), + (0, 0, 0), + "sampled or ticked with no subscription" + ); + assert!(run.watch.is_none(), "watched a subscription that did not open"); + assert_eq!(run.verdict(), None); + assert_eq!(status_of(run.verdict()), crate::IoReportStatus::Unavailable); + } + + /// No subscription, or a subscription that never yields a sample: no + /// verdict, so `Unavailable`, never `Live`, never `CpuRailAbsent`. + #[test] + fn no_energy_model_subscription_or_no_sample_is_unavailable() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let no_group = probe_with(|| None::, fake_tick(&clocks, &ticks), &clocks, BUDGET); + assert_eq!(status_of(no_group.verdict()), crate::IoReportStatus::Unavailable, "no Energy Model subscription"); + let silent = Scripted { script: |_| None, calls: Rc::new(Cell::new(0)) }; + let no_sample = probe_with(|| Some(silent), fake_tick(&clocks, &ticks), &clocks, BUDGET); + assert_eq!( + status_of(no_sample.verdict()), + crate::IoReportStatus::Unavailable, + "a subscription that never samples" + ); + assert!(no_sample.samples.iter().all(|s| s.taken.is_none())); + } + + /// One batch refresh through [`probe_with`]: Frozen, after the whole + /// budget. The record holds, for every sample, exactly the CPU and GPU + /// values the script gave and the rule was handed, and every tick as + /// the clocks saw it. + #[test] + fn probe_with_one_batch_refresh_is_frozen() { + let (clocks, ticks, calls) = (FakeClocks::new(), Cell::new(0), Rc::new(Cell::new(0))); + let sub = Scripted { script: |i| Some(m5_items(i, m5_refresh(i))), calls: Rc::clone(&calls) }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "a batch refresh read as live: {:?}", advances(w)); + assert_eq!(status_of(run.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!(advances(w), [rail("CPU Energy#0", 1)]); + // The whole budget: at 31 ms a tick, the clock reaches 1 s at the 33rd. + assert_eq!((calls.get(), run.samples.len(), ticks.get(), run.ticks.len()), (34, 34, 33, 33)); + for (i, s) in run.samples.iter().enumerate() { + let t = s.taken.as_ref().expect("every scripted sample is usable"); + assert_eq!((t.channels, t.outside_group), (2, 0), "sample {i}"); + assert_eq!( + named(&t.cpu_rails), + [("CPU Energy#0".to_string(), m5_refresh(i))], + "sample {i}: the record is not what the script gave" + ); + assert_eq!(named(&t.gpu_rails), [("GPU Energy#0".to_string(), m5_gpu(i))], "sample {i}"); + assert_eq!(s.at, clocks.base + STEP * i as u32, "sample {i} recorded at the wrong time"); + } + assert!(run.ticks.iter().all(|k| k.cpu == Some(PROBE_TICK) && k.wall == STEP), "{:?}", run.ticks); + } + + /// A rail advancing at every sample, through [`probe_with`] and its + /// wall clock: Live at the tenth interval, when the window first spans + /// 300 ms, with eleven samples and ten ticks. + #[test] + fn probe_with_a_sustained_advance_is_live() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { script: |i| Some(m5_items(i, 541_893_638 + i as i64)), calls: Rc::new(Cell::new(0)) }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Live), "{:?}", advances(w)); + assert_eq!(status_of(run.verdict()), crate::IoReportStatus::Live); + assert_eq!((run.samples.len(), ticks.get(), run.ticks.len()), (11, 10, 10)); + assert_eq!((advances(w), w.window), (vec![rail("CPU Energy#0", 10)], Duration::from_millis(310))); + } + + /// The observed two-step refresh through [`probe_with`]: Frozen, two + /// advances, after the whole budget. + #[test] + fn probe_with_the_observed_two_step_refresh_is_frozen() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { script: |i| Some(m5_items(i, two_step(i, 3, 4))), calls: Rc::new(Cell::new(0)) }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "a two-step refresh read as live: {:?}", advances(w)); + assert_eq!((advances(w), run.samples.len()), (vec![rail("CPU Energy#0", 2)], 34)); + } + + /// Two dies advancing in turn, through [`probe_with`]: Frozen, each + /// die advancing in half the intervals, and the record holds each die's + /// own value. A transform between the sampler and the rule that summed + /// the dies would see one rail advance in every interval, and say Live. + #[test] + fn probe_with_two_dies_advancing_in_turn_is_frozen() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { script: |i| Some(two_dies_advancing_in_turn(i)), calls: Rc::new(Cell::new(0)) }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "two dies advancing in turn read as live: {:?}", advances(w)); + assert_eq!(w.intervals, 33); + assert_eq!(advances(w), in_turn_advances(w.intervals)); + for (i, s) in run.samples.iter().enumerate() { + let t = s.taken.as_ref().expect("every scripted sample is usable"); + assert_eq!( + named(&t.cpu_rails), + [("DIE_0_CPU Energy#0".to_string(), die_0(i)), ("DIE_1_CPU Energy#0".to_string(), die_1(i))], + "sample {i}: the rule was not handed each die's own value" + ); + } + } + + /// Two dies through [`probe_with`]: DIE_0 rising at every interval + /// beside a DIE_1 that holds is Frozen, after the whole budget, with + /// each die's own advances in the record; both rising is Live at the + /// tenth interval and stops the probe there, as one rail does. + #[test] + fn probe_with_one_live_die_beside_a_frozen_die_is_frozen() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { + script: |i| Some(m3_items(i, 21_170_718 + i as i64, 23_300_102)), + calls: Rc::new(Cell::new(0)), + }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "one live die beside a frozen one: {:?}", advances(w)); + assert_eq!(status_of(run.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!((w.intervals, run.samples.len()), (33, 34)); + assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", 33), rail("DIE_1_CPU Energy#0", 0)]); + + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { + script: |i| Some(m3_items(i, 21_170_718 + i as i64, 23_300_102 + i as i64)), + calls: Rc::new(Cell::new(0)), + }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Live), "both dies rising: {:?}", advances(w)); + assert_eq!((run.samples.len(), ticks.get()), (11, 10)); + assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", 10), rail("DIE_1_CPU Energy#0", 10)]); + } + + /// [`group_sample`] counts every channel, counts the ones outside the + /// group, and hands on each in-group channel's own raw value, in order. + #[test] + fn group_sample_counts_channels_and_hands_on_each_rails_own_value() { + let other = |group: Option<&str>, name: Option<&str>, raw| Item { + group: group.map(str::to_string), + name: name.map(str::to_string), + raw, + }; + let g = group_sample(vec![ + item("DIE_0_CPU Energy", 5), + other(Some("CPU Stats"), Some("CPU Energy"), 99), + item("DIE_1_CPU Energy", 7), + other(None, Some("GPU Energy"), 1), + other(Some("energy model"), None, 3), + item("GPU Energy", 11), + ]); + assert_eq!((g.channels, g.outside_group), (6, 2)); + assert_eq!( + g.values, + [ + ("DIE_0_CPU Energy".to_string(), 5), + ("DIE_1_CPU Energy".to_string(), 7), + ("GPU Energy".to_string(), 11) + ] + ); + } + + // ── The tests' own clocks ─────────────────────────────────────────── + + /// Clock ids from macOS ``, the tests' own: not the + /// implementation's constant, so a wrong id there cannot move the + /// tests' clocks with it. + const TEST_CLOCK_PROCESS_CPUTIME_ID: c_int = 12; + const TEST_CLOCK_THREAD_CPUTIME_ID: c_int = 16; + + unsafe extern "C" { + fn clock_gettime_nsec_np(clock_id: c_int) -> u64; + } + + /// A clock read the tests' own way (`clock_gettime_nsec_np`), not + /// through the probe's [`thread_cpu_time`]. + fn test_clock(id: c_int) -> Duration { + let ns = unsafe { clock_gettime_nsec_np(id) }; + assert_ne!(ns, 0, "clock_gettime_nsec_np({id}) failed"); + Duration::from_nanos(ns) + } + + /// CPU time this thread has used, by the tests' own clock. + fn test_thread_cpu() -> Duration { + test_clock(TEST_CLOCK_THREAD_CPUTIME_ID) + } + + /// Spin until this thread has used `cpu` more CPU time, by the tests' + /// own clock: real work, owing nothing to the implementation's burn. + fn spin_cpu(cpu: Duration) { + let start = test_thread_cpu(); + let mut acc: u64 = 0; + while test_thread_cpu().saturating_sub(start) < cpu { + for _ in 0..1_000 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + } + } + + /// The CPU floor and wall cap the tests hold each tick to: their own + /// literals, not the implementation's constants. + const TICK_CPU_FLOOR: Duration = Duration::from_millis(25); + const TICK_WALL_CAP: Duration = Duration::from_millis(500); + + /// How far a CPU reading may pass the wall time around it: the + /// granularity of reading two clocks, nothing more. + const CLOCK_SLACK: Duration = Duration::from_millis(1); + + /// How far the probe's own record of a tick's CPU time may fall short + /// of the tests' measurement, which spans the tick and the probe's + /// bookkeeping between two reads. + const RECORD_SLACK: Duration = Duration::from_millis(2); + + /// A moment by the tests' own clocks: wall time and this thread's CPU + /// time. + #[derive(Debug, Clone, Copy)] + struct Stamp { + wall: Instant, + cpu: Duration, + } + + impl Stamp { + /// Wall, then CPU: where an interval begins whose CPU time must fit + /// inside its wall time. + fn opening() -> Self { + let wall = Instant::now(); + Self { wall, cpu: test_thread_cpu() } + } + + /// CPU, then wall: where such an interval ends. + fn closing() -> Self { + let cpu = test_thread_cpu(); + Self { wall: Instant::now(), cpu } + } + + /// (CPU, wall) from `self` to `later`. + fn until(self, later: Stamp) -> (Duration, Duration) { + (later.cpu.saturating_sub(self.cpu), later.wall.saturating_duration_since(self.wall)) + } + } + + /// Each tick between two FFI reads, by the tests' own clocks: the probe + /// burned [`TICK_CPU_FLOOR`] of this thread's CPU between every two + /// reads, unless the tick ran into [`TICK_WALL_CAP`] of wall time, and + /// no tick shows more CPU than wall time. `reads` are the (entered, + /// left) stamps of each read, in order. Returns each tick's (CPU, wall). + fn assert_ticks_burned(reads: &[(Stamp, Stamp)], what: &str) -> Vec<(Duration, Duration)> { + let ticks: Vec<(Duration, Duration)> = reads.windows(2).map(|w| w[0].1.until(w[1].0)).collect(); + for (k, &(cpu, wall)) in ticks.iter().enumerate() { + assert!( + cpu <= wall + CLOCK_SLACK, + "{what}: tick {k} shows {cpu:?} of this thread's CPU in {wall:?} of wall time, by the tests' clocks" + ); + assert!( + cpu >= TICK_CPU_FLOOR || wall >= TICK_WALL_CAP, + "{what}: tick {k} used {cpu:?} of this thread's CPU in {wall:?}, by the tests' own clock: a tick \ + must burn {TICK_CPU_FLOOR:?} of thread CPU, and only a {TICK_WALL_CAP:?} wall cap may cut that short" + ); + } + ticks + } + + /// The probe's thread clock ([`thread_cpu_time`], which [`burn`] and + /// [`HostClocks`] read) is this thread's own CPU time. Checked on the + /// tests' own clocks, read with their own clock ids: + /// - not wall time: across a 100 ms sleep it advances under 5 ms; + /// - not the process's CPU time: while a helper thread burns 100 ms of + /// CPU and this thread waits for it, it advances under 5 ms, though + /// the process clock advances at least 100 ms; + /// - it tracks this thread's CPU: across 30 ms of this thread's CPU by + /// the tests' clock, it advances the same, within 1 ms. + #[test] + fn the_probe_reads_this_threads_own_cpu_clock() { + let probe = || thread_cpu_time().expect("the probe's thread clock could not be read"); + let before = probe(); + std::thread::sleep(Duration::from_millis(100)); + let slept = probe().saturating_sub(before); + + let (before, process_before) = (probe(), test_clock(TEST_CLOCK_PROCESS_CPUTIME_ID)); + let helper = std::thread::scope(|s| { + s.spawn(|| { + let start = test_thread_cpu(); + spin_cpu(Duration::from_millis(100)); + test_thread_cpu().saturating_sub(start) + }) + .join() + .expect("the helper thread panicked") + }); + let waited = probe().saturating_sub(before); + let process = test_clock(TEST_CLOCK_PROCESS_CPUTIME_ID).saturating_sub(process_before); + + let (before, tests_before) = (probe(), test_thread_cpu()); + spin_cpu(Duration::from_millis(30)); + let (tracked, by_tests) = (probe().saturating_sub(before), test_thread_cpu().saturating_sub(tests_before)); + + eprintln!( + "ioreport thread clock: across a 100ms sleep it advanced {slept:?}; while a helper burned {helper:?} \ + (process clock +{process:?}) it advanced {waited:?}; across {by_tests:?} of this thread's CPU by \ + the tests' clock it advanced {tracked:?}" + ); + assert!( + slept < Duration::from_millis(5), + "the probe's thread clock advanced {slept:?} across a 100ms sleep of this thread: it counts time this \ + thread did not run (wall time, or other threads' CPU)" + ); + assert!(helper >= Duration::from_millis(100), "the helper burned only {helper:?}"); + assert!( + process >= Duration::from_millis(100), + "the process clock advanced only {process:?} while a helper burned {helper:?}, so this test cannot tell a \ + process clock from a thread clock" + ); + assert!( + waited < Duration::from_millis(5), + "the probe's thread clock advanced {waited:?} while this thread waited on a helper burning {helper:?}: it \ + reads the process's CPU time" + ); + assert!( + tracked.abs_diff(by_tests) <= Duration::from_millis(1), + "the probe's thread clock advanced {tracked:?} across {by_tests:?} of this thread's CPU by the tests' clock" + ); + } + + // ── host_status itself, through the seams ─────────────────────────── + + /// Runs [`host_status`] itself on this thread with every FFI read + /// replaced by `script(i)`. Everything else is real: the subscription it + /// opens, [`burn_tick`], [`HostClocks`] and the budget. Returns the + /// status, the run (from the tap), each read's (entered, left) stamps + /// by the tests' own clocks, and how long `host_status` took by the + /// tests' wall clock. + fn host_status_on_script( + script: impl Fn(usize) -> Option> + 'static, + ) -> (crate::IoReportStatus, ProbeRun, Vec<(Stamp, Stamp)>, Duration) { + let reads = Rc::new(RefCell::new(Vec::new())); + let (log, next) = (Rc::clone(&reads), Cell::new(0)); + let _script = seam::stand_in(move |_real_read| { + let entered = Stamp::closing(); + let i = next.get(); + next.set(i + 1); + let items = script(i); + log.borrow_mut().push((entered, Stamp::opening())); + items + }); + let tap = seam::tap(); + let started = Instant::now(); + let status = host_status(); + let took = started.elapsed(); + let run = tap.one_run("host_status"); + (status, run, reads.take(), took) + } + + /// No Energy Model subscription opened, so `host_status` never read the + /// script: the no-subscription case, which must be Unavailable with no + /// read. Not a pass by default: the oracle, on its own all-channel + /// subscription, must find no Energy Model channel either, or the + /// opener failed on a host that has the group. + fn no_energy_model_group_here(status: crate::IoReportStatus, reads: &[(Stamp, Stamp)]) { + let census = oracle_census(); + eprintln!( + "host_status: no Energy Model subscription opened; status={status:?}, {} reads; the oracle counts (in \ + that group, in all) = {census:?}", + reads.len() + ); + assert!( + census.is_none_or(|(group, _)| group == 0), + "no Energy Model subscription opened, but the oracle reads that group's channels (in it, in all) = \ + {census:?}: the opener failed on a host that has the group" + ); + assert_eq!(status, crate::IoReportStatus::Unavailable, "no Energy Model subscription opened"); + assert!(reads.is_empty(), "read {} samples with no subscription", reads.len()); + } + + /// `host_status` itself, with its Energy Model subscription failing: no + /// FFI read, no tick, no CPU burned, no verdict, so Unavailable (never + /// Live, never CpuRailAbsent). A branch put into `host_status` for this + /// case runs here. + #[test] + fn host_status_with_no_energy_model_subscription_is_unavailable() { + let _fail = seam::fail_opens(); + let reads = Rc::new(Cell::new(0)); + let counted = Rc::clone(&reads); + let _count = seam::stand_in(move |read| { + counted.set(counted.get() + 1); + read() + }); + let tap = seam::tap(); + let cpu = test_thread_cpu(); + let status = host_status(); + let cpu = test_thread_cpu().saturating_sub(cpu); + let runs = tap.runs(); + eprintln!( + "host_status, subscription failing: status={status:?}, {} probe runs, {} reads, {} ticks, {cpu:?} of CPU", + runs.len(), + reads.get(), + runs.iter().map(|r| r.ticks.len()).sum::() + ); + assert_eq!(status, crate::IoReportStatus::Unavailable, "no Energy Model subscription"); + assert_eq!(runs.len(), 1, "host_status made {} probe runs, not one: it decides through probe_with", runs.len()); + let run = &runs[0]; + assert!(run.watch.is_none(), "probe_with watched a subscription that did not open"); + assert_eq!((reads.get(), run.samples.len(), run.ticks.len()), (0, 0, 0), "read or ticked with no subscription"); + assert!(cpu < TICK_CPU_FLOOR, "host_status burned {cpu:?} of CPU with no subscription to watch"); + } + + /// `host_status` itself, with the two-step refresh seen on an M5 Max + /// ([`two_step`], steps in consecutive intervals) in place of its FFI + /// reads: Frozen, two advances, having burned a tick of CPU between + /// every two reads, by the tests' own clocks. The two-advance rule + /// said Live here, through every production link but the FFI read. A + /// rail that is not live keeps the probe going for its whole budget, so + /// `host_status` took at least [`crate::CPU_RAIL_LIVENESS_BUDGET`]: a + /// lower bound, which load can only lengthen. + #[test] + fn host_status_on_the_observed_two_step_refresh_is_frozen() { + let _hw = crate::ioreport_hw_lock(); + let refresh = |i| two_step(i, 3, 4); + let (status, run, reads, took) = host_status_on_script(move |i| Some(m5_items(i, refresh(i)))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + let ticks = assert_ticks_burned(&reads, "host_status on the two-step refresh"); + eprintln!( + "host_status, scripted two-step refresh: status={status:?}, advances {:?} in {} intervals over {:?}, {} \ + reads, median tick CPU by the tests' clock {:?}, took {took:?}", + advances(w), + w.intervals, + w.window, + reads.len(), + median(&ticks.iter().map(|t| t.0).collect::>()) + ); + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "a two-step refresh read as live: {:?}", advances(w)); + assert!( + took >= crate::CPU_RAIL_LIVENESS_BUDGET, + "host_status gave up on a rail that is not live after {took:?}, before its {:?} budget", + crate::CPU_RAIL_LIVENESS_BUDGET + ); + assert_eq!(advances(w), [rail("CPU Energy#0", 2)]); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Frozen on {} intervals", w.intervals); + assert_eq!(reads.len(), run.samples.len(), "the record and the reads disagree"); + for (i, s) in run.samples.iter().enumerate() { + let t = s.taken.as_ref().expect("every scripted sample is usable"); + assert_eq!(named(&t.cpu_rails), [("CPU Energy#0".to_string(), refresh(i))], "sample {i}"); + assert_eq!(named(&t.gpu_rails), [("GPU Energy#0".to_string(), m5_gpu(i))], "sample {i}"); + } + } + + /// `host_status` itself, with a rail advancing at every read: Live, + /// once the rail has advanced in at least five intervals and the + /// window, by the probe's real wall clock, spans 300 ms, each interval + /// a tick of burned CPU. By the tests' own clock the reads span 300 ms + /// too (less a clock read's slack), so the window is wall time, not a + /// count or a constant. And the probe stops as soon as it is live: one + /// interval earlier, the window spanned less than 300 ms, or the rail + /// had fewer than five advances. + #[test] + fn host_status_on_a_sustained_advance_is_live() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, took) = host_status_on_script(|i| Some(m5_items(i, 541_893_638 + i as i64))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + let ticks = assert_ticks_burned(&reads, "host_status on a sustained advance"); + let span = reads.last().map(|l| l.1.wall.saturating_duration_since(reads[0].0.wall)).unwrap_or_default(); + // The rule's own reading at the sample before the last, from the + // probe's record: the end of that sample call, measured like the + // window, from the end of the first. + let end = |s: &SampleCall| s.at + s.wall; + let before_last = run.samples.len().checked_sub(2).map(|k| end(&run.samples[k]) - end(&run.samples[0])); + eprintln!( + "host_status, scripted sustained advance: status={status:?}, advances {:?} in {} intervals, window \ + {:?} by the probe's clock ({before_last:?} one interval earlier), {span:?} by the tests' clock, median \ + tick CPU {:?}, took {took:?}", + advances(w), + w.intervals, + w.window, + median(&ticks.iter().map(|t| t.0).collect::>()) + ); + assert_eq!(status, crate::IoReportStatus::Live, "a rail advancing at every read: {:?}", advances(w)); + assert_eq!(advances(w), [rail("CPU Energy#0", w.intervals)]); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Live after {} intervals", w.intervals); + assert!(w.window >= TEST_MIN_WINDOW, "Live over a {:?} window", w.window); + assert!(span + CLOCK_SLACK >= TEST_MIN_WINDOW, "Live, but the reads span {span:?} by the tests' clock"); + let before_last = before_last.expect("fewer than two samples"); + assert!( + w.intervals - 1 < TEST_MIN_ADVANCES || before_last < TEST_MIN_WINDOW + CLOCK_SLACK, + "the probe did not stop as soon as the rail was live: one interval earlier it had {} advances over \ + {before_last:?}", + w.intervals - 1 + ); + let n = w.intervals as usize; + assert_eq!((reads.len(), run.samples.len(), run.ticks.len()), (n + 1, n + 1, n), "reads, samples, ticks"); + } + + /// `host_status` itself, with a CPU rail that holds at every read, as + /// every CPU rail does on macOS 27.0.1 between batch refreshes, beside + /// a GPU rail that rises at every read: Frozen, with no advance, after + /// its whole budget, having burned a tick of CPU between every two + /// reads by the tests' own clocks, and ticked once before every + /// re-read. + #[test] + fn host_status_on_a_scripted_constant_rail_is_frozen() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, took) = host_status_on_script(|i| Some(m5_items(i, 541_893_638))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + let ticks = assert_ticks_burned(&reads, "host_status on a scripted constant rail"); + eprintln!( + "host_status, scripted constant rail: status={status:?}, advances {:?}, {} reads, {} ticks, median tick \ + CPU by the tests' clock {:?}, took {took:?}", + advances(w), + reads.len(), + run.ticks.len(), + median(&ticks.iter().map(|t| t.0).collect::>()) + ); + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "a constant rail read as {status:?}: {:?}", advances(w)); + assert_eq!(advances(w), [rail("CPU Energy#0", 0)], "a constant rail counted as advancing"); + assert!( + took >= crate::CPU_RAIL_LIVENESS_BUDGET, + "host_status gave up on a constant rail after {took:?}, before its {:?} budget", + crate::CPU_RAIL_LIVENESS_BUDGET + ); + assert_eq!(reads.len(), run.samples.len(), "the record and the reads disagree"); + assert_eq!(run.ticks.len() + 1, run.samples.len(), "the probe must tick once before every re-read"); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Frozen on {} intervals", w.intervals); + } + + /// `host_status` itself, with two dies advancing in turn: Frozen, each + /// die advancing in half the intervals. A transform anywhere between + /// the FFI read and the rule that summed the dies' rails would see one + /// rail advance in every interval and say Live. + #[test] + fn host_status_on_two_dies_advancing_in_turn_is_frozen() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, _) = host_status_on_script(|i| Some(two_dies_advancing_in_turn(i))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + assert_ticks_burned(&reads, "host_status on two dies advancing in turn"); + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "two dies advancing in turn read as live: {:?}", advances(w)); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Frozen on {} intervals", w.intervals); + assert_eq!(advances(w), in_turn_advances(w.intervals)); + } + + /// One die live beside a frozen one, through the production + /// composition [`host_status`] (the real tick and clocks, scripted + /// reads): `CpuRailFrozen`, DIE_0 advancing in every interval and + /// DIE_1 in none. + #[test] + fn host_status_on_one_live_die_beside_a_frozen_die_is_frozen() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, _) = + host_status_on_script(|i| Some(m3_items(i, 21_170_718 + i as i64, 23_300_102))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + assert_ticks_burned(&reads, "host_status on one live die beside a frozen one"); + assert_eq!( + status, + crate::IoReportStatus::CpuRailFrozen, + "one live die beside a frozen one read as live: {:?}", + advances(w) + ); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Frozen on {} intervals", w.intervals); + assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", w.intervals), rail("DIE_1_CPU Energy#0", 0)]); + } + + // ── Against the hardware ──────────────────────────────────────────── + + /// The median of some durations (the upper one of an even count). + fn median(of: &[Duration]) -> Option { + let mut v = of.to_vec(); + v.sort(); + v.get(v.len() / 2).copied() + } + + /// The group-only pin by cost: a group sample takes ~8-9 ms of the + /// sampling thread's CPU however contended, an all-channel one ~100-125 + /// ms. + const GROUP_SAMPLE_CPU_CEILING: Duration = Duration::from_millis(50); + + /// How long the oracle may take to read beside the probe before the + /// test gives up on it. + const ORACLE_GIVE_UP: Duration = Duration::from_secs(60); + + /// How long the probe's second read is held for the oracle to see a + /// rail move after its first. + const WITNESS_WAIT: Duration = Duration::from_secs(3); + + /// What the independent oracle saw on this host. + #[derive(Debug, PartialEq)] + enum Oracle { + NoIoReport, + NoCpuRail, + CpuRailMoved(bool), + /// Fewer than [`ORACLE_MIN_CHANGES`] reads after the first, even + /// with the window extended: too few for a moving rail to meet the + /// oracle's bar, so no evidence either way. Every test fails on it. + TooFewReads, + } + + /// The oracle's bar for a moving rail: it changed at this many reads at + /// least, and at 80% of the reads at least ([`oracle_kept_moving`]). + /// Also the fewest reads after the first that the oracle takes, since a + /// rail cannot change at more reads than there are. Its own literal, + /// not the implementation's constant. + const ORACLE_MIN_CHANGES: u32 = 5; + + /// How many times its window the oracle may keep reading to get the + /// [`ORACLE_MIN_CHANGES`] reads it needs. + const ORACLE_WINDOW_CAP: u32 = 5; + + /// Did a rail that changed at `changes` of `reads` reads keep moving? + /// At least [`ORACLE_MIN_CHANGES`] changes, at least 80% of the reads. + /// A live rail changes at nearly every read. The batch refreshes seen + /// were published in one step or two within 64 ms. The oracle's reads + /// are 0.13 s or more apart, so even a refresh spread over 150 ms holds + /// at most two of them, and shows as a change at three reads at most: + /// it cannot vouch for a probe that called it live. + fn oracle_kept_moving(changes: u32, reads: u32) -> bool { + changes >= ORACLE_MIN_CHANGES && changes * 5 >= reads * 4 + } + + /// The failure for an oracle run with too little evidence. + fn too_few_reads(run: &OracleRun, window: Duration) -> ! { + panic!( + "oracle evidence: only {} reads after the first in {:?} (window {window:?}, extended up to \ + {ORACLE_WINDOW_CAP}x): fewer than {ORACLE_MIN_CHANGES} cannot show a rail changing at \ + {ORACLE_MIN_CHANGES} reads, so this run can neither clear nor convict the probe (the host is too \ + loaded for an all-channel IOReport sample, or its reads fail)", + run.reads, + run.took + ) + } + + /// One oracle run: its verdict, and what it read to reach it. + #[derive(Debug)] + struct OracleRun { + verdict: Oracle, + /// Reads taken after the first. + reads: u32, + /// How long the oracle read for: its window, or longer if it had + /// to extend it to get [`ORACLE_MIN_CHANGES`] reads. + took: Duration, + /// Per CPU rail, at how many reads its absolute value had changed. + cpu_changes: Vec, + /// The same for `GPU Energy`. When the GPU draws, this shows the + /// reads are fresh samples; an idle GPU shows few changes or none. + gpu_changes: Vec, + } + + impl OracleRun { + fn bare(verdict: Oracle) -> Self { + Self { verdict, reads: 0, took: Duration::ZERO, cpu_changes: Vec::new(), gpu_changes: Vec::new() } + } + } + + /// The oracle's own CFString conversion, so it does not share the + /// implementation's `cfstring_to_string`. + fn oracle_string(s: CFStringRef) -> Option { + if s.is_null() { + return None; + } + let mut buf = [0u8; 512]; + let ok = unsafe { + CFStringGetCString(s, buf.as_mut_ptr() as *mut i8, buf.len() as c_long, K_CFSTRING_ENCODING_UTF8) + }; + if !ok { + return None; + } + let end = buf.iter().position(|&b| b == 0)?; + String::from_utf8(buf[..end].to_vec()).ok() + } + + /// The oracle's own all-channel subscription. `None` if IOReport does + /// not open. Released by [`oracle_unsubscribe`]. + fn oracle_subscribe() -> Option<(IOReportSubscriptionRef, CFMutableDictionaryRef)> { + unsafe { + let all = IOReportCopyAllChannels(0, 0); + if all.is_null() { + return None; + } + let mut chans: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription(std::ptr::null(), all, &mut chans, 0, std::ptr::null()); + CFRelease(all as CFTypeRef); + if sub.is_null() || chans.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + if !chans.is_null() { + CFRelease(chans as CFTypeRef); + } + return None; + } + Some((sub, chans)) + } + } + + fn oracle_unsubscribe((sub, chans): (IOReportSubscriptionRef, CFMutableDictionaryRef)) { + unsafe { + CFRelease(sub); + CFRelease(chans as CFTypeRef); + } + } + + /// One channel of one oracle read. + #[derive(Debug, Clone)] + struct OracleChannel { + group: String, + name: String, + unit: String, + raw: i64, + } + + /// From one plain sample (no delta), every channel whose name `keep`s, + /// in any group, with its ABSOLUTE raw value. + fn oracle_channels( + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + keep: impl Fn(&str) -> bool, + ) -> Option> { + unsafe { + let smp = IOReportCreateSamples(sub, chans, std::ptr::null()); + if smp.is_null() { + return None; + } + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + let mut out = Vec::new(); + if !key.is_null() { + let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + if !arr.is_null() { + for i in 0..CFArrayGetCount(arr) { + let it = CFArrayGetValueAtIndex(arr, i); + if it.is_null() { + continue; + } + let name = oracle_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); + if keep(&name) { + out.push(OracleChannel { + group: oracle_string(IOReportChannelGetGroup(it)).unwrap_or_default(), + unit: oracle_string(IOReportChannelGetUnitLabel(it)).unwrap_or_default(), + name, + raw: IOReportSimpleGetIntegerValue(it, 0) as i64, + }); + } + } + } + } + CFRelease(smp); + Some(out) + } + } + + /// How many channels the Energy Model group has, and how many there are + /// in all, by one oracle read on its own all-channel subscription. + fn oracle_census() -> Option<(usize, usize)> { + let (sub, chans) = oracle_subscribe()?; + let all = oracle_channels(sub, chans, |_| true); + oracle_unsubscribe((sub, chans)); + let all = all?; + Some((all.iter().filter(|c| c.group.eq_ignore_ascii_case("Energy Model")).count(), all.len())) + } + + /// The ABSOLUTE cumulative values of every channel whose name ends with + /// `CPU Energy`, and of every one ending with `GPU Energy`, in any + /// group, from one plain sample. + fn oracle_read(sub: IOReportSubscriptionRef, chans: CFMutableDictionaryRef) -> Option<(Vec, Vec)> { + let chans = oracle_channels(sub, chans, |n| n.ends_with("CPU Energy") || n.ends_with("GPU Energy"))?; + let pick = + |suffix: &str| chans.iter().filter(|c| c.name.ends_with(suffix)).map(|c| c.raw).collect(); + Some((pick("CPU Energy"), pick("GPU Energy"))) + } + + /// Read until `window` has elapsed, counting per rail at how many reads + /// the absolute value changed from the read before. Stops early once + /// one CPU rail meets [`oracle_kept_moving`]. If `window` ends with + /// fewer than [`ORACLE_MIN_CHANGES`] reads after the first, keeps + /// reading until it has that many, up to [`ORACLE_WINDOW_CAP`] windows, + /// and says [`Oracle::TooFewReads`] if it still has not: never + /// `CpuRailMoved(false)` on too few reads to tell. `read` takes one + /// read, `elapsed` is the time since the oracle started: the hardware + /// ([`oracle_watch`]), or a script and a scripted clock. + fn oracle_watch_with( + mut read: impl FnMut() -> Option<(Vec, Vec)>, + elapsed: impl Fn() -> Duration, + window: Duration, + ) -> OracleRun { + let Some((mut cpu_prev, mut gpu_prev)) = read() else { + return OracleRun::bare(Oracle::NoIoReport); + }; + if cpu_prev.is_empty() { + return OracleRun::bare(Oracle::NoCpuRail); + } + let mut run = OracleRun { + verdict: Oracle::CpuRailMoved(false), + reads: 0, + took: Duration::ZERO, + cpu_changes: vec![0; cpu_prev.len()], + gpu_changes: vec![0; gpu_prev.len()], + }; + while elapsed() < window || (run.reads < ORACLE_MIN_CHANGES && elapsed() < window * ORACLE_WINDOW_CAP) { + let Some((cpu, gpu)) = read() else { + continue; + }; + run.reads += 1; + for (n, (now, before)) in run.cpu_changes.iter_mut().zip(cpu.iter().zip(&cpu_prev)) { + *n += u32::from(now != before); + } + for (n, (now, before)) in run.gpu_changes.iter_mut().zip(gpu.iter().zip(&gpu_prev)) { + *n += u32::from(now != before); + } + (cpu_prev, gpu_prev) = (cpu, gpu); + if run.cpu_changes.iter().any(|&n| oracle_kept_moving(n, run.reads)) { + run.verdict = Oracle::CpuRailMoved(true); + break; + } + } + run.took = elapsed(); + if run.reads < ORACLE_MIN_CHANGES { + run.verdict = Oracle::TooFewReads; + } + run + } + + /// [`oracle_watch_with`] on the oracle's own subscription, timed from + /// `started`. + fn oracle_watch( + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + started: Instant, + window: Duration, + ) -> OracleRun { + oracle_watch_with(|| oracle_read(sub, chans), || started.elapsed(), window) + } + + /// An oracle that shares nothing with the liveness probe but the raw FFI + /// declarations: it opens its own subscription over every channel (not + /// the probe's Energy Model group), converts CFStrings itself, reads + /// ABSOLUTE cumulative values and compares each read with the one + /// before it itself, matches names with `ends_with("CPU Energy")` in any + /// group (not the implementation's group filter and die-prefix + /// stripping), and keeps CPU busy on a HELPER thread for the whole + /// `window`, so the window is under load even while an all-channel sample + /// blocks this thread. + /// + /// `CpuRailMoved(true)` only if one rail's absolute value changed at + /// [`ORACLE_MIN_CHANGES`] reads or more, making up at least 80% of its + /// reads ([`oracle_kept_moving`]). A live rail changes at nearly every + /// read. A batch-refreshed one changes at three reads at most, even + /// when the refresh is published in steps, so a refresh that coincides + /// with the probe cannot vouch for a Live it caused. Its own literal + /// thresholds, not the implementation's constants, so a change to one + /// cannot silently move the other. + fn oracle_cpu_rail(window: Duration) -> OracleRun { + let Some((sub, chans)) = oracle_subscribe() else { + return OracleRun::bare(Oracle::NoIoReport); + }; + let stop = AtomicBool::new(false); + let run = std::thread::scope(|s| { + s.spawn(|| { + let mut acc: u64 = 0; + while !stop.load(Ordering::Relaxed) { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc); + }); + let run = oracle_watch(sub, chans, Instant::now(), window); + stop.store(true, Ordering::Relaxed); + run + }); + oracle_unsubscribe((sub, chans)); + run + } + + /// Every channel whose name ends with `CPU Energy` or `GPU Energy`, + /// keyed by (name, ordinal among the same-named ones), in order: the + /// oracle's own matching, applied alike to its reads and the probe's. + fn keyed_rails(chans: impl IntoIterator) -> Vec<((String, u32), i64)> { + let mut out: Vec<((String, u32), i64)> = Vec::new(); + for (name, raw) in chans { + if name.ends_with("CPU Energy") || name.ends_with("GPU Energy") { + let ordinal = out.iter().filter(|((n, _), _)| *n == name).count() as u32; + out.push(((name, ordinal), raw)); + } + } + out + } + + /// The Energy Model group's CPU and GPU rails in one oracle read, keyed + /// by [`keyed_rails`]. + fn oracle_group_rails( + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + ) -> Option> { + let chans = oracle_channels(sub, chans, |n| n.ends_with("CPU Energy") || n.ends_with("GPU Energy"))?; + Some(keyed_rails( + chans.into_iter().filter(|c| c.group.eq_ignore_ascii_case("Energy Model")).map(|c| (c.name, c.raw)), + )) + } + + /// [`oracle_watch_with`] on a script: read `i` gives one CPU rail at + /// `cpu(i)` and a GPU rail changing at every read, and each read moves a + /// scripted clock on by `per_read`. + fn oracle_on(cpu: impl Fn(usize) -> i64, per_read: Duration, window: Duration) -> OracleRun { + let (clock, n) = (Cell::new(Duration::ZERO), Cell::new(0)); + oracle_watch_with( + || { + clock.set(clock.get() + per_read); + let i = n.get(); + n.set(i + 1); + Some((vec![cpu(i)], vec![1_000_000 * i as i64])) + }, + || clock.get(), + window, + ) + } + + /// The oracle's verdict, on scripted reads 150 ms apart (an + /// all-channel read takes 0.13-0.8 s) over its 3 s window: + /// - (*) the two-step refresh seen on an M5 Max, changing the CPU rail + /// at two consecutive reads and then flat: `CpuRailMoved(false)`, + /// after the whole window. At two changes the oracle said `true`, and + /// a refresh coinciding with the probe vouched for the Live it caused. + /// - (*) a refresh in three steps across three reads: `false`. + /// - a rail changing at every read: `true` at the fifth read, not + /// before. + /// - one changing at four reads in five: `true` at the sixth, 5 of 6. + /// - one changing at three reads in five: `false`. + #[test] + fn the_oracle_says_a_rail_moved_only_if_it_kept_moving() { + let (per_read, window) = (Duration::from_millis(150), Duration::from_millis(3000)); + let run = oracle_on(|i| two_step(i, 3, 4), per_read, window); + assert_eq!( + (&run.verdict, run.reads, run.cpu_changes.as_slice()), + (&Oracle::CpuRailMoved(false), 19, [2].as_slice()), + "a two-step refresh vouched for a moving rail: {run:?}" + ); + let run = oracle_on(|i| 2_643_843_378 + 1_000_000 * i.min(3) as i64, per_read, window); + assert_eq!( + (&run.verdict, run.cpu_changes.as_slice()), + (&Oracle::CpuRailMoved(false), [3].as_slice()), + "a three-step refresh vouched for a moving rail: {run:?}" + ); + let run = oracle_on(|i| i as i64, per_read, window); + assert_eq!( + (&run.verdict, run.reads, run.took), + (&Oracle::CpuRailMoved(true), 5, Duration::from_millis(900)), + "a rail changing at every read: {run:?}" + ); + let missing = |missed: fn(usize) -> bool| move |i: usize| (1..=i).filter(|&k| !missed(k)).count() as i64; + let run = oracle_on(missing(|k| k % 5 == 0), per_read, window); + assert_eq!((&run.verdict, run.reads), (&Oracle::CpuRailMoved(true), 6), "four changes in five reads: {run:?}"); + let run = oracle_on(missing(|k| k % 5 == 0 || k % 5 == 1), per_read, window); + assert_eq!( + (&run.verdict, run.reads, run.cpu_changes.as_slice()), + (&Oracle::CpuRailMoved(false), 19, [12].as_slice()), + "three changes in five reads: {run:?}" + ); + } + + /// The oracle takes enough reads for a moving rail to meet its bar. + /// With reads 1 s apart in a 3 s window it reads on past the window + /// until it has five after the first (at 6 s), and then a frozen rail + /// is `CpuRailMoved(false)` and a moving one `true`. With reads 4 s + /// apart it stops at five windows with three, and says `TooFewReads`, + /// never `CpuRailMoved(false)`. No read is `NoIoReport`; no CPU rail is + /// `NoCpuRail`. + #[test] + fn the_oracle_reads_long_enough_to_tell_or_says_it_cannot() { + let window = Duration::from_millis(3000); + let second = Duration::from_secs(1); + let run = oracle_on(|_| 541_893_638, second, window); + assert_eq!( + (&run.verdict, run.reads, run.took), + (&Oracle::CpuRailMoved(false), 5, Duration::from_secs(6)), + "slow reads: {run:?}" + ); + let run = oracle_on(|i| i as i64, second, window); + assert_eq!((&run.verdict, run.reads), (&Oracle::CpuRailMoved(true), 5), "slow reads: {run:?}"); + let run = oracle_on(|i| i as i64, Duration::from_secs(4), window); + assert_eq!( + (&run.verdict, run.reads, run.took), + (&Oracle::TooFewReads, 3, Duration::from_secs(16)), + "reads too slow to tell: {run:?}" + ); + assert_eq!(oracle_watch_with(|| None, || Duration::ZERO, window).verdict, Oracle::NoIoReport); + let no_cpu = oracle_watch_with(|| Some((Vec::new(), vec![1])), || Duration::ZERO, window); + assert_eq!(no_cpu.verdict, Oracle::NoCpuRail); + } + + /// The property that matters: IOReport is **never** selected when its CPU + /// rail is not live, because then it would report GPU-only energy, zero, + /// or a whole batch refresh as the energy of CPU work. Deterministic on + /// any host — it asserts the meter's decision against what the rail + /// actually did, and every branch asserts; none returns early. + /// + /// The oracle gets three times the probe's budget, so a rail too slow for + /// the probe but moving within the oracle's window is the only case in + /// which declining is not proven right — and that one fails safe: + /// estimated, never over-claimed. A batch refresh landing in both + /// windows does not rescue a probe that called it live, even one + /// published in steps: the oracle sees it change at three reads at most, + /// not a moving rail. An oracle with fewer than five reads after the + /// first is no evidence, and fails the test. + #[test] + fn the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail() { + let _hw = crate::ioreport_hw_lock(); + let status = crate::ioreport_status(); + let selected = matches!(crate::Meter::detect(), crate::Meter::IoReport(_)); + let window = crate::CPU_RAIL_LIVENESS_BUDGET * 3; + let run = oracle_cpu_rail(window); + // One line, so a CI log says which branch below was actually asserted. + eprintln!( + "ioreport liveness: status={status:?} oracle={:?} selected={selected} \ + (oracle: {} reads over {:?} of a {window:?} window with a helper thread burning CPU; \ + changes per CPU rail {:?}, per GPU rail {:?})", + run.verdict, run.reads, run.took, run.cpu_changes, run.gpu_changes + ); + match run.verdict { + Oracle::TooFewReads => too_few_reads(&run, window), + Oracle::NoIoReport => { + assert!(!selected, "IOReport selected (status={status:?}) but no subscription opens"); + assert_eq!(status, crate::IoReportStatus::Unavailable); + } + Oracle::NoCpuRail => { + assert!(!selected, "IOReport selected (status={status:?}) but it has no CPU rail"); + // Absent when the Energy Model group opened without one, + // Unavailable when there is no such group to open. + assert!( + matches!(status, crate::IoReportStatus::CpuRailAbsent | crate::IoReportStatus::Unavailable), + "the oracle found no CPU rail but the status is {status:?}" + ); + } + Oracle::CpuRailMoved(false) => { + assert!( + !selected, + "IOReport selected (status={status:?}) although its CPU rail did not keep moving \ + under {window:?} of CPU burn — it would report GPU-only energy, zero, or a batch \ + refresh as the energy of CPU work" + ); + assert_eq!( + status, + crate::IoReportStatus::CpuRailFrozen, + "the oracle did not see the CPU rail keep moving under {window:?} of CPU burn" + ); + } + Oracle::CpuRailMoved(true) => { + // A live rail. The probe had a third of the oracle's time: if it + // saw the rail keep advancing the rung must be selected; if + // not, declining is the safe side. + assert!( + matches!(status, crate::IoReportStatus::Live | crate::IoReportStatus::CpuRailFrozen), + "the oracle saw a CPU rail keep moving but the status is {status:?}" + ); + assert_eq!(selected, status == crate::IoReportStatus::Live, "status={status:?}"); + } + } + } + + /// The dangerous direction on its own: the probe never calls a rail + /// Live that the oracle cannot see keep moving. It runs [`host_status`] + /// itself, uncached — the tap keeps its run, and a stand-in passes every + /// FFI read through unchanged, stamping it by the tests' own clocks — + /// beside the cached status `detect()` is gated on, and checks on every + /// outcome that the two agree with the oracle about what exists. + /// + /// It also pins how the real probe samples and burns. Nothing here is + /// asserted on wall time or a ratio of wall times: this host is also a + /// loaded CI runner, where other processes' IOReport samples slow every + /// sample call. + /// - Group-only subscription, by structure: every sample the probe took + /// carried exactly the Energy Model group's channels, as the oracle + /// counts them on its own all-channel subscription, and none from + /// another group. An all-channel sample carries ~11,900 (M5 Max) or + /// ~14,300 (M3 Ultra). + /// - And by cost: the median THREAD CPU time of the probe's FFI sample + /// calls, by the tests' own clock, is under 50 ms. A group sample costs + /// ~8-9 ms of CPU however contended; an all-channel one ~100-125 ms. + /// - Burning, by the tests' own clocks: between every two FFI reads the + /// thread burned 25 ms of its CPU, unless that tick ran into a 500 ms + /// wall cap, and no tick shows more CPU than wall time. The probe's + /// own record agrees, tick by tick. Over the whole probe, at least + /// half a tick of CPU per interval, and a whole tick when no tick was + /// capped. + #[test] + fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { + let _hw = crate::ioreport_hw_lock(); + // The cached probe first, so it is not burning and sampling beside + // the probe this test watches. + let cached = crate::ioreport_status(); + let reads = Rc::new(RefCell::new(Vec::<(Stamp, Stamp)>::new())); + let log = Rc::clone(&reads); + let pass_through = seam::stand_in(move |read| { + let entered = Stamp::closing(); + let items = read(); + log.borrow_mut().push((entered, Stamp::opening())); + items + }); + let tap = seam::tap(); + let (cpu_before, t) = (test_thread_cpu(), Instant::now()); + let status = host_status(); + let (took, cpu) = (t.elapsed(), test_thread_cpu().saturating_sub(cpu_before)); + let run = tap.one_run("host_status"); + drop((pass_through, tap)); + let reads = reads.take(); + assert_eq!(status, status_of(run.verdict()), "host_status said {status:?} of a run whose verdict is {:?}", run.verdict()); + let probed = run.watch.as_ref().map(RailWatch::verdict); + let intervals = run.watch.as_ref().map_or(0, |w| w.intervals); + let taken: Vec<&Taken> = run.samples.iter().filter_map(|s| s.taken.as_ref()).collect(); + let read_cpu: Vec = reads.iter().map(|&(entered, left)| entered.until(left).0).collect(); + let record_cpu: Vec = run.samples.iter().filter_map(|s| s.cpu).collect(); + let tick_cpu: Vec = reads.windows(2).map(|w| w[0].1.until(w[1].0).0).collect(); + let tick_wall: Vec = reads.windows(2).map(|w| w[0].1.until(w[1].0).1).collect(); + let record_gap = + run.ticks.iter().zip(&tick_cpu).filter_map(|(recorded, &by_tests)| Some(by_tests.abs_diff(recorded.cpu?))).max(); + let census = oracle_census(); + let window = crate::CPU_RAIL_LIVENESS_BUDGET * 3; + let oracle = oracle_cpu_rail(window); + eprintln!( + "ioreport probe: probed={probed:?} cached={cached:?} took={took:?} thread_cpu={cpu:?} ({:?} per \ + interval) intervals={intervals}; {} FFI sample calls; ticks={} median tick cpu={:?} wall={:?} by the \ + tests' clocks (the probe's own record differs by at most {record_gap:?}); advances per CPU rail {:?}; \ + oracle={:?} ({} reads over {:?})", + cpu.checked_div(intervals), + reads.len(), + run.ticks.len(), + median(&tick_cpu), + median(&tick_wall), + run.watch.as_ref().map(advances).unwrap_or_default(), + oracle.verdict, + oracle.reads, + oracle.took + ); + eprintln!( + "ioreport structure: {} probe samples carried {:?} channels, {:?} of them outside the Energy Model \ + group; the oracle counts (in that group, in all) = {census:?}; thread CPU per FFI sample call: median \ + {:?} by the tests' clock (max {:?}), median {:?} by the probe's record", + taken.len(), + taken.iter().map(|t| t.channels).collect::>(), + taken.iter().map(|t| t.outside_group).collect::>(), + median(&read_cpu), + read_cpu.iter().max(), + median(&record_cpu) + ); + if probed == Some(Some(CpuRail::Live)) || cached == crate::IoReportStatus::Live { + assert_eq!( + oracle.verdict, + Oracle::CpuRailMoved(true), + "the probe said Live (probed={probed:?}, cached={cached:?}), the oracle did not see \ + the CPU rail keep moving" + ); + } + match oracle.verdict { + Oracle::TooFewReads => too_few_reads(&oracle, window), + Oracle::NoIoReport => { + assert!(matches!(probed, None | Some(None)), "the oracle could not open IOReport, the probe did"); + assert_eq!(cached, crate::IoReportStatus::Unavailable); + } + Oracle::NoCpuRail => assert!( + matches!( + (probed, cached), + (None, crate::IoReportStatus::Unavailable) + | (Some(Some(CpuRail::Absent)), crate::IoReportStatus::CpuRailAbsent) + ), + "the oracle found no CPU rail, the probe reported {probed:?} (cached={cached:?})" + ), + Oracle::CpuRailMoved(false) => assert!( + probed == Some(Some(CpuRail::Frozen)) && cached == crate::IoReportStatus::CpuRailFrozen, + "the CPU rail did not keep moving under the oracle's burn, so the probe must say \ + Frozen: probed={probed:?}, cached={cached:?}" + ), + Oracle::CpuRailMoved(true) => assert!( + matches!(probed, Some(Some(CpuRail::Live | CpuRail::Frozen))), + "the oracle saw a CPU rail keep moving but the probe reported {probed:?}" + ), + } + if run.watch.is_some() { + let (group, all) = census.expect("the oracle could not read the channels to count the Energy Model group"); + assert!(!taken.is_empty(), "the probe took no usable sample"); + for (i, t) in taken.iter().enumerate() { + assert_eq!( + t.outside_group, 0, + "probe sample {i} carried {} of its {} channels from outside the Energy Model group: the \ + probe is not subscribed to that group alone", + t.outside_group, t.channels + ); + assert_eq!( + t.channels, group, + "probe sample {i} carried {} channels; the Energy Model group has {group} (all groups: {all})", + t.channels + ); + } + assert!(group < all, "the Energy Model group ({group}) is not smaller than all channels ({all})"); + let m = median(&read_cpu).expect("the probe made no FFI sample call"); + assert!( + m < GROUP_SAMPLE_CPU_CEILING, + "the median of the probe's {} FFI sample calls took {m:?} of this thread's CPU, by the tests' \ + own clock, not under {GROUP_SAMPLE_CPU_CEILING:?}: a group sample costs ~8-9 ms, an all-channel \ + one ~100-125 ms", + reads.len() + ); + assert_eq!( + reads.len(), + run.samples.len(), + "the probe recorded {} sample calls, the FFI was read {} times", + run.samples.len(), + reads.len() + ); + assert_eq!( + run.ticks.len() + 1, + run.samples.len(), + "{} ticks between {} sample calls: the probe must tick once before every re-sample", + run.ticks.len(), + run.samples.len() + ); + let ticks = assert_ticks_burned(&reads, "the real probe"); + for (k, (recorded, &(cpu, _))) in run.ticks.iter().zip(&ticks).enumerate() { + let rec = recorded.cpu.expect("the probe could not read its own thread clock"); + assert!( + rec <= recorded.wall + CLOCK_SLACK, + "tick {k}: the probe's record shows {rec:?} of CPU in {:?} of wall time", + recorded.wall + ); + assert!( + rec <= cpu + CLOCK_SLACK && cpu.saturating_sub(rec) <= RECORD_SLACK, + "tick {k}: the probe recorded {rec:?} of CPU; the tests' clock saw {cpu:?} between the two \ + reads around it" + ); + } + } + if intervals > 0 { + let per_interval = cpu / intervals; + assert!( + per_interval >= TICK_CPU_FLOOR / 2, + "the probing thread used {cpu:?} of CPU over {intervals} intervals, {per_interval:?} each, under \ + half of each {TICK_CPU_FLOOR:?} tick: the probe must burn CPU between samples, not wait" + ); + if tick_wall.iter().all(|&wall| wall < TICK_WALL_CAP) { + assert!( + per_interval >= TICK_CPU_FLOOR, + "the probing thread used {per_interval:?} of CPU per interval, under a whole \ + {TICK_CPU_FLOOR:?} tick, though no tick ran into the {TICK_WALL_CAP:?} wall cap" + ); + } + } + } + + /// What the real probe hands the rule is what the hardware held at that + /// moment. [`host_status`] runs on this thread (the tap keeps its run; + /// a stand-in passes every FFI read through unchanged and records when + /// it ran, by the tests' clock). On a helper thread an oracle (its own + /// all-channel subscription, its own strings, absolute values) reads + /// the Energy Model group's CPU and GPU rails back to back, from before + /// the probe's first read until after its last. The rails only count + /// up, so for each sample the probe handed the rule, an oracle read that + /// ENDED before that FFI read began is a floor under every rail, and one + /// that BEGAN after it ended is a ceiling: + /// - a rail the oracle saw hold across a read must have been handed + /// exactly the value the oracle read (a frozen CPU rail is constant); + /// - a rail that moved must have been handed a value between the two; + /// - the probe's CPU rails are exactly the oracle's, and so are its GPU + /// rails. + /// + /// Freshness is not left to chance. The stand-in holds the probe's + /// second FFI read until the oracle has made a read that began after the + /// probe's first read ended and saw a rail differ from it, or, failing + /// that, until the oracle has made two such reads AND 3 s have passed. + /// That is counted in the oracle's reads, not only in time: on a loaded + /// host one all-channel read can take seconds. A witness counts only if + /// it was seen before the second read was released, so it is a floor + /// under every later probe read: a sampler that repeats a stale sample, + /// per subscription or process-wide, hands the rule a value below it + /// and fails. The M5 Max's GPU rail changes every ~28 ms, so there a + /// witness is always found; on the M3 Ultra the GPU may hold for + /// seconds, and the printed line says whether a witness was found and + /// staleness asserted. + /// + /// A sampler that sums the CPU rails is caught where there are two (the + /// M3 Ultra's dies). On one rail (the M5 Max) the sum of the CPU rails + /// IS the rail; the scripted two-die tests catch a sum there. + #[test] + fn the_real_probe_hands_the_rule_what_the_oracle_reads() { + let _hw = crate::ioreport_hw_lock(); + /// Rails keyed by (name, ordinal), with their raw values. + type Keyed = Vec<((String, u32), i64)>; + struct Read { + start: Instant, + end: Instant, + rails: Keyed, + } + /// From the probe's thread: when its first FFI read ended and the + /// rails it read, then that its second read was released. From the + /// oracle's: how many reads it began after the first read ended, + /// before that release; whether one of them saw a rail differ from + /// the first read; and that it has stopped reading. + #[derive(Default)] + struct Witness { + first: Option<(Instant, Keyed)>, + looked: u32, + seen: bool, + released: bool, + oracle_done: bool, + } + let witness = std::sync::Arc::new((Mutex::new(Witness::default()), std::sync::Condvar::new())); + let windows = Rc::new(RefCell::new(Vec::<(Instant, Instant)>::new())); + let hold = { + let (witness, log, n) = (std::sync::Arc::clone(&witness), Rc::clone(&windows), Cell::new(0)); + seam::stand_in(move |read| { + let i = n.get(); + n.set(i + 1); + if i == 1 { + let (lock, woken) = &*witness; + let from = Instant::now(); + let mut w = lock.lock().unwrap(); + while !w.seen + && !(w.looked >= 2 && from.elapsed() >= WITNESS_WAIT) + && !w.oracle_done + && from.elapsed() < ORACLE_GIVE_UP + { + w = woken.wait_timeout(w, Duration::from_millis(50)).unwrap().0; + } + // From here on the oracle's reads are no floor under + // this read, so none of them may count as its witness. + w.released = true; + } + let start = Instant::now(); + let items = read(); + let end = Instant::now(); + log.borrow_mut().push((start, end)); + if let (0, Some(items)) = (i, &items) { + let rails = keyed_rails( + items + .iter() + .filter(|it| it.group.as_deref().is_some_and(|g| g.eq_ignore_ascii_case("Energy Model"))) + .filter_map(|it| Some((it.name.clone()?, it.raw))), + ); + let (lock, woken) = &*witness; + lock.lock().unwrap().first = Some((end, rails)); + woken.notify_all(); + } + items + }) + }; + let reads = Mutex::new(Vec::::new()); + let probe_ended = Mutex::new(None::); + let (first_read, gave_up) = (AtomicBool::new(false), AtomicBool::new(false)); + let (t0, status, t1, runs) = std::thread::scope(|s| { + let oracle = s.spawn(|| { + let Some(o) = oracle_subscribe() else { + gave_up.store(true, Ordering::SeqCst); + let (lock, woken) = &*witness; + lock.lock().unwrap().oracle_done = true; + woken.notify_all(); + return; + }; + let since = Instant::now(); + loop { + let start = Instant::now(); + let rails = oracle_group_rails(o.0, o.1); + let end = Instant::now(); + let ended = *probe_ended.lock().unwrap(); + if let Some(rails) = rails { + let (lock, woken) = &*witness; + let mut w = lock.lock().unwrap(); + let after_first = w.first.as_ref().is_some_and(|(first_end, _)| start >= *first_end); + if after_first && !w.released { + w.looked += 1; + if w.first.as_ref().is_some_and(|(_, first)| rails != *first) { + w.seen = true; + } + woken.notify_all(); + } + drop(w); + reads.lock().unwrap().push(Read { start, end, rails }); + first_read.store(true, Ordering::SeqCst); + if ended.is_some_and(|t1| start >= t1) { + break; + } + } + if since.elapsed() > ORACLE_GIVE_UP { + gave_up.store(true, Ordering::SeqCst); + break; + } + } + oracle_unsubscribe(o); + let (lock, woken) = &*witness; + lock.lock().unwrap().oracle_done = true; + woken.notify_all(); + }); + // One whole oracle read before the probe starts: the floor under + // its first sample. + let waiting = Instant::now(); + while !first_read.load(Ordering::SeqCst) + && !gave_up.load(Ordering::SeqCst) + && waiting.elapsed() < ORACLE_GIVE_UP + { + std::thread::sleep(Duration::from_millis(1)); + } + let tap = seam::tap(); + let t0 = Instant::now(); + let status = host_status(); + let t1 = Instant::now(); + *probe_ended.lock().unwrap() = Some(t1); + oracle.join().expect("the oracle thread panicked"); + (t0, status, t1, tap.runs()) + }); + drop(hold); + let reads = reads.into_inner().expect("the oracle's reads"); + let windows = windows.take(); + let (witnessed, looked) = { + let w = witness.0.lock().unwrap(); + (w.seen, w.looked) + }; + assert_eq!(runs.len(), 1, "host_status made {} probe runs, not one", runs.len()); + let run = &runs[0]; + assert_eq!(status, status_of(run.verdict()), "host_status said {status:?} of a run whose verdict is {:?}", run.verdict()); + if run.watch.is_none() { + let cpu_rails_seen = reads.iter().flat_map(|r| &r.rails).filter(|((n, _), _)| n.ends_with("CPU Energy")).count(); + eprintln!( + "ioreport freshness: no Energy Model subscription opens; status={status:?}; the oracle saw \ + {cpu_rails_seen} CPU rail readings in that group" + ); + assert_eq!(status, crate::IoReportStatus::Unavailable); + assert!(windows.is_empty(), "the probe read {} samples with no subscription", windows.len()); + assert_eq!(cpu_rails_seen, 0, "the oracle reads Energy Model CPU rails, but the probe could not subscribe to that group"); + return; + } + assert!(!gave_up.load(Ordering::SeqCst), "the oracle could not keep reading beside the probe ({} reads)", reads.len()); + assert_eq!( + windows.len(), + run.samples.len(), + "the probe recorded {} sample calls, the FFI was read {} times", + run.samples.len(), + windows.len() + ); + assert!( + run.started >= t0 && run.samples.first().is_none_or(|s| run.started <= s.at), + "the probe's run is recorded as starting {:?} into host_status's call, after its first sample call", + run.started.saturating_duration_since(t0) + ); + for (k, (s, &(start, end))) in run.samples.iter().zip(&windows).enumerate() { + assert!( + t0 <= s.at && s.at <= start && end <= s.at + s.wall && s.at + s.wall <= t1, + "probe sample call {k} is recorded at {:?} for {:?} into host_status's call, but its FFI read ran \ + {:?}..{:?}: the probe's own clock does not contain its read", + s.at.saturating_duration_since(t0), + s.wall, + start.saturating_duration_since(t0), + end.saturating_duration_since(t0) + ); + } + let taken: Vec<(usize, &Taken)> = + run.samples.iter().enumerate().filter_map(|(k, s)| s.taken.as_ref().map(|t| (k, t))).collect(); + assert!(taken.len() >= 2, "the probe handed the rule {} samples", taken.len()); + let (first_read_at, last_read_end) = (windows[taken[0].0].0, windows[taken[taken.len() - 1].0].1); + assert!(reads.first().is_some_and(|r| r.end <= first_read_at), "no oracle read ended before the probe's first read"); + assert!(reads.last().is_some_and(|r| r.start >= last_read_end), "no oracle read began after the probe's last read"); + let keys = |rails: &[((String, u32), i64)], suffix: &str| -> Vec<(String, u32)> { + rails.iter().filter(|((n, _), _)| n.ends_with(suffix)).map(|(k, _)| k.clone()).collect() + }; + let (oracle_cpu, oracle_gpu) = (keys(&reads[0].rails, "CPU Energy"), keys(&reads[0].rails, "GPU Energy")); + for r in &reads { + assert_eq!(keys(&r.rails, "CPU Energy"), oracle_cpu, "the oracle's CPU rails changed between its reads"); + assert_eq!(keys(&r.rails, "GPU Energy"), oracle_gpu, "the oracle's GPU rails changed between its reads"); + } + let key_of = |k: &RailKey| (k.name.clone(), k.ordinal); + let at = |r: &Read, key: &(String, u32)| -> i64 { + r.rails.iter().find(|(k, _)| k == key).map(|(_, v)| *v).expect("a rail missing from an oracle read") + }; + let handed = |t: &Taken, key: &(String, u32)| -> i64 { + t.cpu_rails + .iter() + .chain(&t.gpu_rails) + .find(|(k, _)| key_of(k) == *key) + .map(|(_, v)| *v) + .expect("a rail missing from a probe sample") + }; + let (mut held, mut moved) = (0, 0); + for &(k, t) in &taken { + let (start, end) = windows[k]; + assert_eq!( + t.cpu_rails.iter().map(|(key, _)| key_of(key)).collect::>(), + oracle_cpu, + "probe sample {k}: the probe's CPU rails are not the ones the oracle reads" + ); + assert_eq!( + t.gpu_rails.iter().map(|(key, _)| key_of(key)).collect::>(), + oracle_gpu, + "probe sample {k}: the probe's GPU rails are not the ones the oracle reads" + ); + for (key, p) in t.cpu_rails.iter().chain(&t.gpu_rails) { + let key = key_of(key); + let rail = format!("{}#{}", key.0, key.1); + let floor = reads.iter().filter(|r| r.end <= start).map(|r| at(r, &key)).max().expect("a read before"); + let ceiling = reads.iter().filter(|r| r.start >= end).map(|r| at(r, &key)).min().expect("a read after"); + if floor == ceiling { + held += 1; + assert_eq!( + *p, floor, + "{rail} held at {floor} in the oracle's reads either side of probe read {k}, but the probe \ + handed the rule {p}" + ); + } else { + moved += 1; + assert!( + (floor..=ceiling).contains(p), + "{rail} moved {floor} -> {ceiling} in the oracle's reads either side of probe read {k}, but \ + the probe handed the rule {p}, outside them: a stale or made-up sample" + ); + } + } + } + // The witness: an oracle read wholly between the probe's first read + // and its second, which saw a rail differ from the first read. + let freshness = if witnessed { + let (first_end, second_start) = (windows[0].1, windows.get(1).map_or(t1, |w| w.0)); + let first = taken.iter().find(|&&(k, _)| k == 0).map(|&(_, t)| t).expect("the probe's first read gave no sample"); + let w = reads + .iter() + .find(|r| { + r.start >= first_end + && r.end <= second_start + && r.rails.iter().any(|(key, v)| handed(first, key) != *v) + }) + .expect("the oracle said it saw a rail move before the probe's second read, but no such read is recorded"); + let later = taken.iter().find(|&&(k, _)| k >= 1).map(|&(k, t)| (k, t)).expect("no usable probe read after the first"); + let mut asserted = Vec::new(); + for (key, o) in w.rails.iter().filter(|(key, v)| handed(first, key) != *v) { + let (p0, p) = (handed(first, key), handed(later.1, key)); + let rail = format!("{}#{}", key.0, key.1); + assert!( + p >= *o && p != p0, + "{rail}: the probe handed the rule {p0} at its first read, the oracle then read {o}, and the \ + probe handed the rule {p} at read {}: a stale sample", + later.0 + ); + asserted.push(format!("{rail}: probe {p0}, then oracle {o}, then probe {p}")); + } + format!( + "witness found {:?} after the probe's first read, at the oracle's read {looked} since it, staleness \ + asserted: {asserted:?}", + w.start.saturating_duration_since(first_end) + ) + } else { + format!( + "no rail moved in the oracle's {looked} reads between the probe's first and second reads (held at \ + least {WITNESS_WAIT:?}), so staleness was not asserted" + ) + }; + eprintln!( + "ioreport freshness: status={status:?}; {} probe samples handed to the rule, bracketed by {} oracle reads; \ + {held} rail readings the oracle saw hold were handed equal, {moved} that moved were handed within the \ + oracle's reads; {freshness}", + taken.len(), + reads.len() + ); + } + + /// The probe's own FFI sampler reads each CPU rail's true absolute + /// value. Around each of two probe samples, the oracle reads the same + /// rails of the Energy Model group absolutely on its own all-channel + /// subscription, once before and once after. A rail the oracle saw + /// hold across those two reads (a frozen rail is constant) must read + /// EQUAL in the probe's sample; one that moved must read within them. + /// So a sampler that returns a constant, gives a rail a sum of rails, or + /// reads the wrong channel fails, on a frozen host as on a live one. + /// `GPU Energy` is held to the same rule; where the GPU draws it moves, + /// so the probe's sample must also be fresh. And the rails the + /// implementation calls CPU rails are exactly the oracle's. + #[test] + fn the_probe_sampler_reads_each_cpu_rail_as_the_oracle_does() { + let _hw = crate::ioreport_hw_lock(); + let Some(sub) = EnergyModelSubscription::open() else { + // No Energy Model subscription, so no sampler to check: the + // oracle must not find a CPU rail in that group either. + let found = oracle_subscribe().and_then(|o| { + let chans = oracle_channels(o.0, o.1, |n| n.ends_with("CPU Energy")); + oracle_unsubscribe(o); + chans + }); + eprintln!("ioreport sampler: no Energy Model subscription opens; the oracle read {found:?}"); + let in_group = + found.unwrap_or_default().iter().filter(|c| c.group.eq_ignore_ascii_case("Energy Model")).count(); + assert_eq!(in_group, 0, "the oracle reads CPU rails in the Energy Model group, but the probe cannot subscribe to it"); + return; + }; + let oracle = + oracle_subscribe().expect("the probe's Energy Model subscription opened, the oracle's all-channel one did not"); + let read = |when: &str| -> Vec<((String, u32), i64)> { + oracle_group_rails(oracle.0, oracle.1).unwrap_or_else(|| panic!("the oracle's read {when} the probe's sample failed")) + }; + let names = |v: &[((String, u32), i64)]| v.iter().map(|(k, _)| k.clone()).collect::>(); + let mut seen = Vec::new(); + for round in 0..2 { + let before = read("before"); + let sample = sub.sample().expect("the probe's sampler gave no sample").values; + let after = read("after"); + let probe = keyed_rails(sample.iter().cloned()); + assert_eq!(names(&probe), names(&before), "the probe's sampler and the oracle read different rails"); + assert_eq!(names(&after), names(&before), "the oracle's rails changed between two of its reads"); + let oracle_cpu: Vec<(String, u32)> = + names(&before).into_iter().filter(|(n, _)| n.ends_with("CPU Energy")).collect(); + let implementation: Vec<(String, u32)> = + cpu_rails(&sample).into_iter().map(|(k, _)| (k.name, k.ordinal)).collect(); + assert_eq!(implementation, oracle_cpu, "the implementation's CPU rails are not the ones the oracle reads"); + for (((key, b), (_, p)), (_, a)) in before.iter().zip(&probe).zip(&after) { + let (b, p, a) = (*b, *p, *a); + let rail = format!("{}#{}", key.0, key.1); + if b == a { + assert_eq!( + p, b, + "{rail} held at {b} across the oracle's reads either side of the probe's sample, but the \ + probe's sampler read {p}" + ); + } else { + assert!( + (b.min(a)..=b.max(a)).contains(&p), + "{rail} moved {b} -> {a} across the oracle's reads either side of the probe's sample, but \ + the probe's sampler read {p}, outside them" + ); + } + seen.push(format!("{rail} r{round}: {b} | {p} | {a} ({})", if b == a { "held: equal" } else { "moved: within" })); + } + } + oracle_unsubscribe(oracle); + eprintln!("ioreport sampler: oracle before | probe | oracle after, per rail: {seen:?}"); + } + + /// When the rung IS selected, its own CPU rail is live on a fresh probe + /// and a CPU burn advances the CPU rail itself, so `measure` gives the + /// span as a reading: `HwShunt` with the measured figure, the grade of + /// RAPL and NVML, not the time×power fallback it degrades to when the + /// CPU rail holds. + /// + /// When it is not, the status says why (never `Live`), and the meter + /// `detect()` returned still meters real work honestly: 100 ms of CPU + /// work is the time×power model's figure, at least 100 ms at the + /// documented default power, tagged `ModelBased` with no measurement. + /// Never a floored or zero figure for work that ran. + #[test] + fn a_selected_ioreport_meter_meters_cpu_work() { + let _hw = crate::ioreport_hw_lock(); + let meter = crate::Meter::detect(); + if let crate::Meter::IoReport(io) = &meter { + assert_eq!( + crate::ioreport_status(), + crate::IoReportStatus::Live, + "detect() selected IOReport although the liveness status is not Live" + ); + let fresh = host_status(); + assert_eq!(fresh, crate::IoReportStatus::Live, "IOReport selected but a fresh probe says {fresh:?}"); + let _ = io.read(); // the first read only sets the baseline sample + let before = io.read().expect("baseline read"); + spin_cpu(Duration::from_millis(200)); + let after = io.read().expect("work read"); + assert!( + after.cpu_uj > before.cpu_uj, + "a live CPU rail metered 200 ms of CPU work as zero: before={before:?} after={after:?}" + ); + let (_, e) = meter.measure(|| spin_cpu(Duration::from_millis(200))); + eprintln!( + "selected meter: 200 ms of CPU work -> {} µJ, provenance={:?}, measured_uj={:?}", + e.uj(), + e.provenance, + e.measured_uj + ); + e.check_provenance().expect("the selected meter's figure must be internally consistent"); + assert_eq!( + e.provenance, + jcp_schema::Provenance::HwShunt, + "a span of a live IOReport CPU rail was not graded as a hardware reading" + ); + assert!(e.measured_uj.is_some_and(|uj| uj > 0), "a live reading measured {:?}", e.measured_uj); + assert!(e.is_whole(), "the SoC total was reported as covering part of the work"); + } else { + let status = crate::ioreport_status(); + assert_ne!(status, crate::IoReportStatus::Live, "status Live but detect() did not select the IOReport rung"); + let work = Duration::from_millis(100); + let (_, e) = meter.measure(|| spin_cpu(work)); + let floor = crate::time_power_uj(work.as_nanos(), crate::DEFAULT_ACTIVE_MILLIWATTS); + eprintln!( + "declined meter: status={status:?} meter={meter:?} {work:?} of CPU work -> {} µJ, \ + provenance={:?}, measured_uj={:?}", + e.uj(), + e.provenance, + e.measured_uj + ); + e.check_provenance().expect("the declined meter's figure must be internally consistent"); + assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased, "the declined meter's figure is not ModelBased"); + assert!(e.measured_uj.is_none(), "the declined meter claimed a measurement: {:?}", e.measured_uj); + assert!(e.uj() > 0, "the declined meter metered {work:?} of CPU work as 0 µJ"); + assert!( + e.uj() >= floor, + "the declined meter metered {work:?} of CPU work as {} µJ, under the time×power model's \ + {floor} µJ at {} mW", + e.uj(), + crate::DEFAULT_ACTIVE_MILLIWATTS + ); + } + } + + /// The counter sums what its rails did, value for value. The symptom + /// seen on 2026-09-19, the counter open and reading 0 µJ across a whole + /// run, is a reader that returns a constant; a check on its delta alone + /// passes whenever nothing happened to move. + /// + /// So the oracle reads, absolutely on its own all-channel subscription, + /// every rail the counter sums (the Energy Model group's channels that + /// `counts_once` admits; that choice is pinned on its own by + /// `counts_once_drops_subrails`), just before and just after each of the + /// counter's two reads. The rails only count up, so, rail by rail in + /// raw units and then converted as the counter converts, what the + /// counter counted between its two reads is at least the rise between + /// the two oracle reads inside that window, and at most the rise + /// between the two outside it. When nothing moved, both are 0 and the + /// counter must have counted exactly 0. + /// + /// The same bounds hold, from the same reads, for the CPU share the + /// counter reports beside its total — the figure `measure` decides a + /// reading on — over the CPU energy rails alone, picked by the oracle's + /// own `ends_with("CPU Energy")`. + #[test] + fn the_counter_advances_when_a_rail_it_sums_advanced() { + let _hw = crate::ioreport_hw_lock(); + let Some(io) = IoReport::open() else { + // Not a pass by default: the oracle's all-channel subscription + // must fail to open too, or the counter's opener is broken. + let oracle_opens = oracle_subscribe().map(oracle_unsubscribe).is_some(); + let selected = matches!(crate::Meter::detect(), crate::Meter::IoReport(_)); + eprintln!("ioreport counter: no counter opens on this host; oracle opens={oracle_opens} selected={selected}"); + assert!(!oracle_opens, "the counter does not open, but the oracle's all-channel subscription does"); + assert!(!selected, "detect() selected an IOReport counter that does not open"); + return; + }; + let oracle = oracle_subscribe().expect("the counter's all-channel subscription opened, the oracle's did not"); + // Every rail the counter sums, keyed by (name, ordinal), with its + // unit and its absolute raw value. + let summed = |when: &str| -> Vec<((String, u32), String, i64)> { + let chans = oracle_channels(oracle.0, oracle.1, counts_once) + .unwrap_or_else(|| panic!("the oracle's read {when} failed")); + let mut out: Vec<((String, u32), String, i64)> = Vec::new(); + for c in chans.into_iter().filter(|c| c.group.eq_ignore_ascii_case("Energy Model")) { + let ordinal = out.iter().filter(|((n, _), _, _)| *n == c.name).count() as u32; + out.push(((c.name, ordinal), c.unit, c.raw)); + } + out + }; + let _ = io.read().expect("the counter's baseline read"); + let before_first = summed("before the counter's first read"); + let first = io.read().expect("the counter's first read"); + let after_first = summed("after the counter's first read"); + spin_cpu(Duration::from_millis(200)); + let before_second = summed("before the counter's second read"); + let second = io.read().expect("the counter's second read"); + let after_second = summed("after the counter's second read"); + oracle_unsubscribe(oracle); + // The rise, from one oracle read to another, of the summed rails + // whose names `pick` accepts, in µJ as the counter converts each + // rail, and how many of them rose. + let rise = |from: &[((String, u32), String, i64)], + to: &[((String, u32), String, i64)], + pick: fn(&str) -> bool| + -> (u64, usize) { + let keys = |v: &[((String, u32), String, i64)]| v.iter().map(|(k, _, _)| k.clone()).collect::>(); + assert_eq!(keys(from), keys(to), "the summed rails changed between two oracle reads"); + let mut uj: u64 = 0; + let mut rose = 0; + for (((name, _), unit, a), (_, _, b)) in from.iter().zip(to) { + if !pick(name) { + continue; + } + let raw = b.saturating_sub(*a).max(0); + rose += usize::from(raw > 0); + uj = uj.saturating_add(energy_to_uj(raw as c_long, unit)); + } + (uj, rose) + }; + let every: fn(&str) -> bool = |_| true; + let cpu: fn(&str) -> bool = |n| n.ends_with("CPU Energy"); + let (inside, rose_inside) = rise(&after_first, &before_second, every); + let (around, rose_around) = rise(&before_first, &after_second, every); + let (cpu_inside, _) = rise(&after_first, &before_second, cpu); + let (cpu_around, cpu_rose_around) = rise(&before_first, &after_second, cpu); + let cpu_rails = after_first.iter().filter(|((n, _), _, _)| cpu(n)).count(); + eprintln!( + "ioreport counter: it sums {} Energy Model rails; between its two reads it counted {} µJ; by the \ + oracle's absolute reads those rails rose {inside} µJ inside that window ({rose_inside} rails rose) and \ + {around} µJ around it ({rose_around} rose). Its CPU share, {cpu_rails} CPU rails: it counted {} µJ; \ + they rose {cpu_inside} µJ inside and {cpu_around} µJ around it ({cpu_rose_around} rose)", + after_first.len(), + second.total_uj.saturating_sub(first.total_uj), + second.cpu_uj.saturating_sub(first.cpu_uj) + ); + assert!(second.total_uj >= first.total_uj, "the counter ran backwards: {first:?} -> {second:?}"); + assert!(second.cpu_uj >= first.cpu_uj, "the counter's CPU share ran backwards: {first:?} -> {second:?}"); + let counted = second.total_uj - first.total_uj; + assert!( + inside <= counted && counted <= around, + "the counter counted {counted} µJ between its two reads, but the rails it sums rose {inside} µJ inside \ + that window and {around} µJ around it, by the oracle's absolute reads: it did not sum what its rails did" + ); + let cpu_counted = second.cpu_uj - first.cpu_uj; + assert!( + cpu_inside <= cpu_counted && cpu_counted <= cpu_around, + "the counter's CPU share rose {cpu_counted} µJ between its two reads, but the CPU energy rails rose \ + {cpu_inside} µJ inside that window and {cpu_around} µJ around it, by the oracle's absolute reads" + ); + } + } +} + +/// Why the IOReport rung was or was not selected on this host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoReportStatus { + /// No subscription to the Energy Model group could be opened (IOReport does + /// not open — not macOS, not Apple Silicon, or Rosetta — or it has no such + /// group), or the subscription gave fewer than two usable samples in five + /// budgets. + Unavailable, + /// The Energy Model group was sampled, but it has no `CPU Energy` rail — an + /// unfamiliar channel naming. Never selected. + CpuRailAbsent, + /// A CPU energy rail exists but not every one kept advancing while CPU + /// was burned: some CPU rail did not advance in at least five separate + /// sample intervals, making up at least 80% of those observed, over at + /// least 300 ms. It held, or stepped a few times together — a batch + /// refresh, not a live reading. The platform is not exposing live CPU + /// energy to this process, so selecting the rung would grade a whole + /// refresh, landing in one span, as a hardware reading of that span's CPU + /// work. On a two-die part one die's rail may be live and the other's + /// not; that is Frozen too, since the counter sums both dies and would miss + /// the frozen one's energy. Observed on macOS 27.0.1 on both an M5 Max and + /// an M3 Ultra, where every CPU rail held. + CpuRailFrozen, + /// Every CPU energy rail kept advancing under load (`CPU Energy`, or each + /// die's on a multi-die part): each in at least five separate sample + /// intervals, making up at least 80% of those observed, over at least + /// 300 ms. The rung is selected. + Live, +} + +/// How long the liveness probe burns CPU, on the calling thread, before +/// concluding the CPU rail is not live. The probe samples its own subscription +/// to the Energy Model group, ~5–12 ms a sample, after every 25 ms of the +/// calling thread's own CPU time (a tick that cannot get that in 500 ms of wall +/// time stops there) — about 30 intervals in the budget on an idle host, fewer +/// under load, each a sample and the one before it. Every CPU rail must keep +/// advancing (each die's, on a multi-die part): each in at least five +/// intervals, making up at least 80% of those observed, over at least 300 ms +/// from the first sample. A live Energy Model +/// rail changes many times a second (the M5 Max GPU rail every ~28 ms), so it +/// is called live about 300 ms in, and one second is generous. A sample that +/// fails is skipped. If samples are slow or failing enough that fewer than five +/// intervals fit, the probe keeps sampling past the budget until it has five, +/// up to five budgets. A slower live rail fails safe — it is estimated, never +/// over-claimed. Paid once per process; see [`ioreport_status`]. +#[cfg(target_os = "macos")] +pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); + +/// This host's IOReport status, probed once per process and cached. +/// +/// [`Meter::detect`] selects the IOReport rung only when this is +/// [`IoReportStatus::Live`]. On macOS the first call runs the probe on the +/// calling thread: about one `CPU_RAIL_LIVENESS_BUDGET` of wall time burning CPU +/// when the rail is not live (at most five, if IOReport samples are very slow or keep +/// failing), less when it is. Exposed so a caller, or a receipt, can say *why* an Apple +/// Silicon host is being metered by the time×power model instead. +pub fn ioreport_status() -> IoReportStatus { + #[cfg(target_os = "macos")] + { + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(ioreport::host_status) + } + #[cfg(not(target_os = "macos"))] + { + IoReportStatus::Unavailable + } +} + +/// Whether a liveness status admits the IOReport rung: [`IoReportStatus::Live`] +/// and nothing else. The one gate the selection path ([`Meter::detect`]) goes +/// through, pure so that every status is checked in a test. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn admits(status: IoReportStatus) -> bool { + matches!(status, IoReportStatus::Live) +} + +/// Test instrumentation, not API: how many times the real IOReport liveness +/// probe has run in this process. [`ioreport_status`] runs it once however +/// many threads ask, and `tests/ioreport_probed_once.rs` pins that, as does a +/// lib test in a fresh process of its own. Always 0 off macOS, where there is +/// no probe. +#[doc(hidden)] +pub fn ioreport_probe_runs() -> usize { + #[cfg(target_os = "macos")] + { + ioreport::host_probe_runs() + } + #[cfg(not(target_os = "macos"))] + { + 0 + } +} + +/// Test instrumentation, not API: how many subscriptions to the Energy Model +/// group this process has opened, one per probe whatever ran it. Always 0 off +/// macOS. +#[doc(hidden)] +pub fn ioreport_energy_model_opens() -> usize { + #[cfg(target_os = "macos")] + { + ioreport::energy_model_opens() + } + #[cfg(not(target_os = "macos"))] + { + 0 } } /// A metering backend, in ladder order. #[derive(Debug)] pub enum Meter { - /// Hardware CPU package energy → `Provenance::HwShunt`. + /// Hardware CPU package energy → `Provenance::HwShunt`. [`Meter::detect`] + /// selects it only while every package counter is live ([`rapl_status`]), and + /// a span over which no package counter advanced is the time×power model's + /// figure. Rapl(Rapl), - /// Hardware GPU energy → `Provenance::HwShunt`. + /// Hardware GPU energy → `Provenance::HwShunt`, `Coverage::Partial`: the + /// counter reads the GPU, and the CPU's share of the work goes unmetered, + /// so a figure from it never claims the whole task (SPEC §5). + /// [`Meter::detect`] selects it only while the counter is live + /// ([`nvml_status`]), and a span over which it did not advance is the + /// time×power model's whole-task figure. #[cfg(feature = "nvml")] Nvml(Box), - /// Apple Silicon IOReport Energy Model — a real on-die reading, but vendor- - /// modeled, so `Provenance::ModelBased`. Sits ahead of the wall-clock model. + /// Apple Silicon IOReport Energy Model energy → `Provenance::HwShunt`, the + /// grade of RAPL's and NVML's vendor counters. [`Meter::detect`] selects it + /// only while every CPU energy rail is live, and a span over which no CPU + /// rail advanced is the time×power model's figure (`ioreport_span`). #[cfg(target_os = "macos")] IoReport(ioreport::IoReport), /// Wall-clock × calibrated active power → `Provenance::ModelBased`. @@ -442,22 +4451,63 @@ pub enum Meter { Const { per_op_uj: u64 }, } +/// Where [`Meter::detect`] gets each rung's liveness status, and the RAPL +/// counter it opens: the cached host probes ([`Ladder::host`]), or in tests +/// any status and a fake counter, so every gate is driven with every status on +/// a host whose own statuses are one value each. +struct Ladder<'a> { + rapl: &'a dyn Fn() -> CounterStatus, + open_rapl: &'a dyn Fn() -> Option, + nvml: &'a dyn Fn() -> CounterStatus, + ioreport: &'a dyn Fn() -> IoReportStatus, +} + +impl Ladder<'static> { + /// The ladder [`Meter::detect`] runs: the cached host probes and the real + /// RAPL opener. + fn host() -> Self { + Ladder { rapl: &rapl_status, open_rapl: &Rapl::open, nvml: &nvml_status, ioreport: &ioreport_status } + } +} + impl Meter { /// Pick the best backend this environment exposes. Never fails: when no /// hardware interface exists the result is the time×power model — there is /// always an estimate, it just says so in its provenance. + /// + /// A vendor counter rung is selected only while its counter is live (SPEC + /// §5): RAPL on [`rapl_status`], NVML on [`nvml_status`], IOReport on + /// [`ioreport_status`], each probed once per process. Each status is + /// asked for only once every rung above it is declined, so a process pays + /// for no probe below the rung it gets. pub fn detect() -> Self { - if let Some(r) = Rapl::open() { + Self::detect_with(&Ladder::host()) + } + + /// [`Meter::detect`] on a given ladder. Each counter rung is opened only + /// when its status admits it ([`select`], [`admits`]). + fn detect_with(ladder: &Ladder<'_>) -> Self { + // Opening a counter is not enough: it must be live (SPEC §5), or a + // count republished in lumps landing in one span is graded a hardware + // reading of that span's work. + if let Some(r) = select((ladder.rapl)(), ladder.open_rapl) { return Meter::Rapl(r); } + let nvml = (ladder.nvml)(); #[cfg(feature = "nvml")] - if let Some(n) = Nvml::open() { + if let Some(n) = select(nvml, Nvml::open) { return Meter::Nvml(Box::new(n)); } + #[cfg(not(feature = "nvml"))] + let _ = nvml; #[cfg(target_os = "macos")] - if let Some(io) = ioreport::IoReport::open() { - return Meter::IoReport(io); + if admits((ladder.ioreport)()) { + if let Some(io) = ioreport::IoReport::open() { + return Meter::IoReport(io); + } } + #[cfg(not(target_os = "macos"))] + let _ = ladder.ioreport; Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS } } @@ -478,7 +4528,7 @@ impl Meter { #[cfg(feature = "nvml")] Meter::Nvml(_) => Provenance::HwShunt, #[cfg(target_os = "macos")] - Meter::IoReport(_) => Provenance::ModelBased, + Meter::IoReport(_) => Provenance::HwShunt, Meter::TimePower { .. } => Provenance::ModelBased, Meter::Const { .. } => Provenance::Estimator, } @@ -495,12 +4545,14 @@ impl Meter { let started = Instant::now(); let out = work(); let elapsed = started.elapsed(); - let energy = match (before, r.read()) { - (Some(b), Some(a)) => Energy::measured(r.delta(&b, &a)), - // the interface vanished mid-span — degrade honestly - _ => Energy::modeled(time_power_uj(elapsed.as_nanos(), DEFAULT_ACTIVE_MILLIWATTS)), + // A read that failed (the interface vanished mid-span) is no + // rise, and degrades honestly. + // So is a package that fell with no known wrap. + let rose = match (before, r.read()) { + (Some(b), Some(a)) => r.delta(&b, &a), + _ => None, }; - (out, energy) + (out, counter_span(rose, elapsed.as_nanos(), Energy::measured)) } #[cfg(feature = "nvml")] Meter::Nvml(n) => { @@ -508,27 +4560,15 @@ impl Meter { let started = Instant::now(); let out = work(); let elapsed = started.elapsed(); - let energy = match (before, n.read_uj()) { - (Some(b), Some(a)) => Energy::measured(a.saturating_sub(b)), - _ => Energy::modeled(time_power_uj(elapsed.as_nanos(), DEFAULT_ACTIVE_MILLIWATTS)), - }; - (out, energy) + (out, nvml_span(before, n.read_uj(), elapsed.as_nanos())) } #[cfg(target_os = "macos")] Meter::IoReport(io) => { - let before = io.read_uj(); + let before = io.read(); let started = Instant::now(); let out = work(); let elapsed = started.elapsed(); - // A real on-die IOReport reading, carried at ModelBased grade in - // the modeled slot (never the HwShunt-reserved `measured_uj`). - // A sub-quantum span (delta doesn't clear ~1 mJ) or an interface - // failure degrades to the time×power model — also ModelBased. - let energy = match (before, io.read_uj()) { - (Some(b), Some(a)) if a > b => Energy::modeled(a.saturating_sub(b)), - _ => Energy::modeled(time_power_uj(elapsed.as_nanos(), DEFAULT_ACTIVE_MILLIWATTS)), - }; - (out, energy) + (out, ioreport_span(before, io.read(), elapsed.as_nanos())) } Meter::TimePower { milliwatts } => { let started = Instant::now(); @@ -540,17 +4580,93 @@ impl Meter { } } +/// One cumulative read of the IOReport counter, in µJ since its first read. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct IoReportReading { + /// Every Energy Model rail the counter sums, CPU included: the SoC total. + total_uj: u64, + /// The CPU energy rails alone (`CPU Energy`, or `DIE__CPU Energy` on + /// multi-die parts), a share of `total_uj`. + cpu_uj: u64, +} + +/// The figure the IOReport rung gives one span, from its reads either side. +/// +/// A reading, `HwShunt`, only when the CPU energy rail itself advanced across +/// the span. The figure is then the SoC total's rise: every rail the counter +/// sums, which like a RAPL package counter takes in whatever else ran on the +/// machine meanwhile, so it over-debits the work rather than leave part of it +/// out. A rail that was live at selection can still hold across a span — a +/// span shorter than its update interval, or a rail that has since stopped +/// publishing — and then the total can rise only through rails this work did +/// not drive (the machine-wide GPU rail moves with display compositing). That +/// is not a reading of the work, so the span is the time×power model's figure, +/// `ModelBased`, as it is when a read fails. Never `HwShunt` on a model figure. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn ioreport_span(before: Option, after: Option, elapsed_ns: u128) -> Energy { + match (before, after) { + (Some(b), Some(a)) if a.cpu_uj > b.cpu_uj => Energy::measured(a.total_uj.saturating_sub(b.total_uj)), + _ => Energy::modeled(time_power_uj(elapsed_ns, DEFAULT_ACTIVE_MILLIWATTS)), + } +} + +/// The figure a counter rung gives one span, from how much its counter rose +/// across it: `reading(rise)` only when the counter advanced. A counter that +/// was live at selection can still hold across one span — a span shorter than +/// its update interval, or a counter that has since stopped publishing — and a +/// rise of zero is then not a reading of zero energy: the work ran, and the +/// counter did not publish it. That span, and one whose read failed (`None`), +/// is the time×power model's figure, `ModelBased`, as IOReport's held spans +/// are ([`ioreport_span`]). Never `HwShunt` on a model figure. +fn counter_span(rose: Option, elapsed_ns: u128, reading: fn(u64) -> Energy) -> Energy { + match rose { + Some(uj) if uj > 0 => reading(uj), + _ => Energy::modeled(time_power_uj(elapsed_ns, DEFAULT_ACTIVE_MILLIWATTS)), + } +} + +/// The figure the NVML rung gives one span, from its counter reads either +/// side, in µJ. The counter reads the GPU and nothing else, so a span over +/// which it advanced is a hardware reading that covers only part of the work — +/// the CPU's share is unmetered, not zero — and is graded so: +/// [`Energy::measured_partial`], `HwShunt` with `Coverage::Partial`, which +/// MUST NOT be presented as the task's total (SPEC §5.2). A counter that fell +/// (a driver reload resets it) or held, or a read that failed, is no reading: +/// the time×power model's whole-task figure ([`counter_span`]). +#[cfg_attr(not(feature = "nvml"), allow(dead_code))] +fn nvml_span(before_uj: Option, after_uj: Option, elapsed_ns: u128) -> Energy { + let rose = match (before_uj, after_uj) { + (Some(b), Some(a)) => a.checked_sub(b), + _ => None, + }; + counter_span(rose, elapsed_ns, Energy::measured_partial) +} + /// µJ for a span: `mW × ns` is 10⁻³ W · 10⁻⁹ s = 10⁻¹² J = 10⁻⁶ µJ. fn time_power_uj(elapsed_ns: u128, milliwatts: u64) -> u64 { u64::try_from(elapsed_ns.saturating_mul(milliwatts as u128) / 1_000_000).unwrap_or(u64::MAX) } +/// Serialises the tests that use IOReport, or burn CPU through the liveness +/// probe, on this host. Otherwise the test harness runs them side by side, and +/// one test's all-channel reads and CPU burn slow another's IOReport samples +/// and thin out its oracle's reads. A test that panics while holding it does +/// not poison the rest. +#[cfg(all(test, target_os = "macos"))] +fn ioreport_hw_lock() -> std::sync::MutexGuard<'static, ()> { + static HW: std::sync::Mutex<()> = std::sync::Mutex::new(()); + HW.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests { use super::*; #[test] fn detect_always_yields_a_meter_with_honest_provenance() { + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); let meter = Meter::detect(); let (out, e) = meter.measure(|| 21 * 2); assert_eq!(out, 42); @@ -591,13 +4707,805 @@ mod tests { assert_eq!(time_power_uj(u128::MAX, u64::MAX), u64::MAX); } + #[cfg(not(target_os = "macos"))] + #[test] + fn ioreport_is_unavailable_off_macos() { + assert_eq!(ioreport_status(), IoReportStatus::Unavailable); + } + + /// Set in the environment of the fresh process that + /// [`the_liveness_probe_runs_once_in_a_fresh_process`] starts. + const FRESH_PROCESS: &str = "JCP_ENERGY_FRESH_PROCESS_PIN"; + + /// The liveness probe runs once per PROCESS, whoever asks first and + /// however many ask at once, pinned in the library's own tests: the macOS + /// CI job runs `cargo test --lib -p jcp-energy`, which does not run + /// `tests/ioreport_probed_once.rs`. In this process other tests have + /// already asked for the status, so this test runs its own binary again, + /// filtered to itself, as a process of its own. There the eight calls + /// below are the first use of the cache. Counted, not timed. + #[test] + fn the_liveness_probe_runs_once_in_a_fresh_process() { + if std::env::var_os(FRESH_PROCESS).is_some() { + return probed_once_from_eight_threads(); + } + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); + let out = std::process::Command::new(std::env::current_exe().expect("this test binary's path")) + .args(["tests::the_liveness_probe_runs_once_in_a_fresh_process", "--exact", "--nocapture", "--test-threads=1"]) + .env(FRESH_PROCESS, "1") + .output() + .expect("could not run this test binary again"); + let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + for line in text.lines().filter(|l| l.starts_with("ioreport cache")) { + eprintln!("{line}"); + } + assert!(out.status.success(), "the pin failed in a fresh process:\n{text}"); + // A filter that matched nothing would also exit 0. + assert!( + text.contains("running 1 test") && text.contains("1 passed"), + "the fresh process did not run the pin:\n{text}" + ); + } + + /// The body [`the_liveness_probe_runs_once_in_a_fresh_process`] runs in + /// the fresh process. Eight threads, released together, ask for the + /// status and build the meter, half in each order, then again three + /// times. The probe must have run, and opened its subscription, once. + fn probed_once_from_eight_threads() { + const THREADS: usize = 8; + assert_eq!( + (ioreport_probe_runs(), ioreport_energy_model_opens(), rapl_probe_runs(), nvml_probe_runs()), + (0, 0, 0, 0), + "something probed before the pin began, so its calls are not the first use" + ); + let barrier = std::sync::Barrier::new(THREADS); + let statuses: Vec = std::thread::scope(|s| { + let workers: Vec<_> = (0..THREADS) + .map(|i| { + let barrier = &barrier; + s.spawn(move || { + barrier.wait(); + let first = if i % 2 == 0 { + let _ = Meter::detect(); + ioreport_status() + } else { + let status = ioreport_status(); + let _ = Meter::detect(); + status + }; + for _ in 0..3 { + assert_eq!(ioreport_status(), first, "the cached status changed between calls"); + let _ = Meter::detect(); + } + first + }) + }) + .collect(); + workers.into_iter().map(|w| w.join().expect("a thread panicked")).collect() + }); + let (runs, opens) = (ioreport_probe_runs(), ioreport_energy_model_opens()); + let (rapl_runs, nvml_runs) = (rapl_probe_runs(), nvml_probe_runs()); + eprintln!( + "ioreport cache (fresh process, lib tests): {THREADS} threads, first use concurrent; they saw {statuses:?}; \ + probe runs = {runs}, Energy Model subscriptions opened = {opens}; RAPL probe runs = {rapl_runs} \ + (status {:?}), NVML probe runs = {nvml_runs} (status {:?})", + rapl_status(), + nvml_status() + ); + // Every `detect()` asks for the RAPL status first, and NVML's next: + // each probe ran once, on whichever thread asked first. + assert_eq!(rapl_runs, 1, "the RAPL liveness probe did not run exactly once in this process"); + assert_eq!(nvml_runs, usize::from(cfg!(feature = "nvml")), "the NVML liveness probe ran {nvml_runs} times"); + assert!(statuses.iter().all(|s| *s == statuses[0]), "threads saw different statuses: {statuses:?}"); + assert_eq!(statuses[0], ioreport_status(), "the spawned threads and this one disagree"); + if cfg!(target_os = "macos") { + assert_eq!(runs, 1, "the liveness probe did not run exactly once in this process"); + assert_eq!(opens, 1, "{opens} Energy Model subscriptions were opened: something probed besides the cached probe"); + } else { + assert_eq!((runs, opens), (0, 0), "a probe ran on a platform with no IOReport"); + assert_eq!(statuses[0], IoReportStatus::Unavailable); + } + } + + /// The gate itself, on every status: only `Live` admits the IOReport rung. + #[test] + fn only_live_admits_the_ioreport_rung() { + use IoReportStatus as S; + assert!(admits(S::Live), "Live does not admit the rung"); + for status in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen] { + assert!(!admits(status), "{status:?} admits the rung"); + } + } + + /// A span over which the IOReport CPU rail advanced is a vendor-counter + /// reading, graded as RAPL's and NVML's are: `HwShunt`, the SoC total's + /// rise as the measured figure, covering the whole of the work. + #[test] + fn a_span_the_ioreport_cpu_rail_advanced_over_is_a_hardware_reading() { + let before = IoReportReading { total_uj: 10_000, cpu_uj: 4_000 }; + let after = IoReportReading { total_uj: 15_500, cpu_uj: 7_000 }; + let e = ioreport_span(Some(before), Some(after), 1_000_000); + assert_eq!(e.provenance, Provenance::HwShunt, "a live span was not graded as a reading: {e:?}"); + assert_eq!(e.measured_uj, Some(5_500), "the reading is not the SoC total's rise: {e:?}"); + assert_eq!(e.uj(), 5_500); + assert!(e.is_whole(), "the SoC total was reported as partial: {e:?}"); + e.check_provenance().expect("a reading must carry its measured figure"); + } + + /// A span over which the CPU rail held is never a reading, whatever the + /// SoC total did: the total rising through other rails (the machine-wide + /// GPU rail) is not this work's energy, and a span that did not move at + /// all, or whose read failed, measured nothing. Each is the time×power + /// model's figure for the span, `ModelBased`, with no measured figure. + #[test] + fn a_span_the_ioreport_cpu_rail_held_over_is_the_models_figure() { + let elapsed_ns = 2_000_000; // 2 ms + let model = time_power_uj(elapsed_ns, DEFAULT_ACTIVE_MILLIWATTS); + assert!(model > 0); + let b = IoReportReading { total_uj: 10_000, cpu_uj: 4_000 }; + let cases = [ + ("the GPU rail rose, the CPU rail held", Some(b), Some(IoReportReading { total_uj: 90_000, cpu_uj: 4_000 })), + ("nothing moved", Some(b), Some(b)), + ("the read before failed", None, Some(IoReportReading { total_uj: 15_000, cpu_uj: 7_000 })), + ("the read after failed", Some(b), None), + ]; + for (what, before, after) in cases { + let e = ioreport_span(before, after, elapsed_ns); + assert_eq!(e.provenance, Provenance::ModelBased, "{what}: {e:?}"); + assert_eq!(e.measured_uj, None, "{what}: a model figure claimed a measurement: {e:?}"); + assert_eq!(e.uj(), model, "{what}: not the time×power figure for the span: {e:?}"); + e.check_provenance().expect("a model figure must be internally consistent"); + } + } + + /// The grade the IOReport rung says it stamps is the grade its live spans + /// carry, and it is the hardware grade. Driven through `detect_with`, so + /// the rung is built on any macOS host whose counter opens, whatever this + /// host's own liveness status. + #[cfg(target_os = "macos")] + #[test] + fn the_ioreport_rung_stamps_the_hardware_grade() { + let _hw = ioreport_hw_lock(); + let meter = Meter::detect_with(&Ladder { ioreport: &|| IoReportStatus::Live, ..Ladder::none() }); + if matches!(meter, Meter::IoReport(_)) { + let live = ioreport_span( + Some(IoReportReading { total_uj: 0, cpu_uj: 0 }), + Some(IoReportReading { total_uj: 2, cpu_uj: 1 }), + 1, + ); + assert_eq!(meter.provenance(), Provenance::HwShunt, "the IOReport rung is not graded as a hardware reading"); + assert_eq!(meter.provenance(), live.provenance, "the rung's grade is not the grade of its live spans"); + } else { + assert!(ioreport::IoReport::open().is_none(), "a Live status did not build the rung, though the counter opens"); + assert!(matches!(meter, Meter::TimePower { .. }), "{meter:?}"); + } + } + + /// `detect()` selects the IOReport rung on a `Live` status wherever the + /// counter opens, and on no other status: the others fall to the + /// time×power model at the documented default power. + #[cfg(target_os = "macos")] + #[test] + fn only_a_live_status_selects_the_ioreport_rung() { + let _hw = ioreport_hw_lock(); + use IoReportStatus as S; + for status in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen] { + let meter = Meter::detect_with(&Ladder { ioreport: &|| status, ..Ladder::none() }); + assert!( + matches!(meter, Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS }), + "status {status:?} gave {meter:?}" + ); + } + let opens = ioreport::IoReport::open().is_some(); + let meter = Meter::detect_with(&Ladder { ioreport: &|| S::Live, ..Ladder::none() }); + assert_eq!( + matches!(meter, Meter::IoReport(_)), + opens, + "a Live status gave {meter:?} where the counter {} open", + if opens { "does" } else { "does not" } + ); + } + + /// A ladder on which every rung is declined and no counter opens: tests + /// set the one rung they drive and take the rest from here. + impl Ladder<'static> { + fn none() -> Self { + Ladder { + rapl: &|| CounterStatus::Unavailable, + open_rapl: &|| None, + nvml: &|| CounterStatus::Unavailable, + ioreport: &|| IoReportStatus::Unavailable, + } + } + } + + // ── The counter rungs' gate: RAPL and NVML ────────────────────────────── + + use std::cell::Cell; + use std::time::Duration; + + /// The gate itself, on every status: only `Live` admits a counter rung, + /// and a counter whose status does not admit it is not even opened. + #[test] + fn only_live_admits_a_counter_rung_and_nothing_else_is_opened() { + use CounterStatus as S; + assert!(admits_counter(S::Live), "Live does not admit the rung"); + for status in [S::Unavailable, S::Frozen] { + assert!(!admits_counter(status), "{status:?} admits the rung"); + let opened = Cell::new(0); + let got = select(status, || { + opened.set(opened.get() + 1); + Some(()) + }); + assert_eq!((got, opened.get()), (None, 0), "{status:?}: the counter was opened"); + } + let opened = Cell::new(0); + let got = select(S::Live, || { + opened.set(opened.get() + 1); + Some(()) + }); + assert_eq!((got, opened.get()), (Some(()), 1), "Live did not open the counter once"); + assert_eq!(select(S::Live, || None::<()>), None, "a Live counter that does not open is not selected"); + } + + /// A probe's watch maps to its status: no probe and no interval are + /// Unavailable, never Live. + #[test] + fn each_counter_probe_reports_its_status() { + assert_eq!(liveness::status_of(None), CounterStatus::Unavailable); + assert_eq!(liveness::status_of(Some(&liveness::Advances::default())), CounterStatus::Unavailable); + let mut held = liveness::Advances::default(); + let rail = |v| liveness::every_rail(&[("intel-rapl:0".to_string(), v)]); + for k in 0..6u32 { + held.observe(rail(7), Duration::from_millis(100) * k); + } + assert_eq!(liveness::status_of(Some(&held)), CounterStatus::Frozen); + let mut live = liveness::Advances::default(); + for k in 0..6u32 { + live.observe(rail(i64::from(k)), Duration::from_millis(100) * k); + } + assert_eq!(liveness::status_of(Some(&live)), CounterStatus::Live); + // Samples that carry no rail at all, past the 300 ms window: no rail + // is not every rail advancing, so never Live. + let mut empty = liveness::Advances::default(); + for k in 0..12u32 { + empty.observe(Vec::new(), Duration::from_millis(100) * k); + } + assert!(!empty.is_live(), "a counter with no rail read as live over {:?}", empty.window); + assert_eq!(liveness::status_of(Some(&empty)), CounterStatus::Frozen); + } + + /// The tests' interval for a CPU-counter probe: a 25 ms tick and a short + /// read. + const COUNTER_STEP: Duration = Duration::from_millis(31); + + /// One counter probe through [`liveness::probe`], the function the RAPL + /// and NVML probes run, on a scripted counter and a scripted clock: sample + /// `i` is `script(i)` (`None`: the read failed), and each tick moves the + /// clock on by `step`. Returns the status, the watch, how many samples + /// were read and how many ticks ran. + fn scripted_counter( + script: impl Fn(usize) -> Option>, + step: Duration, + ) -> (CounterStatus, Option, usize, usize) { + let (base, clock, reads, ticks) = (std::time::Instant::now(), Cell::new(Duration::ZERO), Cell::new(0), Cell::new(0)); + let watch = liveness::probe( + || Some(()), + |_| { + let i = reads.get(); + reads.set(i + 1); + script(i).map(|named| liveness::every_rail(&named)) + }, + || { + clock.set(clock.get() + step); + ticks.set(ticks.get() + 1); + }, + || base + clock.get(), + liveness::BUDGET, + ); + (liveness::status_of(watch.as_ref()), watch, reads.get(), ticks.get()) + } + + /// A RAPL-shaped sample: two packages' `energy_uj`, in µJ. + fn packages(p0: i64, p1: i64) -> Option> { + Some(vec![("intel-rapl:0".to_string(), p0), ("intel-rapl:1".to_string(), p1)]) + } + + /// The RAPL gate on scripted package counters, through the probe the RAPL + /// rung runs. Live only when every package keeps advancing; one package + /// advancing beside one that holds, a counter that holds (a frozen or + /// virtualised powercap tree), one republished in a lump or two steps, + /// packages stepping in turn, and a wrap are all Frozen; a tree that does + /// not open is Unavailable without a sample or a tick. + #[test] + fn the_rapl_gate_on_scripted_package_counters() { + let ms = Duration::from_millis; + // Live: package 0 rises ~0.4 J every interval under the burn, package 1 + // idles but rises too. Live at the tenth interval, when the window + // first spans 300 ms (310 ms), and the probe stops there. + let (status, w, reads, ticks) = + scripted_counter(|i| packages(1_000_000 + 400_000 * i as i64, 9_000_000 + 30_000 * i as i64), COUNTER_STEP); + let w = w.expect("an opened counter was not watched"); + assert_eq!(status, CounterStatus::Live, "{:?}", w.rails); + assert_eq!((w.intervals, reads, ticks, w.window), (10, 11, 10, ms(310)), "a live counter must stop the probe at once"); + // Frozen: package 0 rises at every interval, package 1 holds. A span's + // figure sums both, so it would leave package 1's energy out. + let (status, w, reads, _) = + scripted_counter(|i| packages(1_000_000 + 400_000 * i as i64, 9_000_000), COUNTER_STEP); + let w = w.unwrap(); + assert_eq!(status, CounterStatus::Frozen, "one live package beside a held one read as live: {:?}", w.rails); + assert_eq!((w.intervals, reads), (33, 34)); + assert_eq!(w.rails.iter().map(|(_, n)| *n).collect::>(), [33, 0], "{:?}", w.rails); + // Frozen: a counter that holds, after the whole budget (33 intervals). + let (status, w, reads, _) = scripted_counter(|_| packages(1_000_000, 9_000_000), COUNTER_STEP); + let w = w.unwrap(); + assert_eq!((status, w.intervals, reads), (CounterStatus::Frozen, 33, 34), "{:?}", w.rails); + // Frozen: every package republished once, in one lump. + let lump = |i: usize| if i < 3 { 0 } else { 1_300_000_000 }; + let (status, w, _, _) = scripted_counter(|i| packages(lump(i), 9_000_000 + lump(i)), COUNTER_STEP); + assert_eq!(status, CounterStatus::Frozen, "one lump read as live: {:?}", w.unwrap().rails); + // Frozen: a lump in two steps, and in five quick steps at the start. + for steps in [2usize, 5] { + let (status, w, _, _) = + scripted_counter(|i| packages(1_000_000 + 61_000_000 * i.min(steps) as i64, 9_000_000), COUNTER_STEP); + assert_eq!(status, CounterStatus::Frozen, "a lump in {steps} steps read as live: {:?}", w.unwrap().rails); + } + // Frozen: the packages advance in turn, each in half the intervals. + // Summed, they would be one counter advancing in every interval. + let (status, w, _, _) = + scripted_counter(|i| packages(1_000 * i.div_ceil(2) as i64, 9_000 + 1_000 * (i / 2) as i64), COUNTER_STEP); + let w = w.unwrap(); + assert_eq!(status, CounterStatus::Frozen, "packages in turn read as live: {:?}", w.rails); + assert!(w.rails.iter().all(|(_, n)| *n <= w.intervals.div_ceil(2)), "{:?}", w.rails); + // A wrap is a fall, not an advance: rising, then wrapping at every + // other sample, is never 80%. + let (status, _, _, _) = + scripted_counter(|i| packages(if i % 2 == 0 { 262_143_000 } else { 100 }, 9_000_000), COUNTER_STEP); + assert_eq!(status, CounterStatus::Frozen, "a counter wrapping at every other sample read as live"); + // Unavailable: every read fails, to the five-budget cap. + let (status, w, reads, _) = scripted_counter(|_| None, COUNTER_STEP); + assert_eq!((status, w.unwrap().intervals), (CounterStatus::Unavailable, 0)); + assert!(reads > 100, "the probe gave up on failing reads after {reads}"); + // Unavailable: no tree, no sample, no tick. + let (reads, ticks) = (Cell::new(0), Cell::new(0)); + let watch = liveness::probe( + || None::<()>, + |_| { + reads.set(reads.get() + 1); + None + }, + || ticks.set(ticks.get() + 1), + std::time::Instant::now, + liveness::BUDGET, + ); + assert!(watch.is_none()); + assert_eq!((liveness::status_of(watch.as_ref()), reads.get(), ticks.get()), (CounterStatus::Unavailable, 0, 0)); + } + + /// The NVML gate's rule on scripted device counters, in mJ, sampled every + /// 100 ms as the NVML probe waits. A counter the driver refreshes at least + /// every 100 ms advances in every interval and is Live after five (500 + /// ms); one refreshed every fourth interval advances in a quarter of them + /// and is declined; one that holds is Frozen. + #[test] + fn the_nvml_gate_on_scripted_device_counters() { + let step = Duration::from_millis(100); + let device = |mj: i64| Some(vec![("nvml device 0 total energy".to_string(), mj)]); + let (status, w, reads, _) = scripted_counter(|i| device(48_000_000 + 2_000 * i as i64), step); + let w = w.unwrap(); + assert_eq!((status, w.intervals, reads, w.window), (CounterStatus::Live, 5, 6, Duration::from_millis(500))); + let (status, w, _, _) = scripted_counter(|i| device(48_000_000 + 8_000 * (i / 4) as i64), step); + assert_eq!(status, CounterStatus::Frozen, "a counter advancing in 1 of 4 intervals: {:?}", w.unwrap().rails); + let (status, _, _, _) = scripted_counter(|_| device(48_000_000), step); + assert_eq!(status, CounterStatus::Frozen); + } + + /// A fake powercap tree in the OS temp directory, removed when dropped: + /// zone directories each with a `name`, an `energy_uj` and a + /// `max_energy_range_uj`, as sysfs lays them out. + struct FakePowercap { + root: std::path::PathBuf, + } + + /// One zone of a [`FakePowercap`]: its directory, its `name` file (`None`: + /// no such file), its `energy_uj`, and its `max_energy_range_uj` (`None`: + /// no such file). + type FakeZone<'a> = (&'a str, Option<&'a str>, &'a str, Option<&'a str>); + + impl FakePowercap { + /// `intel-rapl:N` zones named `package-N`, one per value, wrapping at + /// 262,143,328,850 µJ, and an `intel-rapl:0:0` `core` subzone the + /// reader must skip. + fn new(packages: &[&str]) -> Self { + let dirs: Vec<(String, String)> = + (0..packages.len()).map(|k| (format!("intel-rapl:{k}"), format!("package-{k}"))).collect(); + let mut zones: Vec> = dirs + .iter() + .zip(packages) + .map(|((dir, name), value)| (dir.as_str(), Some(name.as_str()), *value, Some("262143328850"))) + .collect(); + zones.push(("intel-rapl:0:0", Some("core"), "5", Some("262143328850"))); + Self::with_zones(&zones) + } + + /// A tree of exactly these zones. + fn with_zones(zones: &[FakeZone<'_>]) -> Self { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let root = std::env::temp_dir().join(format!("jcp-energy-powercap-{}-{n}", std::process::id())); + let fake = FakePowercap { root }; + std::fs::create_dir_all(&fake.root).expect("create a fake powercap tree"); + for (dir, name, value, range) in zones { + let zone = fake.root.join(dir); + std::fs::create_dir_all(&zone).expect("create a fake zone"); + if let Some(name) = name { + std::fs::write(zone.join("name"), format!("{name}\n")).unwrap(); + } + if let Some(range) = range { + std::fs::write(zone.join("max_energy_range_uj"), format!("{range}\n")).unwrap(); + } + fake.write_zone(dir, value); + } + fake + } + + /// Replace package `k`'s `energy_uj` (zone `intel-rapl:k`). + fn write(&self, k: usize, value: &str) { + self.write_zone(&format!("intel-rapl:{k}"), value); + } + + /// Replace zone `dir`'s `energy_uj`, atomically, so a reader sees the + /// old value or the new one. + fn write_zone(&self, dir: &str, value: &str) { + let dir = self.root.join(dir); + std::fs::write(dir.join("energy_uj.tmp"), format!("{value}\n")).unwrap(); + std::fs::rename(dir.join("energy_uj.tmp"), dir.join("energy_uj")).unwrap(); + } + } + + impl Drop for FakePowercap { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + /// The real RAPL probe — the real reader on files, the CPU-burning tick, + /// the real clock, one budget — on fake powercap trees. A tree whose + /// counters hold is Frozen; one whose two packages another thread + /// advances every 2 ms is Live; one where that thread advances package 0 + /// alone, package 1 holding, is Frozen; no tree, or a tree whose + /// `energy_uj` is not a number, is Unavailable, as RAPL was declined + /// before. + #[test] + fn the_rapl_probe_on_fake_powercap_trees() { + /// The real probe on `tree` while another thread advances the + /// packages in `advancing` by 4 mJ every 2 ms. + fn probe_while_advancing(tree: &FakePowercap, advancing: &[usize]) -> CounterStatus { + let stop = std::sync::atomic::AtomicBool::new(false); + std::thread::scope(|s| { + s.spawn(|| { + let mut uj: u64 = 1_000; + while !stop.load(std::sync::atomic::Ordering::SeqCst) { + uj += 4_000; + for &k in advancing { + tree.write(k, &uj.to_string()); + } + std::thread::sleep(Duration::from_millis(2)); + } + }); + let started = std::time::Instant::now(); + let status = rapl_status_on(|| Rapl::open_in(&tree.root)); + eprintln!( + "rapl probe: a fake tree with packages {advancing:?} advancing every 2 ms -> {status:?} in {:?}", + started.elapsed() + ); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + status + }) + } + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); + let frozen = FakePowercap::new(&["73176481", "1000"]); + let started = std::time::Instant::now(); + let status = rapl_status_on(|| Rapl::open_in(&frozen.root)); + eprintln!("rapl probe: a fake tree that holds -> {status:?} in {:?}", started.elapsed()); + assert_eq!(status, CounterStatus::Frozen, "a powercap tree whose counters hold was not declined"); + + let live = FakePowercap::new(&["1000", "1000"]); + let status = probe_while_advancing(&live, &[0, 1]); + assert_eq!(status, CounterStatus::Live, "two packages advancing every 2 ms were not live"); + let half = FakePowercap::new(&["1000", "1000"]); + assert_eq!( + probe_while_advancing(&half, &[0]), + CounterStatus::Frozen, + "package 0 advancing beside a held package 1 was not declined" + ); + + let gone = FakePowercap::new(&[]); + assert_eq!(rapl_status_on(|| Rapl::open_in(&gone.root.join("absent"))), CounterStatus::Unavailable); + let garbled = FakePowercap::new(&["not a number"]); + assert!(Rapl::open_in(&garbled.root).is_none(), "a package whose energy_uj does not parse was opened"); + assert_eq!(rapl_status_on(|| Rapl::open_in(&garbled.root)), CounterStatus::Unavailable); + } + + /// `detect()` selects the RAPL rung on a `Live` status and on no other, + /// and opens no counter it does not select. Driven with a counter on a + /// fake tree, so it runs on hosts with no RAPL. + #[test] + fn only_a_live_status_selects_the_rapl_rung() { + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); + let fake = FakePowercap::new(&["1000"]); + for status in [CounterStatus::Unavailable, CounterStatus::Frozen, CounterStatus::Live] { + let opened = Cell::new(0); + let open = || { + opened.set(opened.get() + 1); + Rapl::open_in(&fake.root) + }; + let meter = Meter::detect_with(&Ladder { rapl: &|| status, open_rapl: &open, ..Ladder::none() }); + let live = status == CounterStatus::Live; + assert_eq!(matches!(meter, Meter::Rapl(_)), live, "status {status:?} gave {meter:?}"); + assert_eq!(opened.get(), usize::from(live), "status {status:?} opened the counter {} times", opened.get()); + if !live { + assert!(matches!(meter, Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS }), "{meter:?}"); + } + } + } + + /// The RAPL rung's spans, through `measure` itself on a fake tree. A span + /// over which a package counter rose is a reading of the rise, `HwShunt` + /// and whole, wraparound-corrected; a span over which no counter rose is + /// the time×power model's figure, not a hardware reading of zero. + #[test] + fn a_rapl_span_is_a_reading_only_if_a_counter_rose() { + let fake = FakePowercap::new(&["1000", "50"]); + let meter = Meter::Rapl(Rapl::open_in(&fake.root).expect("the fake tree opens")); + assert_eq!(meter.provenance(), Provenance::HwShunt); + let (_, e) = meter.measure(|| fake.write(0, "5500")); + assert_eq!((e.provenance, e.measured_uj, e.is_whole()), (Provenance::HwShunt, Some(4_500), true), "{e:?}"); + e.check_provenance().unwrap(); + // Package 1 wraps at 262,143,328,850 µJ: 262,143,328,000 -> 150. + fake.write(1, "262143328000"); + let (_, e) = meter.measure(|| fake.write(1, "150")); + assert_eq!((e.provenance, e.measured_uj), (Provenance::HwShunt, Some(1_000)), "{e:?}"); + let ((), held) = meter.measure(|| std::thread::sleep(Duration::from_millis(2))); + assert_eq!((held.provenance, held.measured_uj), (Provenance::ModelBased, None), "{held:?}"); + assert!(held.uj() >= time_power_uj(2_000_000, DEFAULT_ACTIVE_MILLIWATTS), "{held:?}"); + held.check_provenance().unwrap(); + } + + /// The figure a counter rung gives a span: a reading only when the + /// counter rose; a rise of zero and a failed read are the model's figure. + #[test] + fn a_counter_span_is_a_reading_only_when_the_counter_rose() { + let model = time_power_uj(3_000_000, DEFAULT_ACTIVE_MILLIWATTS); + assert_eq!(counter_span(Some(7), 3_000_000, Energy::measured), Energy::measured(7)); + for rose in [Some(0), None] { + let e = counter_span(rose, 3_000_000, Energy::measured); + assert_eq!((e.provenance, e.measured_uj, e.uj()), (Provenance::ModelBased, None, model), "{rose:?}: {e:?}"); + } + } + + /// An NVML span over which the GPU counter advanced is a hardware reading + /// of PART of the work: `HwShunt`, `Coverage::Partial`, the counter's + /// rise as the measured figure — never a whole-task figure, since the + /// CPU's share is unmetered. A counter that held, fell (a driver reload) + /// or failed to read is the time×power model's whole-task figure. + #[test] + fn an_nvml_span_is_a_partial_reading_of_the_work() { + let e = nvml_span(Some(48_000_000_000), Some(48_000_350_000), 10_000_000); + assert_eq!(e.provenance, Provenance::HwShunt, "{e:?}"); + assert_eq!(e.measured_uj, Some(350_000), "{e:?}"); + assert_eq!(e.coverage, jcp_schema::Coverage::Partial, "a GPU-only figure claimed the whole task: {e:?}"); + assert!(!e.is_whole()); + e.check_provenance().unwrap(); + let model = time_power_uj(10_000_000, DEFAULT_ACTIVE_MILLIWATTS); + let b = Some(48_000_000_000); + for (what, before, after) in [("held", b, b), ("fell", b, Some(1_000)), ("before failed", None, b), ("after failed", b, None)] { + let e = nvml_span(before, after, 10_000_000); + assert_eq!((e.provenance, e.measured_uj, e.uj()), (Provenance::ModelBased, None, model), "{what}: {e:?}"); + assert!(e.is_whole(), "{what}: the model's figure is a whole-task estimate: {e:?}"); + } + } + #[test] fn rapl_wraparound_delta() { let r = Rapl { - packages: vec![(std::path::PathBuf::from("/dev/null"), 1_000)], + packages: vec![(std::path::PathBuf::from("/dev/null"), Some(1_000))], }; - assert_eq!(r.delta(&[100], &[400]), 300); + assert_eq!(r.delta(&[100], &[400]), Some(300)); // counter wrapped at 1_000: 900 → 50 means 150 spent - assert_eq!(r.delta(&[900], &[50]), 150); + assert_eq!(r.delta(&[900], &[50]), Some(150)); + // a snapshot that does not match the packages is no figure + assert_eq!(r.delta(&[900, 1], &[950, 2]), None); + } + + /// One zone's delta, modulo its own wrap: a rise is the rise; a fall + /// wrapped at the zone's range; a fall with no known range, or from above + /// the range, is unknown, never an invented figure. + #[test] + fn a_rapl_zone_wraps_at_its_own_range_or_gives_no_figure() { + assert_eq!(rapl_zone_delta(100, 400, None), Some(300)); + assert_eq!(rapl_zone_delta(7, 7, Some(1_000)), Some(0)); + assert_eq!(rapl_zone_delta(900, 50, Some(1_000)), Some(150)); + assert_eq!(rapl_zone_delta(65_532_600_000, 500, Some(65_532_610_987)), Some(11_487)); + assert_eq!(rapl_zone_delta(900, 50, None), None, "a fall with no known range gave a figure"); + assert_eq!(rapl_zone_delta(2_000, 50, Some(1_000)), None, "a fall from above the range gave a figure"); + // Each package at its own range, summed: package 0 rose 1,000 µJ, + // package 1 wrapped at its own (smaller) range. + let r = Rapl { + packages: vec![ + (std::path::PathBuf::from("/dev/null"), Some(262_143_328_850)), + (std::path::PathBuf::from("/dev/null"), Some(65_532_610_987)), + ], + }; + assert_eq!(r.delta(&[5_000, 65_532_600_000], &[6_000, 500]), Some(12_487)); + // One package's delta unknown: no figure for the span. + let r = Rapl { + packages: vec![ + (std::path::PathBuf::from("/dev/null"), Some(262_143_328_850)), + (std::path::PathBuf::from("/dev/null"), None), + ], + }; + assert_eq!(r.delta(&[5_000, 900], &[6_000, 50]), None); + } + + /// The package zones by their `name` file, and no other: `package-`. + #[test] + fn a_rapl_package_is_a_zone_named_package_n() { + for name in ["package-0", "package-1\n", "package-12", " package-3 \n"] { + assert!(is_rapl_package_name(name), "{name:?}"); + } + for name in ["psys", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", "package0", ""] { + assert!(!is_rapl_package_name(name), "{name:?}"); + } + assert_eq!(rapl_top_level_zone("intel-rapl:0"), Some(0)); + assert_eq!(rapl_top_level_zone("intel-rapl:12"), Some(12)); + for dir in ["intel-rapl", "intel-rapl:", "intel-rapl:0:0", "intel-rapl:1:2", "intel-rapl-mmio:0", "intel-rapl:x"] { + assert_eq!(rapl_top_level_zone(dir), None, "{dir:?}"); + } + } + + /// A tree laid out as a two-socket Intel host with a platform zone: two + /// packages (`intel-rapl:0`, `intel-rapl:2`), `psys` (`intel-rapl:1`), a + /// top-level `dram` zone (`intel-rapl:3`), each package's `core` and + /// `dram` subzones, and the MMIO interface to package 0. Each package + /// wraps at a range of its own. + fn two_sockets_with_psys_and_dram() -> FakePowercap { + FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")), + ("intel-rapl:1", Some("psys"), "90000", Some("262143328850")), + ("intel-rapl:2", Some("package-1"), "2000", Some("65532610987")), + ("intel-rapl:3", Some("dram"), "30000", Some("65532610987")), + ("intel-rapl:0:0", Some("core"), "700", Some("262143328850")), + ("intel-rapl:0:1", Some("dram"), "800", Some("65532610987")), + ("intel-rapl:2:0", Some("core"), "900", Some("262143328850")), + ("intel-rapl-mmio:0", Some("package-0"), "1000", Some("262143328850")), + ]) + } + + /// The figure and the probe take the package zones and nothing else. On + /// a tree with two packages, `psys`, a top-level `dram` zone, subzones and + /// an MMIO interface: the reader holds the two packages, in zone order; + /// the liveness sample names those two; a span's figure is their rise + /// alone, although every other zone rose too; a package wrapping at its own + /// range is corrected at that range. And the real probe is Live while the + /// two packages advance with `psys` and `dram` holding, Frozen while + /// `psys`, `dram` and one package advance with the other package holding. + #[test] + fn rapl_sums_and_probes_the_package_zones_only() { + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); + let tree = two_sockets_with_psys_and_dram(); + let r = Rapl::open_in(&tree.root).expect("a tree with two package zones opens"); + let opened: Vec<_> = r.packages.iter().map(|(p, range)| (p.strip_prefix(&tree.root).unwrap().to_owned(), *range)).collect(); + assert_eq!( + opened, + [ + (std::path::PathBuf::from("intel-rapl:0/energy_uj"), Some(262_143_328_850)), + (std::path::PathBuf::from("intel-rapl:2/energy_uj"), Some(65_532_610_987)), + ], + "the reader did not hold exactly the two package zones" + ); + let sample = r.liveness_sample().expect("the packages read"); + let probed: Vec<(&str, i64)> = sample.iter().map(|(k, v)| (k.name.as_str(), *v)).collect(); + assert_eq!(probed, [("intel-rapl:0", 1_000), ("intel-rapl:2", 2_000)], "the probe watched other zones"); + + let meter = Meter::Rapl(r); + let (_, e) = meter.measure(|| { + tree.write_zone("intel-rapl:0", "4000"); + tree.write_zone("intel-rapl:1", "190000"); + tree.write_zone("intel-rapl:2", "7000"); + tree.write_zone("intel-rapl:3", "80000"); + tree.write_zone("intel-rapl:0:0", "2700"); + tree.write_zone("intel-rapl:0:1", "1800"); + tree.write_zone("intel-rapl-mmio:0", "4000"); + }); + assert_eq!((e.provenance, e.measured_uj), (Provenance::HwShunt, Some(8_000)), "not the packages' rise alone: {e:?}"); + // Package 1 wraps at its own range, 65,532,610,987 µJ, as psys and + // dram wrap too: 65,532,600,000 -> 500 is 11,487 µJ, plus package 0's + // 1,000. + tree.write_zone("intel-rapl:2", "65532600000"); + let (_, e) = meter.measure(|| { + tree.write_zone("intel-rapl:0", "5000"); + tree.write_zone("intel-rapl:2", "500"); + tree.write_zone("intel-rapl:1", "10"); + tree.write_zone("intel-rapl:3", "10"); + }); + assert_eq!((e.provenance, e.measured_uj), (Provenance::HwShunt, Some(12_487)), "{e:?}"); + drop(meter); + + /// The real probe on `tree` while another thread advances the zones + /// in `advancing` by 4 mJ every 2 ms. + fn probe_advancing(tree: &FakePowercap, advancing: &[&str]) -> CounterStatus { + let stop = std::sync::atomic::AtomicBool::new(false); + std::thread::scope(|s| { + s.spawn(|| { + let mut uj: u64 = 100_000; + while !stop.load(std::sync::atomic::Ordering::SeqCst) { + uj += 4_000; + for dir in advancing { + tree.write_zone(dir, &uj.to_string()); + } + std::thread::sleep(Duration::from_millis(2)); + } + }); + let status = rapl_status_on(|| Rapl::open_in(&tree.root)); + eprintln!("rapl probe: zones {advancing:?} advancing, the rest holding -> {status:?}"); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + status + }) + } + let tree = two_sockets_with_psys_and_dram(); + assert_eq!( + probe_advancing(&tree, &["intel-rapl:0", "intel-rapl:2"]), + CounterStatus::Live, + "both packages advancing, psys and dram holding, was not live" + ); + let tree = two_sockets_with_psys_and_dram(); + assert_eq!( + probe_advancing(&tree, &["intel-rapl:0", "intel-rapl:1", "intel-rapl:3", "intel-rapl-mmio:0"]), + CounterStatus::Frozen, + "package 1 holding beside a live psys, dram and package 0 was not declined" + ); + } + + /// No package zone, no RAPL counter: a host exposing only `psys` (and a + /// `dram` zone) is Unavailable without a probe, never a figure. Nor does + /// the reader open with a package left out: one whose `energy_uj` does + /// not read, or a top-level zone whose `name` does not (it may be a + /// package). A package whose range does not read opens, and a span over + /// which it fell is the model's figure, not an invented reading. + #[test] + fn a_rapl_figure_never_leaves_a_package_out_or_takes_another_zone() { + let psys_only = FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("psys"), "90000", Some("262143328850")), + ("intel-rapl:1", Some("dram"), "30000", Some("65532610987")), + ("intel-rapl:0:0", Some("package-0"), "1000", Some("262143328850")), + ]); + assert!(Rapl::open_in(&psys_only.root).is_none(), "a tree with no package zone opened"); + let started = std::time::Instant::now(); + assert_eq!(rapl_status_on(|| Rapl::open_in(&psys_only.root)), CounterStatus::Unavailable); + assert!(started.elapsed() < Duration::from_millis(250), "a tree with no package burned {:?}", started.elapsed()); + + let package_unreadable = FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")), + ("intel-rapl:1", Some("package-1"), "not a number", Some("262143328850")), + ]); + assert!(Rapl::open_in(&package_unreadable.root).is_none(), "a package that does not read was left out"); + let unnamed = FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")), + ("intel-rapl:1", None, "2000", Some("262143328850")), + ]); + assert!(Rapl::open_in(&unnamed.root).is_none(), "a zone whose name does not read was left out"); + + let no_range = FakePowercap::with_zones(&[("intel-rapl:0", Some("package-0"), "900", None)]); + let meter = Meter::Rapl(Rapl::open_in(&no_range.root).expect("a package with no range opens")); + let (_, e) = meter.measure(|| no_range.write(0, "950")); + assert_eq!((e.provenance, e.measured_uj), (Provenance::HwShunt, Some(50)), "{e:?}"); + let (_, e) = meter.measure(|| no_range.write(0, "50")); + assert_eq!((e.provenance, e.measured_uj), (Provenance::ModelBased, None), "a fall with no range: {e:?}"); + e.check_provenance().unwrap(); } } diff --git a/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs b/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs new file mode 100644 index 000000000..4f4a5bec5 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs @@ -0,0 +1,68 @@ +//! The IOReport liveness probe runs once per PROCESS, whoever asks first and +//! however many ask at once. +//! +//! This file holds one test, so it runs in a process of its own, and its +//! concurrent calls ARE the first use: the cache starts cold, whatever order +//! other tests run in. Counted, not timed: how long a probe takes depends on +//! the rail and on the host's load, so a clock cannot tell one probe from two. + +use std::sync::Barrier; + +use jcp_energy::{ioreport_status, IoReportStatus, Meter}; + +#[test] +fn the_liveness_probe_runs_once_per_process() { + const THREADS: usize = 8; + let (runs_before, opens_before) = (jcp_energy::ioreport_probe_runs(), jcp_energy::ioreport_energy_model_opens()); + assert_eq!( + (runs_before, opens_before), + (0, 0), + "something probed before this test began, so its calls are not the first use" + ); + let barrier = Barrier::new(THREADS); + let statuses: Vec = std::thread::scope(|s| { + let workers: Vec<_> = (0..THREADS) + .map(|i| { + let barrier = &barrier; + s.spawn(move || { + barrier.wait(); + // Half the threads build the meter first and half ask for + // the status first, so either path can be the first use. + let first = if i % 2 == 0 { + let _ = Meter::detect(); + ioreport_status() + } else { + let status = ioreport_status(); + let _ = Meter::detect(); + status + }; + for _ in 0..3 { + assert_eq!(ioreport_status(), first, "the cached status changed between calls"); + let _ = Meter::detect(); + } + first + }) + }) + .collect(); + workers.into_iter().map(|w| w.join().expect("a thread panicked")).collect() + }); + let (runs, opens) = (jcp_energy::ioreport_probe_runs(), jcp_energy::ioreport_energy_model_opens()); + eprintln!( + "ioreport cache: {THREADS} threads, first use concurrent, each asked for the status 4 times and built the \ + meter 4 times; they saw {statuses:?}; probe runs in this process = {runs}, Energy Model subscriptions \ + opened = {opens}" + ); + assert!(statuses.iter().all(|s| *s == statuses[0]), "threads saw different statuses: {statuses:?}"); + assert_eq!(statuses[0], ioreport_status(), "the spawned threads and this one disagree"); + if cfg!(target_os = "macos") { + assert_eq!(runs, 1, "the liveness probe did not run exactly once in this process"); + assert_eq!( + opens, 1, + "{opens} Energy Model subscriptions were opened in this process: something probed besides the one cached \ + probe" + ); + } else { + assert_eq!((runs, opens), (0, 0), "a probe ran on a platform with no IOReport"); + assert_eq!(statuses[0], IoReportStatus::Unavailable); + } +} diff --git a/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs b/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs index a374a720d..a93e3fe3c 100644 --- a/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs @@ -18,7 +18,9 @@ //! that a call was refused, without ever sealing the content itself (only its //! hash). A step cannot run un-sealed. //! - **Honest joule accounting.** Each step is metered ([`jcp_energy`]); the -//! receipt records the figure at its true provenance, never overclaiming. +//! receipt records the figure at its true provenance, never overclaiming — +//! and a figure that covers only part of the step says so (`"coverage": +//! "Partial"`), rather than being sealed as the whole step's energy. //! //! A [`Chain`] runs its interceptors forward through the `before_*` seams and in //! reverse through `after_model` (the onion unwinds), stopping at the first @@ -30,7 +32,7 @@ use ed25519_dalek::SigningKey; use jcp_energy::Meter; use jcp_receipt::jcr1; -use jcp_schema::{ContentId, Energy}; +use jcp_schema::{ContentId, Coverage, Energy}; use serde_json::{json, Value}; // ───────────────── the request/response/tool an interceptor transforms ───────────────── @@ -352,7 +354,8 @@ impl Chain { &self.receipts } - /// Total metered energy across every sealed step, microjoules. + /// Total metered energy across every sealed step, microjoules. Where a + /// step's figure was `Partial` (its receipt says so), so is this total. pub fn energy_uj(&self) -> u64 { self.energy_uj } @@ -409,6 +412,13 @@ fn intercept_wire( "prev": prev.to_hex(), "steward": steward, }); + // Coverage rides beside provenance only when the figure is `Partial`. The + // whole-step case is the default and is omitted, so a receipt for a whole + // figure is byte-identical to one sealed before the field existed. Without + // it a partial figure would be sealed as the step's whole energy. + if !energy.is_whole() { + o["coverage"] = json!(energy.coverage.label()); + } if let Some(r) = step.reason() { o["reason"] = json!(r); } @@ -450,6 +460,10 @@ pub struct InterceptReceipt { pub energy_uj: u64, /// The energy provenance label. pub provenance: String, + /// How much of the step the energy figure covers: `Partial` when the + /// receipt carries `"coverage": "Partial"`, `Whole` when it carries no + /// coverage (the only other encoding a receipt may have). + pub coverage: Coverage, /// The reason, for a halt/hold outcome. pub reason: Option, /// The chain sequence number. @@ -480,6 +494,13 @@ pub fn verify(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Result Coverage::Whole, + Some(c) if c.as_str() == Some(Coverage::Partial.label()) => Coverage::Partial, + Some(c) => return Err(InterceptError::Malformed(format!("coverage {c} is not \"Partial\""))), + }; Ok(InterceptReceipt { interceptor: s("interceptor")?, seam: s("seam")?, @@ -489,6 +510,7 @@ pub fn verify(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Result Value { + let core = jcr1::verify_cose_sign1(env, c.steward(), &c.steward_pubkey()).expect("a sealed receipt verifies"); + jcp_schema::from_canonical_cbor(&core).expect("the core decodes") + } + + /// A whole figure seals exactly the bytes it sealed before coverage + /// existed: no `coverage` key, the same envelopes, byte for byte. + #[test] + fn a_whole_figure_seals_byte_identically_to_before() { + let mut c = chain() + .with(Box::new(Redact::secrets())) + .with(Box::new(CallLimit::models(5))); + c.before_model(&mut req()); + assert_eq!(c.receipts().len(), 2); + for (i, env) in c.receipts().iter().enumerate() { + assert!(payload(env, &c).get("coverage").is_none(), "receipt {i} carries a coverage for a whole figure"); + assert_eq!(verify(env, c.steward(), &c.steward_pubkey()).unwrap().coverage, Coverage::Whole); + assert_eq!(hex::encode(env), WHOLE_BEFORE_COVERAGE[i], "receipt {i} is not the envelope sealed before coverage"); + } + } + + /// The same for a whole hardware reading, `HwShunt`, which the + /// constant meter cannot produce: its object is the `intercept/1` object + /// as it was, key for key, and canonicalizes to the same core bytes. + #[test] + fn a_whole_reading_seals_the_object_it_sealed_before() { + let (before, after) = (ContentId::of_bytes(b"a"), ContentId::of_bytes(b"b")); + let prev = ContentId([3u8; 32]); + let wire = intercept_wire( + "redact", Seam::BeforeTool, &Step::Continue, before, after, &Energy::measured(7), 4, &prev, "did:jcp:s", + ); + let as_before = json!({ + "intercept_receipt": "intercept/1", + "interceptor": "redact", + "seam": "before_tool", + "outcome": "continue", + "request_before": before.to_hex(), + "request_after": after.to_hex(), + "transformed": 1u64, + "energy_uj": 7u64, + "provenance": "HwShunt", + "seq": 4u64, + "prev": prev.to_hex(), + "steward": "did:jcp:s", + }); + assert_eq!(wire, as_before); + assert_eq!(jcr1::signed_core_bytes(&wire).unwrap(), jcr1::signed_core_bytes(&as_before).unwrap()); + } + + /// A partial figure is sealed as one: `"coverage": "Partial"` beside its + /// provenance, recovered by `verify`, and that key is the only difference + /// from the same figure sealed whole. + #[test] + fn a_partial_figure_seals_coverage_partial() { + let mut c = chain(); + let d = req().digest(); + c.seal("meter", Seam::BeforeModel, &Step::Continue, d, d, Energy::measured_partial(500)); + c.seal("meter", Seam::BeforeModel, &Step::Continue, d, d, Energy::measured(500)); + let (partial, whole) = (payload(&c.receipts()[0], &c), payload(&c.receipts()[1], &c)); + assert_eq!(partial["coverage"], json!("Partial"), "a partial figure sealed without its coverage: {partial}"); + let rec = verify(&c.receipts()[0], c.steward(), &c.steward_pubkey()).unwrap(); + assert_eq!((rec.coverage, rec.provenance.as_str(), rec.energy_uj), (Coverage::Partial, "HwShunt", 500)); + let strip = |mut v: Value| { + let o = v.as_object_mut().unwrap(); + o.remove("coverage"); + o.remove("seq"); + o.remove("prev"); + v + }; + assert_eq!(strip(partial), strip(whole), "a partial figure's receipt differs from a whole one's beyond its coverage"); + } + + /// `coverage` has one encoding, `"Partial"`, and absence means whole. A + /// receipt carrying any other value, an explicit `"Whole"` included, is + /// refused rather than read either way. + #[test] + fn verify_refuses_a_coverage_other_than_partial() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let steward = "did:jcp:test-intercept"; + let d = req().digest(); + let whole = intercept_wire("m", Seam::BeforeModel, &Step::Continue, d, d, &Energy::measured(1), 0, &d, steward); + for bad in [json!("Whole"), json!("partial"), json!(1u64)] { + let mut wire = whole.clone(); + wire["coverage"] = bad.clone(); + let env = jcr1::cose_sign1_envelope(&jcr1::signed_core_bytes(&wire).unwrap(), &sk, steward); + let got = verify(&env, steward, &sk.verifying_key().to_bytes()); + assert!(matches!(got, Err(InterceptError::Malformed(_))), "coverage {bad} was read: {got:?}"); + } + } } diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index ba23423a4..ab0117e02 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -107,7 +107,14 @@ impl TryFrom for Did { /// How honest an energy reading is (SPEC §5) — the family's provenance model. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Provenance { - /// Read from a hardware energy interface (RAPL/NVML). Requires a measured figure. + /// Read from a hardware energy interface: a dedicated shunt, or a vendor + /// energy counter (RAPL, NVML, Apple IOReport, ...). A vendor counter's + /// count is a real reading, not an estimate; how the vendor derives it is + /// undisclosed and the platform relays it, which is a gap in provenance, + /// not in accuracy. It earns this grade only while it is live for the domain + /// the figure claims (SPEC §5): observed advancing under load. A counter + /// that is present but frozen, or republished in lumps, never yields an + /// `HwShunt` figure. Requires a measured figure. HwShunt, /// A calibrated per-operation model. A measured figure may be absent. ModelBased,