sandbox-meter: a live per-process CPU energy probe for in-process work, and a real reading for a direct child #121

Merged
dcharlot merged 2 commits from sandbox-meter/process-energy into main 2026-10-01 06:02:14 -04:00

2026-09-30

Transaction Science
joule-ui: served and example interactions are metered by this process's own CPU energy
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m39s
standards workspaces build + test / aac-rs (pull_request) Successful in 36s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 37s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 32s
standards workspaces build + test / map-rs (pull_request) Successful in 30s
standards workspaces build + test / mesh-rs (pull_request) Successful in 24s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m27s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m20s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m12s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m9s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m12s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 4m37s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m3s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m0s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m11s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 4m30s
joule-ui-serve's action handlers and the joule-ui-interaction examples' handlers
are in-process work, verified rather than assumed: `Host::handle` calls the
handler inside `run_interaction`, on the worker thread that holds the host
lock, and nothing in joule-ui-serve or the examples spawns a process. So they
now meter with `PowerMeter::detect_in_process()`, and on a host whose
per-process CPU energy counter is live (both Apple Silicon hosts checked,
macOS 27.0.1, where the machine-wide CPU rail is frozen) each interaction is
sealed as a `HwShunt` reading of the energy the kernel attributes to this
process's threads over the interaction, instead of the 20 W wall-clock
estimate. Where the counter is not live the meter is `detect()`'s, as before.
The figure is this process's, not one thread's: in joule-ui-serve it includes
any worker that served a socket during the span.

joule-ui-serve: `main` serves with `served_power_meter()`, which is
`detect_in_process()`, and the end-to-end test now builds its meter from that
same function and asserts, where the counter is live, that the meter is the
process probe and that each ~24 ms served interaction is `HwShunt`.

The examples: `real_meter` and `jcp_metered` use `detect_in_process()` and
say which counter they read. With real readings an interaction of ~150 ms
costs a fraction of a joule, not the estimate's 3 J, so six fires no longer
spend `jcp_metered`'s 15 J grant; it now fires until the real JCP runtime
denies one (at most 200), printing the first five and the last three, so the
budget binds whatever the figures are. The grant itself is unchanged.

SPEC §5 names the per-process counter among the reference meter's sources;
the two Cargo comments say which meter each crate uses.

Tests: joule-ui-serve, -interaction, -conformance, -mcp, -core, -a2ui and
-render pass (72). The served interactions on this M5 Max: `Energy {
measured_uj: Some(25908), ... provenance: HwShunt }` and `Some(23193)`.
`gen_vectors` regenerated into a scratch directory is byte-identical to
`conformance/v1/` (`diff -r` clean). The examples, release build, here:
  real_meter: render #1: allow · 195798 µJ [HwShunt] ... sealed chain: 3 links
  verified, 584156 µJ total — hardware counter deltas (HwShunt), this
  process's CPU energy over each interaction
  jcp_metered: 82 interactions sealed: 81 allowed; 15001229 µJ sealed,
  hardware counter deltas (HwShunt) from process-energy; 15000000 of the
  15000000 µJ budget debited; 1 denied by the real JCP runtime once the budget
  was spent.
(The per-interaction figure varied from 0.04 to 0.21 J: the handlers burn for
150 ms of wall time, and other builds were loading this machine.)

Falsified, restored from git and re-run green: `served_power_meter` returning
`detect()` fails `the_served_meter_never_seals_zero_or_an_estimate_as_a_reading`.

Rebased onto 7cf1f036 with the commit before it; this commit's files are
unchanged. joule-ui-interaction, -serve, -conformance, -mcp, -core, -a2ui
and -render pass, and `gen_vectors` regenerated into a scratch directory is
still byte-identical to `conformance/v1/`.

Rebased again, onto a16e36cf, with the commit before it; this commit's
files are unchanged. The seven joule-ui crates pass (72), and `gen_vectors`
regenerated into a scratch directory is still byte-identical to
`conformance/v1/`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 23:01:05 -04:00
Transaction Science
sandbox-meter: this process's own CPU energy, read only for in-process work and only while it keeps advancing
On macOS 27.0.1 an unprivileged reader sees the machine-wide IOReport CPU rail
frozen, so every meter here has been an estimator on Apple Silicon. The kernel
does keep a live per-process figure: `proc_pid_rusage(getpid(),
RUSAGE_INFO_V6)` and its `ri_energy_nj`, the CPU energy it attributes to this
process's threads, public libproc, no privilege. On this M5 Max it advances
every few milliseconds under load.

Its coverage is the trap. It counts this process's own threads and nothing
else: not child processes (the kernel never folds a child's energy into its
parent, not even after reaping it; `rusage_info` has a child CPU-time field
and no child energy field), not other processes, not the GPU or the ANE.
`PowerMeter` meters subprocess work in jcp-gateway (the upstream MCP server),
joule-code-remediate (cargo) and sandbox-proof (the engine), and in-process
work in joule-ui-serve's handlers. A probe added to `detect()` would read the
gateway waiting, near zero, as the energy of the tool call.

So the new `ProcessEnergyProbe` (`Cpu`, `HwShunt`, source "process-energy") is
never part of `detect()`. It is reached only through a new constructor,
`PowerMeter::detect_in_process()`, documented as only for work on this
process's own threads: where the counter is live the meter reads it and
nothing else (a machine-wide CPU probe beside it would count the same work
twice); where it is not, and off macOS, it is `detect()`. The counter is gated
by the shared rule, probed once per process and cached
(`process_energy_status()`, a `CounterStatus`): it must advance in at least
five intervals, at least four in five, over at least 300 ms, while the probe
burns CPU, through the same `liveness::counter_run` and `gated` as RAPL and
NVML. `Span::stop`'s held-CPU rule applies unchanged: a span over which the
counter did not advance is estimated. The struct is the SDK's
`rusage_info_v6` field for field, pinned by a test at the SDK's own sizeof and
offsets (464 bytes, `ri_energy_nj` at 336).

`run_attributed` (subprocess windows). The question was whether a child's own
figure can be read reliably. It can, for the direct child: wait for the exit
without reaping (`waitid(P_PID, pid, WEXITED | WNOWAIT)`), `proc_pid_rusage`
the exited child, then reap with `wait4` as before. Measured here before
writing it: the read succeeds on the zombie and is stable across repeated
reads, then fails (ESRCH) once reaped; `sh -c 'awk ...'` (sh execs awk) read
1.647 J for 0.6 s of awk's CPU; `sh -c 'awk ...; true'` (awk a reaped child of
sh) read 5.5 mJ, sh's own start-up, beside 0.63 s of reaped-descendant CPU
time. So the new `Attribution::child_energy_uj` is the direct child's own
energy, given only when the per-process counter is live, the child reaped no
descendant that used CPU time, and it advanced. Grandchildren are not covered:
cargo -> rustc gets `None`, as does any shell that forks its commands, and a
descendant the child did not reap leaves no trace at all. The status is read
before the window opens, so its first-use probe never lands inside it. The
apportioned `attributed_uj` is unchanged.

jcp-gateway, joule-code-remediate and sandbox-proof stay on `detect()`, and
their docs now say why: their metered work is a subprocess. remediate's test
helper sets the new field to `None` (cargo).

Tests. Hardware, in their own process (`tests/process_energy.rs`, one at a
time, each against an oracle that reads `ri_energy_nj` into a raw buffer at the
SDK offset and shares no code with the probe):
`the_process_energy_probe_is_live` (pinned Live on Apple Silicon; the oracle
saw 39/39 intervals advance; the probe ran once),
`an_in_process_burn_is_a_hardware_reading_of_its_energy` (200 ms of one
thread's CPU, user-interactive QoS, via `detect_in_process`: one `Cpu`
`HwShunt` record in 0.2..20 J and inside the oracle's reading around it),
`an_in_process_sleep_reads_under_a_tenth_of_a_joule`,
`detect_does_not_include_the_process_probe`,
`work_in_a_child_process_is_not_attributed_to_the_in_process_probe`. Unit:
`the_rusage_struct_is_the_sdks_layout`,
`this_process_reads_itself_and_a_missing_pid_reads_nothing`,
`a_childs_own_energy_is_a_reading_only_for_a_single_process_child`,
`only_a_live_status_builds_the_process_probe`,
`detect_never_reads_this_process_counter` (every status combination),
`detect_in_process_reads_this_process_only_for_a_live_status` (and never calls
the fallback when it reads), `a_single_process_childs_own_energy_is_read_before_it_is_reaped`,
`a_child_whose_work_ran_in_a_descendant_has_no_reading`.

Measured on this M5 Max (macOS 27.0.1, other builds loading it), verbatim:
  200 ms in-process burn: 0.4361 J over 219.588375ms wall (oracle around it: 0.4361 J)
  200 ms in-process sleep: 575 µJ
  child work: child 0.8730 J, this process 0.001304 J around the wait, span [... measured_uj: Some(1304) ... HwShunt]
  process energy: status=Live oracle advanced in 39/39 intervals, probe runs=1
  child energy: status=Live child_energy_uj=Some(3128510) proc_cpu_secs=1.499
On the M3 Ultra (macOS 27.0.1, 10.10.10.2; lib and process_energy test
binaries copied, run once with --nocapture, deleted), verbatim:
  200 ms in-process burn: 1.3150 J over 200.032666ms wall (oracle around it: 1.3150 J)
  200 ms in-process sleep: 1796 µJ
  process energy: status=Live oracle advanced in 40/40 intervals, probe runs=1
  child work: child 2.3750 J, this process 0.014451 J around the wait, span [... measured_uj: Some(14451) ... HwShunt]
  child energy: status=Live child_energy_uj=Some(10145217) proc_cpu_secs=2.044
  ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false
  105 lib tests and 5 process_energy tests passed.
The per-process counter is live on both chips; IOReport's CPU rail stays frozen.

sandbox-meter passes (105 lib, 5 process_energy, both once-per-process
files); every sandbox-rs crate that depends on the meter passes (238 tests);
jcp-gateway and jcp-action-receipt (33) and joule-code-remediate (13) pass.
Clippy is clean for aarch64-apple-darwin and x86_64-unknown-linux-gnu, and
x86_64-apple-darwin checks clean.

Falsified, each restored from git and re-run green: the probe added to
`detect()` fails `detect_never_reads_this_process_counter` and
`detect_does_not_include_the_process_probe`; a constant counter, and the
parent's pid in place of this one, fail `the_process_energy_probe_is_live`
(status Frozen while the oracle saw it advance) and the 200 ms burn (it falls
back to the 4.1 J estimate); `given` ignoring the status fails
`only_a_live_status_builds_the_process_probe` and
`detect_in_process_reads_this_process_only_for_a_live_status`; a child reading
that ignores reaped descendants fails both child tests (sh's 5.5 mJ stood in
for awk's work); one that ignores liveness fails the pure child test.

Rebased onto 7cf1f036 (the RAPL figure sums the package zones by name),
which builds `rapl` under test on every platform and adds its fake-tree
tests. The one conflict was the module declarations in probe/mod.rs; both
`process` and `rapl`'s new cfg stand. The counts above predate the rebase:
on the M5 Max sandbox-meter now has 120 lib tests (114 before it), all
passing, with process_energy (5) and both once-per-process tests; the
sandbox-rs workspace, the sandbox-conformance binary, jcp-gateway,
jcp-action-receipt and joule-code-remediate pass too. Clippy on
sandbox-meter is clean for aarch64-apple-darwin and
x86_64-unknown-linux-gnu. The M3 Ultra run was not repeated.

Rebased again, onto a16e36cf, whose sandbox-meter side builds `liveness`
and `rapl` on every platform and adds `RaplPackages` and
`counter_liveness`. The one conflict was again the module declarations in
probe/mod.rs: `liveness` and `rapl` are unconditional, `process` stays
macOS-only. On the M5 Max sandbox-meter now has 122 lib tests, all passing,
with process_energy (5) and both once-per-process tests; the sandbox-rs
workspace (321), the sandbox-conformance binary, jcp-gateway and
jcp-action-receipt (33) and joule-code-remediate (13) pass. Clippy on
sandbox-meter and sandbox-exec-microvm is clean for aarch64-apple-darwin and
x86_64-unknown-linux-gnu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 23:01:05 -04:00