On macOS 27.0.1 an unprivileged process gets no live CPU energy from IOReport:
its CPU rails are frozen on an M5 Max and an M3 Ultra, so `detect_meter` falls
to the load-average estimator there. The kernel does keep one live CPU energy
figure an unprivileged process can read: `proc_pid_rusage(getpid(),
RUSAGE_INFO_V6)` returns `ri_energy_nj`, the cumulative energy it attributes to
this process's threads. Public libproc API, no privilege. Measured here (M5 Max,
macOS 27.0.1, 2026-09-30, host heavily loaded): it changes about every 0.6 ms
(86 changes in 50 ms of polling); 200 ms of one thread's CPU time read 0.448 J;
a 200 ms sleep read 137 µJ.
The trap is coverage. It reads this process's threads and nothing else: not
other processes, not this process's children, not the GPU, ANE or DRAM, this
process's own GPU work included. A figure from it is a hardware reading of work
that ran on this process's threads, and would bill zero for a model served by a
subprocess. JouleClaw SPEC §1.3: a figure must not claim coverage of a domain
its counter does not read.
- `process::ProcessCpuCounter` (macOS, no feature) reads it over a new domain,
`EnergyDomain::ProcessCpu`, documented to exactly that scope, graded HwShunt
like RAPL and NVML, and only while live: the shared sustained-advance rule,
with the probing thread burning 25 ms of its own CPU between samples, probed
once per process and cached (`process_cpu_liveness`). Whether AMX work a
thread drives is in the kernel's figure is not verified, and the docs say so.
- A window over which it did not advance by a whole µJ is not a reading:
`ProcessCpuCounter::measure` returns the new `EnergyError::NotAdvanced`,
never a zero; a failed read or a counter going backwards is an error too.
- `detect_meter` does not include it, and says why. `detect_in_process_meter`
returns it when live, else exactly `detect_meter`; its docs limit it to work
on this process's own threads. `CompositeMeter::names` lists what a
composite holds, so a test can say what is not in it.
- mesh-meter decides the new domain, as its exhaustive match requires: a
per-process figure does not cover a mesh/1 request, so through `HwMeter` it
would be an estimate. `HwMeter::detect` uses `detect_meter`, so it never
meets this counter unless bridged by hand.
Tests. The hardware checks run in `tests/process_cpu.rs`, one test in its own
process, because the counter reads every thread of the process and concurrent
tests would be in the figure: live, probed once across four threads; 200 ms of
one thread's CPU reads 0.2 to 20 J, HwShunt over ProcessCpu; a 200 ms sleep
reads under 0.1 J or is NotAdvanced; `detect_meter` holds apple-ioreport,
apple-silicon and estimation and not process-cpu, while the in-process meter
selects it; a busy `/bin/sh` child that burned 0.744 J (its own counter, read
independently at the SDK's offset) put 264 µJ in this process's window, which
spans its kill and reap. In the lib: the struct matches the SDK layout (464
bytes, energy at 336, checked with the C compiler); a counter given Frozen or
Unavailable reads and measures nothing; the production probe over a held
reader is Frozen after the budget and over a failing one Unavailable; the
window rule; and a live one-sided burn check.
Ran: `cargo test -p jouleclaw-energy` with default features and with
`ioreport,rapl,nvml`; mesh-meter with `hw-meter`, `hw-meter-apple`,
`hw-meter-nvml`; clippy clean on the new code.
Co-Authored-By: Claude Opus 5.5 <[email protected]>